musehub_domains.py
python
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