gabriel / musehub public
musehub_domains.py python
286 lines 8.6 KB
7f1d07e8 feat: domains, MCP expansion, MIDI player, and production hardening (#8) Gabriel Cardona <cgcardona@gmail.com> 4d 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 from __future__ import annotations
7
8 import hashlib
9 import json
10 import uuid
11 from dataclasses import dataclass
12 from datetime import datetime, timezone
13 from typing import Any
14
15 from sqlalchemy import func, select
16 from sqlalchemy.ext.asyncio import AsyncSession
17
18 from musehub.db.musehub_domain_models import MusehubDomain, MusehubDomainInstall
19 from musehub.db.musehub_models import MusehubRepo
20
21
22 def _utc_now() -> datetime:
23 return datetime.now(tz=timezone.utc)
24
25
26 def compute_manifest_hash(capabilities: dict[str, Any]) -> str:
27 """Return the SHA-256 hex digest of a capabilities JSON blob (sorted keys)."""
28 blob = json.dumps(capabilities, sort_keys=True, separators=(",", ":")).encode()
29 return hashlib.sha256(blob).hexdigest()
30
31
32 # ── Response dataclasses ──────────────────────────────────────────────────────
33
34
35 @dataclass
36 class DomainResponse:
37 domain_id: str
38 author_slug: str
39 slug: str
40 scoped_id: str # "@author/slug"
41 display_name: str
42 description: str
43 version: str
44 manifest_hash: str
45 capabilities: dict[str, Any]
46 viewer_type: str
47 install_count: int
48 is_verified: bool
49 is_deprecated: bool
50 created_at: datetime
51 updated_at: datetime
52
53
54 @dataclass
55 class DomainListResponse:
56 domains: list[DomainResponse]
57 total: int
58
59
60 @dataclass
61 class DomainReposResponse:
62 domain_id: str
63 scoped_id: str
64 repos: list[dict[str, Any]]
65 total: int
66
67
68 # ── Helpers ───────────────────────────────────────────────────────────────────
69
70
71 def _to_response(domain: MusehubDomain) -> DomainResponse:
72 return DomainResponse(
73 domain_id=domain.domain_id,
74 author_slug=domain.author_slug,
75 slug=domain.slug,
76 scoped_id=f"@{domain.author_slug}/{domain.slug}",
77 display_name=domain.display_name,
78 description=domain.description,
79 version=domain.version,
80 manifest_hash=domain.manifest_hash,
81 capabilities=dict(domain.capabilities) if domain.capabilities else {},
82 viewer_type=domain.viewer_type,
83 install_count=domain.install_count,
84 is_verified=domain.is_verified,
85 is_deprecated=domain.is_deprecated,
86 created_at=domain.created_at,
87 updated_at=domain.updated_at,
88 )
89
90
91 # ── Read operations ───────────────────────────────────────────────────────────
92
93
94 async def list_domains(
95 session: AsyncSession,
96 *,
97 query: str | None = None,
98 verified_only: bool = False,
99 page: int = 1,
100 page_size: int = 20,
101 ) -> DomainListResponse:
102 """List registered domains with optional text search and filtering."""
103 stmt = select(MusehubDomain).where(MusehubDomain.is_deprecated.is_(False))
104
105 if verified_only:
106 stmt = stmt.where(MusehubDomain.is_verified.is_(True))
107
108 if query:
109 q = f"%{query}%"
110 stmt = stmt.where(
111 MusehubDomain.display_name.ilike(q)
112 | MusehubDomain.slug.ilike(q)
113 | MusehubDomain.author_slug.ilike(q)
114 | MusehubDomain.description.ilike(q)
115 )
116
117 count_stmt = select(func.count()).select_from(stmt.subquery())
118 total_result = await session.execute(count_stmt)
119 total = total_result.scalar_one()
120
121 offset = (page - 1) * page_size
122 stmt = stmt.order_by(MusehubDomain.install_count.desc(), MusehubDomain.created_at.desc())
123 stmt = stmt.offset(offset).limit(page_size)
124
125 result = await session.execute(stmt)
126 domains = result.scalars().all()
127
128 return DomainListResponse(
129 domains=[_to_response(d) for d in domains],
130 total=total,
131 )
132
133
134 async def get_domain_by_scoped_id(
135 session: AsyncSession,
136 author_slug: str,
137 slug: str,
138 ) -> DomainResponse | None:
139 """Fetch a single domain by its @author/slug identity."""
140 stmt = select(MusehubDomain).where(
141 MusehubDomain.author_slug == author_slug,
142 MusehubDomain.slug == slug,
143 )
144 result = await session.execute(stmt)
145 domain = result.scalar_one_or_none()
146 return _to_response(domain) if domain else None
147
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 UUID 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
160 async def list_repos_for_domain(
161 session: AsyncSession,
162 domain_id: str,
163 *,
164 page: int = 1,
165 page_size: int = 20,
166 ) -> DomainReposResponse:
167 """Return public repos using a specific domain plugin."""
168 domain = await get_domain_by_id(session, domain_id)
169 if domain is None:
170 return DomainReposResponse(
171 domain_id=domain_id, scoped_id="", repos=[], total=0
172 )
173
174 stmt = (
175 select(MusehubRepo)
176 .where(
177 MusehubRepo.domain_id == domain_id,
178 MusehubRepo.visibility == "public",
179 MusehubRepo.deleted_at.is_(None),
180 )
181 .order_by(MusehubRepo.created_at.desc())
182 .offset((page - 1) * page_size)
183 .limit(page_size)
184 )
185 count_stmt = select(func.count()).select_from(
186 select(MusehubRepo).where(
187 MusehubRepo.domain_id == domain_id,
188 MusehubRepo.visibility == "public",
189 MusehubRepo.deleted_at.is_(None),
190 ).subquery()
191 )
192
193 total_result = await session.execute(count_stmt)
194 total = total_result.scalar_one()
195
196 result = await session.execute(stmt)
197 repos = result.scalars().all()
198
199 return DomainReposResponse(
200 domain_id=domain_id,
201 scoped_id=domain.scoped_id,
202 repos=[
203 {
204 "repo_id": r.repo_id,
205 "owner": r.owner,
206 "slug": r.slug,
207 "name": r.name,
208 "description": r.description,
209 "tags": list(r.tags) if r.tags else [],
210 "created_at": r.created_at.isoformat() if r.created_at else None,
211 }
212 for r in repos
213 ],
214 total=total,
215 )
216
217
218 # ── Write operations ──────────────────────────────────────────────────────────
219
220
221 async def create_domain(
222 session: AsyncSession,
223 *,
224 author_user_id: str,
225 author_slug: str,
226 slug: str,
227 display_name: str,
228 description: str,
229 capabilities: dict[str, Any],
230 viewer_type: str = "generic",
231 version: str = "1.0.0",
232 ) -> DomainResponse:
233 """Register a new domain plugin in the MuseHub registry."""
234 manifest_hash = compute_manifest_hash(capabilities)
235 domain = MusehubDomain(
236 domain_id=str(uuid.uuid4()),
237 author_user_id=author_user_id,
238 author_slug=author_slug,
239 slug=slug,
240 display_name=display_name,
241 description=description,
242 version=version,
243 manifest_hash=manifest_hash,
244 capabilities=capabilities,
245 viewer_type=viewer_type,
246 install_count=0,
247 is_verified=False,
248 is_deprecated=False,
249 created_at=_utc_now(),
250 updated_at=_utc_now(),
251 )
252 session.add(domain)
253 await session.flush()
254 return _to_response(domain)
255
256
257 async def record_domain_install(
258 session: AsyncSession,
259 user_id: str,
260 domain_id: str,
261 ) -> None:
262 """Record that a user has adopted a domain plugin (idempotent)."""
263 # Check if already installed
264 existing = await session.execute(
265 select(MusehubDomainInstall).where(
266 MusehubDomainInstall.user_id == user_id,
267 MusehubDomainInstall.domain_id == domain_id,
268 )
269 )
270 if existing.scalar_one_or_none() is not None:
271 return
272
273 install = MusehubDomainInstall(
274 install_id=str(uuid.uuid4()),
275 user_id=user_id,
276 domain_id=domain_id,
277 created_at=_utc_now(),
278 )
279 session.add(install)
280
281 # Increment install_count on the domain row
282 stmt = select(MusehubDomain).where(MusehubDomain.domain_id == domain_id)
283 result = await session.execute(stmt)
284 domain = result.scalar_one_or_none()
285 if domain is not None:
286 domain.install_count = (domain.install_count or 0) + 1