gabriel / musehub public
musehub_snapshot.py python
760 lines 26.5 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Snapshot utility functions — normalized file-tree reads and writes.
2
3 All snapshot data is stored in two tables:
4 - ``musehub_snapshots``: header row (snapshot_id, repo_id, directories, created_at)
5 - ``musehub_snapshot_entries``: one row per file (snapshot_id, path, object_id, size_bytes)
6
7 Public API
8 ----------
9 - ``get_snapshot_manifest`` — {path: object_id} for one snapshot
10 - ``get_snapshot_manifests_batch`` — bulk manifest fetch for N snapshot IDs
11 - ``upsert_snapshot_entries`` — single-snapshot write path (merge, sync)
12 - ``bulk_upsert_snapshot_entries`` — O(1) round-trips for push bundles
13 - ``get_snapshot`` — full SnapshotResponse (entries included)
14 - ``get_snapshot_summary`` — lightweight SnapshotSummaryResponse (no entries)
15 - ``list_snapshots`` — paginated SnapshotSummaryResponse list
16 - ``count_snapshot_entries`` — entry count for one snapshot
17 - ``get_snapshot_entries_page`` — paginated SnapshotEntryResponse list
18 - ``get_snapshot_for_commit`` — resolve commit → snapshot in one query
19 - ``diff_snapshots`` — SnapshotDiffResponse between two snapshots
20 - ``batch_get_snapshots`` — resolve up to 100 snapshot IDs in one round-trip
21 """
22 from __future__ import annotations
23
24 import logging
25 from datetime import datetime, timezone
26
27 from sqlalchemy import func, select, delete
28 from sqlalchemy.ext.asyncio import AsyncSession
29 from sqlalchemy.orm import selectinload
30
31 from musehub.db import musehub_models as db
32 from musehub.muse_contracts.json_types import IntDict, StrDict
33 from musehub.models.musehub import (
34 SnapshotDiffEntry,
35 SnapshotDiffResponse,
36 SnapshotEntryListResponse,
37 SnapshotEntryResponse,
38 SnapshotListResponse,
39 SnapshotResponse,
40 SnapshotSummaryResponse,
41 )
42
43 type ManifestDict = dict[str, str]
44 type BatchManifestDict = dict[str, dict[str, str]]
45 type AggByIdDict = dict[str, tuple[int, int]]
46
47 logger = logging.getLogger(__name__)
48
49 # Maximum snapshot_id length accepted at write time. SHA-256 hex = 64 chars;
50 # allow longer IDs for future algorithm agility but cap to prevent abuse.
51 _MAX_SNAPSHOT_ID_LEN = 128
52
53 # Hard cap on batch lookups — prevents a single request from triggering
54 # O(n) snapshot header queries.
55 _MAX_BATCH_SIZE = 100
56
57
58 # ---------------------------------------------------------------------------
59 # Low-level helpers — used by both the wire push and the read API
60 # ---------------------------------------------------------------------------
61
62
63 def _utc_now() -> datetime:
64 return datetime.now(timezone.utc)
65
66
67 def _to_entry_response(row: db.MusehubSnapshotEntry) -> SnapshotEntryResponse:
68 """Convert an ORM entry row to its wire representation."""
69 return SnapshotEntryResponse(
70 path=row.path,
71 object_id=row.object_id,
72 size_bytes=row.size_bytes,
73 )
74
75
76 def _to_summary_response(
77 snap: db.MusehubSnapshot,
78 entry_count: int,
79 total_size_bytes: int,
80 ) -> SnapshotSummaryResponse:
81 """Assemble a summary response from a header row and pre-computed aggregates."""
82 return SnapshotSummaryResponse(
83 snapshot_id=snap.snapshot_id,
84 repo_id=snap.repo_id,
85 entry_count=entry_count,
86 total_size_bytes=total_size_bytes,
87 directories=snap.directories or [],
88 created_at=snap.created_at,
89 )
90
91
92 def _to_full_response(
93 snap: db.MusehubSnapshot,
94 entries: list[db.MusehubSnapshotEntry],
95 ) -> SnapshotResponse:
96 """Assemble a full response from a header row and its loaded entry rows."""
97 sorted_entries = sorted(entries, key=lambda e: e.path)
98 entry_responses = [_to_entry_response(e) for e in sorted_entries]
99 total_size = sum(e.size_bytes for e in sorted_entries)
100 return SnapshotResponse(
101 snapshot_id=snap.snapshot_id,
102 repo_id=snap.repo_id,
103 directories=snap.directories or [],
104 entries=entry_responses,
105 entry_count=len(entry_responses),
106 total_size_bytes=total_size,
107 created_at=snap.created_at,
108 )
109
110
111 # ---------------------------------------------------------------------------
112 # Write path — used by wire push
113 # ---------------------------------------------------------------------------
114
115
116 async def upsert_snapshot_entries(
117 session: AsyncSession,
118 repo_id: str,
119 snapshot_id: str,
120 manifest: StrDict,
121 directories: list[str] | None = None,
122 size_map: IntDict | None = None,
123 ) -> None:
124 """Write (or overwrite) the file-tree entries for *snapshot_id*.
125
126 Ensures the parent ``musehub_snapshots`` row exists, then deletes any
127 stale entries and inserts the current manifest. Idempotent — safe to
128 call on every push regardless of whether the snapshot already exists.
129
130 Args:
131 session: Async DB session (caller owns the transaction).
132 repo_id: UUID of the repo this snapshot belongs to.
133 snapshot_id: Content-addressed snapshot ID.
134 manifest: ``{path: object_id}`` mapping for all tracked files.
135 directories: Sorted workspace-relative directory paths included in the
136 snapshot_id hash. Must round-trip faithfully so client
137 verification passes on fetch/clone.
138 size_map: Optional ``{path: size_bytes}`` — when supplied, ``size_bytes``
139 is stored per entry for the file browser.
140 """
141 if len(snapshot_id) > _MAX_SNAPSHOT_ID_LEN:
142 raise ValueError(
143 f"snapshot_id too long: {len(snapshot_id)} > {_MAX_SNAPSHOT_ID_LEN}"
144 )
145
146 dirs = sorted(directories) if directories else []
147 sizes = size_map or {}
148
149 existing = await session.get(db.MusehubSnapshot, snapshot_id)
150 if existing is None:
151 session.add(
152 db.MusehubSnapshot(
153 snapshot_id=snapshot_id,
154 repo_id=repo_id,
155 directories=dirs,
156 created_at=_utc_now(),
157 )
158 )
159 await session.flush()
160 elif dirs and existing.directories != dirs:
161 # Back-fill directories if they were missing on a prior push.
162 existing.directories = dirs
163
164 # Replace all entries atomically — idempotent re-push is safe.
165 await session.execute(
166 delete(db.MusehubSnapshotEntry).where(
167 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
168 )
169 )
170 for path, object_id in manifest.items():
171 session.add(
172 db.MusehubSnapshotEntry(
173 snapshot_id=snapshot_id,
174 path=path,
175 object_id=object_id,
176 size_bytes=sizes.get(path, 0),
177 )
178 )
179
180
181 # ---------------------------------------------------------------------------
182 # Bulk write path — used by wire_push to avoid N+1 round-trips
183 # ---------------------------------------------------------------------------
184
185 async def bulk_upsert_snapshot_entries(
186 session: AsyncSession,
187 repo_id: str,
188 snapshots: list[tuple[str, ManifestDict, list[str]]],
189 ) -> None:
190 """Upsert all snapshot rows and entries for a push bundle in O(1) round-trips.
191
192 Replaces the per-snapshot loop (1 SELECT + 1 FLUSH + 1 DELETE per snapshot)
193 with three bulk operations regardless of bundle size:
194
195 1. 1 SELECT IN — find which snapshot rows already exist.
196 2. 1 DELETE IN — clear stale entries for all snapshots at once.
197 3. Bulk INSERT — queue new snapshot rows + all entries in one flush.
198
199 Args:
200 session: Async DB session (caller owns the transaction).
201 repo_id: UUID of the repo these snapshots belong to.
202 snapshots: List of ``(snapshot_id, manifest, directories)`` tuples —
203 one per snapshot in the push bundle.
204 """
205 if not snapshots:
206 return
207
208 snapshot_ids = [s[0] for s in snapshots]
209
210 existing_rows = (await session.execute(
211 select(db.MusehubSnapshot).where(db.MusehubSnapshot.snapshot_id.in_(snapshot_ids))
212 )).scalars().all()
213 existing_by_id = {r.snapshot_id: r for r in existing_rows}
214
215 for snapshot_id, _manifest, directories in snapshots:
216 dirs = sorted(directories) if directories else []
217 if snapshot_id not in existing_by_id:
218 session.add(db.MusehubSnapshot(
219 snapshot_id=snapshot_id,
220 repo_id=repo_id,
221 directories=dirs,
222 created_at=_utc_now(),
223 ))
224 elif dirs and existing_by_id[snapshot_id].directories != dirs:
225 existing_by_id[snapshot_id].directories = dirs
226
227 # Clear ALL stale entries for all snapshots in one DELETE.
228 await session.execute(
229 delete(db.MusehubSnapshotEntry).where(
230 db.MusehubSnapshotEntry.snapshot_id.in_(snapshot_ids)
231 )
232 )
233
234 for snapshot_id, manifest, _ in snapshots:
235 for path, object_id in manifest.items():
236 session.add(db.MusehubSnapshotEntry(
237 snapshot_id=snapshot_id,
238 path=path,
239 object_id=object_id,
240 size_bytes=0,
241 ))
242
243
244 # ---------------------------------------------------------------------------
245 # Read path — manifest helpers (used by pull, tree view, etc.)
246 # ---------------------------------------------------------------------------
247
248
249 async def get_snapshot_manifest(
250 session: AsyncSession,
251 snapshot_id: str,
252 ) -> StrDict:
253 """Return ``{path: object_id}`` for *snapshot_id*, or ``{}`` if unknown.
254
255 This is the fast path used by pull, tree view, and blame — callers that
256 only need the path→object_id mapping and don't need size metadata.
257 """
258 rows = await session.execute(
259 select(db.MusehubSnapshotEntry).where(
260 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
261 )
262 )
263 return {row.path: row.object_id for row in rows.scalars()}
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 Executes a single query over all requested IDs. Unknown IDs appear as
273 empty dicts in the result — callers must not assume presence.
274
275 Args:
276 session: Async DB session.
277 snapshot_ids: Up to ``_MAX_BATCH_SIZE`` snapshot IDs to resolve.
278
279 Returns:
280 Dict keyed by every requested snapshot_id; value is the manifest dict
281 (empty for unknown IDs).
282 """
283 if not snapshot_ids:
284 return {}
285 if len(snapshot_ids) > _MAX_BATCH_SIZE:
286 raise ValueError(
287 f"batch size {len(snapshot_ids)} exceeds limit {_MAX_BATCH_SIZE}"
288 )
289 rows = await session.execute(
290 select(db.MusehubSnapshotEntry).where(
291 db.MusehubSnapshotEntry.snapshot_id.in_(snapshot_ids)
292 )
293 )
294 result: BatchManifestDict = {sid: {} for sid in snapshot_ids}
295 for row in rows.scalars():
296 result[row.snapshot_id][row.path] = row.object_id
297 return result
298
299
300 # ---------------------------------------------------------------------------
301 # Read API — single snapshot
302 # ---------------------------------------------------------------------------
303
304
305 async def get_snapshot(
306 session: AsyncSession,
307 repo_id: str,
308 snapshot_id: str,
309 ) -> SnapshotResponse | None:
310 """Return the full snapshot record including all file-tree entries.
311
312 Loads the header and all entries in a single joined query. For large
313 snapshots (thousands of files) callers should prefer
314 ``get_snapshot_entries_page`` to avoid loading the full manifest.
315
316 Returns ``None`` when the snapshot does not exist or belongs to a
317 different repo (repo_id mismatch is treated as not-found to avoid
318 leaking IDs across visibility boundaries).
319 """
320 result = await session.execute(
321 select(db.MusehubSnapshot)
322 .where(
323 db.MusehubSnapshot.snapshot_id == snapshot_id,
324 db.MusehubSnapshot.repo_id == repo_id,
325 )
326 .options(selectinload(db.MusehubSnapshot.entries))
327 )
328 snap = result.scalar_one_or_none()
329 if snap is None:
330 return None
331 return _to_full_response(snap, list(snap.entries))
332
333
334 async def get_snapshot_summary(
335 session: AsyncSession,
336 repo_id: str,
337 snapshot_id: str,
338 ) -> SnapshotSummaryResponse | None:
339 """Return a lightweight snapshot summary without loading entries.
340
341 Runs two queries: one to fetch the header row, one COUNT+SUM aggregate
342 over entries. Use when you need metadata (entry_count, total_size_bytes)
343 but not the manifest itself.
344
345 Returns ``None`` when the snapshot does not exist or belongs to a
346 different repo.
347 """
348 snap = await session.get(db.MusehubSnapshot, snapshot_id)
349 if snap is None or snap.repo_id != repo_id:
350 return None
351
352 agg = await session.execute(
353 select(
354 func.count(db.MusehubSnapshotEntry.path).label("entry_count"),
355 func.coalesce(func.sum(db.MusehubSnapshotEntry.size_bytes), 0).label("total_size"),
356 ).where(db.MusehubSnapshotEntry.snapshot_id == snapshot_id)
357 )
358 row = agg.one()
359 return _to_summary_response(snap, int(row.entry_count), int(row.total_size))
360
361
362 # ---------------------------------------------------------------------------
363 # Read API — list and paginate
364 # ---------------------------------------------------------------------------
365
366
367 async def list_snapshots(
368 session: AsyncSession,
369 repo_id: str,
370 limit: int = 20,
371 offset: int = 0,
372 ) -> tuple[list[SnapshotSummaryResponse], int]:
373 """Return a page of snapshot summaries (newest first) and the total count.
374
375 Each summary is assembled without loading entry rows — only the header row
376 plus a COUNT+SUM aggregate, so this is O(page_size) queries, not O(total).
377
378 Args:
379 session: Async DB session.
380 repo_id: Repo whose snapshots to list.
381 limit: Page size (max rows to return).
382 offset: Row offset for page-based pagination.
383
384 Returns:
385 A (items, total) tuple where *items* is the page slice and *total*
386 is the count of all snapshots in the repo.
387 """
388 total_result = await session.execute(
389 select(func.count(db.MusehubSnapshot.snapshot_id)).where(
390 db.MusehubSnapshot.repo_id == repo_id
391 )
392 )
393 total = int(total_result.scalar_one())
394
395 if total == 0:
396 return [], 0
397
398 header_result = await session.execute(
399 select(db.MusehubSnapshot)
400 .where(db.MusehubSnapshot.repo_id == repo_id)
401 .order_by(db.MusehubSnapshot.created_at.desc())
402 .limit(limit)
403 .offset(offset)
404 )
405 snaps = list(header_result.scalars())
406
407 if not snaps:
408 return [], total
409
410 snap_ids = [s.snapshot_id for s in snaps]
411 agg_result = await session.execute(
412 select(
413 db.MusehubSnapshotEntry.snapshot_id,
414 func.count(db.MusehubSnapshotEntry.path).label("entry_count"),
415 func.coalesce(func.sum(db.MusehubSnapshotEntry.size_bytes), 0).label("total_size"),
416 )
417 .where(db.MusehubSnapshotEntry.snapshot_id.in_(snap_ids))
418 .group_by(db.MusehubSnapshotEntry.snapshot_id)
419 )
420 agg_by_id: AggByIdDict = {}
421 for agg_row in agg_result:
422 agg_by_id[agg_row.snapshot_id] = (int(agg_row.entry_count), int(agg_row.total_size))
423
424 summaries = [
425 _to_summary_response(snap, *agg_by_id.get(snap.snapshot_id, (0, 0)))
426 for snap in snaps
427 ]
428 return summaries, total
429
430
431 async def count_snapshot_entries(
432 session: AsyncSession,
433 snapshot_id: str,
434 ) -> int:
435 """Return the number of file-tree entries for *snapshot_id*.
436
437 Used to set the ``X-Snapshot-Entry-Count`` response header without
438 loading the full manifest.
439 """
440 result = await session.execute(
441 select(func.count(db.MusehubSnapshotEntry.path)).where(
442 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
443 )
444 )
445 return int(result.scalar_one())
446
447
448 async def get_snapshot_entries_page(
449 session: AsyncSession,
450 repo_id: str,
451 snapshot_id: str,
452 limit: int = 100,
453 offset: int = 0,
454 ) -> SnapshotEntryListResponse | None:
455 """Return a paginated slice of file-tree entries for one snapshot.
456
457 Verifies repo ownership before returning data — returns ``None`` when
458 the snapshot is unknown or belongs to a different repo.
459
460 Entries are sorted alphabetically by path. Navigate pages via the
461 RFC 8288 ``Link`` header returned by the route handler.
462
463 Args:
464 session: Async DB session.
465 repo_id: Must match the snapshot's repo_id.
466 snapshot_id: Snapshot whose entries to paginate.
467 limit: Max entries to return per page (max 500).
468 offset: Row offset for page-based pagination.
469
470 Returns:
471 ``SnapshotEntryListResponse`` with the page slice and total,
472 or ``None`` if the snapshot does not exist for this repo.
473 """
474 snap = await session.get(db.MusehubSnapshot, snapshot_id)
475 if snap is None or snap.repo_id != repo_id:
476 return None
477
478 total_result = await session.execute(
479 select(func.count(db.MusehubSnapshotEntry.path)).where(
480 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
481 )
482 )
483 total = int(total_result.scalar_one())
484
485 entries_result = await session.execute(
486 select(db.MusehubSnapshotEntry)
487 .where(db.MusehubSnapshotEntry.snapshot_id == snapshot_id)
488 .order_by(db.MusehubSnapshotEntry.path)
489 .limit(limit)
490 .offset(offset)
491 )
492 entries = [_to_entry_response(row) for row in entries_result.scalars()]
493
494 return SnapshotEntryListResponse(
495 snapshot_id=snapshot_id,
496 entries=entries,
497 total=total,
498 )
499
500
501 # ---------------------------------------------------------------------------
502 # Read API — commit → snapshot shortcut
503 # ---------------------------------------------------------------------------
504
505
506 async def get_snapshot_for_commit(
507 session: AsyncSession,
508 repo_id: str,
509 commit_id: str,
510 ) -> SnapshotResponse | None:
511 """Resolve a commit ID to its snapshot in one round-trip.
512
513 Looks up the commit, reads its snapshot_id, then returns the full
514 ``SnapshotResponse``. Returns ``None`` when the commit is not found,
515 belongs to a different repo, or has no snapshot attached.
516
517 Args:
518 session: Async DB session.
519 repo_id: Repo the commit must belong to.
520 commit_id: Commit whose snapshot to fetch.
521
522 Returns:
523 Full ``SnapshotResponse``, or ``None``.
524 """
525 commit_result = await session.execute(
526 select(db.MusehubCommit).where(
527 db.MusehubCommit.commit_id == commit_id,
528 db.MusehubCommit.repo_id == repo_id,
529 )
530 )
531 commit = commit_result.scalar_one_or_none()
532 if commit is None or commit.snapshot_id is None:
533 return None
534 return await get_snapshot(session, repo_id, commit.snapshot_id)
535
536
537 # ---------------------------------------------------------------------------
538 # Read API — diff
539 # ---------------------------------------------------------------------------
540
541
542 async def diff_snapshots(
543 session: AsyncSession,
544 repo_id: str,
545 snapshot_id: str,
546 base_snapshot_id: str,
547 include_unchanged: bool = False,
548 ) -> SnapshotDiffResponse | None:
549 """Compute a file-level diff between two snapshots.
550
551 Returns the full per-file change list sorted by path. Both snapshots must
552 belong to *repo_id* — mismatched ownership returns ``None`` rather than
553 leaking cross-repo IDs.
554
555 Args:
556 session: Async DB session.
557 repo_id: Repo both snapshots must belong to.
558 snapshot_id: The "new" snapshot.
559 base_snapshot_id: The "base" snapshot to compare against.
560 include_unchanged: When ``True``, emit ``status="unchanged"`` entries
561 for files identical in both snapshots. Off by
562 default because unchanged files dominate large repos.
563
564 Returns:
565 ``SnapshotDiffResponse``, or ``None`` if either snapshot is missing
566 or belongs to a different repo.
567 """
568 # Verify both snapshots belong to this repo.
569 snap_new = await session.get(db.MusehubSnapshot, snapshot_id)
570 snap_base = await session.get(db.MusehubSnapshot, base_snapshot_id)
571 if snap_new is None or snap_new.repo_id != repo_id:
572 return None
573 if snap_base is None or snap_base.repo_id != repo_id:
574 return None
575
576 # Fetch both manifests with size metadata in two queries.
577 new_rows_result = await session.execute(
578 select(db.MusehubSnapshotEntry).where(
579 db.MusehubSnapshotEntry.snapshot_id == snapshot_id
580 )
581 )
582 base_rows_result = await session.execute(
583 select(db.MusehubSnapshotEntry).where(
584 db.MusehubSnapshotEntry.snapshot_id == base_snapshot_id
585 )
586 )
587
588 new_map = {
589 r.path: r for r in new_rows_result.scalars()
590 }
591 base_map = {
592 r.path: r for r in base_rows_result.scalars()
593 }
594
595 all_paths = sorted(set(new_map) | set(base_map))
596 changes: list[SnapshotDiffEntry] = []
597 added = removed = modified = unchanged = 0
598 bytes_added = bytes_removed = 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 bytes_added += new_map[path].size_bytes
607 changes.append(
608 SnapshotDiffEntry(
609 path=path,
610 status="added",
611 base_object_id=None,
612 new_object_id=new_map[path].object_id,
613 base_size_bytes=0,
614 new_size_bytes=new_map[path].size_bytes,
615 )
616 )
617 elif in_base and not in_new:
618 removed += 1
619 bytes_removed += base_map[path].size_bytes
620 changes.append(
621 SnapshotDiffEntry(
622 path=path,
623 status="removed",
624 base_object_id=base_map[path].object_id,
625 new_object_id=None,
626 base_size_bytes=base_map[path].size_bytes,
627 new_size_bytes=0,
628 )
629 )
630 else:
631 new_entry = new_map[path]
632 base_entry = base_map[path]
633 if new_entry.object_id != base_entry.object_id:
634 modified += 1
635 bytes_added += new_entry.size_bytes
636 bytes_removed += base_entry.size_bytes
637 changes.append(
638 SnapshotDiffEntry(
639 path=path,
640 status="modified",
641 base_object_id=base_entry.object_id,
642 new_object_id=new_entry.object_id,
643 base_size_bytes=base_entry.size_bytes,
644 new_size_bytes=new_entry.size_bytes,
645 )
646 )
647 else:
648 unchanged += 1
649 if include_unchanged:
650 changes.append(
651 SnapshotDiffEntry(
652 path=path,
653 status="unchanged",
654 base_object_id=base_entry.object_id,
655 new_object_id=new_entry.object_id,
656 base_size_bytes=base_entry.size_bytes,
657 new_size_bytes=new_entry.size_bytes,
658 )
659 )
660
661 return SnapshotDiffResponse(
662 snapshot_id=snapshot_id,
663 base_snapshot_id=base_snapshot_id,
664 added_count=added,
665 removed_count=removed,
666 modified_count=modified,
667 unchanged_count=unchanged,
668 bytes_added=bytes_added,
669 bytes_removed=bytes_removed,
670 changes=changes,
671 )
672
673
674 # ---------------------------------------------------------------------------
675 # Read API — batch
676 # ---------------------------------------------------------------------------
677
678
679 async def batch_get_snapshots(
680 session: AsyncSession,
681 repo_id: str,
682 snapshot_ids: list[str],
683 include_entries: bool = False,
684 ) -> list[SnapshotResponse | SnapshotSummaryResponse]:
685 """Resolve up to ``_MAX_BATCH_SIZE`` snapshot IDs in one round-trip.
686
687 Unknown IDs and IDs belonging to a different repo are silently omitted —
688 callers should check that the returned list length matches their request if
689 completeness matters.
690
691 Args:
692 session: Async DB session.
693 repo_id: All returned snapshots must belong to this repo.
694 snapshot_ids: Up to 100 snapshot IDs to resolve.
695 include_entries: When ``True``, return full ``SnapshotResponse`` objects
696 (entries loaded); otherwise return lightweight
697 ``SnapshotSummaryResponse`` objects.
698
699 Returns:
700 List of responses in the same order as *snapshot_ids* (omitting
701 unknown/foreign IDs).
702
703 Raises:
704 ``ValueError`` when ``len(snapshot_ids) > _MAX_BATCH_SIZE``.
705 """
706 if len(snapshot_ids) > _MAX_BATCH_SIZE:
707 raise ValueError(
708 f"batch size {len(snapshot_ids)} exceeds limit {_MAX_BATCH_SIZE}"
709 )
710 if not snapshot_ids:
711 return []
712
713 if include_entries:
714 result = await session.execute(
715 select(db.MusehubSnapshot)
716 .where(
717 db.MusehubSnapshot.snapshot_id.in_(snapshot_ids),
718 db.MusehubSnapshot.repo_id == repo_id,
719 )
720 .options(selectinload(db.MusehubSnapshot.entries))
721 )
722 snaps_by_id = {
723 s.snapshot_id: s for s in result.scalars()
724 }
725 return [
726 _to_full_response(snaps_by_id[sid], list(snaps_by_id[sid].entries))
727 for sid in snapshot_ids
728 if sid in snaps_by_id
729 ]
730
731 # Summary path: one header query + one aggregate query.
732 header_result = await session.execute(
733 select(db.MusehubSnapshot).where(
734 db.MusehubSnapshot.snapshot_id.in_(snapshot_ids),
735 db.MusehubSnapshot.repo_id == repo_id,
736 )
737 )
738 snaps_by_id = {s.snapshot_id: s for s in header_result.scalars()}
739
740 if not snaps_by_id:
741 return []
742
743 agg_result = await session.execute(
744 select(
745 db.MusehubSnapshotEntry.snapshot_id,
746 func.count(db.MusehubSnapshotEntry.path).label("entry_count"),
747 func.coalesce(func.sum(db.MusehubSnapshotEntry.size_bytes), 0).label("total_size"),
748 )
749 .where(db.MusehubSnapshotEntry.snapshot_id.in_(list(snaps_by_id.keys())))
750 .group_by(db.MusehubSnapshotEntry.snapshot_id)
751 )
752 agg_by_id: AggByIdDict = {}
753 for agg_row in agg_result:
754 agg_by_id[agg_row.snapshot_id] = (int(agg_row.entry_count), int(agg_row.total_size))
755
756 return [
757 _to_summary_response(snaps_by_id[sid], *agg_by_id.get(sid, (0, 0)))
758 for sid in snapshot_ids
759 if sid in snaps_by_id
760 ]
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago