gabriel / musehub public
musehub_releases.py python
729 lines 25.1 KB
Raw
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
1 """MuseHub release persistence adapter — single point of DB access for releases.
2
3 This module is the ONLY place that touches the ``musehub_releases`` table.
4 Route handlers delegate here; no business logic lives in routes.
5
6 Releases tie a semver tag (e.g. "v1.2.3") to a commit snapshot and carry
7 structured metadata: distribution channel, parsed semver components,
8 AI provenance fields, and an auto-generated changelog.
9
10 Boundary rules:
11 - Must NOT import state stores, SSE queues, or LLM clients.
12 - May import ORM models from musehub.db domain-specific modules.
13 - May import Pydantic response models from musehub.models.musehub.
14 - May import the packager to resolve download URLs.
15 """
16
17 import json
18 import logging
19 from datetime import datetime, timezone
20
21 from sqlalchemy import func, select
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.core.genesis import compute_asset_id, compute_release_id
25 from musehub.db.musehub_release_models import MusehubRelease, MusehubReleaseAsset
26 from musehub.types.json_types import JSONObject, StrDict
27 from musehub.models.musehub import (
28 ChangelogEntryResponse,
29 ReleaseAssetDownloadCount,
30 ReleaseAssetListResponse,
31 ReleaseAssetResponse,
32 ReleaseDownloadStatsResponse,
33 ReleaseDownloadUrls,
34 ReleaseListResponse,
35 ReleaseResponse,
36 SemanticReleaseReportResponse,
37 )
38 from musehub.services.musehub_release_packager import build_empty_download_urls
39
40 logger = logging.getLogger(__name__)
41
42 # Valid distribution channels.
43 _VALID_CHANNELS: frozenset[str] = frozenset({"stable", "beta", "alpha", "nightly"})
44
45 def _urls_from_json(raw: StrDict) -> ReleaseDownloadUrls:
46 """Coerce the JSON blob stored in ``download_urls`` to a typed model."""
47 return ReleaseDownloadUrls(metadata=raw.get("metadata"))
48
49 def _parse_changelog(raw_json: str) -> list[ChangelogEntryResponse]:
50 """Parse the ``changelog_json`` column into typed response objects."""
51 try:
52 entries = json.loads(raw_json)
53 except (json.JSONDecodeError, TypeError):
54 return []
55 if not isinstance(entries, list):
56 return []
57 result: list[ChangelogEntryResponse] = []
58 for item in entries:
59 if not isinstance(item, dict):
60 continue
61 commit_id = item.get("commit_id", "")
62 message = item.get("message", "")
63 if not isinstance(commit_id, str) or not isinstance(message, str):
64 continue
65 sem_ver_bump_raw = item.get("sem_ver_bump", "")
66 sem_ver_bump = sem_ver_bump_raw if isinstance(sem_ver_bump_raw, str) else ""
67 breaking_raw = item.get("breaking_changes", [])
68 breaking: list[str] = (
69 [str(b) for b in breaking_raw if isinstance(b, str)]
70 if isinstance(breaking_raw, list)
71 else []
72 )
73 author_raw = item.get("author", "")
74 author = author_raw if isinstance(author_raw, str) else ""
75 ts_raw = item.get("timestamp", "")
76 timestamp = ts_raw if isinstance(ts_raw, str) else ""
77 result.append(
78 ChangelogEntryResponse(
79 commit_id=commit_id,
80 message=message,
81 sem_ver_bump=sem_ver_bump,
82 breaking_changes=breaking,
83 author=author,
84 timestamp=timestamp,
85 )
86 )
87 return result
88
89 def _parse_semantic_report(raw_json: str) -> SemanticReleaseReportResponse | None:
90 """Deserialise the ``semantic_report_json`` column into a typed model."""
91 if not raw_json:
92 return None
93 try:
94 data = json.loads(raw_json)
95 except (json.JSONDecodeError, TypeError):
96 return None
97 if not isinstance(data, dict):
98 return None
99 try:
100 return SemanticReleaseReportResponse.model_validate(data)
101 except Exception:
102 logger.warning("⚠️ Failed to parse semantic_report_json; treating as absent.")
103 return None
104
105 def _to_release_response(row: MusehubRelease) -> ReleaseResponse:
106 raw_urls: StrDict = row.download_urls if isinstance(row.download_urls, dict) else {}
107 semantic_report = _parse_semantic_report(getattr(row, "semantic_report_json", "") or "")
108 return ReleaseResponse(
109 release_id=row.release_id,
110 tag=row.tag,
111 title=row.title,
112 body=row.body,
113 commit_id=row.commit_id,
114 snapshot_id=row.snapshot_id,
115 channel=row.channel,
116 semver_major=row.semver_major,
117 semver_minor=row.semver_minor,
118 semver_patch=row.semver_patch,
119 semver_pre=row.semver_pre,
120 semver_build=row.semver_build,
121 download_urls=_urls_from_json(raw_urls),
122 author=row.author,
123 agent_id=row.agent_id,
124 model_id=row.model_id,
125 changelog=_parse_changelog(row.changelog_json),
126 is_draft=row.is_draft,
127 gpg_signature=row.gpg_signature,
128 semantic_report=semantic_report,
129 created_at=row.created_at,
130 )
131
132 async def _tag_exists(session: AsyncSession, repo_id: str, tag: str) -> bool:
133 """Return True if a release with this tag already exists for the repo."""
134 stmt = select(MusehubRelease.release_id).where(
135 MusehubRelease.repo_id == repo_id,
136 MusehubRelease.tag == tag,
137 )
138 result = (await session.execute(stmt)).scalar_one_or_none()
139 return result is not None
140
141 async def create_release(
142 session: AsyncSession,
143 *,
144 repo_id: str,
145 tag: str,
146 title: str = "",
147 body: str = "",
148 commit_id: str | None,
149 snapshot_id: str | None = None,
150 channel: str = "stable",
151 semver_major: int = 0,
152 semver_minor: int = 0,
153 semver_patch: int = 0,
154 semver_pre: str = "",
155 semver_build: str = "",
156 download_urls: ReleaseDownloadUrls | None = None,
157 author: str = "",
158 agent_id: str = "",
159 model_id: str = "",
160 changelog: list[ChangelogEntryResponse] | None = None,
161 is_draft: bool = False,
162 gpg_signature: str | None = None,
163 semantic_report_json: str = "",
164 ) -> ReleaseResponse:
165 """Persist a new release and return its wire representation.
166
167 ``tag`` must be unique per repo. Raises ``ValueError`` if a release with
168 the same tag already exists. The caller is responsible for committing the
169 session after this call.
170
171 Args:
172 session: Active async DB session.
173 repo_id: ID of the target repo.
174 tag: Semver tag (e.g. "v1.2.3") — unique per repo.
175 title: Human-readable release title.
176 body: Markdown release notes.
177 commit_id: Optional commit to pin this release to.
178 snapshot_id: Optional content-addressed snapshot ID.
179 channel: Distribution channel (stable | beta | alpha | nightly).
180 semver_major: Major version component.
181 semver_minor: Minor version component.
182 semver_patch: Patch version component.
183 semver_pre: Pre-release label (empty for stable releases).
184 semver_build: Build metadata (empty when absent).
185 download_urls: Pre-built download URL map; defaults to empty URLs.
186 author: Display name or identifier of the user publishing this release.
187 agent_id: AI agent that produced the tip commit.
188 model_id: AI model used by the agent.
189 changelog: Auto-generated changelog entries.
190 is_draft: Save as draft — not yet publicly visible.
191 gpg_signature: ASCII-armoured GPG signature for the tag object.
192
193 Returns:
194 ``ReleaseResponse`` with all fields populated.
195
196 Raises:
197 ValueError: If a release with ``tag`` already exists for ``repo_id``.
198 ValueError: If ``channel`` is not a recognised distribution channel.
199 """
200 if await _tag_exists(session, repo_id, tag):
201 raise ValueError(f"Release tag '{tag}' already exists for repo {repo_id}")
202
203 if channel not in _VALID_CHANNELS:
204 raise ValueError(f"Unknown channel '{channel}'. Choose: {', '.join(sorted(_VALID_CHANNELS))}")
205
206 urls = download_urls or build_empty_download_urls()
207 urls_dict: StrDict = {
208 k: v
209 for k, v in {
210 "metadata": urls.metadata,
211 }.items()
212 if v is not None
213 }
214
215 changelog_entries = changelog or []
216 changelog_json = json.dumps(
217 [e.model_dump() for e in changelog_entries], default=str
218 )
219
220 _created_at = datetime.now(tz=timezone.utc)
221 release = MusehubRelease(
222 release_id=compute_release_id(repo_id, tag, _created_at.isoformat()),
223 created_at=_created_at,
224 repo_id=repo_id,
225 tag=tag,
226 title=title,
227 body=body,
228 commit_id=commit_id,
229 snapshot_id=snapshot_id,
230 channel=channel,
231 semver_major=semver_major,
232 semver_minor=semver_minor,
233 semver_patch=semver_patch,
234 semver_pre=semver_pre,
235 semver_build=semver_build,
236 download_urls=urls_dict,
237 author=author,
238 agent_id=agent_id,
239 model_id=model_id,
240 changelog_json=changelog_json,
241 is_draft=is_draft,
242 gpg_signature=gpg_signature,
243 semantic_report_json=semantic_report_json,
244 )
245 session.add(release)
246 await session.flush()
247 await session.refresh(release)
248 logger.info("✅ Created release %s for repo %s (channel=%s)", tag, repo_id, channel)
249 return _to_release_response(release)
250
251 async def create_release_from_dict(
252 session: AsyncSession,
253 repo_id: str,
254 data: JSONObject,
255 ) -> ReleaseResponse:
256 """Create a release from a raw ``ReleaseDict`` payload sent by the Muse CLI.
257
258 Accepts the wire format emitted by ``ReleaseRecord.to_dict()`` on the CLI
259 side and maps it to ``create_release``. Any unrecognised fields are ignored.
260
261 Args:
262 session: Active async DB session.
263 repo_id: ID of the target repo (resolved from the URL, not from the payload).
264 data: Parsed JSON dict matching the CLI ``ReleaseDict`` shape.
265
266 Returns:
267 ``ReleaseResponse`` for the newly created release.
268
269 Raises:
270 ValueError: If ``tag`` is missing, already exists, or ``channel`` is invalid.
271 """
272 tag = str(data.get("tag") or "")
273 if not tag:
274 raise ValueError("Release payload is missing required field 'tag'")
275
276 title = str(data.get("title") or "")
277 body = str(data.get("body") or "")
278 commit_id_raw = data.get("commit_id")
279 commit_id = str(commit_id_raw) if isinstance(commit_id_raw, str) else None
280 snapshot_id_raw = data.get("snapshot_id")
281 snapshot_id = str(snapshot_id_raw) if isinstance(snapshot_id_raw, str) else None
282 channel_raw = data.get("channel")
283 channel = str(channel_raw) if isinstance(channel_raw, str) else "stable"
284 agent_id = str(data.get("agent_id") or "")
285 model_id = str(data.get("model_id") or "")
286 is_draft_raw = data.get("is_draft")
287 is_draft = bool(is_draft_raw) if is_draft_raw is not None else False
288 gpg_raw = data.get("gpg_signature")
289 gpg_signature = str(gpg_raw) if isinstance(gpg_raw, str) else None
290
291 # Parse semver components from the nested dict.
292 semver_raw = data.get("semver")
293 semver_major = 0
294 semver_minor = 0
295 semver_patch = 0
296 semver_pre = ""
297 semver_build = ""
298 if isinstance(semver_raw, dict):
299 maj = semver_raw.get("major")
300 semver_major = int(maj) if isinstance(maj, int) else 0
301 min_ = semver_raw.get("minor")
302 semver_minor = int(min_) if isinstance(min_, int) else 0
303 pat = semver_raw.get("patch")
304 semver_patch = int(pat) if isinstance(pat, int) else 0
305 pre = semver_raw.get("pre")
306 semver_pre = str(pre) if isinstance(pre, str) else ""
307 bld = semver_raw.get("build")
308 semver_build = str(bld) if isinstance(bld, str) else ""
309
310 # Parse changelog entries.
311 changelog_raw = data.get("changelog")
312 changelog_entries: list[ChangelogEntryResponse] = []
313 if isinstance(changelog_raw, list):
314 for item in changelog_raw:
315 if not isinstance(item, dict):
316 continue
317 cid = item.get("commit_id", "")
318 msg = item.get("message", "")
319 if not isinstance(cid, str) or not isinstance(msg, str):
320 continue
321 bump_raw = item.get("sem_ver_bump", "")
322 bump = str(bump_raw) if isinstance(bump_raw, str) else ""
323 bc_raw = item.get("breaking_changes", [])
324 bc: list[str] = (
325 [str(b) for b in bc_raw if isinstance(b, str)]
326 if isinstance(bc_raw, list)
327 else []
328 )
329 author_raw = item.get("author", "")
330 author = str(author_raw) if isinstance(author_raw, str) else ""
331 ts_raw = item.get("timestamp", "")
332 timestamp = str(ts_raw) if isinstance(ts_raw, str) else ""
333 changelog_entries.append(
334 ChangelogEntryResponse(
335 commit_id=cid,
336 message=msg,
337 sem_ver_bump=bump,
338 breaking_changes=bc,
339 author=author,
340 timestamp=timestamp,
341 )
342 )
343
344 return await create_release(
345 session,
346 repo_id=repo_id,
347 tag=tag,
348 title=title,
349 body=body,
350 commit_id=commit_id,
351 snapshot_id=snapshot_id,
352 channel=channel,
353 semver_major=semver_major,
354 semver_minor=semver_minor,
355 semver_patch=semver_patch,
356 semver_pre=semver_pre,
357 semver_build=semver_build,
358 author="",
359 agent_id=agent_id,
360 model_id=model_id,
361 changelog=changelog_entries,
362 is_draft=is_draft,
363 gpg_signature=gpg_signature,
364 )
365
366 async def list_releases(
367 session: AsyncSession,
368 repo_id: str,
369 *,
370 channel: str | None = None,
371 include_drafts: bool = False,
372 cursor: str | None = None,
373 limit: int = 20,
374 ) -> ReleaseListResponse:
375 """Return releases for a repo with cursor-based keyset pagination (newest first).
376
377 Args:
378 session: Active async DB session.
379 repo_id: ID of the target repo.
380 channel: When given, restrict to this distribution channel.
381 include_drafts: When False (default), draft releases are excluded.
382 cursor: ISO 8601 ``created_at`` of the last seen release
383 (opaque to callers — pass ``nextCursor`` from a
384 previous response verbatim). Omit to start from
385 the beginning (most recent releases).
386 limit: Maximum releases to return per page (default 20).
387
388 Returns:
389 ``ReleaseListResponse`` with releases ordered newest first.
390 """
391 conditions = [MusehubRelease.repo_id == repo_id]
392 if channel is not None and channel in _VALID_CHANNELS:
393 conditions.append(MusehubRelease.channel == channel)
394 if not include_drafts:
395 conditions.append(MusehubRelease.is_draft.is_(False))
396
397 count_stmt = select(func.count(MusehubRelease.release_id)).where(*conditions)
398 total: int = (await session.execute(count_stmt)).scalar_one()
399
400 data_conditions = list(conditions)
401 if cursor is not None:
402 data_conditions.append(
403 MusehubRelease.created_at < datetime.fromisoformat(cursor)
404 )
405
406 rows = list(
407 (
408 await session.execute(
409 select(MusehubRelease)
410 .where(*data_conditions)
411 .order_by(MusehubRelease.created_at.desc())
412 .limit(limit + 1)
413 )
414 ).scalars()
415 )
416
417 next_cursor: str | None = None
418 if len(rows) == limit + 1:
419 next_cursor = rows[limit - 1].created_at.isoformat()
420 rows = rows[:limit]
421
422 return ReleaseListResponse(
423 releases=[_to_release_response(r) for r in rows],
424 total=total,
425 next_cursor=next_cursor,
426 )
427
428 async def get_release_by_tag(
429 session: AsyncSession,
430 repo_id: str,
431 tag: str,
432 ) -> ReleaseResponse | None:
433 """Return a release by its tag for the given repo, or ``None`` if not found.
434
435 Args:
436 session: Active async DB session.
437 repo_id: ID of the target repo.
438 tag: Version tag to look up (e.g. "v1.2.3").
439
440 Returns:
441 ``ReleaseResponse`` if found, otherwise ``None``.
442 """
443 stmt = select(MusehubRelease).where(
444 MusehubRelease.repo_id == repo_id,
445 MusehubRelease.tag == tag,
446 )
447 row = (await session.execute(stmt)).scalar_one_or_none()
448 if row is None:
449 return None
450 return _to_release_response(row)
451
452 async def get_release_list_response(
453 session: AsyncSession,
454 repo_id: str,
455 cursor: str | None = None,
456 limit: int = 20,
457 ) -> ReleaseListResponse:
458 """Convenience wrapper that returns a ``ReleaseListResponse`` directly.
459
460 Returns stable, non-draft releases only — suitable for the public release
461 listing page.
462
463 Args:
464 session: Active async DB session.
465 repo_id: ID of the target repo.
466 cursor: Opaque cursor from a previous response (pass ``nextCursor``
467 verbatim). Omit to start from the most recent release.
468 limit: Maximum releases to return per page (default 20).
469
470 Returns:
471 ``ReleaseListResponse`` containing all matching releases newest first.
472 """
473 return await list_releases(session, repo_id, include_drafts=False, cursor=cursor, limit=limit)
474
475 async def delete_release_by_tag(
476 session: AsyncSession,
477 repo_id: str,
478 tag: str,
479 ) -> bool:
480 """Delete a release by its tag for the given repo.
481
482 Removes only the release label row — all commits, snapshots, and objects
483 referenced by this release remain intact in the content-addressed store.
484
485 The caller is responsible for committing the session after this call.
486
487 Args:
488 session: Active async DB session.
489 repo_id: ID of the owning repo.
490 tag: Semver tag of the release to remove (e.g. "v1.2.0").
491
492 Returns:
493 ``True`` if the release was found and deleted; ``False`` if not found.
494 """
495 stmt = select(MusehubRelease).where(
496 MusehubRelease.repo_id == repo_id,
497 MusehubRelease.tag == tag,
498 )
499 row = (await session.execute(stmt)).scalar_one_or_none()
500 if row is None:
501 return False
502 await session.delete(row)
503 logger.info("✅ Retracted release %s from repo %s", tag, repo_id)
504 return True
505
506 # ── Release asset helpers ─────────────────────────────────────────────────────
507
508 def _to_asset_response(row: MusehubReleaseAsset) -> ReleaseAssetResponse:
509 """Convert a ``MusehubReleaseAsset`` ORM row to its wire representation."""
510 return ReleaseAssetResponse(
511 asset_id=row.asset_id,
512 release_id=row.release_id,
513 name=row.name,
514 label=row.label,
515 content_type=row.content_type,
516 size=row.size,
517 download_url=row.download_url,
518 download_count=row.download_count,
519 created_at=row.created_at,
520 )
521
522 async def attach_asset(
523 session: AsyncSession,
524 *,
525 release_id: str,
526 repo_id: str,
527 name: str,
528 label: str = "",
529 content_type: str = "",
530 size: int = 0,
531 download_url: str,
532 ) -> ReleaseAssetResponse:
533 """Attach a new downloadable asset to an existing release.
534
535 The caller is responsible for committing the session after this call.
536
537 Args:
538 session: Active async DB session.
539 release_id: ID of the release to attach the asset to.
540 repo_id: ID of the owning repo (denormalised for efficient queries).
541 name: Filename shown in the UI.
542 label: Optional human-readable label (e.g. "MIDI Bundle").
543 content_type: MIME type of the artifact.
544 size: File size in bytes; 0 when unknown.
545 download_url: Direct download URL for the artifact.
546
547 Returns:
548 ``ReleaseAssetResponse`` for the newly created asset.
549 """
550 from datetime import datetime, timezone
551 _asset_now = datetime.now(timezone.utc)
552 asset = MusehubReleaseAsset(
553 asset_id=compute_asset_id(release_id, name, _asset_now.isoformat()),
554 release_id=release_id,
555 repo_id=repo_id,
556 name=name,
557 label=label,
558 content_type=content_type,
559 size=size,
560 download_url=download_url,
561 created_at=_asset_now,
562 )
563 session.add(asset)
564 await session.flush()
565 await session.refresh(asset)
566 logger.info("✅ Attached asset %r to release %s", name, release_id)
567 return _to_asset_response(asset)
568
569 async def get_asset(
570 session: AsyncSession,
571 asset_id: str,
572 ) -> MusehubReleaseAsset | None:
573 """Return the ``MusehubReleaseAsset`` row for ``asset_id``, or ``None``.
574
575 Used by route handlers to validate that the asset belongs to the
576 expected release before performing mutations.
577 """
578 stmt = select(MusehubReleaseAsset).where(
579 MusehubReleaseAsset.asset_id == asset_id
580 )
581 return (await session.execute(stmt)).scalar_one_or_none()
582
583 async def remove_asset(
584 session: AsyncSession,
585 asset_id: str,
586 ) -> bool:
587 """Delete a release asset by its ID.
588
589 The caller is responsible for committing the session after this call.
590
591 Args:
592 session: Active async DB session.
593 asset_id: ID of the asset to remove.
594
595 Returns:
596 ``True`` if the asset was found and deleted; ``False`` if not found.
597 """
598 row = await get_asset(session, asset_id)
599 if row is None:
600 return False
601 await session.delete(row)
602 logger.info("✅ Removed asset %s", asset_id)
603 return True
604
605 async def get_download_stats(
606 session: AsyncSession,
607 release_id: str,
608 tag: str,
609 ) -> ReleaseDownloadStatsResponse:
610 """Return per-asset download counts for a release.
611
612 Args:
613 session: Active async DB session.
614 release_id: ID of the release.
615 tag: Version tag — echoed back in the response for convenience.
616
617 Returns:
618 ``ReleaseDownloadStatsResponse`` with per-asset counts and total.
619 """
620 stmt = (
621 select(MusehubReleaseAsset)
622 .where(MusehubReleaseAsset.release_id == release_id)
623 .order_by(MusehubReleaseAsset.created_at.asc())
624 )
625 rows = (await session.execute(stmt)).scalars().all()
626 asset_counts = [
627 ReleaseAssetDownloadCount(
628 asset_id=row.asset_id,
629 name=row.name,
630 label=row.label,
631 download_count=row.download_count,
632 )
633 for row in rows
634 ]
635 total = sum(a.download_count for a in asset_counts)
636 return ReleaseDownloadStatsResponse(
637 release_id=release_id,
638 tag=tag,
639 assets=asset_counts,
640 total_downloads=total,
641 )
642
643 async def list_release_assets(
644 session: AsyncSession,
645 release_id: str,
646 tag: str,
647 cursor: str | None = None,
648 limit: int = 200,
649 ) -> ReleaseAssetListResponse:
650 """Return assets attached to a release with cursor-based keyset pagination.
651
652 Called by the release detail page to populate the Assets panel.
653 Each asset exposes its file size, download count, and direct download URL
654 so the UI can render the panel without additional API calls.
655
656 Args:
657 session: Active async DB session.
658 release_id: ID of the owning release.
659 tag: Version tag — echoed back in the response for convenience.
660 cursor: ISO 8601 ``created_at`` of the last seen asset (opaque to
661 callers — pass ``nextCursor`` verbatim). Omit to start
662 from the oldest asset.
663 limit: Maximum assets to return per page (default 200).
664
665 Returns:
666 ``ReleaseAssetListResponse`` with assets ordered oldest-first.
667 """
668 conditions = [MusehubReleaseAsset.release_id == release_id]
669
670 count_stmt = select(func.count(MusehubReleaseAsset.asset_id)).where(*conditions)
671 total: int = (await session.execute(count_stmt)).scalar_one()
672
673 data_conditions = list(conditions)
674 if cursor is not None:
675 data_conditions.append(
676 MusehubReleaseAsset.created_at > datetime.fromisoformat(cursor)
677 )
678
679 rows = list(
680 (
681 await session.execute(
682 select(MusehubReleaseAsset)
683 .where(*data_conditions)
684 .order_by(MusehubReleaseAsset.created_at.asc())
685 .limit(limit + 1)
686 )
687 ).scalars()
688 )
689
690 next_cursor: str | None = None
691 if len(rows) == limit + 1:
692 next_cursor = rows[limit - 1].created_at.isoformat()
693 rows = rows[:limit]
694
695 return ReleaseAssetListResponse(
696 release_id=release_id,
697 tag=tag,
698 assets=[_to_asset_response(r) for r in rows],
699 total=total,
700 next_cursor=next_cursor,
701 )
702
703 async def increment_asset_download_count(
704 session: AsyncSession,
705 asset_id: str,
706 ) -> bool:
707 """Atomically increment the download counter for a release asset.
708
709 Called by the UI download-tracking endpoint each time a user clicks a
710 Download button on the release detail page. Uses an UPDATE statement so
711 the counter increment is atomic and does not require a SELECT+UPDATE pair.
712
713 Args:
714 session: Active async DB session.
715 asset_id: ID of the asset to increment.
716
717 Returns:
718 ``True`` if the asset was found and updated; ``False`` otherwise.
719 """
720 from sqlalchemy import update as sa_update
721
722 stmt = (
723 sa_update(MusehubReleaseAsset)
724 .where(MusehubReleaseAsset.asset_id == asset_id)
725 .values(download_count=MusehubReleaseAsset.download_count + 1)
726 .returning(MusehubReleaseAsset.asset_id)
727 )
728 updated_id: str | None = (await session.execute(stmt)).scalar_one_or_none()
729 return updated_id is not None
File History 11 commits
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago