gabriel / musehub public
musehub_domains.py python
308 lines 10.5 KB
Raw
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago
1 """Domain plugin registry service — CRUD, manifest hashing, and discovery.
2
3 Provides all database operations for the musehub_domains and
4 musehub_domain_installs tables introduced in the V2 domain-agnostic migration.
5 """
6
7 from dataclasses import dataclass
8 from datetime import datetime, timezone
9
10 from sqlalchemy import func, select
11 from sqlalchemy.ext.asyncio import AsyncSession
12
13 from muse.core.types import content_hash
14 from musehub.core.genesis import compute_domain_id, compute_domain_install_id
15 from musehub.db.musehub_domain_models import MusehubDomain, MusehubDomainInstall
16 from musehub.db.utils import escape_like
17 from musehub.db.musehub_repo_models import MusehubRepo
18 from musehub.types.json_types import JSONObject
19
20 def _utc_now() -> datetime:
21 return datetime.now(tz=timezone.utc)
22
23 def compute_manifest_hash(capabilities: JSONObject) -> str:
24 """Return the ``sha256:``-prefixed content ID of a capabilities JSON blob (sorted keys)."""
25 return content_hash(capabilities)
26
27 # ── Response dataclasses ──────────────────────────────────────────────────────
28
29 @dataclass
30 class DomainResponse:
31 domain_id: str
32 author_slug: str
33 slug: str
34 scoped_id: str # "@author/slug"
35 display_name: str
36 description: str
37 version: str
38 manifest_hash: str
39 capabilities: JSONObject
40 viewer_type: str
41 install_count: int
42 is_verified: bool
43 is_deprecated: bool
44 created_at: datetime
45 updated_at: datetime
46
47 @dataclass
48 class DomainListResponse:
49 domains: list[DomainResponse]
50 total: int
51 next_cursor: str | None = None
52
53 @dataclass
54 class DomainReposResponse:
55 domain_id: str
56 scoped_id: str
57 repos: list[JSONObject]
58 total: int
59 next_cursor: str | None = None
60
61 # ── Helpers ───────────────────────────────────────────────────────────────────
62
63 def _to_response(domain: MusehubDomain) -> DomainResponse:
64 return DomainResponse(
65 domain_id=domain.domain_id,
66 author_slug=domain.author_slug,
67 slug=domain.slug,
68 scoped_id=f"@{domain.author_slug}/{domain.slug}",
69 display_name=domain.display_name,
70 description=domain.description,
71 version=domain.version,
72 manifest_hash=domain.manifest_hash,
73 capabilities=dict(domain.capabilities) if domain.capabilities else {},
74 viewer_type=domain.viewer_type,
75 install_count=domain.install_count,
76 is_verified=domain.is_verified,
77 is_deprecated=domain.is_deprecated,
78 created_at=domain.created_at,
79 updated_at=domain.updated_at,
80 )
81
82 # ── Read operations ───────────────────────────────────────────────────────────
83
84 async def list_domains(
85 session: AsyncSession,
86 *,
87 query: str | None = None,
88 verified_only: bool = False,
89 cursor: str | None = None,
90 limit: int = 20,
91 ) -> DomainListResponse:
92 """List registered domains with optional text search and cursor-based pagination."""
93 stmt = select(MusehubDomain).where(MusehubDomain.is_deprecated.is_(False))
94
95 if verified_only:
96 stmt = stmt.where(MusehubDomain.is_verified.is_(True))
97
98 if query:
99 q = f"%{escape_like(query)}%"
100 stmt = stmt.where(
101 MusehubDomain.display_name.ilike(q, escape="\\")
102 | MusehubDomain.slug.ilike(q, escape="\\")
103 | MusehubDomain.author_slug.ilike(q, escape="\\")
104 | MusehubDomain.description.ilike(q, escape="\\")
105 )
106
107 count_stmt = select(func.count()).select_from(stmt.subquery())
108 total_result = await session.execute(count_stmt)
109 total = total_result.scalar_one()
110
111 stmt = stmt.order_by(MusehubDomain.install_count.desc(), MusehubDomain.created_at.desc())
112
113 # Apply cursor: filter rows where created_at < cursor_dt (DESC ordering)
114 # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00).
115 if cursor:
116 try:
117 cursor_dt = datetime.fromisoformat(cursor.replace(" ", "+"))
118 stmt = stmt.where(MusehubDomain.created_at < cursor_dt)
119 except ValueError:
120 pass # ignore malformed cursor, start from beginning
121
122 stmt = stmt.limit(limit + 1)
123 result = await session.execute(stmt)
124 domains = list(result.scalars().all())
125 has_more = len(domains) > limit
126 page_domains = domains[:limit]
127 next_cursor = page_domains[-1].created_at.isoformat() if (page_domains and has_more) else None
128
129 return DomainListResponse(
130 domains=[_to_response(d) for d in page_domains],
131 total=total,
132 next_cursor=next_cursor,
133 )
134
135 async def get_domain_by_scoped_id(
136 session: AsyncSession,
137 author_slug: str,
138 slug: str,
139 ) -> DomainResponse | None:
140 """Fetch a single domain by its @author/slug identity."""
141 stmt = select(MusehubDomain).where(
142 MusehubDomain.author_slug == author_slug,
143 MusehubDomain.slug == slug,
144 )
145 result = await session.execute(stmt)
146 domain = result.scalar_one_or_none()
147 return _to_response(domain) if domain else None
148
149 async def get_domain_by_id(
150 session: AsyncSession,
151 domain_id: str,
152 ) -> DomainResponse | None:
153 """Fetch a single domain by its primary key."""
154 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
155 result = await session.execute(stmt)
156 domain = result.scalar_one_or_none()
157 return _to_response(domain) if domain else None
158
159 async def list_repos_for_domain(
160 session: AsyncSession,
161 domain_id: str,
162 *,
163 cursor: str | None = None,
164 limit: int = 20,
165 ) -> DomainReposResponse:
166 """Return public repos using a specific domain plugin with cursor-based pagination."""
167 domain = await get_domain_by_id(session, domain_id)
168 if domain is None:
169 return DomainReposResponse(
170 domain_id=domain_id, scoped_id="", repos=[], total=0
171 )
172
173 base_where = (
174 MusehubRepo.domain_id == domain_id,
175 MusehubRepo.visibility == "public",
176 )
177 count_stmt = select(func.count()).select_from(
178 select(MusehubRepo).where(*base_where).subquery()
179 )
180 total_result = await session.execute(count_stmt)
181 total = total_result.scalar_one()
182
183 stmt = (
184 select(MusehubRepo)
185 .where(*base_where)
186 .order_by(MusehubRepo.created_at.desc())
187 )
188
189 # Apply cursor: filter rows where created_at < cursor_dt (DESC ordering)
190 # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00).
191 if cursor:
192 try:
193 cursor_dt = datetime.fromisoformat(cursor.replace(" ", "+"))
194 stmt = stmt.where(MusehubRepo.created_at < cursor_dt)
195 except ValueError:
196 pass # ignore malformed cursor, start from beginning
197
198 stmt = stmt.limit(limit + 1)
199 result = await session.execute(stmt)
200 repos = list(result.scalars().all())
201 has_more = len(repos) > limit
202 page_repos = repos[:limit]
203 next_cursor = page_repos[-1].created_at.isoformat() if (page_repos and has_more) else None
204
205 return DomainReposResponse(
206 domain_id=domain_id,
207 scoped_id=domain.scoped_id,
208 repos=[
209 {
210 "repo_id": r.repo_id,
211 "owner": r.owner,
212 "slug": r.slug,
213 "name": r.name,
214 "description": r.description,
215 "tags": list(r.tags) if r.tags else [],
216 "created_at": r.created_at.isoformat() if r.created_at else None,
217 "pushed_at": r.pushed_at.isoformat() if r.pushed_at else None,
218 }
219 for r in page_repos
220 ],
221 total=total,
222 next_cursor=next_cursor,
223 )
224
225 # ── Write operations ──────────────────────────────────────────────────────────
226
227 async def create_domain(
228 session: AsyncSession,
229 *,
230 author_user_id: str,
231 author_slug: str,
232 slug: str,
233 display_name: str,
234 description: str,
235 capabilities: JSONObject,
236 viewer_type: str = "generic",
237 version: str = "1.0.0",
238 ) -> DomainResponse:
239 """Register a new domain plugin in the MuseHub registry."""
240 manifest_hash = compute_manifest_hash(capabilities)
241 now = _utc_now()
242 domain = MusehubDomain(
243 domain_id=compute_domain_id(author_slug, slug, now.isoformat()),
244 author_user_id=author_user_id,
245 author_slug=author_slug,
246 slug=slug,
247 display_name=display_name,
248 description=description,
249 version=version,
250 manifest_hash=manifest_hash,
251 capabilities=capabilities,
252 viewer_type=viewer_type,
253 install_count=0,
254 is_verified=False,
255 is_deprecated=False,
256 created_at=now,
257 updated_at=now,
258 )
259 session.add(domain)
260 await session.flush()
261 return _to_response(domain)
262
263 async def record_domain_install(
264 session: AsyncSession,
265 user_id: str,
266 domain_id: str,
267 ) -> None:
268 """Record that a user has adopted a domain plugin (idempotent)."""
269 # Check if already installed
270 existing = await session.execute(
271 select(MusehubDomainInstall).where(
272 MusehubDomainInstall.user_id == user_id,
273 MusehubDomainInstall.domain_id == domain_id,
274 )
275 )
276 if existing.scalar_one_or_none() is not None:
277 return
278
279 install = MusehubDomainInstall(
280 install_id=compute_domain_install_id(user_id, domain_id),
281 user_id=user_id,
282 domain_id=domain_id,
283 created_at=_utc_now(),
284 )
285 session.add(install)
286
287 # Increment install_count on the domain row
288 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
289 result = await session.execute(stmt)
290 domain = result.scalar_one_or_none()
291 if domain is not None:
292 domain.install_count = (domain.install_count or 0) + 1
293
294
295 async def set_domain_verified(
296 session: AsyncSession,
297 domain_id: str,
298 *,
299 verified: bool,
300 ) -> DomainResponse:
301 """Set or clear the is_verified flag on a domain. Returns the updated domain."""
302 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
303 result = await session.execute(stmt)
304 domain = result.scalar_one()
305 domain.is_verified = verified
306 await session.flush()
307 await session.refresh(domain)
308 return _to_response(domain)
File History 1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago