gabriel / musehub public
musehub_domains.py python
465 lines 16.4 KB
Raw
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 17 hours 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 resolve_unambiguous_domain_id_by_category(
160 session: AsyncSession,
161 category: str,
162 ) -> str | None:
163 """Return the sole non-deprecated MusehubDomain.domain_id whose slug
164 matches *category*, or None if zero or multiple candidates exist.
165
166 musehub#120: a repo's `domain_id` column is a plain VCS-plugin category
167 string (e.g. "code"), carrying no author context, so it can never be
168 used to unambiguously identify a specific marketplace MusehubDomain on
169 its own. This helper is the single, never-guess resolution point
170 consulted by both `create_repo` (auto-link at creation) and the
171 marketplace-domain-link backfill script (one-time historical fix) —
172 ambiguity (2+ live domains sharing a category slug) and absence (0
173 domains) both resolve to an honest `None`, never a fallback guess.
174 """
175 stmt = select(MusehubDomain.domain_id).where(
176 MusehubDomain.slug == category,
177 MusehubDomain.is_deprecated.is_(False),
178 )
179 result = await session.execute(stmt)
180 matches = result.scalars().all()
181 return matches[0] if len(matches) == 1 else None
182
183 async def list_repos_for_domain(
184 session: AsyncSession,
185 domain_id: str,
186 *,
187 cursor: str | None = None,
188 limit: int = 20,
189 ) -> DomainReposResponse:
190 """Return public repos explicitly linked to a marketplace domain, cursor-paginated.
191
192 ``domain_id`` here is the marketplace ``MusehubDomain.domain_id`` (a
193 sha256 genesis ID) — matched against ``MusehubRepo.marketplace_domain_id``
194 (musehub#117 Phase 3's explicit repo<->domain link), never against
195 ``MusehubRepo.domain_id`` (a plain VCS-plugin category string like
196 "code"/"midi"/"mist" — a different namespace entirely; comparing it to
197 a marketplace domain_id can never match).
198 """
199 domain = await get_domain_by_id(session, domain_id)
200 if domain is None:
201 return DomainReposResponse(
202 domain_id=domain_id, scoped_id="", repos=[], total=0
203 )
204
205 base_where = (
206 MusehubRepo.marketplace_domain_id == domain_id,
207 MusehubRepo.visibility == "public",
208 )
209 count_stmt = select(func.count()).select_from(
210 select(MusehubRepo).where(*base_where).subquery()
211 )
212 total_result = await session.execute(count_stmt)
213 total = total_result.scalar_one()
214
215 stmt = (
216 select(MusehubRepo)
217 .where(*base_where)
218 .order_by(MusehubRepo.created_at.desc())
219 )
220
221 # Apply cursor: filter rows where created_at < cursor_dt (DESC ordering)
222 # Normalize space→+ because URL-decoding can corrupt the ISO timezone offset (+00:00).
223 if cursor:
224 try:
225 cursor_dt = datetime.fromisoformat(cursor.replace(" ", "+"))
226 stmt = stmt.where(MusehubRepo.created_at < cursor_dt)
227 except ValueError:
228 pass # ignore malformed cursor, start from beginning
229
230 stmt = stmt.limit(limit + 1)
231 result = await session.execute(stmt)
232 repos = list(result.scalars().all())
233 has_more = len(repos) > limit
234 page_repos = repos[:limit]
235 next_cursor = page_repos[-1].created_at.isoformat() if (page_repos and has_more) else None
236
237 return DomainReposResponse(
238 domain_id=domain_id,
239 scoped_id=domain.scoped_id,
240 repos=[
241 {
242 "repo_id": r.repo_id,
243 "owner": r.owner,
244 "slug": r.slug,
245 "name": r.name,
246 "description": r.description,
247 "tags": list(r.tags) if r.tags else [],
248 "created_at": r.created_at.isoformat() if r.created_at else None,
249 "pushed_at": r.pushed_at.isoformat() if r.pushed_at else None,
250 }
251 for r in page_repos
252 ],
253 total=total,
254 next_cursor=next_cursor,
255 )
256
257 async def count_public_repos_by_domain(
258 session: AsyncSession,
259 domain_ids: list[str],
260 ) -> dict[str, int]:
261 """Return ``{domain_id: public_repo_count}`` for each id in *domain_ids*.
262
263 A single grouped query, batched for a page of domains — the listing
264 page needs a per-domain repo count without an N+1 call to
265 ``list_repos_for_domain`` per row. Same ``marketplace_domain_id``
266 match as ``list_repos_for_domain`` (never the plain-string
267 ``domain_id`` column — see that function's docstring). Domain ids
268 with zero matching repos are simply absent from the result; callers
269 should default via ``.get(domain_id, 0)``.
270 """
271 if not domain_ids:
272 return {}
273 stmt = (
274 select(MusehubRepo.marketplace_domain_id, func.count())
275 .where(
276 MusehubRepo.marketplace_domain_id.in_(domain_ids),
277 MusehubRepo.visibility == "public",
278 )
279 .group_by(MusehubRepo.marketplace_domain_id)
280 )
281 result = await session.execute(stmt)
282 return {row[0]: row[1] for row in result.all()}
283
284 # ── Write operations ──────────────────────────────────────────────────────────
285
286 async def create_domain(
287 session: AsyncSession,
288 *,
289 author_user_id: str,
290 author_slug: str,
291 slug: str,
292 display_name: str,
293 description: str,
294 capabilities: JSONObject,
295 viewer_type: str = "generic",
296 version: str = "1.0.0",
297 ) -> DomainResponse:
298 """Register a new domain plugin in the MuseHub registry."""
299 manifest_hash = compute_manifest_hash(capabilities)
300 now = _utc_now()
301 domain = MusehubDomain(
302 domain_id=compute_domain_id(author_slug, slug, now.isoformat()),
303 author_user_id=author_user_id,
304 author_slug=author_slug,
305 slug=slug,
306 display_name=display_name,
307 description=description,
308 version=version,
309 manifest_hash=manifest_hash,
310 capabilities=capabilities,
311 viewer_type=viewer_type,
312 install_count=0,
313 is_verified=False,
314 is_deprecated=False,
315 created_at=now,
316 updated_at=now,
317 )
318 session.add(domain)
319 await session.flush()
320 return _to_response(domain)
321
322 async def record_domain_install(
323 session: AsyncSession,
324 user_id: str,
325 domain_id: str,
326 ) -> None:
327 """Record that a user has adopted a domain plugin (idempotent)."""
328 # Check if already installed
329 existing = await session.execute(
330 select(MusehubDomainInstall).where(
331 MusehubDomainInstall.user_id == user_id,
332 MusehubDomainInstall.domain_id == domain_id,
333 )
334 )
335 if existing.scalar_one_or_none() is not None:
336 return
337
338 install = MusehubDomainInstall(
339 install_id=compute_domain_install_id(user_id, domain_id),
340 user_id=user_id,
341 domain_id=domain_id,
342 created_at=_utc_now(),
343 )
344 session.add(install)
345
346 # Increment install_count on the domain row
347 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
348 result = await session.execute(stmt)
349 domain = result.scalar_one_or_none()
350 if domain is not None:
351 domain.install_count = (domain.install_count or 0) + 1
352
353
354 async def record_domain_uninstall(
355 session: AsyncSession,
356 user_id: str,
357 domain_id: str,
358 ) -> None:
359 """Reverse a prior :func:`record_domain_install` (idempotent).
360
361 musehub#117 DOM_13. No-ops cleanly if the user never installed this
362 domain — mirrors record_domain_install's idempotency so callers never
363 need to check state first.
364 """
365 existing = await session.execute(
366 select(MusehubDomainInstall).where(
367 MusehubDomainInstall.user_id == user_id,
368 MusehubDomainInstall.domain_id == domain_id,
369 )
370 )
371 install = existing.scalar_one_or_none()
372 if install is None:
373 return
374
375 await session.delete(install)
376
377 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
378 result = await session.execute(stmt)
379 domain = result.scalar_one_or_none()
380 if domain is not None:
381 domain.install_count = max((domain.install_count or 0) - 1, 0)
382
383
384 async def set_domain_verified(
385 session: AsyncSession,
386 domain_id: str,
387 *,
388 verified: bool,
389 ) -> DomainResponse:
390 """Set or clear the is_verified flag on a domain. Returns the updated domain."""
391 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
392 result = await session.execute(stmt)
393 domain = result.scalar_one()
394 domain.is_verified = verified
395 await session.flush()
396 await session.refresh(domain)
397 return _to_response(domain)
398
399
400 async def update_domain(
401 session: AsyncSession,
402 domain_id: str,
403 *,
404 display_name: str | None = None,
405 description: str | None = None,
406 capabilities: JSONObject | None = None,
407 viewer_type: str | None = None,
408 version: str | None = None,
409 ) -> DomainResponse | None:
410 """Partially update a domain's mutable fields (musehub#117 DOM_16).
411
412 Only fields passed as non-None are changed — mirrors
413 ``labels.py::update_label``'s partial-update convention. Recomputes
414 ``manifest_hash`` when ``capabilities`` changes, since the hash pins to
415 that exact blob. Returns ``None`` if no domain with this ID exists.
416 """
417 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
418 result = await session.execute(stmt)
419 domain = result.scalar_one_or_none()
420 if domain is None:
421 return None
422
423 if display_name is not None:
424 domain.display_name = display_name
425 if description is not None:
426 domain.description = description
427 if capabilities is not None:
428 domain.capabilities = capabilities
429 domain.manifest_hash = compute_manifest_hash(capabilities)
430 if viewer_type is not None:
431 domain.viewer_type = viewer_type
432 if version is not None:
433 domain.version = version
434
435 await session.flush()
436 await session.refresh(domain)
437 return _to_response(domain)
438
439
440 async def set_domain_deprecated(
441 session: AsyncSession,
442 domain_id: str,
443 *,
444 deprecated: bool,
445 ) -> DomainResponse | None:
446 """Set or clear the is_deprecated flag on a domain (musehub#117 DOM_17).
447
448 This is the Delete of domains' CRUD surface — a soft flag flip, not a
449 row delete, since a hard delete would orphan
450 ``MusehubRepo.marketplace_domain_id`` (Phase 3) and
451 ``MusehubDomainInstall.domain_id`` rows with no cascade story.
452 ``list_domains`` already unconditionally excludes deprecated rows, so
453 this alone removes a domain from `/domains` browsing while it stays
454 individually fetchable by scoped ID — mirrors npm's "deprecated
455 package" semantics. Returns ``None`` if no domain with this ID exists.
456 """
457 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
458 result = await session.execute(stmt)
459 domain = result.scalar_one_or_none()
460 if domain is None:
461 return None
462 domain.is_deprecated = deprecated
463 await session.flush()
464 await session.refresh(domain)
465 return _to_response(domain)
File History 1 commit
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 17 hours ago