gabriel / musehub public
musehub_snapshot.py python
731 lines 25.0 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 144 days ago
1 """Snapshot service — manifest reads, writes, and paginated queries.
2
3 Storage model
4 -------------
5 Every snapshot stores its ``{path: object_id}`` manifest as a single msgpack
6 BYTEA column (``manifest_blob``). A companion ``entry_count`` integer column
7 holds ``len(manifest)`` so that list and summary reads return accurate file
8 counts in O(1) without blob decoding.
9
10 The read hierarchy is therefore:
11
12 - **Count only** (list / summary): read ``entry_count`` directly — no blob
13 decode, no secondary query.
14 - **Manifest needed** (full detail, diff, tree, entries page): decode
15 ``manifest_blob`` in Python once and work with the resulting dict.
16
17 Public API
18 ----------
19 - ``upsert_snapshot_entries`` — single-snapshot write (merge, sync)
20 - ``bulk_upsert_snapshot_entries`` — O(1) bulk write for push bundles
21 - ``get_snapshot_manifest`` — ``{path: object_id}`` for one snapshot
22 - ``get_snapshot_manifests_batch`` — bulk manifest fetch for N snapshot IDs
23 - ``get_snapshot`` — full ``SnapshotResponse`` (manifest decoded)
24 - ``get_snapshot_summary`` — lightweight ``SnapshotSummaryResponse``
25 - ``list_snapshots`` — cursor-paginated summary list
26 - ``count_snapshot_entries`` — O(1) entry count via ``entry_count`` column
27 - ``get_snapshot_entries_page`` — cursor-paginated entries from decoded blob
28 - ``get_snapshot_for_commit`` — resolve commit → snapshot in one round-trip
29 - ``diff_snapshots`` — ``SnapshotDiffResponse`` between two snapshots
30 - ``batch_get_snapshots`` — resolve up to 100 snapshot IDs in one query
31 """
32 from __future__ import annotations
33
34 import logging
35 from datetime import datetime, timezone
36
37 import msgpack
38 from sqlalchemy import select
39 from sqlalchemy.dialects.postgresql import insert as pg_insert
40 from sqlalchemy.ext.asyncio import AsyncSession
41
42 from musehub.db import musehub_models as db
43 from musehub.types.json_types import StrDict
44
45 type _SizeMap = dict[str, int]
46 type _SnapMap = dict[str, db.MusehubSnapshot]
47 from musehub.models.musehub import (
48 SnapshotDiffEntry,
49 SnapshotDiffResponse,
50 SnapshotEntryListResponse,
51 SnapshotEntryResponse,
52 SnapshotListResponse,
53 SnapshotResponse,
54 SnapshotSummaryResponse,
55 )
56
57 type ManifestDict = dict[str, str]
58 type BatchManifestDict = dict[str, ManifestDict]
59
60 logger = logging.getLogger(__name__)
61
62 # Maximum snapshot_id length accepted at write time. SHA-256 hex = 64 chars;
63 # allow longer IDs for future algorithm agility but cap to prevent abuse.
64 _MAX_SNAPSHOT_ID_LEN = 128
65
66 # Hard cap on batch lookups — prevents a single request from triggering
67 # O(n) snapshot header queries.
68 _MAX_BATCH_SIZE = 100
69
70 # msgpack encoding of {} — used as the canonical empty manifest.
71 _EMPTY_BLOB: bytes = msgpack.packb({}, use_bin_type=True)
72
73
74 # ---------------------------------------------------------------------------
75 # Low-level helpers
76 # ---------------------------------------------------------------------------
77
78
79 def _utc_now() -> datetime:
80 return datetime.now(timezone.utc)
81
82
83 def _decode_manifest_blob(blob: bytes) -> ManifestDict:
84 """Decode a msgpack manifest blob into a ``{path: object_id}`` dict.
85
86 Returns an empty dict for a zero-length or malformed blob so that callers
87 never have to handle ``None``.
88 """
89 if not blob:
90 return {}
91 raw = msgpack.unpackb(blob, raw=False)
92 if not isinstance(raw, dict):
93 return {}
94 return {str(k): str(v) for k, v in raw.items()}
95
96
97 def _entry_response(path: str, object_id: str) -> SnapshotEntryResponse:
98 """Build a ``SnapshotEntryResponse`` from a manifest path/object_id pair.
99
100 ``size_bytes`` is 0 because the manifest blob stores only
101 ``{path: object_id}`` — per-file sizes are not persisted. The model
102 documents this: *"0 when sizes were not recorded"*.
103 """
104 return SnapshotEntryResponse(path=path, object_id=object_id, size_bytes=0)
105
106
107 def _to_summary_response(snap: db.MusehubSnapshot) -> SnapshotSummaryResponse:
108 """Assemble a ``SnapshotSummaryResponse`` from an ORM header row.
109
110 Reads ``entry_count`` directly from the column — no blob decode required.
111 ``total_size_bytes`` is 0 because size metadata is not stored in the blob.
112 """
113 return SnapshotSummaryResponse(
114 snapshot_id=snap.snapshot_id,
115 repo_id=snap.repo_id,
116 entry_count=snap.entry_count,
117 total_size_bytes=0,
118 directories=snap.directories or [],
119 created_at=snap.created_at,
120 )
121
122
123 def _to_full_response(snap: db.MusehubSnapshot, manifest: ManifestDict) -> SnapshotResponse:
124 """Assemble a full ``SnapshotResponse`` from a header row and decoded manifest.
125
126 Entries are sorted alphabetically by path — the canonical order for all
127 snapshot reads. ``total_size_bytes`` is 0 because per-file sizes are not
128 stored in the manifest blob.
129 """
130 sorted_paths = sorted(manifest)
131 entries = [_entry_response(p, manifest[p]) for p in sorted_paths]
132 return SnapshotResponse(
133 snapshot_id=snap.snapshot_id,
134 repo_id=snap.repo_id,
135 directories=snap.directories or [],
136 entries=entries,
137 entry_count=snap.entry_count,
138 total_size_bytes=0,
139 created_at=snap.created_at,
140 )
141
142
143 # ---------------------------------------------------------------------------
144 # Write path
145 # ---------------------------------------------------------------------------
146
147
148 async def upsert_snapshot_entries(
149 session: AsyncSession,
150 repo_id: str,
151 snapshot_id: str,
152 manifest: StrDict,
153 directories: list[str] | None = None,
154 size_map: _SizeMap | None = None,
155 ) -> None:
156 """Write (or no-op if already present) the manifest for *snapshot_id*.
157
158 Stores ``manifest`` as a single msgpack BYTEA blob in
159 ``musehub_snapshots.manifest_blob`` and records ``len(manifest)`` in
160 ``entry_count``. This is O(1) writes per snapshot regardless of file
161 count.
162
163 Snapshots are content-addressed: if a row with *snapshot_id* already
164 exists the call is a no-op — same ID means identical content.
165
166 Args:
167 session: Async DB session (caller owns the transaction).
168 repo_id: UUID of the repo this snapshot belongs to.
169 snapshot_id: Content-addressed snapshot ID.
170 manifest: ``{path: object_id}`` mapping for all tracked files.
171 directories: Sorted workspace-relative directory paths. Must
172 round-trip faithfully so that client verification passes
173 on fetch/clone.
174 size_map: Ignored — not persisted. Retained so call-sites that
175 pass size information do not need to be updated.
176 """
177 if len(snapshot_id) > _MAX_SNAPSHOT_ID_LEN:
178 raise ValueError(
179 f"snapshot_id too long: {len(snapshot_id)} > {_MAX_SNAPSHOT_ID_LEN}"
180 )
181
182 existing = await session.get(db.MusehubSnapshot, snapshot_id)
183 if existing is not None:
184 return # content-addressed: same ID = same content, nothing to do
185
186 dirs = sorted(directories) if directories else []
187 blob = msgpack.packb(manifest, use_bin_type=True)
188 session.add(
189 db.MusehubSnapshot(
190 snapshot_id=snapshot_id,
191 repo_id=repo_id,
192 directories=dirs,
193 manifest_blob=blob,
194 entry_count=len(manifest),
195 created_at=_utc_now(),
196 )
197 )
198 await session.flush()
199
200
201 async def bulk_upsert_snapshot_entries(
202 session: AsyncSession,
203 repo_id: str,
204 snapshots: list[tuple[str, ManifestDict, list[str]]],
205 ) -> None:
206 """Upsert all snapshot rows for a push bundle in one round-trip.
207
208 Uses a single ``INSERT … ON CONFLICT DO NOTHING`` so that N snapshots
209 cost exactly one DB round-trip. Snapshots are content-addressed: a
210 conflict means the row already exists with identical content, so doing
211 nothing is always correct.
212
213 Args:
214 session: Async DB session (caller owns the transaction).
215 repo_id: UUID of the repo these snapshots belong to.
216 snapshots: List of ``(snapshot_id, manifest, directories)`` tuples.
217 """
218 if not snapshots:
219 return
220
221 now = _utc_now()
222 rows = [
223 {
224 "snapshot_id": snapshot_id,
225 "repo_id": repo_id,
226 "directories": sorted(directories) if directories else [],
227 "manifest_blob": msgpack.packb(manifest, use_bin_type=True),
228 "entry_count": len(manifest),
229 "created_at": now,
230 }
231 for snapshot_id, manifest, directories in snapshots
232 ]
233 stmt = pg_insert(db.MusehubSnapshot).values(rows)
234 await session.execute(stmt.on_conflict_do_nothing(index_elements=["snapshot_id"]))
235
236
237 # ---------------------------------------------------------------------------
238 # Read path — manifest helpers
239 # ---------------------------------------------------------------------------
240
241
242 async def get_snapshot_manifest(
243 session: AsyncSession,
244 snapshot_id: str,
245 ) -> StrDict:
246 """Return ``{path: object_id}`` for *snapshot_id*, or ``{}`` if unknown.
247
248 Decodes ``manifest_blob`` from the snapshot header row. This is the fast
249 path used by pull, tree view, and blame — callers that only need the path →
250 object_id mapping without size metadata or full ``SnapshotResponse`` shape.
251
252 Args:
253 session: Async DB session.
254 snapshot_id: Snapshot to resolve.
255
256 Returns:
257 Path-to-object-ID mapping, or an empty dict when *snapshot_id* is not
258 found.
259 """
260 snap = await session.get(db.MusehubSnapshot, snapshot_id)
261 if snap is None:
262 return {}
263 return _decode_manifest_blob(snap.manifest_blob)
264
265
266 async def get_snapshot_manifests_batch(
267 session: AsyncSession,
268 snapshot_ids: list[str],
269 ) -> BatchManifestDict:
270 """Return ``{snapshot_id: {path: object_id}}`` for all *snapshot_ids*.
271
272 Issues a single SELECT for all requested header rows, then decodes each
273 ``manifest_blob`` in Python. Unknown IDs appear as empty dicts — callers
274 must not assume presence.
275
276 Args:
277 session: Async DB session.
278 snapshot_ids: Up to ``_MAX_BATCH_SIZE`` snapshot IDs to resolve.
279
280 Returns:
281 Dict keyed by every requested snapshot_id.
282
283 Raises:
284 ValueError: When ``len(snapshot_ids) > _MAX_BATCH_SIZE``.
285 """
286 if not snapshot_ids:
287 return {}
288 if len(snapshot_ids) > _MAX_BATCH_SIZE:
289 raise ValueError(
290 f"batch size {len(snapshot_ids)} exceeds limit {_MAX_BATCH_SIZE}"
291 )
292
293 result: BatchManifestDict = {sid: {} for sid in snapshot_ids}
294 snap_rows = list(
295 (
296 await session.execute(
297 select(db.MusehubSnapshot).where(
298 db.MusehubSnapshot.snapshot_id.in_(snapshot_ids)
299 )
300 )
301 ).scalars()
302 )
303 for snap in snap_rows:
304 result[snap.snapshot_id] = _decode_manifest_blob(snap.manifest_blob)
305 return result
306
307
308 # ---------------------------------------------------------------------------
309 # Read API — single snapshot
310 # ---------------------------------------------------------------------------
311
312
313 async def get_snapshot(
314 session: AsyncSession,
315 repo_id: str,
316 snapshot_id: str,
317 ) -> SnapshotResponse | None:
318 """Return the full snapshot record including all file-tree entries.
319
320 Decodes ``manifest_blob`` and sorts entries alphabetically by path. For
321 snapshots with thousands of files, callers that only need a paginated slice
322 should use ``get_snapshot_entries_page`` instead.
323
324 Returns ``None`` when the snapshot does not exist or belongs to a different
325 repo. Repo-mismatch is treated as not-found to prevent cross-repo ID
326 enumeration.
327
328 Args:
329 session: Async DB session.
330 repo_id: Must match the snapshot's ``repo_id``.
331 snapshot_id: Snapshot to fetch.
332
333 Returns:
334 Full ``SnapshotResponse``, or ``None``.
335 """
336 snap = await session.get(db.MusehubSnapshot, snapshot_id)
337 if snap is None or snap.repo_id != repo_id:
338 return None
339 return _to_full_response(snap, _decode_manifest_blob(snap.manifest_blob))
340
341
342 async def get_snapshot_summary(
343 session: AsyncSession,
344 repo_id: str,
345 snapshot_id: str,
346 ) -> SnapshotSummaryResponse | None:
347 """Return a lightweight snapshot summary without decoding the manifest.
348
349 Reads ``entry_count`` directly from the header column — O(1), no blob
350 decoding, no secondary query.
351
352 Returns ``None`` when the snapshot does not exist or belongs to a different
353 repo.
354
355 Args:
356 session: Async DB session.
357 repo_id: Must match the snapshot's ``repo_id``.
358 snapshot_id: Snapshot to fetch.
359
360 Returns:
361 Lightweight ``SnapshotSummaryResponse``, or ``None``.
362 """
363 snap = await session.get(db.MusehubSnapshot, snapshot_id)
364 if snap is None or snap.repo_id != repo_id:
365 return None
366 return _to_summary_response(snap)
367
368
369 # ---------------------------------------------------------------------------
370 # Read API — list and paginate
371 # ---------------------------------------------------------------------------
372
373
374 async def list_snapshots(
375 session: AsyncSession,
376 repo_id: str,
377 cursor: str | None = None,
378 limit: int = 20,
379 ) -> SnapshotListResponse:
380 """Return snapshot summaries with cursor-based keyset pagination (newest first).
381
382 Each summary is assembled from the header row alone — ``entry_count`` is
383 read directly from the column so no blob decode or secondary query is
384 needed. This makes the function O(page_size) in both time and data
385 transferred regardless of manifest size.
386
387 Args:
388 session: Async DB session.
389 repo_id: Repo whose snapshots to list.
390 cursor: Opaque ISO 8601 ``created_at`` from a previous response's
391 ``nextCursor``. Omit to start from the most recent snapshot.
392 limit: Page size (default 20, capped by the route at 200).
393
394 Returns:
395 ``SnapshotListResponse`` with summaries newest-first, ``total``, and
396 ``nextCursor`` when more pages exist.
397 """
398 from sqlalchemy import func
399
400 total: int = (
401 await session.execute(
402 select(func.count(db.MusehubSnapshot.snapshot_id)).where(
403 db.MusehubSnapshot.repo_id == repo_id
404 )
405 )
406 ).scalar_one()
407
408 if total == 0:
409 return SnapshotListResponse(snapshots=[], total=0, next_cursor=None)
410
411 conditions = [db.MusehubSnapshot.repo_id == repo_id]
412 if cursor is not None:
413 conditions.append(
414 db.MusehubSnapshot.created_at < datetime.fromisoformat(cursor)
415 )
416
417 snaps = list(
418 (
419 await session.execute(
420 select(db.MusehubSnapshot)
421 .where(*conditions)
422 .order_by(db.MusehubSnapshot.created_at.desc())
423 .limit(limit + 1)
424 )
425 ).scalars()
426 )
427
428 next_cursor: str | None = None
429 if len(snaps) == limit + 1:
430 next_cursor = snaps[limit - 1].created_at.isoformat()
431 snaps = snaps[:limit]
432
433 return SnapshotListResponse(
434 snapshots=[_to_summary_response(s) for s in snaps],
435 total=total,
436 next_cursor=next_cursor,
437 )
438
439
440 async def count_snapshot_entries(
441 session: AsyncSession,
442 snapshot_id: str,
443 ) -> int:
444 """Return the number of tracked files for *snapshot_id* in O(1).
445
446 Reads ``entry_count`` directly from the header column — no blob decode,
447 no secondary query. Returns 0 when *snapshot_id* is unknown.
448
449 Args:
450 session: Async DB session.
451 snapshot_id: Snapshot to count.
452
453 Returns:
454 Number of tracked files, or 0 if not found.
455 """
456 snap = await session.get(db.MusehubSnapshot, snapshot_id)
457 return snap.entry_count if snap is not None else 0
458
459
460 async def get_snapshot_entries_page(
461 session: AsyncSession,
462 repo_id: str,
463 snapshot_id: str,
464 cursor: str | None = None,
465 limit: int = 100,
466 ) -> SnapshotEntryListResponse | None:
467 """Return a cursor-paginated slice of file-tree entries sorted by path.
468
469 Decodes ``manifest_blob`` once, sorts paths alphabetically, then slices
470 the in-memory list using the cursor as a lower bound. ``total`` is read
471 from ``entry_count`` (O(1)) rather than ``len(manifest)`` so that a
472 second blob decode is avoided.
473
474 Verifies repo ownership before returning data — returns ``None`` when the
475 snapshot is unknown or belongs to a different repo.
476
477 Args:
478 session: Async DB session.
479 repo_id: Must match the snapshot's ``repo_id``.
480 snapshot_id: Snapshot whose entries to paginate.
481 cursor: Opaque path from a previous response's ``nextCursor``.
482 Omit to start from the first path alphabetically.
483 limit: Max entries per page (default 100).
484
485 Returns:
486 ``SnapshotEntryListResponse`` with the page slice and ``nextCursor``,
487 or ``None`` if the snapshot does not exist for this repo.
488 """
489 snap = await session.get(db.MusehubSnapshot, snapshot_id)
490 if snap is None or snap.repo_id != repo_id:
491 return None
492
493 manifest = _decode_manifest_blob(snap.manifest_blob)
494 # Sort once; all pagination is done in Python on this sorted list.
495 sorted_paths = sorted(manifest)
496
497 if cursor is not None:
498 sorted_paths = [p for p in sorted_paths if p > cursor]
499
500 next_cursor: str | None = None
501 if len(sorted_paths) > limit:
502 next_cursor = sorted_paths[limit - 1]
503 sorted_paths = sorted_paths[:limit]
504
505 entries = [_entry_response(p, manifest[p]) for p in sorted_paths]
506 return SnapshotEntryListResponse(
507 snapshot_id=snapshot_id,
508 entries=entries,
509 total=snap.entry_count,
510 next_cursor=next_cursor,
511 )
512
513
514 # ---------------------------------------------------------------------------
515 # Read API — commit → snapshot shortcut
516 # ---------------------------------------------------------------------------
517
518
519 async def get_snapshot_for_commit(
520 session: AsyncSession,
521 repo_id: str,
522 commit_id: str,
523 ) -> SnapshotResponse | None:
524 """Resolve a commit ID to its snapshot in one round-trip.
525
526 Looks up the commit, reads its ``snapshot_id``, then returns the full
527 ``SnapshotResponse``. Returns ``None`` when the commit is not found,
528 belongs to a different repo, or has no snapshot attached.
529
530 Args:
531 session: Async DB session.
532 repo_id: Repo the commit must belong to.
533 commit_id: Commit whose snapshot to fetch.
534
535 Returns:
536 Full ``SnapshotResponse``, or ``None``.
537 """
538 commit = (
539 await session.execute(
540 select(db.MusehubCommit).where(
541 db.MusehubCommit.commit_id == commit_id,
542 db.MusehubCommit.repo_id == repo_id,
543 )
544 )
545 ).scalar_one_or_none()
546 if commit is None or commit.snapshot_id is None:
547 return None
548 return await get_snapshot(session, repo_id, commit.snapshot_id)
549
550
551 # ---------------------------------------------------------------------------
552 # Read API — diff
553 # ---------------------------------------------------------------------------
554
555
556 async def diff_snapshots(
557 session: AsyncSession,
558 repo_id: str,
559 snapshot_id: str,
560 base_snapshot_id: str,
561 include_unchanged: bool = False,
562 ) -> SnapshotDiffResponse | None:
563 """Compute a file-level diff between two snapshots.
564
565 Decodes both manifests and diffs the resulting ``{path: object_id}``
566 mappings. Files are classified as added, removed, modified (different
567 object_id), or unchanged. ``size_bytes`` fields are 0 because per-file
568 sizes are not stored in the manifest.
569
570 Both snapshots must belong to *repo_id* — mismatched ownership returns
571 ``None`` rather than leaking cross-repo IDs.
572
573 Args:
574 session: Async DB session.
575 repo_id: Repo both snapshots must belong to.
576 snapshot_id: The "new" snapshot.
577 base_snapshot_id: The "base" snapshot to compare against.
578 include_unchanged: When ``True``, emit ``status="unchanged"`` entries
579 for files identical in both snapshots. Off by
580 default because unchanged files dominate large repos.
581
582 Returns:
583 ``SnapshotDiffResponse``, or ``None`` if either snapshot is missing
584 or belongs to a different repo.
585 """
586 snap_new = await session.get(db.MusehubSnapshot, snapshot_id)
587 snap_base = await session.get(db.MusehubSnapshot, base_snapshot_id)
588 if snap_new is None or snap_new.repo_id != repo_id:
589 return None
590 if snap_base is None or snap_base.repo_id != repo_id:
591 return None
592
593 new_map = _decode_manifest_blob(snap_new.manifest_blob)
594 base_map = _decode_manifest_blob(snap_base.manifest_blob)
595
596 all_paths = sorted(set(new_map) | set(base_map))
597 changes: list[SnapshotDiffEntry] = []
598 added = removed = modified = unchanged = 0
599
600 for path in all_paths:
601 in_new = path in new_map
602 in_base = path in base_map
603
604 if in_new and not in_base:
605 added += 1
606 changes.append(
607 SnapshotDiffEntry(
608 path=path,
609 status="added",
610 base_object_id=None,
611 new_object_id=new_map[path],
612 base_size_bytes=0,
613 new_size_bytes=0,
614 )
615 )
616 elif in_base and not in_new:
617 removed += 1
618 changes.append(
619 SnapshotDiffEntry(
620 path=path,
621 status="removed",
622 base_object_id=base_map[path],
623 new_object_id=None,
624 base_size_bytes=0,
625 new_size_bytes=0,
626 )
627 )
628 elif new_map[path] != base_map[path]:
629 modified += 1
630 changes.append(
631 SnapshotDiffEntry(
632 path=path,
633 status="modified",
634 base_object_id=base_map[path],
635 new_object_id=new_map[path],
636 base_size_bytes=0,
637 new_size_bytes=0,
638 )
639 )
640 else:
641 unchanged += 1
642 if include_unchanged:
643 changes.append(
644 SnapshotDiffEntry(
645 path=path,
646 status="unchanged",
647 base_object_id=base_map[path],
648 new_object_id=new_map[path],
649 base_size_bytes=0,
650 new_size_bytes=0,
651 )
652 )
653
654 return SnapshotDiffResponse(
655 snapshot_id=snapshot_id,
656 base_snapshot_id=base_snapshot_id,
657 added_count=added,
658 removed_count=removed,
659 modified_count=modified,
660 unchanged_count=unchanged,
661 bytes_added=0,
662 bytes_removed=0,
663 changes=changes,
664 )
665
666
667 # ---------------------------------------------------------------------------
668 # Read API — batch
669 # ---------------------------------------------------------------------------
670
671
672 async def batch_get_snapshots(
673 session: AsyncSession,
674 repo_id: str,
675 snapshot_ids: list[str],
676 include_entries: bool = False,
677 ) -> list[SnapshotResponse | SnapshotSummaryResponse]:
678 """Resolve up to ``_MAX_BATCH_SIZE`` snapshot IDs in one round-trip.
679
680 Fetches all matching header rows in a single SELECT, then either decodes
681 ``manifest_blob`` for full ``SnapshotResponse`` objects or reads
682 ``entry_count`` for lightweight ``SnapshotSummaryResponse`` objects.
683
684 Unknown IDs and IDs belonging to a different repo are silently omitted.
685 Callers that need completeness guarantees should compare the returned list
686 length against their request.
687
688 Args:
689 session: Async DB session.
690 repo_id: All returned snapshots must belong to this repo.
691 snapshot_ids: Up to ``_MAX_BATCH_SIZE`` IDs to resolve.
692 include_entries: When ``True``, return full ``SnapshotResponse``
693 objects (blob decoded); otherwise return lightweight
694 ``SnapshotSummaryResponse`` objects.
695
696 Returns:
697 Responses in the same order as *snapshot_ids*, omitting unknown or
698 foreign IDs.
699
700 Raises:
701 ValueError: When ``len(snapshot_ids) > _MAX_BATCH_SIZE``.
702 """
703 if len(snapshot_ids) > _MAX_BATCH_SIZE:
704 raise ValueError(
705 f"batch size {len(snapshot_ids)} exceeds limit {_MAX_BATCH_SIZE}"
706 )
707 if not snapshot_ids:
708 return []
709
710 snaps_by_id: _SnapMap = {
711 s.snapshot_id: s
712 for s in (
713 await session.execute(
714 select(db.MusehubSnapshot).where(
715 db.MusehubSnapshot.snapshot_id.in_(snapshot_ids),
716 db.MusehubSnapshot.repo_id == repo_id,
717 )
718 )
719 ).scalars()
720 }
721
722 results: list[SnapshotResponse | SnapshotSummaryResponse] = []
723 for sid in snapshot_ids:
724 snap = snaps_by_id.get(sid)
725 if snap is None:
726 continue
727 if include_entries:
728 results.append(_to_full_response(snap, _decode_manifest_blob(snap.manifest_blob)))
729 else:
730 results.append(_to_summary_response(snap))
731 return results
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ 144 days ago