diff --git a/src/ada/assets/manifest.py b/src/ada/assets/manifest.py index 23592a70e..d19ecf5af 100644 --- a/src/ada/assets/manifest.py +++ b/src/ada/assets/manifest.py @@ -44,6 +44,7 @@ "ChangeRecord", "ManifestError", "CORE_ARTEFACT_ROLES", + "manifest_summary", "parse_manifest", ] @@ -328,3 +329,20 @@ def parse_manifest(doc: bytes | str) -> AssetManifest: artefacts=tuple(artefacts), counts=dict(raw.get("counts") or {}), ) + + +def manifest_summary(m: AssetManifest) -> dict: + """The few fields the browser derives its badges from, for the index route to fold in. + + Deliberately NOT the whole manifest: ``build.options`` is opaque provider data, and the index + is fetched on every refresh. A field lands here only when a badge or a flag reads it. + """ + return _drop_none( + { + "provider": m.provider, + "node": m.node, + "delivery": m.delivery, + "produced_at": m.produced_at, + "hierarchy_revision": m.hierarchy_revision, + } + ) diff --git a/src/ada/comms/rest/routes/assets.py b/src/ada/comms/rest/routes/assets.py index 7283e02d1..bfe312f6e 100644 --- a/src/ada/comms/rest/routes/assets.py +++ b/src/ada/comms/rest/routes/assets.py @@ -29,6 +29,7 @@ HIERARCHY_FILENAME, MANIFEST_FILENAME, ManifestError, + manifest_summary, parse_manifest, ) from ada.assets.projection import HierarchyError, parse_hierarchy @@ -82,6 +83,7 @@ async def api_asset_providers(scope_obj: Scope = Depends(scope_from_path)) -> JS @router.get("/scopes/{scope}/assets/index") async def api_asset_index( collection: str | None = None, + manifests: bool = False, scope_obj: Scope = Depends(scope_from_path), ctx: RestContext = Depends(rest_context), ) -> JSONResponse: @@ -89,10 +91,21 @@ async def api_asset_index( One bounded ``list_prefix`` -- scoped to a collection when one is named, so browsing a collection costs its own keys rather than a walk of every asset in the scope. + + ``manifests=true`` (collection required) also folds in, per revision, the few manifest fields + the browser's badges are derived from -- the delivery claim, the producing provider and the + ``hierarchy_revision`` a leaf was published against. Read here rather than by the browser + because the alternative is one fetch per subject-revision from the tab, and a manifest is + immutable at its key (barring ``replace``), so the server read is the cheap one. """ + if manifests and not collection: + raise HTTPException(status_code=400, detail="manifests=true needs a collection") prefix = f"{ASSET_PREFIX}/{collection}/" if collection else f"{ASSET_PREFIX}/" index = fold_listing(await _list_asset_keys(ctx, scope_obj, prefix)) - return JSONResponse(index.to_dict()) + body = index.to_dict() + if manifests: + await _fold_manifest_summaries(ctx, scope_obj, collection, body) + return JSONResponse(body) @router.get("/scopes/{scope}/assets/tree/{provider}/{collection}") @@ -212,6 +225,41 @@ async def api_asset_delivery( # --- helpers ------------------------------------------------------------------------------------- +# Bounded fan-out for the manifest fold: enough to hide per-object latency, few enough that one +# index request cannot monopolise the storage client. +_MANIFEST_READ_CONCURRENCY = 16 + + +async def _fold_manifest_summaries(ctx: RestContext, scope: Scope, collection: str, body: dict) -> None: + """Attach ``manifest`` (or ``manifest_error``) to every revision entry that has one. + + A manifest that cannot be read is reported on its revision, never dropped: the revision is + still listed, and the tab has to be able to say why it cannot badge it. + """ + gate = asyncio.Semaphore(_MANIFEST_READ_CONCURRENCY) + + async def one(subject: str, rev: dict) -> None: + key = asset_key(collection, subject, rev["revision"], MANIFEST_FILENAME) + async with gate: + try: + raw = await ctx.storage.get_bytes(scope, key) + except (FileNotFoundError, KeyError): + rev["manifest_error"] = f"listed but not readable: {key}" + return + try: + rev["manifest"] = manifest_summary(parse_manifest(raw)) + except ManifestError as exc: + rev["manifest_error"] = str(exc) + + await asyncio.gather( + *( + one(entry["subject"], rev) + for entry in body["collections"].get(collection, []) + for rev in entry["revisions"] + if MANIFEST_FILENAME in rev["files"] + ) + ) + async def _latest_complete_revision(ctx: RestContext, scope: Scope, collection: str, subject: str) -> str | None: """Newest revision that actually HAS a manifest. diff --git a/src/frontend/src/__tests__/assets/assetIndex.test.ts b/src/frontend/src/__tests__/assets/assetIndex.test.ts new file mode 100644 index 000000000..ec3d6b917 --- /dev/null +++ b/src/frontend/src/__tests__/assets/assetIndex.test.ts @@ -0,0 +1,332 @@ +// The folded asset index: collection -> subject -> revision -> files. +// +// `indexFromWire` (the production path) and `foldListing` (the test oracle +// over raw keys) must agree on the same data. Also pins: `_staging` keys are +// skipped, a malformed key is reported rather than thrown, `defaultCollection` +// picks the newest publish rather than the alphabetically first collection, +// and the manifest-summary mapping from the wire's snake_case. +// +// Also ports `projectIndexes.test.ts`'s coverage of what is now +// `collectionIndexRevisions`: the reported failure was that reading only the +// newest collection index made every other root vanish behind whichever +// publish happened last. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + collectionIndexRevisions, + compareRevisions, + defaultCollection, + foldListing, + indexFromWire, + isComplete, + MANIFEST_FILENAME, + HIERARCHY_FILENAME, + revisionsOf, + subjectsOf, +} from "../../assets/assetIndex"; +import { resolveCollection } from "../../assets/resolve"; +import type { ManifestSummary, WireAssetIndex } from "../../assets/types"; + +const R_A = "20260825T135553Z"; +const R_B = "20260826T071500Z"; +const R_C = "20260827T090000Z"; + +const COLL = "plant-a"; + +// --- indexFromWire vs foldListing agree ------------------------------------ + +function wireDoc(): WireAssetIndex { + return { + collections: { + [COLL]: [ + { + subject: COLL, + // Wire order is NEWEST FIRST — the fold must still land ascending. + revisions: [ + { revision: R_B, files: [MANIFEST_FILENAME, HIERARCHY_FILENAME] }, + { revision: R_A, files: [MANIFEST_FILENAME, HIERARCHY_FILENAME] }, + ], + }, + { + subject: "area-1", + revisions: [ + { + revision: R_A, + files: [MANIFEST_FILENAME, "mesh.glb"], + manifest: { + provider: "fixture-lines", + node: "area-1", + delivery: "mesh", + produced_at: R_A, + hierarchy_revision: R_A, + }, + }, + ], + }, + ], + }, + malformed: [{ key: "assets/plant-a/bad key", reason: "invalid segment" }], + }; +} + +function equivalentKeysAndManifests(): { keys: string[]; manifests: Map } { + const keys = [ + `assets/${COLL}/${COLL}/${R_A}/${MANIFEST_FILENAME}`, + `assets/${COLL}/${COLL}/${R_A}/${HIERARCHY_FILENAME}`, + `assets/${COLL}/${COLL}/${R_B}/${MANIFEST_FILENAME}`, + `assets/${COLL}/${COLL}/${R_B}/${HIERARCHY_FILENAME}`, + `assets/${COLL}/area-1/${R_A}/${MANIFEST_FILENAME}`, + `assets/${COLL}/area-1/${R_A}/mesh.glb`, + ]; + const manifests = new Map([ + [ + `${COLL}/area-1/${R_A}`, + { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: R_A, hierarchyRevision: R_A }, + ], + ]); + return { keys, manifests }; +} + +test("indexFromWire and foldListing agree on the same data", () => { + const fromWire = indexFromWire(wireDoc()); + const { keys, manifests } = equivalentKeysAndManifests(); + const folded = foldListing(keys, manifests); + + assert.deepEqual(revisionsOf(fromWire, COLL), revisionsOf(folded, COLL)); + const wireSubject = subjectsOf(fromWire, COLL).get("area-1"); + const foldedSubject = subjectsOf(folded, COLL).get("area-1"); + assert.deepEqual( + wireSubject?.revisions.map((r) => [r.revision, [...r.files].sort(), r.manifest]), + foldedSubject?.revisions.map((r) => [r.revision, [...r.files].sort(), r.manifest]), + ); +}); + +test("wire revisions arrive newest-first; the folded model is ascending either way", () => { + const fromWire = indexFromWire(wireDoc()); + const collRevs = subjectsOf(fromWire, COLL).get(COLL)?.revisions.map((r) => r.revision); + assert.deepEqual(collRevs, [R_A, R_B]); +}); + +// --- _staging is skipped, malformed keys reported -------------------------- + +test("_staging keys never enter the index", () => { + const idx = foldListing([`assets/_staging/upload-1/source.bin`, `assets/${COLL}/area-1/${R_A}/${MANIFEST_FILENAME}`]); + assert.equal(idx.collections.size, 1); + assert.ok(!idx.collections.has("_staging")); +}); + +test("a malformed key is reported, not thrown, and the rest of the listing still folds", () => { + const idx = foldListing([ + `assets/${COLL}/area-1/${R_A}/${MANIFEST_FILENAME}`, + `assets/bad`, // wrong arity + `assets/${COLL}/-bad-leading-dash/${R_A}/${MANIFEST_FILENAME}`, + ]); + assert.equal(idx.malformed.length, 2); + assert.ok(subjectsOf(idx, COLL).has("area-1")); +}); + +test("indexFromWire carries the server's malformed list through as keys", () => { + const idx = indexFromWire(wireDoc()); + assert.deepEqual(idx.malformed, ["assets/plant-a/bad key"]); +}); + +// --- defaultCollection picks the newest publish ----------------------------- + +test("defaultCollection opens on the newest publish, not the alphabetically first collection", () => { + const idx = foldListing([ + `assets/aaa-older/area-1/${R_A}/${MANIFEST_FILENAME}`, + `assets/zzz-newer/area-1/${R_C}/${MANIFEST_FILENAME}`, + ]); + assert.equal(defaultCollection(idx), "zzz-newer"); +}); + +test("defaultCollection is null for an empty or absent index", () => { + assert.equal(defaultCollection(null), null); + assert.equal(defaultCollection(foldListing([])), null); +}); + +// --- manifest summary mapping ---------------------------------------------- + +test("the wire's snake_case manifest fields map to camelCase", () => { + const idx = indexFromWire(wireDoc()); + const rev = subjectsOf(idx, COLL).get("area-1")?.revisions[0]; + assert.deepEqual(rev?.manifest, { + provider: "fixture-lines", + node: "area-1", + delivery: "mesh", + producedAt: R_A, + hierarchyRevision: R_A, + }); +}); + +test("a missing hierarchy_revision on the wire maps to null, not undefined", () => { + const idx = indexFromWire({ + collections: { + [COLL]: [ + { + subject: "area-1", + revisions: [ + { + revision: R_A, + files: [MANIFEST_FILENAME], + manifest: { provider: "fixture-lines", node: "area-1", delivery: "mesh", produced_at: R_A }, + }, + ], + }, + ], + }, + malformed: [], + }); + const rev = subjectsOf(idx, COLL).get("area-1")?.revisions[0]; + assert.equal(rev?.manifest?.hierarchyRevision, null); +}); + +test("a revision with no manifest at all maps to manifest: null", () => { + const idx = indexFromWire({ + collections: { + [COLL]: [{ subject: "area-2", revisions: [{ revision: R_A, files: [MANIFEST_FILENAME] }] }], + }, + malformed: [], + }); + const rev = subjectsOf(idx, COLL).get("area-2")?.revisions[0]; + assert.equal(rev?.manifest, null); + assert.equal(rev?.manifestError, null); +}); + +test("a manifest_error on the wire is carried through", () => { + const idx = indexFromWire({ + collections: { + [COLL]: [ + { + subject: "area-2", + revisions: [{ revision: R_A, files: [MANIFEST_FILENAME], manifest_error: "bad json" }], + }, + ], + }, + malformed: [], + }); + assert.equal(subjectsOf(idx, COLL).get("area-2")?.revisions[0].manifestError, "bad json"); +}); + +// --- isComplete -------------------------------------------------------------- + +test("a revision is complete once it has the manifest filename", () => { + const idx = foldListing([`assets/${COLL}/area-1/${R_A}/${MANIFEST_FILENAME}`]); + const rev = subjectsOf(idx, COLL).get("area-1")?.revisions[0]; + assert.ok(rev); + assert.equal(isComplete(rev!), true); +}); + +test("a revision missing the manifest is incomplete — a publish that died partway", () => { + const idx = foldListing([`assets/${COLL}/area-1/${R_A}/mesh.glb`]); + const rev = subjectsOf(idx, COLL).get("area-1")?.revisions[0]; + assert.ok(rev); + assert.equal(isComplete(rev!), false); +}); + +// --- collectionIndexRevisions (ported from projectIndexes.test.ts) --------- + +const K = (subject: string, rev: string, file: string): string => `assets/${COLL}/${subject}/${rev}/${file}`; + +/** Every collection-level publish writes its own manifest + hierarchy.json; + * two of them (B, C) also carry a leaf publish of their own. */ +const SWEEP = R_A; // the wide, hierarchy-only collection sweep +const RUN_B = R_B; // a later publish naming one subject +const RUN_C = R_C; // and another + +function threeIndexRevisions(): string[] { + return [ + K(COLL, SWEEP, MANIFEST_FILENAME), + K(COLL, SWEEP, HIERARCHY_FILENAME), + K(COLL, RUN_B, MANIFEST_FILENAME), + K(COLL, RUN_B, HIERARCHY_FILENAME), + K("area-1", RUN_B, MANIFEST_FILENAME), + K(COLL, RUN_C, MANIFEST_FILENAME), + K(COLL, RUN_C, HIERARCHY_FILENAME), + K("area-2", RUN_C, MANIFEST_FILENAME), + ]; +} + +test("latest reads EVERY collection index, so no earlier root vanishes behind a newer publish", () => { + const idx = foldListing(threeIndexRevisions()); + const revs = collectionIndexRevisions(idx, COLL, { kind: "latest" }).map((r) => r.revision); + assert.deepEqual(revs, [SWEEP, RUN_B, RUN_C]); +}); + +test("they come back oldest first, so the newest row wins a shared node on merge", () => { + const idx = foldListing(threeIndexRevisions()); + const revs = collectionIndexRevisions(idx, COLL, { kind: "latest" }).map((r) => r.revision); + assert.deepEqual(revs, [...revs].sort(compareRevisions)); +}); + +test("run reads exactly one index — coeval means that publish and nothing else", () => { + const idx = foldListing(threeIndexRevisions()); + const revs = collectionIndexRevisions(idx, COLL, { kind: "run", revision: RUN_B }).map((r) => r.revision); + assert.deepEqual(revs, [RUN_B]); +}); + +test("as-of reads every index up to its instant", () => { + const idx = foldListing(threeIndexRevisions()); + assert.deepEqual( + collectionIndexRevisions(idx, COLL, { kind: "as-of", revision: RUN_B }).map((r) => r.revision), + [SWEEP, RUN_B], + ); + assert.deepEqual( + collectionIndexRevisions(idx, COLL, { kind: "as-of", revision: SWEEP }).map((r) => r.revision), + [SWEEP], + ); +}); + +test("a collection revision missing hierarchy.json is skipped, not fetched as a hole", () => { + const idx = foldListing([ + K(COLL, SWEEP, MANIFEST_FILENAME), + K(COLL, SWEEP, HIERARCHY_FILENAME), + K(COLL, RUN_B, MANIFEST_FILENAME), // no hierarchy.json at this revision + ]); + assert.deepEqual( + collectionIndexRevisions(idx, COLL, { kind: "latest" }).map((r) => r.revision), + [SWEEP], + ); +}); + +test("a collection revision missing asset.json (incomplete publish) is skipped too", () => { + const idx = foldListing([ + K(COLL, SWEEP, MANIFEST_FILENAME), + K(COLL, SWEEP, HIERARCHY_FILENAME), + K(COLL, RUN_B, HIERARCHY_FILENAME), // no asset.json — died partway + ]); + assert.deepEqual( + collectionIndexRevisions(idx, COLL, { kind: "latest" }).map((r) => r.revision), + [SWEEP], + ); +}); + +test("an unknown collection, or no index, is empty rather than an error", () => { + const idx = foldListing(threeIndexRevisions()); + assert.deepEqual(collectionIndexRevisions(idx, "nope", { kind: "latest" }), []); + assert.deepEqual(collectionIndexRevisions(null, COLL, { kind: "latest" }), []); +}); + +test("under run, a subject from another publish resolves to nothing — the lens", () => { + const idx = foldListing([ + K(COLL, SWEEP, MANIFEST_FILENAME), + K(COLL, SWEEP, HIERARCHY_FILENAME), + K("area-1", SWEEP, MANIFEST_FILENAME), + K("area-1", SWEEP, HIERARCHY_FILENAME), + K("area-2", RUN_B, MANIFEST_FILENAME), + K("area-2", RUN_B, HIERARCHY_FILENAME), + ]); + const run = resolveCollection(idx, COLL, { kind: "run", revision: RUN_B }); + assert.equal(run.subjects.has("area-2"), true, "the run's own subject resolves"); + assert.equal( + run.subjects.has("area-1"), + false, + "a subject the run does not name resolves to NOTHING — which is what the lens hides", + ); + + const latest = resolveCollection(idx, COLL, { kind: "latest" }); + assert.equal(latest.subjects.has("area-1"), true); + assert.equal(latest.subjects.has("area-2"), true); +}); diff --git a/src/frontend/src/__tests__/assets/assetView.test.ts b/src/frontend/src/__tests__/assets/assetView.test.ts new file mode 100644 index 000000000..56d9eb777 --- /dev/null +++ b/src/frontend/src/__tests__/assets/assetView.test.ts @@ -0,0 +1,294 @@ +// THE ONE OBJECT THE ASSETS TAB READS — the Phase 2 acceptance for +// `buildAssetView`: orphans, drift, mixed-resolution, freshness, and the +// one-derived-view property (same input in, same badge-relevant output out, +// whether or not the caller precomputed the hierarchy). + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildAssetHierarchy, buildAssetView, type AssetView } from "../../assets/assetView"; +import { EMPTY_FOREST, mergeSpine, type Forest } from "../../assets/merge"; +import type { AssetNode, ManifestSummary } from "../../assets/types"; + +const R1 = "20260825T000000Z"; +const R2 = "20260826T000000Z"; +const R3 = "20260827T000000Z"; +const COLL = "plant-a"; +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +const an = (id: string, parent: string | null, kind: string, leaf: boolean): AssetNode => ({ + id, + parent, + label: id, + kind, + leaf, + delivery: "none", + provider: "fixture-lines", +}); + +// --------------------------------------------------------------------------- +// (a) orphans — removed vs. ahead, and the path from `forest.retired` +// --------------------------------------------------------------------------- +// +// Shape: a collection index places area-1 > level-2 at R1. level-2's own +// subtree spine, also fetched at R1, additionally carries member-9. level-2 is +// re-fetched at R2 and no longer carries member-9 — a real removal at the +// source — so member-9 is pruned and retired under level-2. member-9 is +// nonetheless its own published subject (it has its own manifest, published +// at R1, once independently of the row it used to have). member-20 is a +// second published subject that was never merged into any spine at all. + +function orphanFixture(): { forest: Forest; index: ReturnType } { + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false), an("level-2", "area-1", "level", false)], { + subject: COLL, + revision: R1, + root: null, + }); + forest = mergeSpine(forest, [an("level-2", null, "level", false), an("member-9", "level-2", "member", true)], { + subject: "level-2", + revision: R1, + root: "level-2", + }); + // level-2 re-published without member-9. + forest = mergeSpine(forest, [an("level-2", null, "level", false), an("member-3", "level-2", "member", true)], { + subject: "level-2", + revision: R2, + root: "level-2", + }); + + const manifests = new Map([ + [`${COLL}/member-9/${R1}`, { provider: "p", node: "member-9", delivery: "mesh", producedAt: R1, hierarchyRevision: null }], + [`${COLL}/member-20/${R3}`, { provider: "p", node: "member-20", delivery: "mesh", producedAt: R3, hierarchyRevision: null }], + ]); + const index = foldListing( + [ + K(COLL, R1, MANIFEST_FILENAME), + K(COLL, R1, HIERARCHY_FILENAME), + K(COLL, R2, MANIFEST_FILENAME), + K(COLL, R2, HIERARCHY_FILENAME), + K("level-2", R1, MANIFEST_FILENAME), + K("level-2", R1, HIERARCHY_FILENAME), + K("level-2", R2, MANIFEST_FILENAME), + K("level-2", R2, HIERARCHY_FILENAME), + K("member-9", R1, MANIFEST_FILENAME), + K("member-20", R3, MANIFEST_FILENAME), + ], + manifests, + ); + return { forest, index }; +} + +test("an orphan published BEFORE the newest merged index is `removed`, with its retired path", () => { + const { forest, index } = orphanFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + const orphan = view.orphans.find((o) => o.id === "member-9"); + assert.ok(orphan, "member-9 is a published subject with no row in the forest"); + assert.equal(orphan!.cause, "removed"); + assert.equal(orphan!.revision, R1); + assert.equal(orphan!.spineRevision, R2); + assert.deepEqual(orphan!.path, ["area-1", "level-2"], "the path comes from forest.retired, outermost first"); +}); + +test("an orphan published AFTER the newest merged index is `ahead`, with an empty path", () => { + const { forest, index } = orphanFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + const orphan = view.orphans.find((o) => o.id === "member-20"); + assert.ok(orphan); + assert.equal(orphan!.cause, "ahead"); + assert.deepEqual(orphan!.path, [], "never retired, so there is nothing to report"); +}); + +test("a subject WITH a row in the forest is never listed as an orphan", () => { + const { forest, index } = orphanFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + assert.ok(!view.orphans.some((o) => o.id === "level-2")); + assert.ok(!view.orphans.some((o) => o.id === "area-1")); +}); + +// --------------------------------------------------------------------------- +// (b) drift — published against an older collection hierarchy +// --------------------------------------------------------------------------- + +function driftFixture(): { forest: Forest; index: ReturnType } { + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R2, root: null }); + forest = mergeSpine(forest, [an("member-3", "area-1", "member", true)], { subject: "member-3", revision: R1, root: null }); + forest = mergeSpine(forest, [an("member-4", "area-1", "member", true)], { subject: "member-4", revision: R1, root: null }); + + const manifests = new Map([ + [`${COLL}/member-3/${R1}`, { provider: "p", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: R1 }], + // member-4 recorded the CURRENT collection revision — no drift. + [`${COLL}/member-4/${R1}`, { provider: "p", node: "member-4", delivery: "mesh", producedAt: R1, hierarchyRevision: R2 }], + ]); + const index = foldListing( + [ + K(COLL, R1, MANIFEST_FILENAME), + K(COLL, R1, HIERARCHY_FILENAME), + K(COLL, R2, MANIFEST_FILENAME), + K(COLL, R2, HIERARCHY_FILENAME), + K("member-3", R1, MANIFEST_FILENAME), + K("member-4", R1, MANIFEST_FILENAME), + ], + manifests, + ); + return { forest, index }; +} + +test("a subject published against an OLDER collection tree is flagged in `drift`", () => { + const { forest, index } = driftFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + assert.deepEqual(view.drift.get("member-3"), { publishedAgainst: R1, shownFrom: R2 }); +}); + +test("a subject published against the CURRENT collection tree is not in `drift`", () => { + const { forest, index } = driftFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + assert.equal(view.drift.has("member-4"), false); +}); + +// --------------------------------------------------------------------------- +// (c) summary.mixed — two non-collection subjects vs. a collection-only gap +// --------------------------------------------------------------------------- + +function mixedFixture(): { forest: Forest; index: ReturnType } { + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R1, root: null }); + forest = mergeSpine(forest, [an("member-3", "area-1", "member", true)], { subject: "member-3", revision: R1, root: null }); + forest = mergeSpine(forest, [an("member-5", "area-1", "member", true)], { subject: "member-5", revision: R2, root: null }); + const index = foldListing([ + K(COLL, R1, MANIFEST_FILENAME), + K(COLL, R1, HIERARCHY_FILENAME), + K("member-3", R1, MANIFEST_FILENAME), + K("member-5", R2, MANIFEST_FILENAME), + ]); + return { forest, index }; +} + +test("two non-collection subjects resolving to different revisions ARE mixed under latest", () => { + const { forest, index } = mixedFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1] }); + assert.equal(view.summary.mixed, true); + assert.deepEqual([...view.summary.revisions], [R1, R2]); +}); + +test("the same collection is NEVER mixed under run — the only coeval mode", () => { + const { forest, index } = mixedFixture(); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "run", revision: R1 }, indexRevisions: [] }); + assert.equal(view.summary.mixed, false); + assert.equal(view.summary.coeval, true); +}); + +test("a difference that is ONLY the collection subject's own revision does not make the view mixed", () => { + // The collection subject resolves to R2 (its own newer sweep) while every + // other subject agrees on R1. `describeResolution` skips the collection + // subject for exactly this reason. + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R2, root: null }); + forest = mergeSpine(forest, [an("member-3", "area-1", "member", true)], { subject: "member-3", revision: R1, root: null }); + forest = mergeSpine(forest, [an("member-4", "area-1", "member", true)], { subject: "member-4", revision: R1, root: null }); + const index = foldListing([ + K(COLL, R1, MANIFEST_FILENAME), + K(COLL, R1, HIERARCHY_FILENAME), + K(COLL, R2, MANIFEST_FILENAME), + K(COLL, R2, HIERARCHY_FILENAME), + K("member-3", R1, MANIFEST_FILENAME), + K("member-4", R1, MANIFEST_FILENAME), + ]); + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }); + assert.equal(view.summary.mixed, false); + assert.deepEqual([...view.summary.revisions], [R1]); +}); + +// --------------------------------------------------------------------------- +// (d) freshness — a stale subtree row vs. a current older-index row +// --------------------------------------------------------------------------- + +test("a row drawn from a subtree spine is stale once its subject resolves to a newer revision", () => { + // level-2's subtree was fetched at R1 and never re-fetched; the server has + // since published level-2 again at R2, so `latest` resolves past it. + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R1, root: null }); + forest = mergeSpine(forest, [an("level-2", null, "level", false), an("member-3", "level-2", "member", true)], { + subject: "level-2", + revision: R1, + root: "level-2", + }); + const manifests = new Map([ + [`${COLL}/level-2/${R2}`, { provider: "p", node: "level-2", delivery: "none", producedAt: R2, hierarchyRevision: null }], + ]); + const index = foldListing( + [ + K("level-2", R1, MANIFEST_FILENAME), + K("level-2", R1, HIERARCHY_FILENAME), + K("level-2", R2, MANIFEST_FILENAME), + K("level-2", R2, HIERARCHY_FILENAME), + ], + manifests, + ); + // Only R1 of the collection index (none here) is "merged" — indexRevisions + // stays empty, which is fine: this scenario is about the level-2 subtree. + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [] }); + assert.equal(view.freshness.get("level-2")?.stale, true); + assert.equal(view.freshness.get("member-3")?.stale, true); + assert.equal(view.staleCount, 2); +}); + +test("a row from an OLDER collection index the mode still merges is NOT stale, even if the collection has since republished", () => { + let forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R1, root: null }); + const index = foldListing([ + K(COLL, R1, MANIFEST_FILENAME), + K(COLL, R1, HIERARCHY_FILENAME), + K(COLL, R2, MANIFEST_FILENAME), + K(COLL, R2, HIERARCHY_FILENAME), + ]); + // The collection's own newest publish (R2) is NOT in indexRevisions: only R1 + // was actually merged into this forest, and the union model says that is + // still current, whatever the collection subject resolves to elsewhere. + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1] }); + assert.equal(view.freshness.get("area-1")?.stale, false); + assert.equal(view.staleCount, 0); +}); + +// --------------------------------------------------------------------------- +// (e) one derived view — determinism, and hierarchy-precompute equivalence +// --------------------------------------------------------------------------- + +function badgeSnapshot(view: AssetView) { + const coverage = [...view.coverage.byId.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([id, c]) => ({ + id, + rooted: [...c.rooted].sort(), + covered: [...c.covered].sort(), + below: [...c.below].sort(), + dimmed: c.dimmed, + gap: c.gap, + })); + const orphans = [...view.orphans].sort((a, b) => (a.id < b.id ? -1 : 1)); + const drift = [...view.drift.entries()].sort(([a], [b]) => (a < b ? -1 : 1)); + return { coverage, orphans, drift }; +} + +test("calling buildAssetView twice on identical input gives deep-equal badge-relevant output", () => { + const { forest, index } = orphanFixture(); + const input = { forest, index, collection: COLL, mode: { kind: "latest" as const }, indexRevisions: [R1, R2] }; + const first = badgeSnapshot(buildAssetView(input)); + const second = badgeSnapshot(buildAssetView(input)); + assert.deepEqual(first, second); +}); + +test("passing a prebuilt hierarchy gives the same result as building it internally", () => { + const { forest, index } = orphanFixture(); + const withoutPrebuilt = badgeSnapshot( + buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2] }), + ); + const hierarchy = buildAssetHierarchy(forest); + const withPrebuilt = badgeSnapshot( + buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1, R2], hierarchy }), + ); + assert.deepEqual(withoutPrebuilt, withPrebuilt); +}); + +test("a mode switch alone re-derives the same shape of output deterministically", () => { + const { forest, index } = mixedFixture(); + const latest1 = badgeSnapshot(buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1] })); + const latest2 = badgeSnapshot(buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1] })); + assert.deepEqual(latest1, latest2); +}); diff --git a/src/frontend/src/__tests__/assets/assetViewPerf.test.ts b/src/frontend/src/__tests__/assets/assetViewPerf.test.ts new file mode 100644 index 000000000..8f144612e --- /dev/null +++ b/src/frontend/src/__tests__/assets/assetViewPerf.test.ts @@ -0,0 +1,96 @@ +// Performance shape check over a ~41k-row synthetic spine: parse, merge, +// hierarchy build, view build and flatten should all stay well under a +// generous CI bound. The real target is sub-100ms for each; this test prints +// the actual numbers and only fails on gross regression, since CI runners +// vary a lot in speed. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildAssetHierarchy, buildAssetView } from "../../assets/assetView"; +import { flattenVisible } from "../../assets/hierarchy"; +import { EMPTY_FOREST, mergeSpine } from "../../assets/merge"; +import { parseHierarchySlice } from "../../assets/projection"; +import type { ManifestSummary } from "../../assets/types"; +import { buildSyntheticSpine, SYNTHETIC_ROOT } from "./synthetic"; + +// Generous, CI-safe bound. The real target is < 100ms per phase; see the +// printed timings for the actual numbers on this machine. +const CI_BOUND_MS = 400; + +const COLL = "plant-a"; +const REVISION = "20260825T135553Z"; +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +test("a ~41k-row spine parses, merges, builds and flattens within a generous bound", () => { + const synthetic = buildSyntheticSpine(); + + const manifests = new Map([ + [`${COLL}/${COLL}/${REVISION}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: REVISION, hierarchyRevision: null }], + [`${COLL}/${SYNTHETIC_ROOT}/${REVISION}`, { provider: "fixture-lines", node: SYNTHETIC_ROOT, delivery: "none", producedAt: REVISION, hierarchyRevision: null }], + ]); + const keys = [ + K(COLL, REVISION, MANIFEST_FILENAME), + K(COLL, REVISION, HIERARCHY_FILENAME), + K(SYNTHETIC_ROOT, REVISION, MANIFEST_FILENAME), + K(SYNTHETIC_ROOT, REVISION, HIERARCHY_FILENAME), + ]; + for (const leafId of synthetic.sampleLeafIds) { + keys.push(K(leafId, REVISION, MANIFEST_FILENAME)); + manifests.set(`${COLL}/${leafId}/${REVISION}`, { + provider: "fixture-lines", + node: leafId, + delivery: "mesh", + producedAt: REVISION, + hierarchyRevision: null, + }); + } + const index = foldListing(keys, manifests); + + const t0 = performance.now(); + const slice = parseHierarchySlice(synthetic.wire); + const tParse = performance.now(); + + const forest = mergeSpine(EMPTY_FOREST, slice.nodes, { subject: SYNTHETIC_ROOT, revision: REVISION, root: SYNTHETIC_ROOT }); + const tMerge = performance.now(); + + const hierarchy = buildAssetHierarchy(forest); + const tHierarchy = performance.now(); + + const view = buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [REVISION], hierarchy }); + const tView = performance.now(); + + const expanded = new Set([SYNTHETIC_ROOT, ...synthetic.branchIds]); + const rows = flattenVisible(hierarchy, expanded); + const tFlatten = performance.now(); + + const parseMs = tParse - t0; + const mergeMs = tMerge - tParse; + const hierarchyMs = tHierarchy - tMerge; + const viewMs = tView - tHierarchy; + const flattenMs = tFlatten - tView; + const expandPathMs = tFlatten - t0; + + // A mode-change re-derive: the hierarchy is already built, only resolution + // and its downstream passes redo work. + const tRe0 = performance.now(); + const rerun = buildAssetView({ forest, index, collection: COLL, mode: { kind: "run", revision: REVISION }, indexRevisions: [REVISION], hierarchy }); + const rederiveMs = performance.now() - tRe0; + + console.log( + `[assetViewPerf] rows=${synthetic.rowCount} parse=${parseMs.toFixed(2)}ms merge=${mergeMs.toFixed(2)}ms ` + + `hierarchy=${hierarchyMs.toFixed(2)}ms view=${viewMs.toFixed(2)}ms flatten=${flattenMs.toFixed(2)}ms ` + + `expand-path-total=${expandPathMs.toFixed(2)}ms mode-rederive=${rederiveMs.toFixed(2)}ms`, + ); + + // Sanity: the fixture is the shape it claims to be, and both views resolved. + assert.equal(slice.nodes.length, synthetic.rowCount); + assert.equal(forest.nodes.size, synthetic.rowCount); + assert.ok(rows.length >= 1 + synthetic.branchIds.length); + assert.ok(view.hierarchy.order.length === synthetic.rowCount); + assert.equal(rerun.summary.coeval, true); + + assert.ok(expandPathMs < CI_BOUND_MS, `expand path took ${expandPathMs.toFixed(2)}ms, bound is ${CI_BOUND_MS}ms`); + assert.ok(rederiveMs < CI_BOUND_MS, `mode re-derive took ${rederiveMs.toFixed(2)}ms, bound is ${CI_BOUND_MS}ms`); +}); diff --git a/src/frontend/src/__tests__/assets/coverageBelow.test.ts b/src/frontend/src/__tests__/assets/coverageBelow.test.ts new file mode 100644 index 000000000..8c79bec72 --- /dev/null +++ b/src/frontend/src/__tests__/assets/coverageBelow.test.ts @@ -0,0 +1,141 @@ +// THE THIRD DIRECTION: a role rooted beneath a node, announced on the node. +// +// `rooted` and `covered` both answer from a node's own line — what is here, +// what reaches me from above. Neither is visible until the row that HAS the +// publish is on screen, so a collapsed area with a publish three levels down +// looked exactly like an area with nothing in it. The only way to tell was to +// open every branch, which is the work `below` exists to save. +// +// Ported verbatim (same API) from coverageBelow.test.ts. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildHierarchy } from "../../assets/hierarchy"; +import { projectCoverage } from "../../assets/coverage"; + +/** area-1 + * ├── level-a ── frame ── (content rooted here) + * └── level-b (nothing) */ +const H = buildHierarchy([ + { id: "area-1", parent: null, data: { leaf: false } }, + { id: "level-a", parent: "area-1", data: { leaf: false } }, + { id: "frame", parent: "level-a", data: { leaf: true } }, + { id: "level-b", parent: "area-1", data: { leaf: true } }, +]); + +const PROPAGATING = new Set(["content", "extra"]); + +const cover = (rootedRoles: Map>) => + projectCoverage(H, { + rootedRoles, + publishedSubjects: [...rootedRoles.keys()], + propagating: PROPAGATING, + payloadOf: (n) => ((n.data as { leaf: boolean }).leaf ? 1 : 0), + }); + +test("a publish deep in a branch is announced all the way up", () => { + const cov = cover(new Map([["frame", new Set(["content"])]])); + assert.ok(cov.byId.get("area-1")?.below.has("content"), "the area says something is under it"); + assert.ok(cov.byId.get("level-a")?.below.has("content"), "and so does the level between"); +}); + +test("it is announced only on the branch that holds it", () => { + const cov = cover(new Map([["frame", new Set(["content"])]])); + assert.equal(cov.byId.get("level-b")?.below.size, 0, "a sibling branch claims nothing"); +}); + +test("`below` names the shallowest descendant, which is the one to open", () => { + // Both the level and the frame under it root a publish. The area should + // point at the level: it is the first thing you would meet going down, and + // the frame is reachable from there. + const cov = cover( + new Map([ + ["level-a", new Set(["content"])], + ["frame", new Set(["content"])], + ]), + ); + assert.equal(cov.byId.get("area-1")?.belowBy.get("content"), "level-a"); +}); + +test("a role answered at this row is not also announced from below", () => { + // Weights are exclusive. "It is here" is the stronger and more useful claim, + // and two badges for one role on one row reads as two publishes. + const cov = cover( + new Map([ + ["area-1", new Set(["content"])], + ["frame", new Set(["content"])], + ]), + ); + const area1 = cov.byId.get("area-1"); + assert.ok(area1?.rooted.has("content")); + assert.equal(area1?.below.has("content"), false); +}); + +test("a role covered from above is not announced from below either", () => { + // level-a inherits the area's content as a ghost. Adding a `below` for the + // frame beneath it would put two marks on one row for one publish. + const cov = cover( + new Map([ + ["area-1", new Set(["content"])], + ["frame", new Set(["content"])], + ]), + ); + const level = cov.byId.get("level-a"); + assert.ok(level?.covered.has("content"), "the ghost is the row's answer"); + assert.equal(level?.below.has("content"), false); +}); + +test("only propagating roles travel upward", () => { + // A non-propagating role is rooted at every root of a sweep. If it climbed, + // every ancestor of everything would wear it and it would distinguish + // nothing — the same argument that keeps it out of the downward pass. + const cov = cover(new Map([["frame", new Set(["tree"])]])); + assert.equal(cov.byId.get("area-1")?.below.size, 0); +}); + +test("a tree with no publishes at all announces nothing anywhere", () => { + const cov = cover(new Map()); + for (const id of H.order) assert.equal(cov.byId.get(id)?.below.size, 0, id); +}); + +// --------------------------------------------------------------------------- +// gap is counted PER PAYLOAD (`uncoveredSubtree`), not decided per row. +// --------------------------------------------------------------------------- + +/** branch + * ├── leaf-a + * └── leaf-b */ +const LEAVES = buildHierarchy([ + { id: "branch", parent: null, data: { leaf: false } }, + { id: "leaf-a", parent: "branch", data: { leaf: true } }, + { id: "leaf-b", parent: "branch", data: { leaf: true } }, +]); + +const coverLeaves = (rootedRoles: Map>) => + projectCoverage(LEAVES, { + rootedRoles, + publishedSubjects: [...rootedRoles.keys()], + propagating: PROPAGATING, + payloadOf: (n) => ((n.data as { leaf: boolean }).leaf ? 1 : 0), + }); + +test("a branch whose every leaf is published leaf-by-leaf is NOT a gap, though nothing is published at the branch itself", () => { + const cov = coverLeaves( + new Map([ + ["leaf-a", new Set(["content"])], + ["leaf-b", new Set(["content"])], + ]), + ); + const branch = cov.byId.get("branch"); + assert.equal(branch?.gap, false); + assert.equal(branch?.uncoveredSubtree, 0); + assert.equal(branch?.dimmed, false, "there IS payload here, just none of it uncovered"); +}); + +test("a branch with one uncovered leaf among covered ones is a gap of exactly that leaf", () => { + const cov = coverLeaves(new Map([["leaf-a", new Set(["content"])]])); + const branch = cov.byId.get("branch"); + assert.equal(branch?.gap, true); + assert.equal(branch?.uncoveredSubtree, 1); +}); diff --git a/src/frontend/src/__tests__/assets/freshness.test.ts b/src/frontend/src/__tests__/assets/freshness.test.ts new file mode 100644 index 000000000..77002d369 --- /dev/null +++ b/src/frontend/src/__tests__/assets/freshness.test.ts @@ -0,0 +1,142 @@ +// Two "is this row out of date" facts, kept apart on purpose. +// +// STALE: the shape of the tree on screen no longer matches the resolution its +// badges are read from. DRIFT: a subject was published against an older +// collection hierarchy than the one on screen now. +// +// Ported from freshness.test.ts. `nodeFreshness` now takes `revisionOf` PER +// ORIGIN (subject + the revision it was fetched at), not per subject — one +// subject can legitimately contribute rows at several revisions. Drift tests +// are new here. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + hierarchyDrift, + nodeFreshness, + staleCount, + type NodeOrigin, +} from "../../assets/freshness"; + +const R1 = "20260825T135553Z"; +const R2 = "20260827T090000Z"; + +const origins = (entries: Array<[string, string, string]>): Map => + new Map(entries.map(([id, subject, revision]) => [id, { subject, revision }])); + +test("a row is fresh when its spine is the revision the resolution names", () => { + const f = nodeFreshness(origins([["n-1", "area-a", R1]]), () => R1); + assert.equal(f.get("n-1")?.stale, false); + assert.equal(f.get("n-1")?.shownAt, R1); + assert.equal(f.get("n-1")?.resolvedAt, R1); +}); + +test("a row is stale when the resolution has moved to another revision", () => { + // The mode switch: badges re-point at R2, the already-fetched rows do not. + const f = nodeFreshness(origins([["n-1", "area-a", R1]]), () => R2); + assert.equal(f.get("n-1")?.stale, true); + assert.equal(f.get("n-1")?.shownAt, R1); + assert.equal(f.get("n-1")?.resolvedAt, R2); +}); + +test("a subject the mode resolves to nothing is NOT stale", () => { + // Under `run` and `as-of` a subject that did not participate is simply + // absent. The tab says that in its own words; calling it staleness would + // put a second, wronger sentence beside it. + const f = nodeFreshness(origins([["n-1", "area-a", R1]]), () => null); + assert.equal(f.get("n-1")?.stale, false); + assert.equal(f.get("n-1")?.resolvedAt, null); +}); + +test("staleness is per subject — one branch can be stale while another is current", () => { + const f = nodeFreshness( + origins([ + ["n-1", "area-a", R1], + ["n-2", "area-a", R1], + ["n-3", "area-b", R2], + ]), + (origin) => (origin.subject === "area-a" ? R2 : R2), + ); + assert.equal(f.get("n-1")?.stale, true); + assert.equal(f.get("n-2")?.stale, true); + assert.equal(f.get("n-3")?.stale, false); + assert.equal(staleCount(f), 2); +}); + +test("the resolution lookup is memoised per origin, not asked once per row", () => { + // One spine is up to ~41k rows against a single origin. + const many: Array<[string, string, string]> = []; + for (let i = 0; i < 5_000; i++) many.push([`n-${i}`, "area-a", R1]); + let calls = 0; + const f = nodeFreshness(origins(many), () => { + calls++; + return R1; + }); + assert.equal(f.size, 5_000); + assert.equal(calls, 1); +}); + +test("two different origins (same subject, different fetch revision) are memoised separately", () => { + const entries = origins([ + ["n-1", "area-a", R1], + ["n-2", "area-a", R2], + ]); + const seen: string[] = []; + const f = nodeFreshness(entries, (origin) => { + seen.push(origin.revision); + return origin.revision; + }); + assert.deepEqual(seen.sort(), [R1, R2]); + assert.equal(f.get("n-1")?.stale, false); + assert.equal(f.get("n-2")?.stale, false); +}); + +test("a node absent from the newer spine keeps its old revision and shows as stale", () => { + // THE DELETION CASE, spelled out. A-B-C was fetched at R1. The branch is + // re-published at R2 as A-B only. A union merge sets A and B to R2 and + // CANNOT remove C — absence is not a delete instruction unless the document + // claims completeness for that subject. So C survives, still stamped R1, + // and that disagreement is the only honest signal available. + const merged = origins([ + ["A", "area-a", R1], + ["B", "area-a", R1], + ["C", "area-a", R1], + ]); + // The R2 spine arrives carrying A and B only. + for (const id of ["A", "B"]) merged.set(id, { subject: "area-a", revision: R2 }); + + const f = nodeFreshness(merged, () => R2); + assert.equal(f.get("A")?.stale, false); + assert.equal(f.get("B")?.stale, false); + assert.equal(f.get("C")?.stale, true, "C outlived the publish that produced it"); + assert.equal(f.get("C")?.shownAt, R1); + assert.equal(staleCount(f), 1); +}); + +test("staleCount is zero on an untouched forest", () => { + assert.equal(staleCount(nodeFreshness(new Map(), () => R1)), 0); +}); + +// --- hierarchyDrift ---------------------------------------------------------- + +test("drift is reported when a subject was published against an OLDER collection tree", () => { + const d = hierarchyDrift(R1, R2); + assert.deepEqual(d, { publishedAgainst: R1, shownFrom: R2 }); +}); + +test("no drift when the recorded revision equals what's on screen", () => { + assert.equal(hierarchyDrift(R1, R1), null); +}); + +test("no drift when the subject's manifest recorded NO hierarchy_revision", () => { + assert.equal(hierarchyDrift(null, R2), null); +}); + +test("no drift when there is no collection index on screen at all", () => { + assert.equal(hierarchyDrift(R1, null), null); +}); + +test("no drift when the recorded revision is NEWER than what's on screen — that is `ahead`, not drift", () => { + assert.equal(hierarchyDrift(R2, R1), null); +}); diff --git a/src/frontend/src/__tests__/assets/hierarchy.test.ts b/src/frontend/src/__tests__/assets/hierarchy.test.ts new file mode 100644 index 000000000..ee50462f0 --- /dev/null +++ b/src/frontend/src/__tests__/assets/hierarchy.test.ts @@ -0,0 +1,169 @@ +// The forest: incremental assembly, absent parents, subtree folds, flattening. +// Ported verbatim (same API) from the out-of-tree plugin's hierarchy module. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + ancestorsOf, + buildHierarchy, + flattenVisible, + foldSubtrees, + type HierarchyInput, +} from "../../assets/hierarchy"; + +const n = (id: string, parent: string | null, payload = 0): HierarchyInput => ({ + id, + parent, + data: payload, +}); + +test("depth and parent-before-child order come out of one pass", () => { + const h = buildHierarchy([n("a", null), n("b", "a"), n("c", "b"), n("d", "a")]); + assert.deepEqual(h.roots, ["a"]); + assert.equal(h.byId.get("c")?.depth, 2); + assert.deepEqual(h.order, ["a", "b", "d", "c"]); + for (const id of h.order) { + const parent = h.byId.get(id)?.parent; + if (parent) assert.ok(h.order.indexOf(parent) < h.order.indexOf(id), id); + } +}); + +test("a node whose parent has not been fetched becomes a root, and says so", () => { + // The normal case, not corruption: the tab holds a collection index and one + // subtree spine, and a row rooted deeper arrives before its spine does. + const h = buildHierarchy([n("orphan", "not-fetched-yet"), n("a", null)]); + assert.deepEqual([...h.roots].sort(), ["a", "orphan"]); + assert.equal(h.byId.get("orphan")?.parent, null); + assert.equal(h.byId.get("orphan")?.declaredParent, "not-fetched-yet"); +}); + +test("a later duplicate replaces an earlier one — the richer spine wins over the index stub", () => { + const h = buildHierarchy([n("s", null, 0), n("s", null, 7)]); + assert.equal(h.byId.get("s")?.data, 7); + assert.equal(h.byId.size, 1); +}); + +test("a cycle terminates and its members surface as stray roots rather than vanishing", () => { + const h = buildHierarchy([n("a", null), n("x", "y"), n("y", "x")]); + assert.equal(h.byId.size, 3); + assert.ok(h.roots.includes("x")); + assert.ok(h.roots.includes("y")); + assert.deepEqual(ancestorsOf(h, "x"), []); +}); + +test("ancestors come back nearest first", () => { + const h = buildHierarchy([n("a", null), n("b", "a"), n("c", "b")]); + assert.deepEqual(ancestorsOf(h, "c"), ["b", "a"]); + assert.deepEqual(ancestorsOf(h, "a"), []); +}); + +test("foldSubtrees aggregates bottom-up in one reverse pass", () => { + const h = buildHierarchy([n("a", null, 0), n("b", "a", 1), n("c", "b", 2), n("d", "a", 0)]); + const totals = foldSubtrees(h, (node) => node.data); + assert.equal(totals.get("a"), 3); + assert.equal(totals.get("b"), 3); + assert.equal(totals.get("c"), 2); + assert.equal(totals.get("d"), 0); +}); + +test("foldSubtrees does not recurse — a deep chain must not overflow", () => { + const deep: HierarchyInput[] = []; + for (let i = 0; i < 50_000; i++) deep.push(n(`n${i}`, i === 0 ? null : `n${i - 1}`, 1)); + const h = buildHierarchy(deep); + assert.equal(h.byId.get("n49999")?.depth, 49999); + assert.equal(foldSubtrees(h, (node) => node.data).get("n0"), 50_000); + assert.equal(flattenVisible(h, new Set()).length, 1); +}); + +test("flattenVisible walks only what is expanded, in display order", () => { + const h = buildHierarchy([n("a", null), n("b", "a"), n("c", "b"), n("d", "a")]); + assert.deepEqual(flattenVisible(h, new Set()).map((r) => r.id), ["a"]); + assert.deepEqual(flattenVisible(h, new Set(["a"])).map((r) => r.id), ["a", "b", "d"]); + assert.deepEqual(flattenVisible(h, new Set(["a", "b"])).map((r) => r.id), ["a", "b", "c", "d"]); +}); + +test("a node with no fetched children is still expandable when the caller says so", () => { + const h = buildHierarchy([n("area-1", null)]); + assert.equal(flattenVisible(h, new Set())[0].hasChildren, false); + const rows = flattenVisible(h, new Set(), { expandable: (id) => id === "area-1" }); + assert.equal(rows[0].hasChildren, true); + // Expanded-but-nothing-fetched-yet must not claim to be open: the chevron + // would point down over no rows. + assert.equal(rows[0].expanded, false); + const opened = flattenVisible(h, new Set(["area-1"]), { expandable: () => true }); + assert.equal(opened[0].expanded, true); +}); + +// --- `include`: the tab's kind filter --------------------------------- // + +test("include drops the rows outside it and keeps display order", () => { + const h = buildHierarchy([n("a", null), n("b", "a"), n("c", "b"), n("d", "a")]); + // Ancestor-closed, as the contract requires: c is kept, so b and a are too. + const include = new Set(["a", "b", "c"]); + assert.deepEqual(flattenVisible(h, new Set(["a", "b"]), { include }).map((r) => r.id), ["a", "b", "c"]); +}); + +test("a root outside the include set does not appear", () => { + const h = buildHierarchy([n("a", null), n("z", null), n("b", "a")]); + assert.deepEqual( + flattenVisible(h, new Set(["a"]), { include: new Set(["a", "b"]) }).map((r) => r.id), + ["a", "b"], + ); +}); + +test("a row whose every child was filtered out loses its twisty", () => { + // Otherwise the filter leaves an expandable row that opens onto nothing — + // the user clicks and the tree does not move. + const h = buildHierarchy([n("a", null), n("b", "a")]); + const rows = flattenVisible(h, new Set(["a"]), { include: new Set(["a"]) }); + assert.deepEqual(rows.map((r) => r.id), ["a"]); + assert.equal(rows[0].hasChildren, false); + assert.equal(rows[0].expanded, false); +}); + +test("include does not suppress `expandable` — an unfetched spine stays openable", () => { + // The filter must never hide a branch for the reason that nobody has looked + // inside it yet. A branch kept by the unfetched clause still has to offer the + // expansion that fetches its spine. + const h = buildHierarchy([n("area-1", null)]); + const rows = flattenVisible(h, new Set(), { + include: new Set(["area-1"]), + expandable: () => true, + }); + assert.equal(rows[0].hasChildren, true); +}); + +test("depth survives filtering, so indentation still lines up", () => { + const h = buildHierarchy([n("a", null), n("b", "a"), n("c", "b")]); + const rows = flattenVisible(h, new Set(["a", "b"]), { include: new Set(["a", "b", "c"]) }); + assert.deepEqual(rows.map((r) => r.depth), [0, 1, 2]); +}); + +test("the kind filter keeps the branch whole — it does not flatten to the assets", () => { + // How the tab builds `include`: fold "roots a published entry" upward. The + // closure is what makes it legal to pass to flattenVisible without a second + // pass, AND what keeps the containers above an asset on screen. A filter that + // kept only the entries themselves would render memberA at depth 0 with no + // area above it, which is not a tree. + const h = buildHierarchy([ + n("area-1", null, 0), + n("level-a", "area-1", 0), + n("member-a", "level-a", 1), // has a published entry + n("level-b", "area-1", 0), // nothing published anywhere under it + ]); + const totals = foldSubtrees(h, (node) => node.data); + const include = new Set([...totals].filter(([, v]) => v > 0).map(([k]) => k)); + // level-b is gone; the chain that PLACES member-a is not. + assert.deepEqual([...include].sort(), ["area-1", "level-a", "member-a"]); + const rows = flattenVisible(h, new Set(["area-1", "level-a"]), { include }); + assert.deepEqual(rows.map((r) => r.id), ["area-1", "level-a", "member-a"]); + assert.deepEqual(rows.map((r) => r.depth), [0, 1, 2]); +}); + +test("an area with nothing published under it drops out entirely", () => { + const h = buildHierarchy([n("areaA", null, 1), n("areaB", null, 0), n("levelB", "areaB", 0)]); + const totals = foldSubtrees(h, (node) => node.data); + const include = new Set([...totals].filter(([, v]) => v > 0).map(([k]) => k)); + assert.deepEqual(flattenVisible(h, new Set(["areaA", "areaB"]), { include }).map((r) => r.id), ["areaA"]); +}); diff --git a/src/frontend/src/__tests__/assets/merge.test.ts b/src/frontend/src/__tests__/assets/merge.test.ts new file mode 100644 index 000000000..58bbc2f1c --- /dev/null +++ b/src/frontend/src/__tests__/assets/merge.test.ts @@ -0,0 +1,144 @@ +// The forest merge: a union that is allowed to delete, but only inside a +// document that claims completeness for a subject. +// +// Ported from merge.test.ts, driven through the store there — here through +// the pure `mergeSpine(forest, incoming, {subject, revision, root})` this +// module set exposes instead, plus new coverage for `retired` (which the +// store version did not expose directly) and for the null-parent guard that +// keeps a subtree document's self-declared, parent-less root from re-parenting +// a node the forest already places. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { EMPTY_FOREST, mergeSpine, type Forest } from "../../assets/merge"; +import type { AssetNode } from "../../assets/types"; + +const R1 = "20260825T135553Z"; +const R2 = "20260827T090000Z"; +const AREA = "area-a"; + +const node = (id: string, parent: string | null): AssetNode => ({ + id, + parent, + label: id, + kind: "level", + leaf: false, + delivery: "none", + provider: "fixture-lines", +}); + +/** A-B-C, one chain, published as AREA's subtree at R1. */ +const chain = [node("A", null), node("B", "A"), node("C", "B")]; + +const ids = (f: Forest): string[] => [...f.nodes.keys()].sort(); + +test("a subtree merge prunes what its newer revision no longer carries", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); + + // C was removed at the source; the R2 publish of the same root carries A-B only. + forest = mergeSpine(forest, [node("A", null), node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B"]); + // The origins move with the rows — a dropped row leaves no provenance behind. + assert.equal(forest.origins.get("C"), undefined); + assert.equal(forest.origins.get("B")?.revision, R2); +}); + +test("a collection index may never prune — absent there means deeper, not gone", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + // A depth-1 index listing only the area. root=null => no prune. + forest = mergeSpine(forest, [node("A", null)], { subject: "plant-a", revision: R2, root: null }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); +}); + +test("a prune touches only rows the SAME subject claims", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + // A nested publish claims C for itself. + forest = mergeSpine(forest, [node("C", "B")], { subject: "member-c", revision: R1, root: "C" }); + // AREA re-publishes without C. C is no longer AREA's to retire. + forest = mergeSpine(forest, [node("A", null), node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); + assert.equal(forest.origins.get("C")?.subject, "member-c"); +}); + +test("re-merging the revision already held deletes nothing", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + // A redundant fetch of the same revision is not authority to delete: it + // says nothing the forest does not already hold. + forest = mergeSpine(forest, [node("A", null)], { subject: AREA, revision: R1, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); +}); + +test("going back to the older revision restores the pruned row", () => { + // Storage is immutable per revision, so the R1 spine still carries C and + // re-merging it brings C back — the prune was only ever a fact about the + // forest in this browser. + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + forest = mergeSpine(forest, [node("A", null), node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B"]); + + forest = mergeSpine(forest, chain, { subject: AREA, revision: R1, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); + assert.equal(forest.origins.get("C")?.revision, R1); +}); + +// --- retired ------------------------------------------------------------- + +test("a pruned row's last parent is recorded in `retired`", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + forest = mergeSpine(forest, [node("A", null), node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.equal(forest.retired.get("C"), "B", "C's parent at the moment it was pruned"); +}); + +test("a row that returns is no longer retired", () => { + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + forest = mergeSpine(forest, [node("A", null), node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.equal(forest.retired.has("C"), true); + + forest = mergeSpine(forest, chain, { subject: AREA, revision: R1, root: "A" }); + assert.equal(forest.retired.has("C"), false, "C is back in the forest, so it is not retired any more"); +}); + +test("a root-level retired row records null, not a stale id", () => { + // The root itself can be pruned too, if a later revision's document simply + // does not carry it any more: its recorded parent is null, not a lie. + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + forest = mergeSpine(forest, [node("B", "A")], { subject: AREA, revision: R2, root: "A" }); + assert.equal(forest.retired.get("A"), null); + assert.equal(forest.retired.get("C"), "B"); +}); + +test("an empty document is always a no-op — never a deletion instruction", () => { + // A subtree publish always enumerates at least its own root row; zero rows + // is a fetch gone wrong, not a completeness claim that the subtree is now + // empty. This must not wipe out a previously known subtree. + let forest = mergeSpine(EMPTY_FOREST, chain, { subject: AREA, revision: R1, root: "A" }); + forest = mergeSpine(forest, [], { subject: AREA, revision: R2, root: "A" }); + assert.deepEqual(ids(forest), ["A", "B", "C"]); +}); + +// --- the null-parent guard, at the merge boundary ------------------------- + +test("a subtree top with a parent null keeps the parent an earlier slice gave", () => { + // The collection index places area-1 under region-0. Expanding area-1 later + // fetches its own subtree document, which — being self-contained — names + // area-1 as ITS top with no parent. Merging that must not re-parent area-1 + // to null. + let forest = mergeSpine(EMPTY_FOREST, [node("region-0", null), node("area-1", "region-0")], { + subject: "plant-a", + revision: R1, + root: null, + }); + assert.equal(forest.nodes.get("area-1")?.parent, "region-0"); + + const subtreeDoc = [node("area-1", null), node("level-2", "area-1")]; + forest = mergeSpine(forest, subtreeDoc, { subject: "area-1", revision: R1, root: "area-1" }); + assert.equal(forest.nodes.get("area-1")?.parent, "region-0", "the known parent survives the self-declared root"); + assert.equal(forest.nodes.get("level-2")?.parent, "area-1"); +}); + +test("an empty forest merges an empty collection index into itself as a no-op", () => { + const forest = mergeSpine(EMPTY_FOREST, [], { subject: "plant-a", revision: R1, root: null }); + assert.equal(forest, EMPTY_FOREST, "an empty document with no root is ignored, not a deletion of nothing"); +}); diff --git a/src/frontend/src/__tests__/assets/orphans.test.ts b/src/frontend/src/__tests__/assets/orphans.test.ts new file mode 100644 index 000000000..f123c8844 --- /dev/null +++ b/src/frontend/src/__tests__/assets/orphans.test.ts @@ -0,0 +1,98 @@ +// The two orphan causes, and the path that answers "where was it?". +// +// Ported from orphans.test.ts. This module's API is now generic: +// `OrphanEntry.id` instead of `ref`, and `lastKnownPath` takes a +// `labelOf: (data: T) => string` rather than assuming a fixed node shape. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildHierarchy } from "../../assets/hierarchy"; +import { lastKnownPath, orphanCause, orphanHeading, orphanSentence, type OrphanEntry } from "../../assets/orphans"; + +const R1 = "20260825T000000Z"; +const R2 = "20260827T000000Z"; +const label = (r: string) => (r === R1 ? "25 Aug" : "27 Aug"); + +interface TestNode { + readonly kind: string; + readonly name: string; +} + +const node = (name: string): TestNode => ({ kind: "level", name }); + +// --------------------------------------------------------------------------- +// which of the two things happened +// --------------------------------------------------------------------------- + +test("an entry OLDER than the hierarchy was removed by a newer sweep", () => { + assert.equal(orphanCause(R1, R2), "removed"); +}); + +test("an entry NEWER than the hierarchy outran the sweep", () => { + assert.equal(orphanCause(R2, R1), "ahead"); +}); + +test("equal revisions cannot be 'the tree moved' — that reads as ahead", () => { + assert.equal(orphanCause(R1, R1), "ahead"); +}); + +test("the two headings are distinct — a shared string for both would be a bug in the panel", () => { + assert.notEqual(orphanHeading("removed"), orphanHeading("ahead")); +}); + +test("both revisions are IN the sentence, not in a tooltip", () => { + // A `title` is unreachable on touch, and the list is short. + const removed: OrphanEntry = { id: "n-0042", revision: R1, spineRevision: R2, cause: "removed", path: [] }; + const s = orphanSentence(removed, label); + assert.match(s, /25 Aug/); + assert.match(s, /27 Aug/); + assert.match(s, /still there/); + + const ahead: OrphanEntry = { ...removed, revision: R2, spineRevision: R1, cause: "ahead" }; + const a = orphanSentence(ahead, label); + assert.match(a, /25 Aug/); + assert.match(a, /a newer hierarchy publish would show where it sits/); +}); + +test("the sentences do not claim the opposite of each other's fact", () => { + const base: OrphanEntry = { id: "n-0042", revision: R1, spineRevision: R2, cause: "removed", path: [] }; + assert.match(orphanSentence(base, label), /no longer contains/); + assert.doesNotMatch( + orphanSentence({ ...base, cause: "ahead", revision: R2, spineRevision: R1 }, label), + /no longer contains/, + ); +}); + +// --------------------------------------------------------------------------- +// the path — what a list can answer without a tombstone in the tree +// --------------------------------------------------------------------------- + +const FOREST = buildHierarchy( + [ + { id: "area-1", parent: null, data: node("Area 1") }, + { id: "level-2", parent: "area-1", data: node("Level 2") }, + ], +); + +test("an orphan whose ancestors are loaded reports where it sat, outermost first", () => { + // n-0009 is NOT in the forest; its declared parent is, and so is that + // parent's. + const path = lastKnownPath( + FOREST, + "n-0009", + (id) => (id === "n-0009" ? "level-2" : (FOREST.byId.get(id)?.parent ?? null)), + (data) => data.name, + ); + assert.deepEqual(path, ["Area 1", "Level 2"]); +}); + +test("an orphan no spine can place reports an empty path rather than a guess", () => { + assert.deepEqual(lastKnownPath(FOREST, "n-9999", () => null, (data) => data.name), []); +}); + +test("a cycle in published data terminates", () => { + const parents: Record = { "n-a": "n-b", "n-b": "n-a" }; + const path = lastKnownPath(FOREST, "n-a", (id) => parents[id] ?? null, (data) => data.name); + assert.ok(path.length < 32); +}); diff --git a/src/frontend/src/__tests__/assets/projection.test.ts b/src/frontend/src/__tests__/assets/projection.test.ts new file mode 100644 index 000000000..2ccf461f0 --- /dev/null +++ b/src/frontend/src/__tests__/assets/projection.test.ts @@ -0,0 +1,140 @@ +// Reading `hierarchy.json` off the wire: unknown schemas are refused, columns +// are found by name (never by position), `leaf` accepts several spellings, and +// an absent/blank `provider` cell falls back to the slice's own provider. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_SCHEMA, HierarchyError, parseHierarchySlice } from "../../assets/projection"; +import type { WireHierarchySlice } from "../../assets/types"; + +const BASE_COLS = ["id", "parent", "label", "kind", "leaf", "delivery"]; + +function slice(overrides: Partial = {}): WireHierarchySlice { + return { + schema: HIERARCHY_SCHEMA, + provider: "fixture-lines", + collection: "plant-a", + root: null, + produced_at: "2026-08-25T13:55:53Z", + depth: 1, + cols: BASE_COLS, + rows: [["area-1", null, "Area 1", "area", 1, "mesh"]], + ...overrides, + }; +} + +test("an unknown schema is refused rather than partially read", () => { + assert.throws(() => parseHierarchySlice(slice({ schema: "ada.assets/hierarchy@2" })), HierarchyError); + assert.throws(() => parseHierarchySlice(slice({ schema: "something-else" })), HierarchyError); +}); + +test("columns are found by NAME — shuffled order and an extra unknown column give the same result", () => { + const inOrder = parseHierarchySlice(slice()); + const shuffled = parseHierarchySlice( + slice({ + cols: ["kind", "delivery", "extra-future-col", "id", "leaf", "label", "parent"], + rows: [["area", "mesh", "ignored-value", "area-1", 1, "Area 1", null]], + }), + ); + assert.deepEqual(shuffled.nodes, inOrder.nodes); +}); + +test("a row narrower or wider than the header throws", () => { + assert.throws( + () => parseHierarchySlice(slice({ rows: [["area-1", null, "Area 1", "area", 1]] })), + HierarchyError, + ); + assert.throws( + () => parseHierarchySlice(slice({ rows: [["area-1", null, "Area 1", "area", 1, "mesh", "extra"]] })), + HierarchyError, + ); +}); + +test("a missing required column is refused, named in the message", () => { + assert.throws(() => { + parseHierarchySlice( + slice({ + cols: ["id", "parent", "label", "kind", "delivery"], // no `leaf` + rows: [["area-1", null, "Area 1", "area", "mesh"]], + }), + ); + }, /leaf/); +}); + +test("leaf accepts 1, true, and their string forms", () => { + for (const v of [1, true, "1", "true", "TRUE", " true "]) { + const doc = slice({ rows: [["a", null, "A", "area", v, "none"]] }); + assert.equal(parseHierarchySlice(doc).nodes[0].leaf, true, JSON.stringify(v)); + } + for (const v of [0, false, "0", "false", "", "nah"]) { + const doc = slice({ rows: [["a", null, "A", "area", v, "none"]] }); + assert.equal(parseHierarchySlice(doc).nodes[0].leaf, false, JSON.stringify(v)); + } +}); + +test("delivery: empty string on the wire becomes 'none'", () => { + const doc = slice({ rows: [["a", null, "A", "area", 1, ""]] }); + assert.equal(parseHierarchySlice(doc).nodes[0].delivery, "none"); +}); + +test("delivery: an unrecognised value also collapses to 'none'", () => { + const doc = slice({ rows: [["a", null, "A", "area", 1, "sculpture"]] }); + assert.equal(parseHierarchySlice(doc).nodes[0].delivery, "none"); +}); + +test("an absent `provider` column: every node takes the slice's own provider", () => { + const doc = slice({ + provider: "fixture-outline", + rows: [ + ["area-1", null, "Area 1", "area", 0, "none"], + ["level-2", "area-1", "Level 2", "level", 1, "mesh"], + ], + }); + const parsed = parseHierarchySlice(doc); + assert.equal(parsed.nodes[0].provider, "fixture-outline"); + assert.equal(parsed.nodes[1].provider, "fixture-outline"); +}); + +test("a present-but-empty provider cell also falls back to the slice's provider", () => { + const doc = slice({ + provider: "fixture-outline", + cols: [...BASE_COLS, "provider"], + rows: [["area-1", null, "Area 1", "area", 0, "none", ""]], + }); + assert.equal(parseHierarchySlice(doc).nodes[0].provider, "fixture-outline"); +}); + +test("a non-empty provider cell overrides the slice's provider, per row", () => { + const doc = slice({ + provider: "fixture-outline", + cols: [...BASE_COLS, "provider"], + rows: [ + ["area-1", null, "Area 1", "area", 0, "none", ""], + ["level-2", "area-1", "Level 2", "level", 1, "mesh", "fixture-lines"], + ], + }); + const parsed = parseHierarchySlice(doc); + assert.equal(parsed.nodes[0].provider, "fixture-outline"); + assert.equal(parsed.nodes[1].provider, "fixture-lines"); +}); + +test("a null/empty parent cell means a root row", () => { + const doc = slice({ + rows: [ + ["area-1", null, "Area 1", "area", 0, "none"], + ["area-2", "", "Area 2", "area", 0, "none"], + ], + }); + const parsed = parseHierarchySlice(doc); + assert.equal(parsed.nodes[0].parent, null); + assert.equal(parsed.nodes[1].parent, null); +}); + +test("the rest of the slice's header fields pass through", () => { + const parsed = parseHierarchySlice(slice({ root: "area-1", depth: 3, produced_at: "2026-08-25T13:55:53Z" })); + assert.equal(parsed.root, "area-1"); + assert.equal(parsed.depth, 3); + assert.equal(parsed.producedAt, "2026-08-25T13:55:53Z"); + assert.equal(parsed.collection, "plant-a"); +}); diff --git a/src/frontend/src/__tests__/assets/resolve.test.ts b/src/frontend/src/__tests__/assets/resolve.test.ts new file mode 100644 index 000000000..b19b7b823 --- /dev/null +++ b/src/frontend/src/__tests__/assets/resolve.test.ts @@ -0,0 +1,146 @@ +// The three resolution modes, over a listing with genuinely mixed dates. +// +// The scenario: a collection re-published on the 27th while one subject was +// last published on the 25th and another only on the 26th. Under `latest` +// those badges sit side by side in the same tree though they came out of +// publishes days apart — fine, but `describeResolution().mixed` has to say so. +// +// Ported from resolve.test.ts, dropping `resolutionLabel`/`absentHere` (not +// carried into this module set) and adding coverage for `incomplete`, which +// is new here: only COMPLETE revisions (asset.json present) are candidates. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { describeResolution, resolveCollection, revisionsInPlay } from "../../assets/resolve"; + +const R_A = "20260825T135553Z"; +const R_B = "20260826T071500Z"; +const R_C = "20260827T090000Z"; + +const COLL = "plant-a"; +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +/** The collection re-published on the 25th and the 26th; area-1 only on the + * 25th; area-2 only on the 27th. */ +const INDEX = foldListing([ + K(COLL, R_A, MANIFEST_FILENAME), + K(COLL, R_A, HIERARCHY_FILENAME), + K("area-0", R_A, MANIFEST_FILENAME), + K("area-0", R_A, HIERARCHY_FILENAME), + + K(COLL, R_B, MANIFEST_FILENAME), + K("area-0", R_B, MANIFEST_FILENAME), + + K("area-1", R_A, MANIFEST_FILENAME), + + K(COLL, R_C, MANIFEST_FILENAME), + K("area-2", R_C, MANIFEST_FILENAME), +]); + +test("latest takes each subject's newest, independently — so it can mix", () => { + const r = resolveCollection(INDEX, COLL, { kind: "latest" }); + assert.equal(r.subjects.get("area-0")?.revision.revision, R_B); + assert.equal(r.subjects.get("area-1")?.revision.revision, R_A); + assert.equal(r.subjects.get("area-2")?.revision.revision, R_C); + assert.equal(r.subjects.get(COLL)?.revision.revision, R_C); + assert.equal(r.missing.size, 0, "latest can never leave a subject unresolved"); + + const s = describeResolution(r); + assert.deepEqual(revisionsInPlay(r), [R_A, R_B, R_C]); + assert.equal(s.mixed, true); + assert.equal(s.coeval, false); + assert.equal(s.subjectCount, 4); +}); + +test("as-of takes each subject's newest AT OR BEFORE the cut — reproducible, still not coeval", () => { + const mode = { kind: "as-of", revision: R_B } as const; + const r = resolveCollection(INDEX, COLL, mode); + assert.equal(r.subjects.get("area-0")?.revision.revision, R_B); + assert.equal(r.subjects.get("area-1")?.revision.revision, R_A); + // Published after the cut, so it resolves to nothing — and the tab is told + // what IS there so it can offer one click to go and see it. + assert.equal(r.subjects.has("area-2"), false); + assert.equal(r.missing.get("area-2")?.newest, R_C); + + const s = describeResolution(r); + assert.equal(s.mixed, true); + assert.equal(s.coeval, false); +}); + +test("as-of before every revision resolves to nothing at all", () => { + const r = resolveCollection(INDEX, COLL, { kind: "as-of", revision: "20200101T000000Z" }); + assert.equal(r.subjects.size, 0); + assert.equal(r.missing.size, 4); +}); + +test("run is exact — and is the only genuinely coeval mode", () => { + const mode = { kind: "run", revision: R_A } as const; + const r = resolveCollection(INDEX, COLL, mode); + assert.deepEqual([...r.subjects.keys()].sort(), ["area-0", "area-1", COLL].sort()); + assert.equal(r.subjects.get("area-0")?.revision.revision, R_A); + assert.equal(r.missing.get("area-2")?.newest, R_C); + + const s = describeResolution(r); + assert.deepEqual(s.revisions, [R_A]); + assert.equal(s.mixed, false); + assert.equal(s.coeval, true); +}); + +test("run is coeval even when only one subject participates", () => { + const mode = { kind: "run", revision: R_C } as const; + const s = describeResolution(resolveCollection(INDEX, COLL, mode)); + assert.equal(s.coeval, true); + assert.equal(s.mixed, false); +}); + +test("`newest` is carried on every resolved subject, whatever the mode picked", () => { + const r = resolveCollection(INDEX, COLL, { kind: "run", revision: R_A }); + assert.equal(r.subjects.get("area-0")?.revision.revision, R_A); + assert.equal(r.subjects.get("area-0")?.newest, R_B); +}); + +test("`skip` removes a subject from both revisionsInPlay and describeResolution", () => { + const r = resolveCollection(INDEX, COLL, { kind: "latest" }); + const skipCollection = (s: string) => s === COLL; + // Without the collection subject in the mix, only area-0/1/2's dates count — + // still three distinct dates here, so still mixed either way, but the count + // of subjects considered changes. + const s = describeResolution(r, skipCollection); + assert.equal(s.subjectCount, 4, "subjectCount is NOT affected by skip — only mixed/revisions are"); + assert.deepEqual(revisionsInPlay(r, skipCollection).length <= revisionsInPlay(r).length, true); +}); + +// --- incomplete: a publish that died partway -------------------------------- + +const WITH_INCOMPLETE = foldListing([ + K(COLL, R_A, MANIFEST_FILENAME), + K(COLL, R_A, HIERARCHY_FILENAME), + K("area-1", R_A, MANIFEST_FILENAME), + // area-3's only revision has a hierarchy.json but never got its asset.json — + // the publish died before the manifest was written. + K("area-3", R_B, HIERARCHY_FILENAME), +]); + +test("a subject with no complete revision is reported in `incomplete`, not resolved and not missing", () => { + const r = resolveCollection(WITH_INCOMPLETE, COLL, { kind: "latest" }); + assert.equal(r.subjects.has("area-3"), false); + assert.equal(r.missing.has("area-3"), false, "missing implies a concrete complete alternative exists"); + assert.deepEqual(r.incomplete, ["area-3"]); +}); + +test("incomplete is empty when every subject's newest revision has a manifest", () => { + const r = resolveCollection(INDEX, COLL, { kind: "latest" }); + assert.deepEqual(r.incomplete, []); +}); + +test("a subject with an OLDER complete revision behind a newer incomplete one still resolves to the complete one", () => { + const idx = foldListing([ + K("area-4", R_A, MANIFEST_FILENAME), // complete at R_A + K("area-4", R_B, HIERARCHY_FILENAME), // died partway at R_B + ]); + const r = resolveCollection(idx, "plant-a", { kind: "latest" }); + assert.equal(r.subjects.get("area-4")?.revision.revision, R_A); + assert.deepEqual(r.incomplete, ["area-4"]); +}); diff --git a/src/frontend/src/__tests__/assets/rowFacts.test.ts b/src/frontend/src/__tests__/assets/rowFacts.test.ts new file mode 100644 index 000000000..c5c47a15b --- /dev/null +++ b/src/frontend/src/__tests__/assets/rowFacts.test.ts @@ -0,0 +1,125 @@ +// The row renderer's single entry point: `rowFacts` reduces one `AssetView` +// plus an id to everything one row shows, and `searchRows` is the +// ancestor-closed match set a search box needs to keep a hit visible inside +// a collapsed branch. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildAssetView } from "../../assets/assetView"; +import { EMPTY_FOREST, mergeSpine } from "../../assets/merge"; +import { rowFacts, searchRows } from "../../assets/rowFacts"; +import type { AssetNode, ManifestSummary } from "../../assets/types"; + +const COLL = "plant-a"; +const R1 = "20260825T000000Z"; +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +const an = (id: string, parent: string | null, kind: string, leaf: boolean): AssetNode => ({ + id, + parent, + label: id, + kind, + leaf, + delivery: "none", + provider: "fixture-lines", +}); + +// area-1 -> level-2 -> member-3 (published, content), member-4 (unpublished, gap) +function fixture() { + const forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false), an("level-2", "area-1", "level", false), an("member-3", "level-2", "member", true), an("member-4", "level-2", "member", true)], { + subject: COLL, + revision: R1, + root: null, + }); + const manifests = new Map([ + [`${COLL}/member-3/${R1}`, { provider: "fixture-lines", node: "member-3", delivery: "mesh", producedAt: R1, hierarchyRevision: null }], + ]); + const index = foldListing( + [K(COLL, R1, MANIFEST_FILENAME), K(COLL, R1, HIERARCHY_FILENAME), K("member-3", R1, MANIFEST_FILENAME)], + manifests, + ); + return buildAssetView({ forest, index, collection: COLL, mode: { kind: "latest" }, indexRevisions: [R1] }); +} + +test("a row rooting content gets a solid badge naming itself", () => { + const view = fixture(); + const facts = rowFacts(view, "member-3"); + assert.equal(facts?.badge?.weight, "solid"); + assert.equal(facts?.badge?.at, "member-3"); + assert.equal(facts?.badge?.delivery, "mesh"); + assert.equal(facts?.gap, false); +}); + +test("an ancestor of a published leaf gets a below badge, not solid or ghost", () => { + const view = fixture(); + const facts = rowFacts(view, "level-2"); + assert.equal(facts?.badge?.weight, "below"); + assert.equal(facts?.badge?.at, "member-3"); +}); + +test("an unpublished sibling leaf is a gap with no badge", () => { + const view = fixture(); + const facts = rowFacts(view, "member-4"); + assert.equal(facts?.badge, null); + assert.equal(facts?.gap, true); + assert.equal(facts?.uncovered, 1); +}); + +test("an id outside the hierarchy returns null rather than throwing", () => { + const view = fixture(); + assert.equal(rowFacts(view, "not-a-row"), null); +}); + +test("dimmed is suppressed for an unexplored branch — absence there is not yet known", () => { + // A published subtree spine at level-2 has not been fetched: `spineLoaded` + // omits it, so `unexplored` covers level-2 and its ancestors. + const forest = mergeSpine(EMPTY_FOREST, [an("area-1", null, "area", false)], { subject: COLL, revision: R1, root: null }); + const manifests = new Map([ + [`${COLL}/area-1/${R1}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: R1, hierarchyRevision: null }], + ]); + const index = foldListing( + [K(COLL, R1, MANIFEST_FILENAME), K(COLL, R1, HIERARCHY_FILENAME), K("area-1", R1, MANIFEST_FILENAME), K("area-1", R1, HIERARCHY_FILENAME)], + manifests, + ); + const view = buildAssetView({ + forest, + index, + collection: COLL, + mode: { kind: "latest" }, + indexRevisions: [R1], + spineLoaded: new Map(), // nothing fetched yet + }); + assert.ok(view.unexplored.has("area-1")); + assert.equal(rowFacts(view, "area-1")?.dimmed, false); +}); + +// --- searchRows -------------------------------------------------------------- + +test("searchRows matches by label or id, case-insensitively, and closes over ancestors", () => { + const view = fixture(); + const result = searchRows(view.hierarchy, "member-3"); + assert.ok(result); + assert.equal(result!.matches, 1); + assert.ok(result!.include.has("member-3")); + assert.ok(result!.include.has("level-2"), "the ancestor chain is kept so the hit stays reachable"); + assert.ok(result!.include.has("area-1")); + assert.ok(result!.open.has("level-2")); + assert.ok(result!.open.has("area-1")); + assert.equal(result!.open.has("member-3"), false, "the match itself need not be forced open"); +}); + +test("a blank search term is not a search", () => { + const view = fixture(); + assert.equal(searchRows(view.hierarchy, ""), null); + assert.equal(searchRows(view.hierarchy, " "), null); +}); + +test("no matches still returns an (empty) result, not null", () => { + const view = fixture(); + const result = searchRows(view.hierarchy, "nothing-matches-this"); + assert.ok(result); + assert.equal(result!.matches, 0); + assert.equal(result!.include.size, 0); +}); diff --git a/src/frontend/src/__tests__/assets/spines.test.ts b/src/frontend/src/__tests__/assets/spines.test.ts new file mode 100644 index 000000000..56da18b74 --- /dev/null +++ b/src/frontend/src/__tests__/assets/spines.test.ts @@ -0,0 +1,263 @@ +// WHICH PUBLISHED SPINE COVERS A NODE, and whether it is already merged. +// +// Ported from spineCoverage.test.ts. Looking a node's spine up by its own id +// answers "is a spine published AT this node", not "does a published spine +// CONTAIN this node" -- the same thing only when every clickable row is +// itself a publish root. `spineCoverage` resolves NEAREST SPINE AT OR ABOVE +// instead. +// +// In this module set a node id IS the subject directly (no ref-to-subject +// encoding the way the plugin's `=16509/38555` refs needed). + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildHierarchy, flattenVisible, type Hierarchy } from "../../assets/hierarchy"; +import { resolveCollection } from "../../assets/resolve"; +import { canFetchSpine, rowSpineState, spineCoverage, spineMerged, spineRootedAt, type SpineSource } from "../../assets/spines"; +import type { AssetNode } from "../../assets/types"; + +const REVISION = "20260914T173816Z"; +const COLL = "plant-a"; +const AREA = "area-1"; +const LEVELS = ["level-1", "level-2", "level-3"]; + +function node(id: string, parent: string | null, kind: string, leaf: boolean): AssetNode { + return { id, parent, label: id, kind, leaf, delivery: "none", provider: "fixture-lines" }; +} + +/** The collection-index rows: the area and its levels, every one `leaf: false` + * -- the publish's positive claim that it HAS children. */ +function indexRows(): AssetNode[] { + return [node(AREA, null, "area", false), ...LEVELS.map((z) => node(z, AREA, "level", false))]; +} + +/** The area's own spine: the same rows, plus what is under each level. */ +function spineRows(): AssetNode[] { + const out = indexRows(); + for (const [i, level] of LEVELS.entries()) { + const frame = `frame-${i}`; + out.push(node(frame, level, "frame", false)); + out.push(node(`member-${i}`, frame, "member", true)); + } + return out; +} + +function forest(rows: readonly AssetNode[]): Hierarchy { + return buildHierarchy(rows.map((n) => ({ id: n.id, parent: n.parent, data: n }))); +} + +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +/** A resolution where only `AREA`'s own subtree spine is published. */ +function areaResolution() { + const idx = foldListing([K(AREA, REVISION, MANIFEST_FILENAME), K(AREA, REVISION, HIERARCHY_FILENAME)]); + return resolveCollection(idx, COLL, { kind: "latest" }); +} + +const NOTHING_LOADED: ReadonlyMap = new Map(); +const AREA_LOADED: ReadonlyMap = new Map([[AREA, REVISION]]); + +// --------------------------------------------------------------------------- // +// spineRootedAt / spineCoverage: nearest-at-or-above +// --------------------------------------------------------------------------- // + +test("a level is covered by the spine published at the area above it", () => { + const res = areaResolution(); + const cover = spineCoverage(forest(indexRows()), (id) => spineRootedAt(res, id)); + for (const level of LEVELS) { + const source = cover.get(level); + assert.ok(source, `${level} must resolve to the spine that contains it`); + assert.equal(source!.root, AREA); + assert.equal(source!.subject, AREA, "the id IS the subject directly — no ref transform"); + assert.equal(source!.revision, REVISION); + } +}); + +test("the collection subject itself is never its own spine", () => { + const res = areaResolution(); + assert.equal(spineRootedAt(res, COLL), null); +}); + +test("a level the index advertises can be expanded before its spine is in", () => { + const h = forest(indexRows()); + const res = areaResolution(); + const cover = spineCoverage(h, (id) => spineRootedAt(res, id)); + const level = h.byId.get(LEVELS[0])?.data; + assert.equal(canFetchSpine(level, cover.get(LEVELS[0]) ?? null, NOTHING_LOADED), true); + + const rows = flattenVisible(h, new Set([AREA]), { + expandable: (id) => canFetchSpine(h.byId.get(id)?.data, cover.get(id) ?? null, NOTHING_LOADED), + }); + const levelRow = rows.find((r) => r.id === LEVELS[0]); + assert.ok(levelRow); + assert.equal(levelRow!.hasChildren, true, "a branch with a covering spine must offer an expansion"); +}); + +test("once the area spine is merged the leaves under it offer nothing", () => { + const h = forest(spineRows()); + const res = areaResolution(); + const cover = spineCoverage(h, (id) => spineRootedAt(res, id)); + const rows = flattenVisible(h, new Set([AREA, ...LEVELS, "frame-0"]), { + expandable: (id) => canFetchSpine(h.byId.get(id)?.data, cover.get(id) ?? null, AREA_LOADED), + }); + const leaf = rows.find((r) => r.id === "member-0"); + assert.ok(leaf); + assert.equal(leaf!.hasChildren, false, "a leaf must never sprout a twisty"); + const levelRow = rows.find((r) => r.id === LEVELS[0]); + assert.equal(levelRow?.hasChildren, true, "the level draws its twisty from the rows it now has"); + assert.equal(canFetchSpine(h.byId.get(LEVELS[0])?.data, cover.get(LEVELS[0]) ?? null, AREA_LOADED), false); +}); + +test("the nearest spine wins, so a nested publish beats the area above it", () => { + const idx = foldListing([ + K(AREA, REVISION, MANIFEST_FILENAME), + K(AREA, REVISION, HIERARCHY_FILENAME), + K(LEVELS[0], "20260915T090000Z", MANIFEST_FILENAME), + K(LEVELS[0], "20260915T090000Z", HIERARCHY_FILENAME), + ]); + const res = resolveCollection(idx, COLL, { kind: "latest" }); + const cover = spineCoverage(forest(spineRows()), (id) => spineRootedAt(res, id)); + assert.equal(cover.get(LEVELS[0])?.root, LEVELS[0]); + assert.equal(cover.get("frame-0")?.root, LEVELS[0], "a row inside the nested spine follows it"); + assert.equal(cover.get(LEVELS[1])?.root, AREA, "a level outside it still follows the area"); +}); + +test("a spine at a new revision is a different document and is re-read", () => { + const idx = foldListing([K(AREA, "20260915T090000Z", MANIFEST_FILENAME), K(AREA, "20260915T090000Z", HIERARCHY_FILENAME)]); + const res = resolveCollection(idx, COLL, { kind: "latest" }); + const h = forest(spineRows()); + const cover = spineCoverage(h, (id) => spineRootedAt(res, id)); + const stale = new Map([[AREA, REVISION]]); + assert.equal(spineMerged(cover.get(LEVELS[0]) ?? null, stale), false); + // ...but a leaf still offers nothing. + assert.equal(canFetchSpine(h.byId.get("member-0")?.data, cover.get("member-0") ?? null, stale), false); + assert.equal(canFetchSpine(h.byId.get(LEVELS[0])?.data, cover.get(LEVELS[0]) ?? null, stale), true); +}); + +// --------------------------------------------------------------------------- // +// rowSpineState: the promise on the row, and what it costs +// --------------------------------------------------------------------------- // + +test("a row states the spine it is waiting on, not the id it was asked about", () => { + const h = forest(indexRows()); + const res = areaResolution(); + const cover = spineCoverage(h, (id) => spineRootedAt(res, id)); + const ask = (id: string, loading: ReadonlySet, errors: ReadonlyMap) => + rowSpineState({ + node: h.byId.get(id)?.data, + hasChildren: h.childrenOf(id).length > 0, + source: cover.get(id) ?? null, + loaded: NOTHING_LOADED, + loading, + errors, + }); + + // The area's file is in flight; the level inside it is what the user clicked. + const inFlight = ask(LEVELS[0], new Set([AREA]), new Map()); + assert.equal(inFlight.loading, true); + assert.equal(inFlight.error, null); + assert.equal(inFlight.deadEnd, false); + + const failed = ask(LEVELS[0], new Set(), new Map([[AREA, "404 Not Found"]])); + assert.equal(failed.loading, false); + assert.equal(failed.error, "404 Not Found"); +}); + +test("a merged spine goes quiet again for every row it contributed", () => { + const h = forest(indexRows()); + const res = areaResolution(); + const cover = spineCoverage(h, (id) => spineRootedAt(res, id)); + const quiet = rowSpineState({ + node: h.byId.get(LEVELS[0])?.data, + hasChildren: true, + source: cover.get(LEVELS[0]) ?? null, + loaded: AREA_LOADED, + loading: new Set([AREA]), + errors: new Map([[AREA, "404 Not Found"]]), + }); + assert.equal(quiet.loading, false); + assert.equal(quiet.error, null); +}); + +test("a branch nothing covers is a dead end once explored; a leaf never is", () => { + const h = forest(indexRows()); + const cover = spineCoverage(h, () => null); + const level = h.byId.get(LEVELS[0])?.data; + const none = cover.get(LEVELS[0]) ?? null; + + const state = rowSpineState({ + node: level, + hasChildren: false, + source: none, + loaded: NOTHING_LOADED, + loading: new Set(), + errors: new Map(), + }); + assert.equal(state.deadEnd, true, "leaf:false, nothing covers it, no children held -> dead end"); + + const leafState = rowSpineState({ + node: h.byId.get(AREA)?.data, + hasChildren: true, + source: none, + loaded: NOTHING_LOADED, + loading: new Set(), + errors: new Map(), + }); + assert.equal(leafState.deadEnd, false, "it has children, so it is not a dead end"); +}); + +// --------------------------------------------------------------------------- // +// spineCoverage is lazy and memoised, not one eager pass +// --------------------------------------------------------------------------- // + +test("spineCoverage does no work until a row is asked about", () => { + const h = forest(spineRows()); + const res = areaResolution(); + const asked: string[] = []; + const counting = (id: string): SpineSource | null => { + asked.push(id); + return spineRootedAt(res, id); + }; + spineCoverage(h, counting); + assert.equal(asked.length, 0, "building the lookup must not walk the forest eagerly"); +}); + +test("visiting every row once costs one rootedAt call per row, not a walk per row", () => { + // The questions come from the rows on screen; a walk up stops at the first + // memoised ancestor, so asking about a whole branch top-to-bottom costs one + // `rootedAt` call per distinct id, never more. + const h = forest(spineRows()); + const res = areaResolution(); + const asked: string[] = []; + const counting = (id: string): SpineSource | null => { + asked.push(id); + return spineRootedAt(res, id); + }; + const cover = spineCoverage(h, counting); + for (const id of h.order) cover.get(id); + assert.equal(asked.length, h.order.length); + assert.equal(new Set(asked).size, h.order.length); +}); + +test("asking the same row again after it is memoised costs nothing more", () => { + const h = forest(spineRows()); + const res = areaResolution(); + const asked: string[] = []; + const counting = (id: string): SpineSource | null => { + asked.push(id); + return spineRootedAt(res, id); + }; + const cover = spineCoverage(h, counting); + cover.get(LEVELS[0]); + const afterFirst = asked.length; + cover.get(LEVELS[0]); + assert.equal(asked.length, afterFirst, "a memoised row answers without calling rootedAt again"); +}); + +test("an id not in the forest resolves to undefined rather than throwing", () => { + const h = forest(indexRows()); + const cover = spineCoverage(h, () => null); + assert.equal(cover.get("not-a-real-id"), undefined); +}); diff --git a/src/frontend/src/__tests__/assets/sweepShadowing.test.ts b/src/frontend/src/__tests__/assets/sweepShadowing.test.ts new file mode 100644 index 000000000..afbc3f495 --- /dev/null +++ b/src/frontend/src/__tests__/assets/sweepShadowing.test.ts @@ -0,0 +1,119 @@ +// A HIERARCHY SWEEP MUST NOT SHADOW THE CONTENT UNDER IT. +// +// The fault this pins, ported from the original plugin's regression test. A +// collection sweep writes a manifest at every root it covers and no delivery +// claim anywhere, because it is a hierarchy-only publish. If resolution picked +// one revision per subject — the newest — the sweep would become every +// subject's answer and a genuinely delivered publish underneath it would be +// shadowed. `resolveCollection`'s second pick (`content`, gated by +// `carriesContent`) is what keeps the badge alive: one resolution, same mode, +// same bound, answering two questions that were always different. +// +// "Carries content" here is `assetView.ts`'s `carriesContent`: the resolved +// revision's manifest summary has `delivery !== "none"`. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { HIERARCHY_FILENAME, MANIFEST_FILENAME, foldListing } from "../../assets/assetIndex"; +import { buildAssetHierarchy, buildAssetView, carriesContent, ROLE_CONTENT, ROLE_TREE } from "../../assets/assetView"; +import { EMPTY_FOREST, mergeSpine } from "../../assets/merge"; +import { resolveCollection } from "../../assets/resolve"; +import type { AssetNode, ManifestSummary } from "../../assets/types"; + +const COLL = "plant-a"; +const CONTENT_RUN = "20260825T135553Z"; // a real delivery of area-1's mesh +const SWEEP = "20260825T235800Z"; // a later hierarchy-only sweep touching area-1 too + +const K = (s: string, r: string, f: string): string => `assets/${COLL}/${s}/${r}/${f}`; + +const INDEX = foldListing( + [ + K(COLL, CONTENT_RUN, MANIFEST_FILENAME), + K(COLL, CONTENT_RUN, HIERARCHY_FILENAME), + K("area-1", CONTENT_RUN, MANIFEST_FILENAME), + K("area-1", CONTENT_RUN, HIERARCHY_FILENAME), + + K(COLL, SWEEP, MANIFEST_FILENAME), + K(COLL, SWEEP, HIERARCHY_FILENAME), + K("area-1", SWEEP, MANIFEST_FILENAME), + K("area-1", SWEEP, HIERARCHY_FILENAME), + ], + new Map([ + [`${COLL}/${COLL}/${CONTENT_RUN}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: CONTENT_RUN, hierarchyRevision: null }], + [`${COLL}/area-1/${CONTENT_RUN}`, { provider: "fixture-lines", node: "area-1", delivery: "mesh", producedAt: CONTENT_RUN, hierarchyRevision: null }], + [`${COLL}/${COLL}/${SWEEP}`, { provider: "fixture-lines", node: null, delivery: "none", producedAt: SWEEP, hierarchyRevision: null }], + [`${COLL}/area-1/${SWEEP}`, { provider: "fixture-lines", node: "area-1", delivery: "none", producedAt: SWEEP, hierarchyRevision: null }], + ]), +); + +// -- resolution --------------------------------------------------------------- + +test("a subject resolves twice: its newest revision, and its newest revision WITH content", () => { + const res = resolveCollection(INDEX, COLL, { kind: "latest" }, { carriesContent }); + const area1 = res.subjects.get("area-1"); + assert.equal(area1?.revision.revision, SWEEP, "the newest revision is still the sweep"); + assert.equal(area1?.content?.revision, CONTENT_RUN, "and the content publish is still found"); +}); + +test("with no content predicate the second pick IS the first, not null", () => { + // Callers that do not make the distinction must be unaffected by its + // existence — otherwise adding the field is a silent behaviour change. + const res = resolveCollection(INDEX, COLL, { kind: "latest" }); + const area1 = res.subjects.get("area-1"); + assert.equal(area1?.content, area1?.revision); +}); + +test("the content pick stays inside the bound the mode draws", () => { + // `run` is the only coeval mode, and it has to stay that way: a run that + // published no content answers "none" rather than reaching back to an + // earlier publish that was not part of it. + const res = resolveCollection(INDEX, COLL, { kind: "run", revision: SWEEP }, { carriesContent }); + const area1 = res.subjects.get("area-1"); + assert.equal(area1?.revision.revision, SWEEP); + assert.equal(area1?.content, null, "the sweep is not coeval with the earlier content publish"); +}); + +test("as-of cuts the content pick too — it does not reach past the cut", () => { + const res = resolveCollection(INDEX, COLL, { kind: "as-of", revision: "20260101T000000Z" }, { carriesContent }); + assert.equal(res.subjects.get("area-1"), undefined); + assert.equal(res.missing.get("area-1")?.newest, SWEEP); +}); + +// -- the badge the sweep must not erase --------------------------------------- + +const NODES: readonly AssetNode[] = [ + { id: "area-1", parent: null, label: "Area 1", kind: "area", leaf: false, delivery: "none", provider: "fixture-lines" }, + { id: "level-2", parent: "area-1", label: "Level 2", kind: "level", leaf: false, delivery: "none", provider: "fixture-lines" }, + { id: "member-3", parent: "level-2", label: "Member 3", kind: "member", leaf: true, delivery: "none", provider: "fixture-lines" }, +]; + +function view(mode: Parameters[0]["mode"] = { kind: "latest" }) { + const forest = mergeSpine(EMPTY_FOREST, NODES, { subject: "area-1", revision: SWEEP, root: "area-1" }); + const hierarchy = buildAssetHierarchy(forest); + const indexRevisions = ["20260825T000000Z" /* an older sweep index too */, SWEEP].filter((r) => + // Only the ones actually merged into the index would be passed in + // production; here the mode's own bound decides what counts. + mode.kind !== "run" ? true : r === mode.revision, + ); + return buildAssetView({ forest, index: INDEX, collection: COLL, mode, hierarchy, indexRevisions }); +} + +test("the sweep does not take the content badge off the area it swept", () => { + const cov = view().coverage.byId.get("area-1"); + assert.ok(cov, "the area is in the tree"); + assert.ok(cov!.rooted.has(ROLE_CONTENT), "content is still rooted at the area"); + assert.ok(cov!.rooted.has(ROLE_TREE), "and the sweep's own tree-role still is too"); +}); + +test("the content badge propagates to a descendant as a ghost, not lost", () => { + const cov = view().coverage.byId.get("member-3"); + assert.ok(cov!.covered.has(ROLE_CONTENT)); + assert.equal(cov!.coveredBy.get(ROLE_CONTENT), "area-1"); +}); + +test("under the sweep's own run there is genuinely no content — and that is correct", () => { + const cov = view({ kind: "run", revision: SWEEP }).coverage.byId.get("area-1"); + assert.equal(cov!.rooted.has(ROLE_CONTENT), false, "a run lens must not manufacture content that run did not publish"); + assert.equal(cov!.rooted.has(ROLE_TREE), true); +}); diff --git a/src/frontend/src/__tests__/assets/synthetic.ts b/src/frontend/src/__tests__/assets/synthetic.ts new file mode 100644 index 000000000..0e75f978e --- /dev/null +++ b/src/frontend/src/__tests__/assets/synthetic.ts @@ -0,0 +1,63 @@ +// A synthetic large spine, shaped like the real worst case this module set is +// sized for: one root, forty branches, ten groups per branch, a hundred +// leaves per group -- ~40k rows, depth 3. Exported so the performance test +// and any future one can share it without regenerating fixtures by hand. + +import { HIERARCHY_SCHEMA } from "../../assets/projection"; +import type { WireHierarchySlice } from "../../assets/types"; + +export const SYNTHETIC_ROOT = "area-1"; +const BRANCH_COUNT = 40; +const GROUPS_PER_BRANCH = 10; +const LEAVES_PER_GROUP = 100; + +export interface SyntheticSpine { + readonly wire: WireHierarchySlice; + /** Every branch id, in generation order. */ + readonly branchIds: readonly string[]; + /** A sample of leaf ids spread across the tree, for spot manifests. */ + readonly sampleLeafIds: readonly string[]; + readonly rowCount: number; +} + +export function buildSyntheticSpine(): SyntheticSpine { + const cols = ["id", "parent", "label", "kind", "leaf", "delivery"]; + const rows: unknown[][] = []; + const branchIds: string[] = []; + const sampleLeafIds: string[] = []; + + rows.push([SYNTHETIC_ROOT, null, "Area 1", "area", 0, "none"]); + + for (let b = 0; b < BRANCH_COUNT; b++) { + const branchId = `branch-${b}`; + branchIds.push(branchId); + rows.push([branchId, SYNTHETIC_ROOT, `Branch ${b}`, "level", 0, "none"]); + for (let g = 0; g < GROUPS_PER_BRANCH; g++) { + const groupId = `${branchId}-group-${g}`; + rows.push([groupId, branchId, `Group ${b}.${g}`, "frame", 0, "none"]); + for (let l = 0; l < LEAVES_PER_GROUP; l++) { + const leafId = `${groupId}-leaf-${l}`; + rows.push([leafId, groupId, `Member ${b}.${g}.${l}`, "member", 1, "mesh"]); + // One sample leaf per group keeps the sample spread across every + // branch without walking the whole array afterwards. + if (l === 0) sampleLeafIds.push(leafId); + } + } + } + + return { + wire: { + schema: HIERARCHY_SCHEMA, + provider: "fixture-lines", + collection: "plant-a", + root: SYNTHETIC_ROOT, + produced_at: "2026-08-25T13:55:53Z", + depth: 3, + cols, + rows, + }, + branchIds, + sampleLeafIds, + rowCount: rows.length, + }; +} diff --git a/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts b/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts new file mode 100644 index 000000000..35ce2be79 --- /dev/null +++ b/src/frontend/src/__tests__/state/assetBrowserLoader.test.ts @@ -0,0 +1,191 @@ +/** + * The Assets tab's fetch side, driven against canned wire documents. + * + * Pins: the collection opened is the one with the newest publish; the tops of + * the tree are the UNION of every collection index the mode admits, merged + * oldest first so the newest word about a shared node wins even when an older + * index arrives late; a spine is fetched once per (root, revision); and a + * response for a collection the user has since left is dropped. + */ + +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import type { WireAssetIndex, WireHierarchySlice } from "../../assets/types"; +import { useAssetBrowserStore } from "../../state/assetBrowserStore"; +import { createAssetBrowserLoader, type AssetsApiLike } from "../../state/assetBrowserLoader"; + +const SCOPE = "user:me"; +const R1 = "20260901T100000Z"; +const R2 = "20260902T100000Z"; +const R3 = "20260903T100000Z"; + +const COLS = ["id", "parent", "label", "kind", "leaf", "delivery"]; + +function slice(root: string | null, rows: unknown[][], collection = "plant-a"): WireHierarchySlice { + return { + schema: "ada.assets/hierarchy@1", + provider: "fixture-lines", + collection, + root, + produced_at: "2026-09-01T10:00:00Z", + depth: root ? 9 : 1, + cols: COLS, + rows, + }; +} + +const rev = (revision: string, files: string[], delivery = "none") => ({ + revision, + files, + manifest: { provider: "fixture-lines", delivery: delivery as "none", produced_at: "x" }, +}); + +/** plant-a: two collection indexes (R1 lists area-1, R2 relabels it and adds + * area-2); area-1 has its own subtree spine at R1. other: an older collection. */ +function makeApi() { + const calls: string[] = []; + const indexFor: Record = { + "*": { + collections: { + "plant-a": [{ subject: "plant-a", revisions: [{ revision: R2, files: [] }] }], + "old-b": [{ subject: "old-b", revisions: [{ revision: R1, files: [] }] }], + }, + malformed: [], + }, + "plant-a": { + collections: { + "plant-a": [ + { + subject: "plant-a", + // newest first, as the server sends it + revisions: [ + rev(R2, ["asset.json", "hierarchy.json"]), + rev(R1, ["asset.json", "hierarchy.json"]), + ], + }, + { subject: "area-1", revisions: [rev(R1, ["asset.json", "hierarchy.json"], "build")] }, + ], + }, + malformed: [], + }, + "old-b": { collections: { "old-b": [] }, malformed: [] }, + }; + const trees: Record = { + [`plant-a|index|${R1}`]: slice(null, [ + ["area-1", null, "Area One (old label)", "area", 0, ""], + ]), + [`plant-a|index|${R2}`]: slice(null, [ + ["area-1", null, "Area One", "area", 0, ""], + ["area-2", null, "Area Two", "area", 0, ""], + ]), + [`plant-a|area-1|${R1}`]: slice("area-1", [ + ["area-1", null, "Area One", "area", 0, "build"], + ["level-1", "area-1", "Level 1", "level", 0, ""], + ["member-1", "level-1", "Member 1", "member", 1, "build"], + ]), + }; + const gates = new Map void>(); + const api: AssetsApiLike = { + async getAssetIndex(_scope, collection) { + calls.push(`index:${collection ?? "*"}`); + return indexFor[collection ?? "*"]; + }, + async getAssetTree(_scope, _provider, collection, opts) { + const key = `${collection}|${opts.root ?? "index"}|${opts.revision}`; + calls.push(`tree:${key}`); + const gate = gates.get(key); + if (gate) await new Promise((resolve) => gates.set(key, resolve)); + const doc = trees[key]; + if (!doc) throw new Error(`no tree ${key}`); + return doc; + }, + }; + return { api, calls, gates }; +} + +beforeEach(() => { + useAssetBrowserStore.getState().resetForScope(SCOPE); + useAssetBrowserStore.getState().setMode({ kind: "latest" }); +}); + +test("opens the collection with the newest publish, and unions its indexes oldest first", async () => { + const { api } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + await loader.loadCollections(SCOPE); + const s = useAssetBrowserStore.getState(); + assert.deepEqual(s.collections, ["old-b", "plant-a"]); + assert.equal(s.collection, "plant-a"); // newest revision wins, not alphabetical + assert.deepEqual(s.mergedIndexRevisions, [R1, R2]); + // R2 merged after R1: its label wins for the shared node, and area-2 is added. + assert.equal(s.forest.nodes.get("area-1")?.label, "Area One"); + assert.ok(s.forest.nodes.has("area-2")); + assert.deepEqual(s.forest.origins.get("area-1"), { subject: "plant-a", revision: R2 }); +}); + +test("run mode narrows the admitted indexes without refetching them", async () => { + const { api, calls } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + await loader.loadCollections(SCOPE); + const before = calls.length; + useAssetBrowserStore.getState().setMode({ kind: "run", revision: R1 }); + await loader.syncCollectionIndexes(SCOPE); + assert.deepEqual(useAssetBrowserStore.getState().mergedIndexRevisions, [R1]); + assert.equal(calls.length, before, "nothing new to fetch"); +}); + +test("a late older index does not overwrite a newer one's rows", async () => { + const { api } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + // Start coeval on R2 only, then widen to latest -- R1 arrives after R2. + useAssetBrowserStore.getState().setMode({ kind: "run", revision: R2 }); + await loader.loadCollections(SCOPE); + assert.deepEqual(useAssetBrowserStore.getState().mergedIndexRevisions, [R2]); + useAssetBrowserStore.getState().setMode({ kind: "latest" }); + await loader.syncCollectionIndexes(SCOPE); + const s = useAssetBrowserStore.getState(); + assert.deepEqual(s.mergedIndexRevisions, [R1, R2]); + assert.equal(s.forest.nodes.get("area-1")?.label, "Area One", "R2 re-merged after the late R1"); +}); + +test("a spine is fetched once per (root, revision) and merged under its root", async () => { + const { api, calls } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + await loader.loadCollections(SCOPE); + const source = { subject: "area-1", revision: R1, root: "area-1" }; + await loader.loadSpine(SCOPE, source); + await loader.loadSpine(SCOPE, source); + assert.equal(calls.filter((c) => c === `tree:plant-a|area-1|${R1}`).length, 1); + const s = useAssetBrowserStore.getState(); + assert.equal(s.spineLoaded.get("area-1"), R1); + assert.equal(s.forest.nodes.get("member-1")?.parent, "level-1"); + // The spine's top says parent=null; the forest keeps it a root either way here, + // and its origin is now the spine's subject. + assert.deepEqual(s.forest.origins.get("member-1"), { subject: "area-1", revision: R1 }); +}); + +test("a failed spine is recorded against its root and retried explicitly", async () => { + const { api } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + await loader.loadCollections(SCOPE); + await loader.loadSpine(SCOPE, { subject: "area-2", revision: R2, root: "area-2" }); + const s = useAssetBrowserStore.getState(); + assert.match(s.spineErrors.get("area-2") ?? "", /no tree/); + assert.ok(!s.spineLoading.has("area-2")); +}); + +test("a response for a collection the user has left is dropped", async () => { + const { api, gates } = makeApi(); + const loader = createAssetBrowserLoader(useAssetBrowserStore, api); + await loader.loadCollections(SCOPE); + const key = `plant-a|area-1|${R1}`; + gates.set(key, () => {}); + const pending = loader.loadSpine(SCOPE, { subject: "area-1", revision: R1, root: "area-1" }); + await new Promise((r) => setTimeout(r, 0)); + await loader.chooseCollection(SCOPE, "old-b"); + gates.get(key)!(); + await pending; + const s = useAssetBrowserStore.getState(); + assert.equal(s.collection, "old-b"); + assert.ok(!s.forest.nodes.has("member-1"), "the late plant-a spine did not land in old-b's forest"); +}); diff --git a/src/frontend/src/assets/assetIndex.ts b/src/frontend/src/assets/assetIndex.ts new file mode 100644 index 000000000..dc8c74d9a --- /dev/null +++ b/src/frontend/src/assets/assetIndex.ts @@ -0,0 +1,172 @@ +/** + * The asset index: collection -> subject -> revision -> files. + * + * The SERVER folds it (`GET /assets/index`, `ada/assets/index.py`): a scope + * holds tens of thousands of derived blobs and the browser must never list them + * to answer "which revisions exist". `indexFromWire` is therefore the production + * path. `foldListing` is the same fold over raw keys, kept as the test oracle + * the design asks for -- a test written as a key list is far easier to read + * than a nested wire document, and the two must agree. + * + * Revisions are held ASCENDING (newest last) here, whatever order the wire + * used. The compact-UTC revision form makes lexical order chronological, so a + * plain string sort is the whole rule. + */ + +import { ASSET_PREFIX, STAGING_SEGMENT, parseAssetKey } from "./keys"; +import type { + AssetIndex, + AssetRevision, + AssetSubject, + ManifestSummary, + ResolutionMode, + WireAssetIndex, + WireManifestSummary, +} from "./types"; + +export const MANIFEST_FILENAME = "asset.json"; +export const HIERARCHY_FILENAME = "hierarchy.json"; + +export function compareRevisions(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function summaryFromWire(m: WireManifestSummary | undefined): ManifestSummary | null { + if (!m) return null; + return { + provider: m.provider, + node: m.node ?? null, + delivery: m.delivery, + producedAt: m.produced_at, + hierarchyRevision: m.hierarchy_revision ?? null, + }; +} + +export function indexFromWire(wire: WireAssetIndex): AssetIndex { + const collections = new Map>(); + for (const [collection, subjects] of Object.entries(wire.collections)) { + const out = new Map(); + for (const s of subjects) { + const revisions: AssetRevision[] = s.revisions + .map((r) => ({ + revision: r.revision, + files: new Set(r.files) as ReadonlySet, + manifest: summaryFromWire(r.manifest), + manifestError: r.manifest_error ?? null, + })) + .sort((a, b) => compareRevisions(a.revision, b.revision)); + out.set(s.subject, { collection, subject: s.subject, revisions }); + } + collections.set(collection, out); + } + return { collections, malformed: wire.malformed.map((m) => m.key) }; +} + +/** The same fold over raw keys. A TEST ORACLE: production reads the server's. */ +export function foldListing( + keys: readonly string[], + manifests: ReadonlyMap = new Map(), +): AssetIndex { + const tree = new Map>>>(); + const malformed: string[] = []; + for (const key of keys) { + if (!key.startsWith(`${ASSET_PREFIX}/`) || key.endsWith("/")) continue; + if (key.startsWith(`${ASSET_PREFIX}/${STAGING_SEGMENT}/`)) continue; + let parsed; + try { + parsed = parseAssetKey(key); + } catch { + malformed.push(key); + continue; + } + const subjects = tree.get(parsed.collection) ?? new Map(); + tree.set(parsed.collection, subjects); + const revs = subjects.get(parsed.subject) ?? new Map(); + subjects.set(parsed.subject, revs); + const files = revs.get(parsed.revision) ?? new Set(); + revs.set(parsed.revision, files); + files.add(parsed.filename); + } + const collections = new Map>(); + for (const [collection, subjects] of tree) { + const out = new Map(); + for (const [subject, revs] of subjects) { + const revisions = [...revs.entries()] + .sort(([a], [b]) => compareRevisions(a, b)) + .map(([revision, files]) => ({ + revision, + files: files as ReadonlySet, + manifest: manifests.get(`${collection}/${subject}/${revision}`) ?? null, + manifestError: null, + })); + out.set(subject, { collection, subject, revisions }); + } + collections.set(collection, out); + } + return { collections, malformed }; +} + +const NO_SUBJECTS: ReadonlyMap = new Map(); + +export function subjectsOf(index: AssetIndex, collection: string): ReadonlyMap { + return index.collections.get(collection) ?? NO_SUBJECTS; +} + +/** Every distinct revision in a collection, ascending: the "run" picker's list. + * Small by construction -- one publish fans out to N subjects under ONE stamp. */ +export function revisionsOf(index: AssetIndex, collection: string): string[] { + const seen = new Set(); + for (const s of subjectsOf(index, collection).values()) for (const r of s.revisions) seen.add(r.revision); + return [...seen].sort(compareRevisions); +} + +export function collectionsOf(index: AssetIndex): string[] { + return [...index.collections.keys()].sort(); +} + +/** Open on the collection holding the newest revision, not the alphabetically + * first: the question on opening is "what was published most recently". */ +export function defaultCollection(index: AssetIndex | null): string | null { + if (!index) return null; + let best: string | null = null; + let bestRev = ""; + for (const name of collectionsOf(index)) { + const revs = revisionsOf(index, name); + const newest = revs[revs.length - 1] ?? ""; + if (best === null || compareRevisions(newest, bestRev) > 0) { + best = name; + bestRev = newest; + } + } + return best; +} + +/** A revision a reader may resolve to: its manifest exists. Manifests are + * written LAST, so a manifest-less revision is a publish that died partway -- + * the server's tree and delivery routes skip it, and so does the browser. */ +export function isComplete(rev: AssetRevision): boolean { + return rev.files.has(MANIFEST_FILENAME); +} + +/** + * Which collection-level hierarchies make up the tops of the tree, OLDEST FIRST + * (so a later merge leaves the newest row winning for a shared node). + * + * A UNION, not the newest one: each publish writes its own collection index and + * it covers only what that publish declared. Reading only the newest collapsed + * the tree to whatever was published last. The modes differ exactly on the + * coeval question -- `run` means "this one publish", so only its index. + */ +export function collectionIndexRevisions( + index: AssetIndex | null, + collection: string | null, + mode: ResolutionMode, +): readonly AssetRevision[] { + if (!index || !collection) return []; + const entry = index.collections.get(collection)?.get(collection); + if (!entry) return []; + const withTree = entry.revisions.filter((r) => isComplete(r) && r.files.has(HIERARCHY_FILENAME)); + if (mode.kind === "run") return withTree.filter((r) => r.revision === mode.revision); + if (mode.kind === "as-of") return withTree.filter((r) => compareRevisions(r.revision, mode.revision) <= 0); + return withTree; +} diff --git a/src/frontend/src/assets/assetView.ts b/src/frontend/src/assets/assetView.ts new file mode 100644 index 000000000..c2b7a4637 --- /dev/null +++ b/src/frontend/src/assets/assetView.ts @@ -0,0 +1,235 @@ +// THE ONE OBJECT THE ASSETS TAB READS. +// +// Every badge, dimmed row, gap marker, stale mark, drift flag and orphan is +// derived from a single `AssetView`, computed from (forest, index, collection, +// mode). Nothing in the UI resolves a revision for itself. +// +// That is the difference between a tree that is merely populated and a tree +// that is CONSISTENT. If each row resolved its own newest revision, a parent's +// badge could come from one publish, its child's from another, and a Load from +// a third -- and no screenshot of the result would be reproducible. Lifting +// resolution into one context makes "which publish am I looking at" a question +// with one answer, and lets the tab state it. +// +// React-free and side-effect-free: this is where the reasoning is, so it is +// what the tests drive. It knows no provider and no source format -- only core's +// two documents (`asset.json`, summarised by the index route, and +// `hierarchy.json`). +// +// PERFORMANCE SHAPE. The hierarchy depends on the forest only; everything else +// depends on the mode too. So `buildAssetView` accepts a prebuilt hierarchy and +// the tab memoises it on the forest, which keeps a mode switch over a 41k-row +// spine to the O(n) passes below with no re-indexing. + +import { HIERARCHY_FILENAME, isComplete } from "./assetIndex"; +import { projectCoverage, type CoverageResult } from "./coverage"; +import { hierarchyDrift, nodeFreshness, staleCount, type HierarchyDrift, type NodeFreshness } from "./freshness"; +import { ancestorsOf, buildHierarchyFrom, type Hierarchy } from "./hierarchy"; +import type { Forest } from "./merge"; +import { lastKnownPath, orphanCause, type OrphanEntry } from "./orphans"; +import { + describeResolution, + resolveCollection, + type Resolution, + type ResolutionSummary, +} from "./resolve"; +import { spineCoverage, spineRootedAt, type SpineLookup, type SpineSource } from "./spines"; +import type { AssetIndex, AssetNode, AssetRevision, ResolutionMode } from "./types"; + +/** A subject whose resolved revision carries a delivery claim. Propagates: a + * claim at a branch delivers that branch's descendants. */ +export const ROLE_CONTENT = "content"; +/** A subject whose resolved revision carries its own subtree `hierarchy.json`. + * Does NOT propagate: a hierarchy rooted above a row contains that row by + * definition, so a ghost for it would sit on every descendant and say nothing. */ +export const ROLE_TREE = "tree"; +const PROPAGATING: ReadonlySet = Object.freeze(new Set([ROLE_CONTENT])); + +/** A revision carries content when its manifest makes a delivery claim. + * + * An UNKNOWN manifest (the index could not summarise it) is NOT content: a + * badge must be a fact, and the revision's error is reported in + * `manifestErrors` instead, so the row is explained rather than decorated. */ +export function carriesContent(rev: AssetRevision): boolean { + return rev.manifest !== null && rev.manifest.delivery !== "none"; +} + +export interface AssetViewInput { + readonly forest: Forest; + readonly index: AssetIndex; + readonly collection: string; + readonly mode: ResolutionMode; + /** The collection-index revisions actually MERGED into the forest, ascending. + * An orphan and a drift are judged against the newest of these -- the tree + * on screen -- not against whatever the subject itself resolved to. */ + readonly indexRevisions?: readonly string[]; + /** A hierarchy already built from `forest.nodes`, so a mode switch does not + * re-index the forest. MUST be built from the same forest. */ + readonly hierarchy?: Hierarchy; + /** Spine root -> revision merged. Decides which branches are UNEXPLORED: a + * branch whose published subtree has not been fetched yet cannot be called + * "nothing to deliver here" -- that would be a guess about rows nobody has + * read. Omitted, every branch counts as explored. */ + readonly spineLoaded?: ReadonlyMap; +} + +export interface AssetView { + readonly collection: string; + readonly hierarchy: Hierarchy; + readonly resolution: Resolution; + readonly summary: ResolutionSummary; + readonly coverage: CoverageResult; + /** node id -> the nearest published spine at or above it (lazy, memoised). */ + readonly spines: SpineLookup; + /** Published subjects no loaded spine contains, with the cause and last + * known place. Listed under the collection root, never dropped. */ + readonly orphans: readonly OrphanEntry[]; + /** The newest merged collection index, or null when none is merged. */ + readonly spineRevision: string | null; + readonly freshness: ReadonlyMap; + readonly staleCount: number; + /** Rows with an unfetched published subtree at or below them. Never + * `dimmed`: absence of payload there is not yet known. */ + readonly unexplored: ReadonlySet; + /** Published spines on screen that are not merged at the revision the + * resolution names -- what "place everything" would fetch. */ + readonly unmergedSpines: readonly SpineSource[]; + /** Published subjects with no row YET, while spines that might contain them + * are still unfetched. Not orphans: calling a subject "removed" or "ahead" + * because its branch was never opened would be a guess stated as a fact. */ + readonly pending: readonly string[]; + /** subject -> "published against an older tree". Only subjects that drift. */ + readonly drift: ReadonlyMap; + /** Distinct producing providers among the loaded rows. More than one is a + * MIXED collection -- first class, and worth a legend. */ + readonly providers: readonly string[]; + /** subject -> why its resolved manifest could not be read. */ + readonly manifestErrors: ReadonlyMap; + readonly malformedKeys: readonly string[]; +} + +export function buildAssetHierarchy(forest: Forest): Hierarchy { + // The forest is already de-duplicated by its merge, so index it directly. + return buildHierarchyFrom(forest.nodes, (n) => n.parent, (n) => n); +} + +export function buildAssetView(input: AssetViewInput): AssetView { + const { forest, index, collection, mode } = input; + const hierarchy = input.hierarchy ?? buildAssetHierarchy(forest); + const isCollectionSubject = (s: string) => s === collection; + + const resolution = resolveCollection(index, collection, mode, { carriesContent }); + + const rootedRoles = new Map>(); + const publishedSubjects: string[] = []; + const manifestErrors = new Map(); + for (const [subject, resolved] of resolution.subjects) { + if (resolved.revision.manifestError) manifestErrors.set(subject, resolved.revision.manifestError); + if (isCollectionSubject(subject)) continue; + publishedSubjects.push(subject); + const roles = new Set(); + if (resolved.content) roles.add(ROLE_CONTENT); + if (resolved.revision.files.has(HIERARCHY_FILENAME)) roles.add(ROLE_TREE); + if (roles.size) rootedRoles.set(subject, roles); + } + + const coverage = projectCoverage(hierarchy, { + rootedRoles, + publishedSubjects, + propagating: PROPAGATING, + // Payload = a leaf: the thing a publish would end up delivering. Only + // zero-vs-nonzero decides `dimmed`; the count feeds collapsed-branch labels. + payloadOf: (node) => (node.data.leaf ? 1 : 0), + }); + + const spines = spineCoverage(hierarchy, (id) => spineRootedAt(resolution, id)); + // Walked from the UNMERGED spine roots rather than folded over every row: on + // a settled tree there are none, and the cost of this pass should scale with + // what is still to fetch, not with the 41k rows already in. + const unexplored = new Set(); + const unmergedSpines: SpineSource[] = []; + if (input.spineLoaded) { + const loaded = input.spineLoaded; + for (const [subject, resolved] of resolution.subjects) { + if (!hierarchy.byId.has(subject)) continue; + const source = spineRootedAt(resolution, subject); + if (!source || loaded.get(source.root) === resolved.revision.revision) continue; + unmergedSpines.push(source); + // The root, every non-leaf row the forest already holds under it, and + // every ancestor above it: none of them can yet say "nothing below". + const stack = [subject]; + while (stack.length) { + const id = stack.pop()!; + if (hierarchy.byId.get(id)?.data.leaf || unexplored.has(id)) continue; + unexplored.add(id); + for (const c of hierarchy.childrenOf(id)) stack.push(c); + } + for (const a of ancestorsOf(hierarchy, subject)) unexplored.add(a); + } + } + + const merged = input.indexRevisions ?? []; + const spineRevision = merged.length ? merged[merged.length - 1] : null; + + const labelOf = (n: AssetNode) => n.label || n.id; + const orphans: OrphanEntry[] = []; + // An absent subject is an orphan only once nothing still unfetched could hold + // it. Until then it is PENDING: the honest statement is "not placed yet". + const pending: string[] = unmergedSpines.length ? [...coverage.orphanSubjects] : []; + if (spineRevision && !unmergedSpines.length) { + for (const id of coverage.orphanSubjects) { + const revision = resolution.subjects.get(id)?.revision.revision; + if (!revision) continue; + orphans.push({ + id, + revision, + spineRevision, + cause: orphanCause(revision, spineRevision), + path: lastKnownPath(hierarchy, id, (x) => forest.retired.get(x) ?? null, labelOf), + }); + } + } + + // Rows from the collection index are current when their index revision is one + // the mode still merges (the index is a union); every other row is current + // when its spine's subject still resolves to the revision it was drawn at. + const mergedSet = new Set(merged); + const freshness = nodeFreshness(forest.origins, (origin) => { + if (isCollectionSubject(origin.subject)) { + return mergedSet.has(origin.revision) ? origin.revision : spineRevision; + } + return resolution.subjects.get(origin.subject)?.revision.revision ?? null; + }); + + const drift = new Map(); + for (const [subject, resolved] of resolution.subjects) { + if (isCollectionSubject(subject) || !isComplete(resolved.revision)) continue; + const d = hierarchyDrift(resolved.revision.manifest?.hierarchyRevision ?? null, spineRevision); + if (d) drift.set(subject, d); + } + + const providers = new Set(); + for (const n of forest.nodes.values()) providers.add(n.provider); + + return { + collection, + hierarchy, + resolution, + // The collection subject is re-stamped by every publish; counting it would + // make every leaf-only publish read as `mixed` against the index. + summary: describeResolution(resolution, isCollectionSubject), + coverage, + spines, + unexplored, + unmergedSpines, + pending, + orphans, + spineRevision, + freshness, + staleCount: staleCount(freshness), + drift, + providers: [...providers].sort(), + manifestErrors, + malformedKeys: index.malformed, + }; +} diff --git a/src/frontend/src/assets/coverage.ts b/src/frontend/src/assets/coverage.ts new file mode 100644 index 000000000..5bb3e6d2c --- /dev/null +++ b/src/frontend/src/assets/coverage.ts @@ -0,0 +1,299 @@ +// Project a set of published subjects onto a hierarchy and derive, per node, +// what the row has to show. +// +// Generic. Roles are opaque strings here — this module does not know what any +// provider publishes, only which roles it was told propagate. +// +// FOUR OUTPUTS, and two of them are the reason this module exists. +// +// rooted a role published AT this node. Solid badge. +// covered a role published at an ANCESTOR, which therefore includes this +// node. Ghost badge. Only roles declared `propagating` do this: an +// export rooted above me contains my geometry, but a HIERARCHY +// export rooted above me contains my row too — so a ghost for it +// would sit on literally every descendant row and carry no +// information. Non-propagating roles are shown only where rooted. +// +// dimmed no payload at or below this node. "There is nothing here to +// export." On one real collection measured, three of five branches are +// exactly this, so it is the common case, not an edge case. +// A dimmed row must still be RENDERED — hiding it is what makes a +// user hunt for an export that was never possible. +// gap payload at or below this node that NO publish covers -- neither one +// at or above this row, nor one on the payload's own line below it. +// "There is something here and nobody has exported it." This is the +// actionable one. Counted per payload, not decided per row: a branch +// whose every leaf is published leaf-by-leaf has no gap, although +// nothing is published at the branch itself. +// +// `dimmed` and `gap` are complementary halves of the same question and both are +// derived, never authored. There is deliberately NO is-asset-root marker +// anywhere in this design: nobody in the pipeline has the authority to write +// one, so a stored flag would be somebody's guess dressed as a fact. + +import type { Hierarchy, HierarchyNode } from "./hierarchy"; + +export interface CoverageInput { + /** node id -> the roles published AT that node under the active resolution. + * Subjects that name a node the hierarchy does not contain are ignored here + * and reported by `orphanSubjects` instead. */ + readonly rootedRoles: ReadonlyMap>; + /** Every subject that resolved to a published entry, whether or not it earns + * a badge. Orphan-hood means "a published entry whose ref matches no row" -- + * which is independent of which badges the entry happens to earn. Tying the + * two together made an entry with no badge vanish from the report entirely, + * and an entry nothing can see is the failure this whole panel exists to + * remove. Defaults to the keys of `rootedRoles`. */ + readonly publishedSubjects?: Iterable; + /** Roles whose coverage extends to descendants. A role NOT listed here is + * shown only where it is rooted. */ + readonly propagating: ReadonlySet; + /** How much exportable payload sits at this node itself. Zero for a + * container. The units are the caller's; only zero-vs-nonzero is used for + * `dimmed`, while the count is passed through for the row's own wording + * ("14 members here, no export covers them"). */ + readonly payloadOf: (node: HierarchyNode) => number; +} + +export interface NodeCoverage { + /** Roles published at this exact node. */ + readonly rooted: ReadonlySet; + /** Propagating roles inherited from the nearest ancestors that publish them, + * minus anything already rooted here (a role is never both weights). */ + readonly covered: ReadonlySet; + /** The nearest ancestor that publishes each covered role — what the row's + * "Covered by the export rooted at ..." sentence names. */ + readonly coveredBy: ReadonlyMap; + /** Propagating roles rooted STRICTLY BELOW this node, minus anything already + * rooted or covered here (a role is never two weights at once). + * + * The third direction, and the one a collapsed tree needs most. `rooted` and + * `covered` both answer from a node's own line -- what is here, what reaches + * me from above -- so neither can be seen without expanding to the row that + * has it. A root with an export three levels beneath it therefore looked + * exactly like a root with nothing in it, and the only way to tell them apart + * was to open every branch: the question the browser exists to answer without + * doing that. + * + * Restricted to `propagating` for the reason coverage is: a role describing + * CONTENT is worth announcing upward, while `tree` is rooted at every root of + * a sweep and would mark every ancestor of everything. */ + readonly below: ReadonlySet; + /** The shallowest descendant rooting each role in `below`, so a tooltip can + * name where to look rather than only that there is somewhere. */ + readonly belowBy: ReadonlyMap; + readonly payloadSelf: number; + readonly payloadSubtree: number; + /** Payload at or below this node that no propagating role covers. */ + readonly uncoveredSubtree: number; + /** payloadSubtree === 0. */ + readonly dimmed: boolean; + /** uncoveredSubtree > 0. */ + readonly gap: boolean; +} + +export interface CoverageResult { + readonly byId: ReadonlyMap; + /** Subjects that resolved to something but name a node no loaded spine + * contains. These are NOT dropped: an export published after the last + * hierarchy sweep is a real, common state, and dropping it means the user + * sees no trace of an asset that exists. The tab lists them under the + * collection root with its cause (./orphans). */ + readonly orphanSubjects: readonly string[]; +} + +const EMPTY_SET: ReadonlySet = Object.freeze(new Set()); +const EMPTY_MAP: ReadonlyMap = Object.freeze(new Map()); + +/** The per-node record while it is being built. Exposed as `NodeCoverage` + * (readonly) once both passes are done. `anyCoverage` is internal. */ +interface Draft { + rooted: ReadonlySet; + covered: ReadonlySet; + coveredBy: ReadonlyMap; + below: ReadonlySet; + belowBy: ReadonlyMap; + payloadSelf: number; + payloadSubtree: number; + uncoveredSubtree: number; + dimmed: boolean; + gap: boolean; + /** What this node hands its children: inherited, overridden by its own roots. */ + downward: ReadonlyMap; + anyCoverage: boolean; +} + +export function projectCoverage( + h: Hierarchy, + input: CoverageInput, +): CoverageResult { + // TWO PASSES, O(n) each, and the shape of both is what makes a 41k-row spine + // re-derive on a mode switch without a visible pause: + // + // down (in `order`, parent before child) -- what a node inherits and shows + // as ghosts. Each node reads its parent's finished record rather than + // walking its own ancestor chain. + // up (reverse `order`, children before parent) -- payload totals, the + // `below` announcements, and the two flags that depend on the total + // (`dimmed`, `gap`). One pass for all three, where a separate + // subtree fold used to be a third walk and a 41k-entry map. + const byId = new Map(); + const ghostsOf = new Map< + ReadonlyMap, + { covered: ReadonlySet; coveredBy: ReadonlyMap } + >(); + + for (const id of h.order) { + const node = h.byId.get(id) as HierarchyNode; + const rooted = input.rootedRoles.get(id) ?? EMPTY_SET; + const fromParent = (node.parent !== null ? byId.get(node.parent)?.downward : undefined) ?? EMPTY_MAP; + + // What THIS node passes down: whatever it inherited, overridden by any + // propagating role it roots itself — nearest ancestor wins, which is what + // makes `coveredBy` name the closest publish rather than the outermost one. + let downward = fromParent; + let rootedPropagating = false; + for (const role of rooted) { + if (!input.propagating.has(role)) continue; + if (!rootedPropagating) { + downward = new Map(fromParent); + rootedPropagating = true; + } + (downward as Map).set(role, id); + } + + // What this node SHOWS as ghosts: inherited roles it does not root itself. + // + // SHARED, NOT PER-ROW, in the common case. A row that roots none of the + // roles it inherits shows exactly its parent's `downward` map -- and every + // sibling under that parent shows the same one. Allocating a Set and a Map + // per row made this pass dominate a 41k-row re-derive; one pair per + // distinct `fromParent` makes it an allocation per PUBLISH ROOT instead. + let covered: ReadonlySet = EMPTY_SET; + let coveredBy: ReadonlyMap = EMPTY_MAP; + if (fromParent.size) { + let overlaps = false; + for (const role of rooted) if (fromParent.has(role)) overlaps = true; + if (!overlaps) { + let shared = ghostsOf.get(fromParent); + if (!shared) { + shared = { covered: new Set(fromParent.keys()), coveredBy: fromParent }; + ghostsOf.set(fromParent, shared); + } + covered = shared.covered; + coveredBy = shared.coveredBy; + } else { + const set = new Set(); + const by = new Map(); + for (const [role, owner] of fromParent) { + if (rooted.has(role)) continue; + set.add(role); + by.set(role, owner); + } + if (set.size) { + covered = set; + coveredBy = by; + } + } + } + + const payloadSelf = input.payloadOf(node); + // "Covered at or above" is exactly "the parent handed something down, or I + // root something propagating myself". + const anyCoverage = fromParent.size > 0 || rootedPropagating; + byId.set(id, { + rooted, + covered, + coveredBy, + below: EMPTY_SET, + belowBy: EMPTY_MAP, + payloadSelf, + // Final for a childless node; the upward pass adds the children's. + payloadSubtree: payloadSelf, + uncoveredSubtree: anyCoverage ? 0 : payloadSelf, + dimmed: payloadSelf === 0, + gap: payloadSelf > 0 && !anyCoverage, + downward, + anyCoverage, + }); + } + + // SHALLOWEST WINS for `below`, so a child's own roots are considered before + // the deeper things that child merely reports: `belowBy` names the first + // publish you would meet on the way down, which is the one worth opening to. + for (let i = h.order.length - 1; i >= 0; i--) { + const id = h.order[i]; + const kids = h.childrenOf(id); + if (!kids.length) continue; // a leaf's record is already final + const self = byId.get(id) as Draft; + + let total = self.payloadSelf; + let uncovered = self.anyCoverage ? 0 : self.payloadSelf; + // Allocated only when a child actually reports something: most rows of a + // large spine have nothing below, and a Map per row was this pass's cost. + let by: Map | null = null; + for (const child of kids) { + const cov = byId.get(child); + if (!cov) continue; + total += cov.payloadSubtree; + uncovered += cov.uncoveredSubtree; + for (const role of cov.rooted) { + if (!input.propagating.has(role)) continue; + by ??= new Map(); + if (!by.has(role)) by.set(role, child); + } + } + for (const child of kids) { + const cov = byId.get(child); + if (!cov || !cov.belowBy.size) continue; + by ??= new Map(); + for (const [role, owner] of cov.belowBy) if (!by.has(role)) by.set(role, owner); + } + self.payloadSubtree = total; + self.uncoveredSubtree = uncovered; + self.dimmed = total === 0; + self.gap = uncovered > 0; + if (by === null) continue; + // A role already answered on this row is not also announced from below: the + // weights are exclusive, and "here" is always the more useful statement. + for (const role of self.rooted) by.delete(role); + for (const role of self.covered) by.delete(role); + if (by.size) { + self.below = new Set(by.keys()); + self.belowBy = by; + } + } + + const orphanSubjects: string[] = []; + for (const subject of input.publishedSubjects ?? input.rootedRoles.keys()) { + if (!h.byId.has(subject)) orphanSubjects.push(subject); + } + orphanSubjects.sort(); + + // The drafts carry two internal fields (`downward`, `anyCoverage`) beyond + // `NodeCoverage`; handing them out as the readonly type keeps them internal + // without a 41k-object copy. + return { byId: byId as ReadonlyMap, orphanSubjects }; +} + +/** The nearest ancestor (or `id` itself) satisfying `pred`, or null. + * + * The primitive behind "resolve this node to the root that contains it" and + * "which export would a Load here actually use". Kept here rather than in a + * caller so both questions are answered by the same walk. */ +export function nearestSelfOrAncestor( + h: Hierarchy, + id: string, + pred: (node: HierarchyNode) => boolean, +): string | null { + const seen = new Set(); + let cur: string | null = id; + while (cur !== null && !seen.has(cur)) { + seen.add(cur); + const node: HierarchyNode | undefined = h.byId.get(cur); + if (!node) return null; + if (pred(node)) return cur; + cur = node.parent; + } + return null; +} diff --git a/src/frontend/src/assets/freshness.ts b/src/frontend/src/assets/freshness.ts new file mode 100644 index 000000000..88feea8d5 --- /dev/null +++ b/src/frontend/src/assets/freshness.ts @@ -0,0 +1,115 @@ +// Two "is this row out of date" facts that live in the asset store itself, and +// must stay two facts. (The third -- "the upstream source moved since this was +// published" -- is the change feed's, a different input, and never folded into +// either of these.) +// +// 1. STALE: the SHAPE of the tree on screen no longer matches the resolution its +// badges are read from. +// +// The forest is a UNION: a merge adds rows and deletes only inside a subtree +// document that claims completeness (./merge). So a row can outlive the +// publish that produced it -- after a mode switch (which deliberately does +// not re-collapse the tree or drop spines already held), or after a node was +// removed at the source and absence could not delete it. Marking is the +// honest move and dropping is not: this module says which revision a row was +// drawn from and whether the resolution has moved on, and leaves the row on +// screen. Refresh rebuilds from nothing. +// +// By SUBJECT, not by walking ancestors: each row records the spine that +// contributed it, so the question is O(1) per row. +// +// 2. DRIFT: a subject was PUBLISHED AGAINST an older collection hierarchy than +// the one the tree is drawn from. A leaf publish records, in its manifest, +// the `hierarchy_revision` it was derived against; when the tree on screen +// comes from a newer collection index, the leaf's placement was decided on a +// tree that has since changed, and the row says so rather than guessing +// whether it matters. + +/** Where a row in the forest came from. Recorded at merge time. */ +export interface NodeOrigin { + /** The subject whose `hierarchy.json` contributed this row. */ + readonly subject: string; + /** The revision that spine was fetched at. */ + readonly revision: string; +} + +export interface NodeFreshness { + /** The revision of the spine this row is actually drawn from. */ + readonly shownAt: string; + /** What that subject resolves to NOW, or null when the mode resolves it to + * nothing (normal under `run` / `as-of`, and NOT staleness). */ + readonly resolvedAt: string | null; + readonly stale: boolean; +} + +/** Per-row freshness. + * + * `revisionOf` is asked per ORIGIN (subject + the revision it was fetched at), + * not per subject, because one subject can legitimately contribute rows at + * several revisions: the collection index is a UNION of every index the mode + * admits (./assetIndex `collectionIndexRevisions`), so a row from an older index + * the mode still includes is current, not stale. Memoised per origin: one spine + * is up to ~41k rows against a single origin. */ +export function nodeFreshness( + origins: ReadonlyMap, + revisionOf: (origin: NodeOrigin) => string | null, +): Map { + const out = new Map(); + // Two memo levels. By object first: every row one merge contributed carries + // the SAME origin object, so a 41k-row spine is one lookup and one shared + // result rather than 41k string keys and 41k objects. By value second, for + // origins that are equal without being identical. + const byObject = new Map(); + const byValue = new Map(); + for (const [id, origin] of origins) { + let f = byObject.get(origin); + if (f === undefined) { + const key = `${origin.subject}@${origin.revision}`; + f = byValue.get(key); + if (f === undefined) { + const resolvedAt = revisionOf(origin); + f = Object.freeze({ + shownAt: origin.revision, + resolvedAt, + stale: resolvedAt !== null && resolvedAt !== origin.revision, + }); + byValue.set(key, f); + } + byObject.set(origin, f); + } + out.set(id, f); + } + return out; +} + +/** How many rows are drawn from a superseded spine. The banner counts these, + * because a stale row inside a collapsed branch is otherwise invisible. */ +export function staleCount(freshness: ReadonlyMap): number { + let n = 0; + for (const f of freshness.values()) if (f.stale) n++; + return n; +} + +export interface HierarchyDrift { + /** The collection hierarchy the subject's manifest says it was derived against. */ + readonly publishedAgainst: string; + /** The collection hierarchy the tree is drawn from now. */ + readonly shownFrom: string; +} + +/** Drift for one subject, or null when there is none to report. + * + * Null -- not "fine" -- when the manifest recorded no `hierarchy_revision` + * (most whole-collection publishes derive the index themselves and have nothing + * to point back to) or when no collection index is on screen. Only a strictly + * OLDER recorded revision is drift: equal is the normal case, and newer means + * the subject is ahead of the tree, which orphans/`ahead` already says. */ +export function hierarchyDrift( + hierarchyRevision: string | null, + spineRevision: string | null, +): HierarchyDrift | null { + if (!hierarchyRevision || !spineRevision) return null; + return hierarchyRevision < spineRevision + ? { publishedAgainst: hierarchyRevision, shownFrom: spineRevision } + : null; +} diff --git a/src/frontend/src/assets/hierarchy.ts b/src/frontend/src/assets/hierarchy.ts new file mode 100644 index 000000000..cbb46dbce --- /dev/null +++ b/src/frontend/src/assets/hierarchy.ts @@ -0,0 +1,240 @@ +// A parent-pointer forest, indexed for the three questions the tree asks: +// "who are my children", "who are my ancestors", and "what is the flat row list +// for the current expansion". +// +// Generic: a node here has an id, a parent and an +// opaque `data` payload — this module never looks inside `data`. +// +// TWO PROPERTIES THAT ARE NOT DEFENSIVE PROGRAMMING, they are the normal case: +// +// * The forest is ASSEMBLED INCREMENTALLY. The tab opens on a collection index +// (depth 1: the declared roots) and fetches each root's full spine only when +// it is expanded, because one spine can reach ~41k nodes and a collection +// has many. So at any moment most roots are leaves-of-the-known-world, and +// a node whose declared parent is not present yet is normal, not corrupt. +// * A node whose parent is ABSENT is promoted to a root rather than dropped. +// Dropping it is how a published subject at a node no spine contains becomes +// invisible, which is precisely the "stale spine" state the tab is +// required to show. +// +// Recursion is avoided throughout. A measured spine is 5 deep, but the module +// makes no assumption about that and a 41k-node chain must not overflow. + +export interface HierarchyInput { + readonly id: string; + readonly parent: string | null; + readonly data: T; +} + +export interface HierarchyNode { + readonly id: string; + /** The EFFECTIVE parent: null when the declared parent is absent from the + * forest, so a subtree with an unfetched parent stands on its own. */ + readonly parent: string | null; + /** What the source said, kept so "this node's parent is in no spine we hold" + * stays distinguishable from "this node is genuinely a root". */ + readonly declaredParent: string | null; + readonly depth: number; + readonly data: T; +} + +export interface Hierarchy { + readonly byId: ReadonlyMap>; + readonly childrenOf: (id: string) => readonly string[]; + readonly roots: readonly string[]; + /** Every id in an order where a parent always precedes its children. + * `foldSubtrees` walks it backwards to aggregate bottom-up in one pass. */ + readonly order: readonly string[]; +} + +const NO_CHILDREN: readonly string[] = Object.freeze([]); + +export function buildHierarchy(input: readonly HierarchyInput[]): Hierarchy { + const declared = new Map>(); + // Last writer wins on a duplicate id. Two spines can legitimately carry the + // same node (a collection index lists the roots, and so does each root's own + // spine); taking the later one means the richer per-root row replaces the + // stub rather than the other way round. + // + // EXCEPT for a parent that a later row does not know. A subtree document is + // self-contained, so it names its own top with NO parent -- correct for that + // document, and a lie about the forest. Letting it win re-parents the node to + // null, promotes it to a root, and drops it to the bottom of the tree the + // moment it is expanded. "Richer wins" is the intent; a null parent is not + // richer, so it does not overwrite a known one. + for (const n of input) { + const prev = declared.get(n.id); + declared.set( + n.id, + prev && n.parent === null && prev.parent !== null ? { ...n, parent: prev.parent } : n, + ); + } + return buildHierarchyFrom(declared, (n) => n.parent, (n) => n.data); +} + +/** The same forest, built straight from a map that is ALREADY de-duplicated by + * id (the asset forest is: its merge applies the rules above). Skips the copy + * into a second 41k-entry map, which was a third of the build. */ +export function buildHierarchyFrom( + declared: ReadonlyMap, + parentOf: (s: S) => string | null, + dataOf: (s: S) => T, +): Hierarchy { + const children = new Map(); + const roots: string[] = []; + for (const [id, n] of declared) { + const p = parentOf(n); + const parent = p !== null && declared.has(p) ? p : null; + if (parent === null) { + roots.push(id); + } else { + const bucket = children.get(parent); + if (bucket) bucket.push(id); + else children.set(parent, [id]); + } + } + + // Breadth-first from the roots gives a parent-before-child order and assigns + // depth in the same pass. No visited-set is needed: each id sits in exactly + // one parent's bucket, so a walk from the roots meets it at most once, and a + // cyclic parent chain is simply unreachable from any root -- its members never + // enter `order` and are appended below as their own roots. The queue is two + // parallel arrays rather than an object per entry; at 41k rows both the + // objects and the set were measurable. + const byId = new Map>(); + const order: string[] = []; + const qId: string[] = roots.slice(); + const qDepth: number[] = new Array(roots.length).fill(0); + for (let head = 0; head < qId.length; head++) { + const id = qId[head]; + const depth = qDepth[head]; + const n = declared.get(id) as S; + const p = parentOf(n); + byId.set(id, { + id, + parent: p !== null && declared.has(p) ? p : null, + declaredParent: p, + depth, + data: dataOf(n), + }); + order.push(id); + const kids = children.get(id); + if (!kids) continue; + for (const child of kids) { + qId.push(child); + qDepth.push(depth + 1); + } + } + // Anything a cycle kept out of the walk. Recorded rather than lost, at depth + // 0, so corrupt data shows up as a stray root instead of a missing row. + const extraRoots: string[] = []; + if (byId.size < declared.size) { + for (const [id, n] of declared) { + if (byId.has(id)) continue; + byId.set(id, { id, parent: null, declaredParent: parentOf(n), depth: 0, data: dataOf(n) }); + order.push(id); + extraRoots.push(id); + } + } + + const allRoots = extraRoots.length ? [...roots, ...extraRoots] : roots; + return { + byId, + childrenOf: (id) => children.get(id) ?? NO_CHILDREN, + roots: allRoots, + order, + }; +} + +/** Ancestors of `id`, NEAREST FIRST, excluding `id` itself. + * + * Nearest-first is what every caller wants: "the closest root above me", "the + * closest published export above me". Bounded by the forest size so a cycle + * that survived `buildHierarchy` still cannot hang the UI. */ +export function ancestorsOf(h: Hierarchy, id: string): string[] { + const out: string[] = []; + const seen = new Set([id]); + let cur = h.byId.get(id)?.parent ?? null; + while (cur !== null && !seen.has(cur)) { + out.push(cur); + seen.add(cur); + cur = h.byId.get(cur)?.parent ?? null; + } + return out; +} + +/** Aggregate a per-node value bottom-up: total(n) = self(n) + sum of children. + * + * One reverse pass over `order`, so O(n) with no recursion. This is how "does + * anything under here have geometry" is answered for 41k nodes on every change + * of the resolution context. */ +export function foldSubtrees( + h: Hierarchy, + self: (node: HierarchyNode) => number, +): Map { + const totals = new Map(); + for (let i = h.order.length - 1; i >= 0; i--) { + const id = h.order[i]; + const node = h.byId.get(id) as HierarchyNode; + let total = self(node); + for (const child of h.childrenOf(id)) total += totals.get(child) ?? 0; + totals.set(id, total); + } + return totals; +} + +export interface FlatRow { + readonly id: string; + readonly depth: number; + readonly hasChildren: boolean; + readonly expanded: boolean; +} + +/** The visible rows, in display order, for the current expansion set. + * + * This is the input to the virtualiser: the tab slices a window out of it and + * renders only that. Iterative with an explicit stack — a 41k-node spine fully + * expanded is a real thing a user can do. + * + * A node whose children have not been FETCHED yet is indistinguishable here + * from a node with no children; this module only knows what it was given. The + * caller supplies `expandable` when it can tell the difference (a root row + * whose spine has not been pulled). + * + * `include` restricts the output to a subset — the tab's "assets only" + * filter. IT MUST BE ANCESTOR-CLOSED: `depth` is the node's depth in the WHOLE + * forest, not in the filtered one, so a kept node whose parent was dropped + * would render indented under a row that is not there. Building the set with + * `foldSubtrees` gives that closure by construction, which is why there is no + * closure step here — a filter that needs one has computed the wrong set. */ +export function flattenVisible( + h: Hierarchy, + expanded: ReadonlySet, + opts?: { + readonly expandable?: (id: string) => boolean; + readonly include?: ReadonlySet; + }, +): FlatRow[] { + const out: FlatRow[] = []; + const expandable = opts?.expandable; + const include = opts?.include; + const visible = (id: string) => include === undefined || include.has(id); + // Reversed so the stack pops in source order. + const stack: string[] = [...h.roots].filter(visible).reverse(); + while (stack.length) { + const id = stack.pop() as string; + const node = h.byId.get(id); + if (!node) continue; + // Filtered before `hasChildren` is decided, so a row whose every child was + // filtered out loses its twisty instead of offering an expansion that + // reveals nothing. + const kids = include === undefined ? h.childrenOf(id) : h.childrenOf(id).filter(visible); + const hasChildren = kids.length > 0 || (expandable ? expandable(id) : false); + const isExpanded = expanded.has(id); + out.push({ id, depth: node.depth, hasChildren, expanded: isExpanded && hasChildren }); + if (isExpanded && kids.length) { + for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); + } + } + return out; +} diff --git a/src/frontend/src/assets/merge.ts b/src/frontend/src/assets/merge.ts new file mode 100644 index 000000000..d7aab20a6 --- /dev/null +++ b/src/frontend/src/assets/merge.ts @@ -0,0 +1,100 @@ +// Merge an arriving hierarchy slice into the forest the tab holds. +// +// Pure, so the one rule with data-loss potential is testable without a store. +// +// A merge is mostly a UNION -- later wins, so a richer per-root row replaces a +// collection index's stub. But a union alone can never drop a row, and a node +// removed at the source would then outlive every publish that mentioned it. +// Deleting on absence is sound only inside a document that CLAIMS completeness, +// and exactly three conditions establish one: +// +// root !== null a subtree spine carries its root's whole subtree. A +// collection index is depth-bounded and claims nothing, +// so it never prunes. +// same subject a row stamped with this subject was put there by an +// older fetch of THIS spine. Rows another subject +// contributed (a nested spine, the index stub) are that +// subject's to retire. +// different revision a re-merge of the revision already held says nothing +// new; treating it as authority to delete would turn a +// redundant fetch into data loss. + +import type { NodeOrigin } from "./freshness"; +import type { AssetNode } from "./types"; + +export interface SpineMerge { + readonly subject: string; + readonly revision: string; + /** The slice's declared root, or null for a collection index. */ + readonly root: string | null; +} + +export interface Forest { + readonly nodes: ReadonlyMap; + readonly origins: ReadonlyMap; + /** id -> the parent a PRUNED row last had. What lets an orphan with cause + * `removed` say where it used to sit, after the row itself has gone. */ + readonly retired: ReadonlyMap; +} + +export const EMPTY_FOREST: Forest = Object.freeze({ + nodes: new Map(), + origins: new Map(), + retired: new Map(), +}); + +/** The merged forest, or the SAME object when the slice changes nothing -- so a + * store can skip a render for an empty document. + * + * An EMPTY document is always a no-op, whatever `root` says. A genuine subtree + * publish always enumerates at least its own root row, so zero rows is a + * fetch gone wrong (a transient error, a truncated response) rather than a + * completeness claim of "this subtree is now empty" -- and the one thing this + * module must never do on an ambiguous input is delete a previously known + * subtree. */ +export function mergeSpine(forest: Forest, incoming: readonly AssetNode[], merge: SpineMerge): Forest { + if (!incoming.length) return forest; + const nodes = new Map(forest.nodes); + const origins = new Map(forest.origins); + let retired = forest.retired; + const origin: NodeOrigin = { subject: merge.subject, revision: merge.revision }; + + if (merge.root !== null) { + const arriving = new Set(); + for (const n of incoming) arriving.add(n.id); + const gone: string[] = []; + for (const [id, was] of origins) { + if (was.subject !== merge.subject || was.revision === merge.revision || arriving.has(id)) continue; + gone.push(id); + } + if (gone.length) { + const next = new Map(retired); + for (const id of gone) { + next.set(id, nodes.get(id)?.parent ?? null); + nodes.delete(id); + origins.delete(id); + } + retired = next; + } + } + + for (const n of incoming) { + const prev = nodes.get(n.id); + // A subtree document names its own top with NO parent -- true of the + // document, a lie about the forest. Keep the parent a previous slice gave + // (the same rule ./hierarchy applies to duplicate input rows). + nodes.set(n.id, prev && n.parent === null && prev.parent !== null ? { ...n, parent: prev.parent } : n); + origins.set(n.id, origin); + } + if (retired.size) { + // A row that comes back is no longer retired. + let next: Map | null = null; + for (const n of incoming) { + if (!retired.has(n.id)) continue; + next ??= new Map(retired); + next.delete(n.id); + } + if (next) retired = next; + } + return { nodes, origins, retired }; +} diff --git a/src/frontend/src/assets/orphans.ts b/src/frontend/src/assets/orphans.ts new file mode 100644 index 000000000..99808712b --- /dev/null +++ b/src/frontend/src/assets/orphans.ts @@ -0,0 +1,80 @@ +// Why a published subject has no row, and where its node used to sit. +// +// An orphan is a published subject whose node matches no row in any loaded +// spine. It is NOT dropped: the subject is real and readable, and dropping it +// makes a reachable asset invisible. But "there is an entry you cannot see" is +// only half an answer; the other half is WHY, and there are two causes that are +// opposites: +// +// removed the subject is OLDER than the hierarchy being shown. A newer +// hierarchy no longer contains the node; the publish outlived it. +// ahead the subject is NEWER than the hierarchy being shown. Nothing in +// this hierarchy places the node yet; the node outran the tree. +// +// Which applies is decidable from revisions already held -- no feed, no +// publisher record -- so this is a small pure function. + +import type { Hierarchy } from "./hierarchy"; + +export type OrphanCause = "removed" | "ahead"; + +export interface OrphanEntry { + /** The node id the subject is published against. */ + readonly id: string; + /** The revision the subject itself resolved to. */ + readonly revision: string; + /** The revision of the hierarchy it is being judged against. */ + readonly spineRevision: string; + readonly cause: OrphanCause; + /** Labels of the ancestors still present in the forest, OUTERMOST FIRST, or + * empty when none are loaded. */ + readonly path: readonly string[]; +} + +/** Equal revisions cannot be "the tree moved" -- the subject and the hierarchy + * are one publish -- so that case reads `ahead`, whose sentence ("nothing in + * this hierarchy places it") is the true one there too. */ +export function orphanCause(entryRevision: string, spineRevision: string): OrphanCause { + return entryRevision < spineRevision ? "removed" : "ahead"; +} + +/** The loaded ancestors of a node that is NOT itself in the forest, found from + * a parent it declared somewhere (a manifest's node, a previous spine). A node + * with no recoverable parent reports an empty path rather than a guess. */ +export function lastKnownPath( + hierarchy: Hierarchy, + id: string, + parentOf: (id: string) => string | null, + labelOf: (data: T) => string, +): readonly string[] { + const out: string[] = []; + const seen = new Set([id]); + let cursor = parentOf(id); + while (cursor !== null && !seen.has(cursor) && out.length < 32) { + seen.add(cursor); + const node = hierarchy.byId.get(cursor); + if (!node) break; + out.push(labelOf(node.data)); + cursor = node.parent; + } + return out.reverse(); +} + +/** The sentence for an orphan. Both revisions are IN the sentence rather than + * in a tooltip: a `title` is unreachable on touch, and the list is short. */ +export function orphanSentence(o: OrphanEntry, revLabel: (rev: string) => string): string { + if (o.cause === "removed") { + return ( + `Removed by a newer hierarchy. The ${revLabel(o.spineRevision)} tree no longer contains ` + + `this node, but its publish from ${revLabel(o.revision)} is still there.` + ); + } + return ( + `Published after this hierarchy. Nothing in the ${revLabel(o.spineRevision)} tree places ` + + `this node; a newer hierarchy publish would show where it sits.` + ); +} + +export function orphanHeading(cause: OrphanCause): string { + return cause === "removed" ? "Removed by a newer hierarchy" : "Published after this hierarchy"; +} diff --git a/src/frontend/src/assets/projection.ts b/src/frontend/src/assets/projection.ts new file mode 100644 index 000000000..f5167c09d --- /dev/null +++ b/src/frontend/src/assets/projection.ts @@ -0,0 +1,88 @@ +/** + * Read a `hierarchy.json` slice off the wire -- the TS half of + * `ada/assets/projection.py`, and the same three rules: + * + * 1. REFUSE an unknown schema. A future document may give familiar columns a + * new meaning; reading the subset we recognise would be a guess. + * 2. Index columns by NAME, never by position. `cols` is a header; a producer + * adding a column must not shift what an older reader sees. + * 3. Accept `leaf` as 1 or true (and their string forms). Producers have + * emitted all of them, and a strict reader silently turns every node into + * a branch. + * + * Columnar on the wire because a 41k-row spine is ~2.6 MB that way; this is the + * one place it is unpacked into objects, in one pass with the column positions + * looked up once. + */ + +import type { AssetNode, DeliveryKind, HierarchySlice, WireHierarchySlice } from "./types"; + +export const HIERARCHY_SCHEMA = "ada.assets/hierarchy@1"; +export const BASE_COLS = ["id", "parent", "label", "kind", "leaf", "delivery"] as const; + +export class HierarchyError extends Error {} + +function asLeaf(v: unknown): boolean { + if (typeof v === "boolean") return v; + if (typeof v === "number") return v !== 0; + if (typeof v === "string") return ["1", "true", "yes"].includes(v.trim().toLowerCase()); + return false; +} + +function asDelivery(v: unknown): DeliveryKind { + // "" on the wire is "no claim"; core's manifests spell the same thing "none". + return v === "mesh" || v === "build" ? v : "none"; +} + +export function parseHierarchySlice(doc: WireHierarchySlice): HierarchySlice { + if (!doc || typeof doc !== "object") throw new HierarchyError("hierarchy slice is not an object"); + if (doc.schema !== HIERARCHY_SCHEMA) { + throw new HierarchyError( + `unknown hierarchy schema ${JSON.stringify(doc.schema)}: this viewer reads ${HIERARCHY_SCHEMA} only`, + ); + } + const at = new Map(); + doc.cols.forEach((c, i) => at.set(c, i)); + const missing = BASE_COLS.filter((c) => !at.has(c)); + if (missing.length) throw new HierarchyError(`hierarchy is missing column(s): ${missing.join(", ")}`); + + const iId = at.get("id")!; + const iParent = at.get("parent")!; + const iLabel = at.get("label")!; + const iKind = at.get("kind")!; + const iLeaf = at.get("leaf")!; + const iDelivery = at.get("delivery")!; + const iPath = at.get("path"); + const iProvider = at.get("provider"); + const width = doc.cols.length; + + const nodes: AssetNode[] = new Array(doc.rows.length); + for (let r = 0; r < doc.rows.length; r++) { + const row = doc.rows[r]; + if (row.length !== width) { + throw new HierarchyError(`row ${r} has ${row.length} values but there are ${width} columns`); + } + const parent = row[iParent]; + const path = iPath === undefined ? undefined : row[iPath]; + const provider = iProvider === undefined ? null : row[iProvider]; + nodes[r] = { + id: String(row[iId]), + parent: parent === null || parent === undefined || parent === "" ? null : String(parent), + label: String(row[iLabel] ?? row[iId]), + kind: String(row[iKind] ?? ""), + leaf: asLeaf(row[iLeaf]), + delivery: asDelivery(row[iDelivery]), + ...(typeof path === "string" && path ? { path } : {}), + provider: typeof provider === "string" && provider ? provider : doc.provider, + }; + } + return { + schema: HIERARCHY_SCHEMA, + provider: doc.provider, + collection: doc.collection, + root: doc.root ?? null, + producedAt: doc.produced_at, + depth: doc.depth, + nodes, + }; +} diff --git a/src/frontend/src/assets/resolve.ts b/src/frontend/src/assets/resolve.ts new file mode 100644 index 000000000..8fc6f3ab1 --- /dev/null +++ b/src/frontend/src/assets/resolve.ts @@ -0,0 +1,170 @@ +// ONE resolution context for the whole tree, not a max() per row. +// +// WHY THIS IS A MODULE AND NOT AN EXPRESSION. The obvious implementation is +// `max(revision)` computed wherever a row needs it. That produces a tree that +// disagrees with itself: the badge on a parent can come from one publish and the +// badge on its child from another, a Load can fetch a third, and no screenshot +// of the result is reproducible. So resolution is lifted out into a context +// object computed once per (index, collection, mode), and EVERY badge reads that +// one object. If the modes below are ever extended, the extension goes here and +// the tree cannot fall out of step. +// +// The three modes are not three flavours of the same thing: +// +// latest per subject, max(revision). Browsing. NOT coeval. +// as-of X per subject, max(revision <= X). Reproducible. NOT coeval. +// run T per subject, revision == T exactly. The only coeval mode. +// +// "Coeval" means every subject in the resolution came out of the same publish. +// Only `run` guarantees it, and it is free: a fan-out shares one revision stamp, +// so asking for one stamp returns a set that was genuinely produced together. +// `latest` is the default because browsing is the common case, but the tab must +// SAY when it is mixing -- see `describeResolution`. +// +// Only COMPLETE revisions (a manifest exists) are candidates, in every mode. +// Manifests are written last, so a manifest-less revision is a publish that died +// partway; the server's tree and delivery routes skip it, and a browser that +// resolved to it would badge a row the server then refuses to serve. + +import { compareRevisions, isComplete, subjectsOf } from "./assetIndex"; +import type { AssetIndex, AssetRevision, AssetSubject, ResolutionMode } from "./types"; + +export type { ResolutionMode } from "./types"; + +/** What one subject resolved to under the active mode. */ +export interface ResolvedSubject { + readonly subject: string; + readonly revision: AssetRevision; + /** The subject's newest complete revision overall, whatever the mode picked -- + * so an absence can be stated with a concrete alternative. */ + readonly newest: string; + /** The newest revision satisfying the mode that also CARRIES CONTENT, or null. + * + * WHY A SECOND PICK EXISTS, given this module's whole argument is one + * resolution rather than a `max()` per row: a revision can mention a subject + * without containing anything for it. A hierarchy-only publish writes a + * manifest at every root it declares and no delivery claim anywhere; under a + * single pick it -- being newest -- becomes every subject's answer, and an + * earlier publish that DOES carry content is shadowed. It is still ONE + * resolution (same mode, same bound, computed once) answering two questions + * that were always different. Equal to `revision` when no predicate is given. */ + readonly content: AssetRevision | null; +} + +export interface ResolveOptions { + /** Whether a revision carries content, as opposed to merely naming subjects. + * Omitted, every revision counts and `content` collapses onto `revision`. */ + readonly carriesContent?: (revision: AssetRevision) => boolean; +} + +export interface Resolution { + readonly collection: string; + readonly mode: ResolutionMode; + /** subject -> what it resolved to. A subject nothing satisfies is ABSENT here + * and recorded in `missing` instead. */ + readonly subjects: ReadonlyMap; + readonly missing: ReadonlyMap; + /** Subjects whose newest revision has no manifest: a publish that died + * partway. Counted so the tab can say so rather than quietly resolving past it. */ + readonly incomplete: readonly string[]; +} + +const ANY = (): boolean => true; + +/** The revision the mode selects among those `accept` allows. `accept` narrows + * the CANDIDATES, never the rule -- so under `run` the content pick is that + * run's own revision or null, which keeps the mode coeval. */ +function pick( + subject: AssetSubject, + mode: ResolutionMode, + accept: (revision: AssetRevision) => boolean = ANY, +): AssetRevision | null { + const revs = subject.revisions; // ascending + switch (mode.kind) { + case "latest": { + for (let i = revs.length - 1; i >= 0; i--) if (isComplete(revs[i]) && accept(revs[i])) return revs[i]; + return null; + } + case "as-of": { + for (let i = revs.length - 1; i >= 0; i--) { + const r = revs[i]; + if (compareRevisions(r.revision, mode.revision) <= 0 && isComplete(r) && accept(r)) return r; + } + return null; + } + case "run": { + const hit = revs.find((r) => r.revision === mode.revision) ?? null; + return hit && isComplete(hit) && accept(hit) ? hit : null; + } + } +} + +export function resolveCollection( + index: AssetIndex, + collection: string, + mode: ResolutionMode, + options: ResolveOptions = {}, +): Resolution { + const subjects = new Map(); + const missing = new Map(); + const incomplete: string[] = []; + const carries = options.carriesContent; + for (const [name, subject] of subjectsOf(index, collection)) { + const revs = subject.revisions; + if (revs.length && !isComplete(revs[revs.length - 1])) incomplete.push(name); + let newest = ""; + for (let i = revs.length - 1; i >= 0; i--) { + if (isComplete(revs[i])) { + newest = revs[i].revision; + break; + } + } + const revision = pick(subject, mode); + if (revision) { + const content = carries ? pick(subject, mode, carries) : revision; + subjects.set(name, { subject: name, revision, newest, content }); + } else if (newest) missing.set(name, { subject: name, newest }); + } + incomplete.sort(); + return { collection, mode, subjects, missing, incomplete }; +} + +/** The distinct revisions actually in play, ascending. */ +export function revisionsInPlay(resolution: Resolution, skip?: (subject: string) => boolean): string[] { + const seen = new Set(); + for (const s of resolution.subjects.values()) { + if (skip?.(s.subject)) continue; + seen.add(s.revision.revision); + } + return [...seen].sort(compareRevisions); +} + +/** What the indicator beside the mode control says. `mixed` is the load-bearing + * field: a `latest` resolution spanning three publish dates is not wrong, but a + * user reading badges off it is comparing things never produced together, and + * that has to be on screen rather than inferable. `run` is coeval by + * construction, so `mixed` is false there whatever the data looks like. */ +export interface ResolutionSummary { + readonly mixed: boolean; + readonly coeval: boolean; + readonly revisions: readonly string[]; + readonly subjectCount: number; + readonly missingCount: number; + readonly incompleteCount: number; +} + +export function describeResolution( + resolution: Resolution, + skip?: (subject: string) => boolean, +): ResolutionSummary { + const revisions = revisionsInPlay(resolution, skip); + const coeval = resolution.mode.kind === "run"; + return { + mixed: !coeval && revisions.length > 1, + coeval, + revisions, + subjectCount: resolution.subjects.size, + missingCount: resolution.missing.size, + incompleteCount: resolution.incomplete.length, + }; +} diff --git a/src/frontend/src/assets/rowFacts.ts b/src/frontend/src/assets/rowFacts.ts new file mode 100644 index 000000000..47f0f9123 --- /dev/null +++ b/src/frontend/src/assets/rowFacts.ts @@ -0,0 +1,110 @@ +// What ONE row of the Assets tab says, as data. +// +// The row renderer receives the view and an id and nothing else, and this is +// the function it calls: every badge, dim, gap, stale mark and drift flag is a +// pure function of one `AssetView`. That is the one-derived-view property the +// note makes a verification gate -- a row that computed anything for itself +// could disagree with its neighbour, and the tree would stop being consistent. + +import type { AssetView } from "./assetView"; +import { ROLE_CONTENT } from "./assetView"; +import type { HierarchyDrift, NodeFreshness } from "./freshness"; +import { ancestorsOf, type Hierarchy } from "./hierarchy"; +import type { AssetNode, DeliveryKind } from "./types"; + +export type BadgeWeight = "solid" | "ghost" | "below"; + +export interface RowBadge { + /** Where the published content stands to this row: rooted HERE, covering + * from ABOVE, or rooted BENEATH. */ + readonly weight: BadgeWeight; + /** The delivery claim of the publish the badge is about -- the letter shown. */ + readonly delivery: DeliveryKind; + /** The node that publish is rooted at (this row for `solid`). */ + readonly at: string; + /** The revision it resolved to. */ + readonly revision: string; +} + +export interface RowFacts { + readonly node: AssetNode; + readonly badge: RowBadge | null; + /** Nothing to deliver at or below this row. Rendered, never hidden. */ + readonly dimmed: boolean; + /** Something at or below, and no publish at or above covers it. */ + readonly gap: boolean; + /** Leaves at or below -- the count a collapsed branch shows. */ + readonly payload: number; + /** Of those, how many no publish covers -- what `gap` is about. */ + readonly uncovered: number; + readonly freshness: NodeFreshness | null; + /** This row's own subject was published against an older tree. */ + readonly drift: HierarchyDrift | null; + /** The revision this row's own subject resolved to, if it is one. */ + readonly resolvedRevision: string | null; +} + +function deliveryOf(view: AssetView, subject: string): { delivery: DeliveryKind; revision: string } | null { + const content = view.resolution.subjects.get(subject)?.content; + if (!content) return null; + return { delivery: content.manifest?.delivery ?? "none", revision: content.revision }; +} + +export function rowFacts(view: AssetView, id: string): RowFacts | null { + const hnode = view.hierarchy.byId.get(id); + if (!hnode) return null; + const cov = view.coverage.byId.get(id); + + let badge: RowBadge | null = null; + if (cov?.rooted.has(ROLE_CONTENT)) { + const d = deliveryOf(view, id); + if (d) badge = { weight: "solid", at: id, ...d }; + } else if (cov?.covered.has(ROLE_CONTENT)) { + const owner = cov.coveredBy.get(ROLE_CONTENT)!; + const d = deliveryOf(view, owner); + if (d) badge = { weight: "ghost", at: owner, ...d }; + } else if (cov?.below.has(ROLE_CONTENT)) { + const owner = cov.belowBy.get(ROLE_CONTENT)!; + const d = deliveryOf(view, owner); + if (d) badge = { weight: "below", at: owner, ...d }; + } + + return { + node: hnode.data, + badge, + dimmed: (cov?.dimmed ?? false) && !view.unexplored.has(id), + gap: cov?.gap ?? false, + payload: cov?.payloadSubtree ?? 0, + uncovered: cov?.uncoveredSubtree ?? 0, + freshness: view.freshness.get(id) ?? null, + drift: view.drift.get(id) ?? null, + resolvedRevision: view.resolution.subjects.get(id)?.revision.revision ?? null, + }; +} + +/** The rows a search keeps: every match and every ancestor of one, so the set + * is ancestor-closed as `flattenVisible`'s `include` requires. Also returns the + * ancestors, which the tab treats as expanded while the search is active -- + * a hit inside a collapsed branch is otherwise found and not shown. */ +export function searchRows( + h: Hierarchy, + term: string, +): { include: ReadonlySet; open: ReadonlySet; matches: number } | null { + const q = term.trim().toLowerCase(); + if (!q) return null; + const include = new Set(); + const open = new Set(); + let matches = 0; + for (const [id, node] of h.byId) { + const d = node.data; + if (!(d.label.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))) continue; + matches++; + include.add(id); + for (const a of ancestorsOf(h, id)) { + if (include.has(a) && open.has(a)) break; // the rest of the chain is already in + include.add(a); + open.add(a); + } + } + return { include, open, matches }; +} diff --git a/src/frontend/src/assets/spines.ts b/src/frontend/src/assets/spines.ts new file mode 100644 index 000000000..ae7ad5fdd --- /dev/null +++ b/src/frontend/src/assets/spines.ts @@ -0,0 +1,133 @@ +// WHICH PUBLISHED SPINE COVERS A NODE, and whether it is already in. +// +// A subtree `hierarchy.json` is published UNDER A SUBJECT and covers that +// subject's whole subtree. Looking a node's spine up by its own id answers "is a +// spine published AT this node", not "does a published spine CONTAIN this +// node" -- the same thing only when every clickable row is itself a publish +// root. The moment a spine is rooted at a branch whose children arrive from the +// collection index, those children advertise more rows (`leaf: false`), sit +// inside a published file the tab holds the key to, and a direct lookup reads +// them as dead ends. So coverage resolves NEAREST SPINE AT OR ABOVE -- lazily, +// for the rows that ask (see `spineCoverage`). +// +// A subject IS a node id here (core's node-identity contract: single segment, +// stable, unique within the collection), so a spine's `root` and its `subject` +// are the same string; both are kept because they answer different questions -- +// "which document" and "which row is its top". + +import { HIERARCHY_FILENAME } from "./assetIndex"; +import type { Hierarchy } from "./hierarchy"; +import type { Resolution } from "./resolve"; +import type { AssetNode } from "./types"; + +export interface SpineSource { + readonly subject: string; + /** Part of the identity: the same root at a new revision is a different + * document and must be re-read. */ + readonly revision: string; + readonly root: string; +} + +/** The spine published AT a node under the active resolution, if any. The + * collection subject is never a node's spine -- its hierarchy is the index. */ +export function spineRootedAt(resolution: Resolution, id: string): SpineSource | null { + if (id === resolution.collection) return null; + const resolved = resolution.subjects.get(id); + if (!resolved || !resolved.revision.files.has(HIERARCHY_FILENAME)) return null; + return { subject: id, revision: resolved.revision.revision, root: id }; +} + +/** Answers "which spine covers this node" for the rows that ASK. + * + * Lazy with a memo, rather than one eager pass over the forest: the questions + * come from the rows on screen (a few dozen) and the expanded set, never from + * all 41k rows at once, so an eager map was work nobody read. A walk up stops + * at the first memoised ancestor and memoises the path it took, so repeated + * questions under one branch cost one step each. */ +export interface SpineLookup { + get(id: string): SpineSource | undefined; +} + +export function spineCoverage( + h: Hierarchy, + rootedAt: (id: string) => SpineSource | null, +): SpineLookup { + const memo = new Map(); + return { + get(id: string): SpineSource | undefined { + if (!h.byId.has(id)) return undefined; + const path: string[] = []; + let cur: string | null = id; + let found: SpineSource | null = null; + const seen = new Set(); + while (cur !== null && !seen.has(cur)) { + seen.add(cur); + const hit = memo.get(cur); + if (hit !== undefined) { + found = hit; + break; + } + path.push(cur); + const own = rootedAt(cur); + if (own) { + found = own; + break; + } + cur = h.byId.get(cur)?.parent ?? null; + } + // Nearest wins: a node below its own spine root inherits that root, and + // the root itself resolves to its own spine. + for (const p of path) memo.set(p, found); + return found ?? undefined; + }, + }; +} + +/** Whether the covering spine is merged -- KEYED BY THE SPINE'S ROOT, so the + * second branch expanded under one spine does not re-fetch it, and compared by + * REVISION, because a resolution change re-points the tree at another document. */ +export function spineMerged(source: SpineSource | null, loaded: ReadonlyMap): boolean { + return source !== null && loaded.get(source.root) === source.revision; +} + +/** Whether expanding this row would bring something back. `leaf` is load- + * bearing both ways: a leaf has nothing beneath by definition, and a + * `leaf: false` row is a positive claim that it HAS children. */ +export function canFetchSpine( + node: AssetNode | undefined, + source: SpineSource | null, + loaded: ReadonlyMap, +): boolean { + if (!node || node.leaf) return false; + return source !== null && !spineMerged(source, loaded); +} + +export interface RowSpineState { + readonly loading: boolean; + readonly error: string | null; + /** A branch that promises children it can never deliver: `leaf: false`, no + * children held, nothing (more) to fetch. Legitimate -- an index can name a + * node whose subtree was never published -- and never silent. */ + readonly deadEnd: boolean; +} + +/** What one row says about the spine covering it. The mark goes where the wait + * is FELT: on a row with nothing under it yet, or on the spine's own root -- + * not on every already-drawn row a re-read happens to cover. */ +export function rowSpineState(input: { + readonly node: AssetNode | undefined; + /** The FOREST's answer, not the filtered row's: a branch whose children a + * filter removed is filtered, not a dead end. */ + readonly hasChildren: boolean; + readonly source: SpineSource | null; + readonly loaded: ReadonlyMap; + readonly loading: ReadonlySet; + readonly errors: ReadonlyMap; +}): RowSpineState { + const { node, hasChildren, source, loaded, loading, errors } = input; + const explored = source === null || spineMerged(source, loaded); + const deadEnd = !!node && !node.leaf && !hasChildren && explored; + const waiting = source !== null && !!node && !node.leaf && (!hasChildren || source.root === node.id); + if (source === null || !waiting || spineMerged(source, loaded)) return { loading: false, error: null, deadEnd }; + return { loading: loading.has(source.root), error: errors.get(source.root) ?? null, deadEnd }; +} diff --git a/src/frontend/src/assets/types.ts b/src/frontend/src/assets/types.ts new file mode 100644 index 000000000..52854d972 --- /dev/null +++ b/src/frontend/src/assets/types.ts @@ -0,0 +1,125 @@ +/** + * The browser's view of the asset-store contracts in `ada/assets/`. + * + * Two layers, kept apart on purpose: + * + * Wire* exactly what the REST routes send (snake_case, columnar). Only + * `./projection` and `./assetIndex` read these. + * the rest what every other module in this directory reads: rows resolved + * through their column NAMES, revisions sorted, sets instead of arrays. + * + * Nothing here names a provider or a source format. A provider's vocabulary + * reaches the browser only as opaque strings -- `kind`, `label`, `provider` -- + * which the tab displays and never branches on. + */ + +export type DeliveryKind = "none" | "mesh" | "build"; + +// --- wire ---------------------------------------------------------------------- + +export interface WireHierarchySlice { + readonly schema: string; + readonly provider: string; + readonly collection: string; + readonly root: string | null; + readonly produced_at: string; + readonly depth: number; + readonly cols: readonly string[]; + readonly rows: readonly (readonly unknown[])[]; +} + +/** The per-revision manifest fields the index route folds in with `manifests=true`. */ +export interface WireManifestSummary { + readonly provider: string; + readonly node?: string | null; + readonly delivery: DeliveryKind; + readonly produced_at: string; + readonly hierarchy_revision?: string | null; +} + +export interface WireRevision { + readonly revision: string; + readonly files: readonly string[]; + readonly manifest?: WireManifestSummary; + readonly manifest_error?: string; +} + +export interface WireAssetIndex { + readonly collections: Readonly< + Record + >; + readonly malformed: readonly { readonly key: string; readonly reason: string }[]; +} + +export interface WireProvider { + readonly id: string; + readonly label: string; + readonly live: boolean; + readonly delivery: readonly string[]; +} + +// --- the browser's model ------------------------------------------------------- + +/** One row of a hierarchy, in core's vocabulary. */ +export interface AssetNode { + readonly id: string; + readonly parent: string | null; + readonly label: string; + /** The provider's own node type. Opaque: shown, filtered on, never branched on. */ + readonly kind: string; + readonly leaf: boolean; + readonly delivery: DeliveryKind; + readonly path?: string; + /** Which provider produced this node. Always present after parsing: absent on + * the wire means "the slice's provider", and ./projection fills it in so a + * mixed collection and a single-source one read the same. */ + readonly provider: string; +} + +export interface HierarchySlice { + readonly schema: "ada.assets/hierarchy@1"; + readonly provider: string; + readonly collection: string; + /** null = a collection index (depth-bounded, claims no completeness); + * otherwise the subtree spine of this root (complete for that root). */ + readonly root: string | null; + readonly producedAt: string; + readonly depth: number; + readonly nodes: readonly AssetNode[]; +} + +export interface ManifestSummary { + readonly provider: string; + readonly node: string | null; + readonly delivery: DeliveryKind; + readonly producedAt: string; + readonly hierarchyRevision: string | null; +} + +export interface AssetRevision { + readonly revision: string; + readonly files: ReadonlySet; + /** Present when the index was asked for manifests and this revision has one + * that core could read. */ + readonly manifest: ManifestSummary | null; + /** Why a listed manifest could not be summarised, verbatim from the server. */ + readonly manifestError: string | null; +} + +export interface AssetSubject { + readonly collection: string; + readonly subject: string; + /** ASCENDING by revision, so the last element is the newest. */ + readonly revisions: readonly AssetRevision[]; +} + +export interface AssetIndex { + readonly collections: ReadonlyMap>; + /** Keys under `assets/` that did not parse, with the server's reason. */ + readonly malformed: readonly string[]; +} + +export type ResolutionMode = + | { readonly kind: "latest" } + | { readonly kind: "as-of"; readonly revision: string } + | { readonly kind: "run"; readonly revision: string }; diff --git a/src/frontend/src/components/asset_browser/AssetBrowser.tsx b/src/frontend/src/components/asset_browser/AssetBrowser.tsx new file mode 100644 index 000000000..a000d9abf --- /dev/null +++ b/src/frontend/src/components/asset_browser/AssetBrowser.tsx @@ -0,0 +1,81 @@ +// The tab shell inside the tree drawer: `Files | Assets`, a fixed pair. +// +// NOT A REGISTRY. Out-of-tree code extends the browser through asset providers +// (collections inside `Assets`) and the existing `panels` slot, never by adding +// a tab; a tab slot would be a seam nothing pays for. +// +// THE FILES TAB IS TODAY'S TREE, UNTOUCHED, and it stays MOUNTED while hidden. +// Its react-arborist instance is what 3D picking selects into +// (`treeViewStore.tree`), so unmounting it on a tab switch would break +// pick -> row sync for as long as the Assets tab is open. Hidden, it keeps +// listening, and a node loaded from Assets appears in it as a normal source. +// +// The Assets tab needs a server (a scope, the asset routes), so outside REST +// mode -- the notebook and websocket viewers -- there is no tab strip at all and +// the drawer is exactly what it was. + +import React from "react"; + +import { runtime } from "@/runtime/config"; +import { useViewerStores } from "@/state/AdaViewerContext"; +import type { AssetBrowserTab } from "@/state/assetBrowserStore"; + +import AssetsTab from "./AssetsTab"; +import FilesTab from "./FilesTab"; + +export function assetsTabAvailable(): boolean { + return runtime.isRestMode(); +} + +const TABS: readonly { id: AssetBrowserTab; label: string; title: string }[] = [ + { id: "files", label: "Files", title: "The loaded models' selection tree" }, + { id: "assets", label: "Assets", title: "Published asset collections in this scope" }, +]; + +/** The header strip. Rendered in the drawer's title slot so the tree below keeps + * the height it had. */ +export const AssetBrowserTabs: React.FC = () => { + const { useAssetBrowserStore } = useViewerStores(); + const tab = useAssetBrowserStore((s) => s.tab); + const setTab = useAssetBrowserStore((s) => s.setTab); + return ( +
+ {TABS.map((t) => ( + + ))} +
+ ); +}; + +const AssetBrowser: React.FC = () => { + const { useAssetBrowserStore } = useViewerStores(); + const stored = useAssetBrowserStore((s) => s.tab); + const withAssets = assetsTabAvailable(); + const tab: AssetBrowserTab = withAssets ? stored : "files"; + return ( + <> + + {withAssets && tab === "assets" && ( +
+ +
+ )} + + ); +}; + +export default AssetBrowser; diff --git a/src/frontend/src/components/asset_browser/AssetTree.tsx b/src/frontend/src/components/asset_browser/AssetTree.tsx new file mode 100644 index 000000000..eb2b25ddd --- /dev/null +++ b/src/frontend/src/components/asset_browser/AssetTree.tsx @@ -0,0 +1,226 @@ +// The asset forest as a virtualised row list. +// +// VIRTUALISED BECAUSE IT HAS TO BE: one spine can be ~41k rows and expanding it +// is the normal way to use the tab. The flat row list is computed once per +// (view, expansion, search) by `flattenVisible`, and only the window on screen +// is rendered. +// +// A row receives the view and its id and nothing else (`rowFacts`), so every +// mark on it is one function of one derived object. +// +// FOUR WAYS A ROW CAN READ AS LESS THAN ORDINARY, kept visually apart: +// dimmed nothing at or below to deliver -- reduced opacity, and it SAYS so +// in its title. Rendered, never hidden: "there is nothing here" is +// an answer, a missing row is not. +// gap something below, no publish covers it -- an amber `gap` tag. +// stale drawn from a spine the resolution moved past -- a `stale` tag. +// drift published against an older tree -- an amber `older tree` tag. + +import React, { useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +import type { AssetView } from "@/assets/assetView"; +import { flattenVisible } from "@/assets/hierarchy"; +import { rowFacts, searchRows, type RowBadge } from "@/assets/rowFacts"; +import { canFetchSpine, rowSpineState, type SpineSource } from "@/assets/spines"; +import { useViewerStores } from "@/state/AdaViewerContext"; + +import { formatRevision } from "./format"; + +const ROW_HEIGHT = 22; + +const BADGE_LETTER: Record = { mesh: "M", build: "B", none: "·" }; + +const BADGE_TITLE: Record = { + solid: "Published at this node", + ghost: "Covered by a publish rooted above", + below: "Published content beneath this node", +}; + +const Badge: React.FC<{ badge: RowBadge }> = ({ badge }) => { + const cls = + badge.weight === "solid" + ? "bg-blue-500 text-white" + : badge.weight === "below" + ? "bg-blue-500/25 text-blue-200 ring-1 ring-inset ring-blue-400" + : "text-gray-300 ring-1 ring-inset ring-gray-500"; + return ( + + {BADGE_LETTER[badge.delivery] ?? "?"} + + ); +}; + +const Tag: React.FC<{ tone: "amber" | "gray"; title: string; children: React.ReactNode }> = ({ tone, title, children }) => ( + + {children} + +); + +const AssetRow: React.FC<{ + view: AssetView; + id: string; + depth: number; + hasChildren: boolean; + expanded: boolean; + selected: boolean; + spine: ReturnType; + showProvider: boolean; + onToggle: () => void; + onSelect: () => void; + onRetry: () => void; +}> = ({ view, id, depth, hasChildren, expanded, selected, spine, showProvider, onToggle, onSelect, onRetry }) => { + const facts = rowFacts(view, id); + if (!facts) return null; + const { node } = facts; + const title = facts.dimmed + ? `${node.label} — nothing at or below this node to deliver` + : facts.gap + ? `${node.label} — ${facts.uncovered} of ${facts.payload} leaf node(s) below are not covered by any publish` + : node.label; + return ( +
+ { + e.stopPropagation(); + if (spine.error) onRetry(); + else if (hasChildren) onToggle(); + }} + > + {spine.loading ? "…" : spine.error ? ! : hasChildren ? (expanded ? "▼" : "▶") : ""} + + {node.label} + {node.kind && {node.kind}} + {hasChildren && !expanded && facts.payload > 0 && ( + {facts.payload} + )} + {facts.badge && } + {facts.gap && gap} + {spine.deadEnd && ( + no subtree + )} + {facts.freshness?.stale && ( + + stale + + )} + {facts.drift && ( + + older tree + + )} + {showProvider && {node.provider}} +
+ ); +}; + +const AssetTree: React.FC<{ view: AssetView; onRetrySpine: (source: SpineSource) => void }> = ({ view, onRetrySpine }) => { + const { useAssetBrowserStore } = useViewerStores(); + const expanded = useAssetBrowserStore((s) => s.expanded); + const selected = useAssetBrowserStore((s) => s.selected); + const searchTerm = useAssetBrowserStore((s) => s.searchTerm); + const spineLoaded = useAssetBrowserStore((s) => s.spineLoaded); + const spineLoading = useAssetBrowserStore((s) => s.spineLoading); + const spineErrors = useAssetBrowserStore((s) => s.spineErrors); + const { toggleExpanded, select } = useAssetBrowserStore.getState(); + + const search = useMemo(() => searchRows(view.hierarchy, searchTerm), [view.hierarchy, searchTerm]); + const open = useMemo(() => { + if (!search) return expanded; + const s = new Set(expanded); + for (const id of search.open) s.add(id); + return s; + }, [expanded, search]); + + const rows = useMemo( + () => + flattenVisible(view.hierarchy, open, { + expandable: (id) => + canFetchSpine(view.hierarchy.byId.get(id)?.data, view.spines.get(id) ?? null, spineLoaded), + include: search?.include, + }), + [view, open, search, spineLoaded], + ); + + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12, + }); + const showProvider = view.providers.length > 1; + + if (!rows.length) { + return ( +
+ {search ? `Nothing matches "${searchTerm}".` : "No rows yet for this resolution."} +
+ ); + } + return ( +
+
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index]; + const source = view.spines.get(row.id) ?? null; + const spine = rowSpineState({ + node: view.hierarchy.byId.get(row.id)?.data, + hasChildren: view.hierarchy.childrenOf(row.id).length > 0, + source, + loaded: spineLoaded, + loading: spineLoading, + errors: spineErrors, + }); + return ( +
+ toggleExpanded(row.id)} + onSelect={() => select(row.id)} + onRetry={() => source && onRetrySpine(source)} + /> +
+ ); + })} +
+
+ ); +}; + +export default AssetTree; diff --git a/src/frontend/src/components/asset_browser/AssetsTab.tsx b/src/frontend/src/components/asset_browser/AssetsTab.tsx new file mode 100644 index 000000000..a59f8d377 --- /dev/null +++ b/src/frontend/src/components/asset_browser/AssetsTab.tsx @@ -0,0 +1,394 @@ +// The Assets tab: published collections in this scope, as one lazily-assembled +// forest read through ONE derived `AssetView`. +// +// Data flow, one direction only: +// +// routes --(loader)--> store: index, forest, mode, expansion (inputs) +// store --(buildAssetView, memoised)--> view (derived) +// view + id --(rowFacts)--> every badge a row shows (rendered) +// +// Nothing below the view computes a revision for itself. The hierarchy half of +// the view is memoised on the forest alone, so a mode switch re-derives the +// resolution passes without re-indexing a 41k-row spine. +// +// No delivery yet (Phase 3): a row says what is published and where, and a +// selected row's detail says what claim it carries; loading into the scene +// comes with the delivery kinds. + +import React, { useEffect, useMemo } from "react"; + +import { revisionsOf } from "@/assets/assetIndex"; +import { buildAssetHierarchy, buildAssetView, type AssetView } from "@/assets/assetView"; +import { orphanHeading, orphanSentence, type OrphanEntry } from "@/assets/orphans"; +import { rowFacts } from "@/assets/rowFacts"; +import { canFetchSpine } from "@/assets/spines"; +import type { ResolutionMode } from "@/assets/types"; +import { assetsApi } from "@/services/api/assets"; +import { useViewerStores } from "@/state/AdaViewerContext"; +import { loaderFor } from "@/state/assetBrowserLoader"; +import { scopeUrlPart } from "@/state/scopeStore"; + +import AssetTree from "./AssetTree"; +import { formatRevision } from "./format"; + +const Banner: React.FC<{ tone: "info" | "warn" | "error"; children: React.ReactNode; title?: string }> = ({ + tone, + children, + title, +}) => ( +
+ {children} +
+); + +const ModePicker: React.FC<{ mode: ResolutionMode; revisions: readonly string[]; onChange: (m: ResolutionMode) => void }> = ({ + mode, + revisions, + onChange, +}) => { + const newest = revisions[revisions.length - 1]; + return ( +
+ + {mode.kind !== "latest" && ( + + )} +
+ ); +}; + +/** Published subjects no loaded tree places, under the collection root. + * Collapsed to one line by default: the full sentence is on the entry's title + * and in the detail block when selected, so a long list cannot push the tree + * off a small screen. */ +const Orphans: React.FC<{ + orphans: readonly OrphanEntry[]; + pending: readonly string[]; + unmergedSpines: number; + loadingSpines: boolean; + selected: string | null; + onSelect: (id: string) => void; + onPlace: () => void; +}> = ({ orphans, pending, unmergedSpines, loadingSpines, selected, onSelect, onPlace }) => { + const [open, setOpen] = React.useState(false); + if (pending.length) { + // Not orphans yet: their branches are unopened. Neutral colour, and the + // action that turns "not placed yet" into a real answer. + return ( +
+ + {pending.length} published subject(s) not placed yet — their branches are unopened + + +
+ ); + } + if (!orphans.length) return null; + const ahead = orphans.filter((o) => o.cause === "ahead").length; + const removed = orphans.length - ahead; + return ( +
+ + {open && ( +
+ {(["ahead", "removed"] as const).map((cause) => + orphans + .filter((o) => o.cause === cause) + .map((o) => ( + + )), + )} +
+ )} +
+ ); +}; + +const Detail: React.FC<{ view: AssetView; id: string }> = ({ view, id }) => { + const facts = rowFacts(view, id); + const orphan = view.orphans.find((o) => o.id === id); + const resolved = view.resolution.subjects.get(id); + const manifest = resolved?.revision.manifest ?? null; + const lines: [string, React.ReactNode][] = []; + if (facts) { + lines.push(["Kind", facts.node.kind || "—"]); + lines.push(["Provider", facts.node.provider]); + lines.push(["Claim", facts.node.delivery === "none" ? "none" : facts.node.delivery]); + } + if (resolved) lines.push(["Published", `${formatRevision(resolved.revision.revision)}${manifest ? ` · ${manifest.delivery}` : ""}`]); + else if (facts) lines.push(["Published", "not as a subject of its own"]); + if (facts?.badge && facts.badge.weight !== "solid") { + lines.push([facts.badge.weight === "ghost" ? "Covered by" : "Content below", `${facts.badge.at} @ ${formatRevision(facts.badge.revision)}`]); + } + if (facts?.freshness?.stale) { + lines.push(["Drawn from", `${formatRevision(facts.freshness.shownAt)} — now resolves to ${formatRevision(facts.freshness.resolvedAt!)}`]); + } + if (facts?.drift) { + lines.push(["Tree", `published against ${formatRevision(facts.drift.publishedAgainst)}; shown from ${formatRevision(facts.drift.shownFrom)}`]); + } + if (orphan) lines.push(["Not in tree", orphanSentence(orphan, formatRevision)]); + const err = view.manifestErrors.get(id); + if (err) lines.push(["Manifest", err]); + return ( +
+
+ {facts?.node.label ?? id} {id} +
+ {lines.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ ); +}; + +const AssetsTab: React.FC = () => { + const { useAssetBrowserStore, useScopeStore } = useViewerStores(); + const scope = scopeUrlPart(useScopeStore((s) => s.current)); + const loader = loaderFor(useAssetBrowserStore, assetsApi); + + const storeScope = useAssetBrowserStore((s) => s.scope); + const collections = useAssetBrowserStore((s) => s.collections); + const collection = useAssetBrowserStore((s) => s.collection); + const index = useAssetBrowserStore((s) => s.index); + const indexLoading = useAssetBrowserStore((s) => s.indexLoading); + const indexError = useAssetBrowserStore((s) => s.indexError); + const mode = useAssetBrowserStore((s) => s.mode); + const forest = useAssetBrowserStore((s) => s.forest); + const forestVersion = useAssetBrowserStore((s) => s.forestVersion); + const merged = useAssetBrowserStore((s) => s.mergedIndexRevisions); + const expanded = useAssetBrowserStore((s) => s.expanded); + const spineLoaded = useAssetBrowserStore((s) => s.spineLoaded); + const spineLoading = useAssetBrowserStore((s) => s.spineLoading); + const spineErrors = useAssetBrowserStore((s) => s.spineErrors); + const selected = useAssetBrowserStore((s) => s.selected); + const searchTerm = useAssetBrowserStore((s) => s.searchTerm); + const { setMode, select, setSearchTerm } = useAssetBrowserStore.getState(); + + // A scope switch invalidates everything; the first open of the tab loads. + useEffect(() => { + const s = useAssetBrowserStore.getState(); + if (s.scope !== scope) { + s.resetForScope(scope); + void loader.loadCollections(scope); + } else if (s.collections === null && !s.indexLoading) { + void loader.loadCollections(scope); + } + }, [scope, loader, useAssetBrowserStore]); + + // The mode decides which collection indexes form the tops of the tree. + useEffect(() => { + if (storeScope === scope && index) void loader.syncCollectionIndexes(scope); + }, [mode, index, scope, storeScope, loader]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const hierarchy = useMemo(() => buildAssetHierarchy(forest), [forestVersion]); + const view = useMemo( + () => + index && collection + ? buildAssetView({ forest, index, collection, mode, indexRevisions: merged, hierarchy, spineLoaded }) + : null, + // `forest` changes exactly when `hierarchy` does. + // eslint-disable-next-line react-hooks/exhaustive-deps + [hierarchy, index, collection, mode, merged, spineLoaded], + ); + + // Lazy spines: an expanded row whose covering spine is not in (at the + // revision the resolution names) fetches it. An errored spine waits for an + // explicit retry rather than looping. + useEffect(() => { + if (!view) return; + for (const id of expanded) { + const node = view.hierarchy.byId.get(id)?.data; + const source = view.spines.get(id) ?? null; + if (!source || !canFetchSpine(node, source, spineLoaded)) continue; + if (spineLoading.has(source.root) || spineErrors.has(source.root)) continue; + void loader.loadSpine(scope, source); + } + }, [view, expanded, spineLoaded, spineLoading, spineErrors, loader, scope]); + + const revisions = useMemo(() => (index && collection ? revisionsOf(index, collection) : []), [index, collection]); + + if (indexError && !index) { + return Could not read the asset index: {indexError}; + } + if (!collections || (indexLoading && !index)) { + return
Reading published assets…
; + } + if (!collections.length) { + return ( +
+ Nothing is published under assets/ in this scope. +
+ ); + } + + const summary = view?.summary; + return ( +
+
+ + + +
+
+ setSearchTerm(e.target.value)} + /> +
+ +
+ {indexError && {indexError}} + {summary?.mixed && ( + + Mixed: this view spans {summary.revisions.length} publishes ( + {formatRevision(summary.revisions[0])} … {formatRevision(summary.revisions[summary.revisions.length - 1])}). Pick a + run for a coeval view. + + )} + {summary?.coeval && mode.kind === "run" && ( + + Run {formatRevision(mode.revision)} — coeval + {summary.missingCount > 0 && `; ${summary.missingCount} subject(s) have nothing in this run`} + + )} + {view && view.staleCount > 0 && ( + + {view.staleCount} row(s) are drawn from a hierarchy the resolution has moved past. Refresh to rebuild. + + )} + {view && view.drift.size > 0 && ( + {view.drift.size} subject(s) were published against an older tree than the one shown. + )} + {view && view.providers.length > 1 && ( + Mixed collection — providers: {view.providers.join(", ")} + )} + {summary && summary.incompleteCount > 0 && ( + + {summary.incompleteCount} subject(s) have a half-written newest revision (no manifest); the last complete one is shown. + + )} + {view && view.manifestErrors.size > 0 && ( + {view.manifestErrors.size} manifest(s) could not be read. + )} + {view && view.malformedKeys.length > 0 && ( + + {view.malformedKeys.length} key(s) under assets/ do not parse. + + )} + {view && view.spineRevision === null && ( + No collection hierarchy is published for this resolution; rows come from subject spines only. + )} +
+ +
+ {view && ( + void loader.loadSpine(scope, source)} + /> + )} +
+ {view && ( + 0} + selected={selected} + onSelect={select} + onPlace={() => void loader.loadSpines(scope, view.unmergedSpines)} + /> + )} + {view && selected && } +
+ ); +}; + +export default AssetsTab; diff --git a/src/frontend/src/components/asset_browser/FilesTab.tsx b/src/frontend/src/components/asset_browser/FilesTab.tsx new file mode 100644 index 000000000..56ab9aa09 --- /dev/null +++ b/src/frontend/src/components/asset_browser/FilesTab.tsx @@ -0,0 +1,10 @@ +// The Files tab: today's selection tree on its unchanged store. It exists as a +// name so the shell reads as two tabs; there is no logic here on purpose. + +import React from "react"; + +import TreeViewComponent from "@/components/tree_view/TreeViewComponent"; + +const FilesTab: React.FC = () => ; + +export default FilesTab; diff --git a/src/frontend/src/components/asset_browser/format.ts b/src/frontend/src/components/asset_browser/format.ts new file mode 100644 index 000000000..2568e18ab --- /dev/null +++ b/src/frontend/src/components/asset_browser/format.ts @@ -0,0 +1,7 @@ +/** `20260825T135553Z` -> `2026-08-25 13:55Z`. Anything else is shown verbatim: + * a revision the grammar refuses should look wrong, not be tidied. */ +export function formatRevision(rev: string | null | undefined): string { + if (!rev) return "—"; + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(rev); + return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}Z` : rev; +} diff --git a/src/frontend/src/components/tree_view/ResizableTreeView.tsx b/src/frontend/src/components/tree_view/ResizableTreeView.tsx index a57b9c626..a9023d69f 100644 --- a/src/frontend/src/components/tree_view/ResizableTreeView.tsx +++ b/src/frontend/src/components/tree_view/ResizableTreeView.tsx @@ -1,5 +1,5 @@ import React, { useRef } from 'react'; -import TreeViewComponent from './TreeViewComponent'; +import AssetBrowser, { AssetBrowserTabs, assetsTabAvailable } from '@/components/asset_browser/AssetBrowser'; import { useViewerStores } from "@/state/AdaViewerContext"; // Floating left drawer that holds the selection tree. Toggled from the @@ -42,7 +42,9 @@ const ResizableTreeView: React.FC = () => { gives mobile users an obvious way back to the viewer when the drawer covers most of the screen. */}
- Selection + {/* With a server the title slot carries the Files | Assets tabs; + without one (notebook, websocket) the drawer is unchanged. */} + {assetsTabAvailable() ? : Selection}
-
- -
+ {/* Resize handle — desktop only, no value on touch. */}
{ + const r = await authedFetch(`${base(scope)}/providers`); + return (await jsonOrThrow<{ providers: WireProvider[] }>(r, `listAssetProviders(${scope})`)).providers; + }, + + /** The server-folded index. With `collection`, one bounded listing of that + * collection plus the per-revision manifest summaries the badges read; + * without, the whole `assets/` prefix (collections only -- used to pick one). */ + async getAssetIndex(scope: ScopeUrl, collection?: string): Promise { + const q = new URLSearchParams(); + if (collection) { + q.set("collection", collection); + q.set("manifests", "true"); + } + const qs = q.toString(); + const r = await authedFetch(`${base(scope)}/index${qs ? `?${qs}` : ""}`); + return jsonOrThrow(r, `getAssetIndex(${scope}, ${collection ?? "*"})`); + }, + + /** One hierarchy slice. `root` absent = the collection index at `revision`; + * present = that root's subtree spine. `revision` is always passed by the tab + * -- it resolves revisions itself, from the index, so the server never picks. */ + async getAssetTree( + scope: ScopeUrl, + provider: string, + collection: string, + opts: { root?: string | null; revision: string }, + ): Promise { + const q = new URLSearchParams({ revision: opts.revision }); + if (opts.root) q.set("root", opts.root); + const url = `${base(scope)}/tree/${encodeURIComponent(provider)}/${encodeURIComponent(collection)}?${q}`; + const r = await authedFetch(url); + return jsonOrThrow(r, `getAssetTree(${collection}, ${opts.root ?? "index"}@${opts.revision})`); + }, +}; diff --git a/src/frontend/src/state/AdaViewerContext.tsx b/src/frontend/src/state/AdaViewerContext.tsx index 2b6bea10b..5bf72dbd3 100644 --- a/src/frontend/src/state/AdaViewerContext.tsx +++ b/src/frontend/src/state/AdaViewerContext.tsx @@ -44,6 +44,7 @@ import { } from "./viewerRuntime" import { useAnimationStore as g_useAnimationStore } from "./animationStore" +import { useAssetBrowserStore as g_useAssetBrowserStore } from "./assetBrowserStore" import { useAuditFilterStore as g_useAuditFilterStore } from "./auditFilterStore" import { useAuditToastStore as g_useAuditToastStore } from "./auditToastStore" import { useCellBuilderStore as g_useCellBuilderStore } from "./cellBuilderStore" @@ -103,7 +104,7 @@ export type AdaViewerRefs = ViewerRuntime * (`useModelState`, not `useModel`) so a consumer migrating off a direct import * does not have to rename call sites. * - * 44 of the viewer's 46 stores are here. The two that are not, and why: + * 45 of the viewer's 47 stores are here. The two that are not, and why: * * `useThemeStore` A process singleton by intent. The theme is persisted * per browser and applied to `document.documentElement`, @@ -127,6 +128,7 @@ export type AdaViewerRefs = ViewerRuntime */ export interface AdaViewerStores { useAnimationStore: typeof g_useAnimationStore + useAssetBrowserStore: typeof g_useAssetBrowserStore useAuditFilterStore: typeof g_useAuditFilterStore useAuditToastStore: typeof g_useAuditToastStore useCellBuilderStore: typeof g_useCellBuilderStore @@ -192,6 +194,7 @@ const AdaViewerContext = createContext(null) */ export const SINGLETON_VIEWER_STORES: AdaViewerStores = { useAnimationStore: g_useAnimationStore, + useAssetBrowserStore: g_useAssetBrowserStore, useAuditFilterStore: g_useAuditFilterStore, useAuditToastStore: g_useAuditToastStore, useCellBuilderStore: g_useCellBuilderStore, diff --git a/src/frontend/src/state/assetBrowserLoader.ts b/src/frontend/src/state/assetBrowserLoader.ts new file mode 100644 index 000000000..1577edf43 --- /dev/null +++ b/src/frontend/src/state/assetBrowserLoader.ts @@ -0,0 +1,191 @@ +// The Assets tab's fetch side: index -> collection indexes -> spines on expand. +// +// React-free and injected with its API so it can be driven under `node --test` +// against canned documents. Every writer below goes through the store's +// actions; nothing here holds state of its own except a cache of collection +// index SLICES, kept so a re-merge in the right order never refetches. +// +// ORDER MATTERS FOR THE COLLECTION INDEX. The tops of the tree are a union of +// every index the mode admits, merged OLDEST FIRST so the newest word about a +// shared node wins. When a mode change admits an index older than one already +// merged, the newer ones are re-merged after it from the cache. + +import { collectionIndexRevisions, compareRevisions, defaultCollection, indexFromWire } from "@/assets/assetIndex"; +import { parseHierarchySlice } from "@/assets/projection"; +import type { SpineSource } from "@/assets/spines"; +import type { AssetNode, WireAssetIndex, WireHierarchySlice } from "@/assets/types"; + +import type { AssetBrowserState } from "./assetBrowserStore"; + +export interface AssetsApiLike { + getAssetIndex(scope: string, collection?: string): Promise; + getAssetTree( + scope: string, + provider: string, + collection: string, + opts: { root?: string | null; revision: string }, + ): Promise; +} + +export interface StoreLike { + getState(): AssetBrowserState; +} + +/** Phase 2 browses published collections only; live providers arrive with + * delivery (Phase 3) through the same route with their own id. */ +const PROVIDER = "published"; + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function createAssetBrowserLoader(store: StoreLike, api: AssetsApiLike) { + const indexSlices = new Map(); // `${collection}@${revision}` + let generation = 0; // bumped on scope/collection change; stale responses are dropped + + const alive = (gen: number, scope: string, collection: string | null) => { + const s = store.getState(); + return gen === generation && s.scope === scope && (collection === null || s.collection === collection); + }; + + async function loadCollections(scope: string): Promise { + const gen = ++generation; + const s = store.getState(); + s.setIndexLoading(true); + try { + const all = indexFromWire(await api.getAssetIndex(scope)); + if (!alive(gen, scope, null)) return; + const names = [...all.collections.keys()].sort(); + const cur = store.getState(); + cur.setCollections(names); + const keep = cur.collection && names.includes(cur.collection) ? cur.collection : defaultCollection(all); + cur.setCollection(keep); + if (keep) await openCollection(scope, keep); + } catch (e) { + if (alive(gen, scope, null)) store.getState().setIndexError(message(e)); + } finally { + if (gen === generation) store.getState().setIndexLoading(false); + } + } + + async function openCollection(scope: string, collection: string): Promise { + const gen = generation; + try { + const index = indexFromWire(await api.getAssetIndex(scope, collection)); + if (!alive(gen, scope, collection)) return; + store.getState().setIndex(index); + await syncCollectionIndexes(scope); + } catch (e) { + if (alive(gen, scope, collection)) store.getState().setIndexError(message(e)); + } + } + + /** Bring the merged collection indexes in line with what the mode admits. */ + async function syncCollectionIndexes(scope: string): Promise { + const gen = generation; + const s = store.getState(); + const collection = s.collection; + if (!collection || !s.index) return; + const wanted = collectionIndexRevisions(s.index, collection, s.mode).map((r) => r.revision); + const merged = s.mergedIndexRevisions; + const missing = wanted.filter((r) => !merged.includes(r)); + if (!missing.length) { + if (wanted.length !== merged.length || wanted.some((r, i) => r !== merged[i])) { + // Nothing to fetch; only the admitted set changed (e.g. `run` narrowing). + s.setMergedIndexRevisions(wanted); + } + return; + } + const fetched = await Promise.all( + missing.map(async (revision) => { + const key = `${collection}@${revision}`; + if (!indexSlices.has(key)) { + const slice = parseHierarchySlice(await api.getAssetTree(scope, PROVIDER, collection, { revision })); + indexSlices.set(key, slice.nodes); + } + return revision; + }), + ); + if (!alive(gen, scope, collection)) return; + // Re-merge from the oldest newly-fetched revision upward, so a late older + // index cannot overwrite a newer one's rows. + const oldestNew = [...fetched].sort(compareRevisions)[0]; + const cur = store.getState(); + for (const revision of wanted) { + if (compareRevisions(revision, oldestNew) < 0) continue; + cur.mergeSlice(indexSlices.get(`${collection}@${revision}`) ?? [], { + subject: collection, + revision, + root: null, + }); + } + cur.setMergedIndexRevisions(wanted); + } + + /** Fetch and merge one subtree spine. Idempotent per (root, revision). */ + async function loadSpine(scope: string, source: SpineSource): Promise { + const gen = generation; + const s = store.getState(); + const collection = s.collection; + if (!collection) return; + if (s.spineLoading.has(source.root) || s.spineLoaded.get(source.root) === source.revision) return; + s.beginSpine(source.root); + try { + const wire = await api.getAssetTree(scope, PROVIDER, collection, { + root: source.root, + revision: source.revision, + }); + if (!alive(gen, scope, collection)) return; + const slice = parseHierarchySlice(wire); + const cur = store.getState(); + cur.mergeSlice(slice.nodes, { subject: source.subject, revision: source.revision, root: source.root }); + cur.endSpine(source.root, source.revision); + } catch (e) { + if (alive(gen, scope, collection)) store.getState().failSpine(source.root, message(e)); + } + } + + /** Fetch every listed spine -- what "place the pending subjects" does. A few + * at a time: each can be megabytes, and the point is to finish, not to race. */ + async function loadSpines(scope: string, sources: readonly SpineSource[], concurrency = 3): Promise { + const queue = [...sources]; + const worker = async () => { + for (let next = queue.shift(); next; next = queue.shift()) await loadSpine(scope, next); + }; + await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker)); + } + + /** Switch collection: invalidates in-flight responses for the old one. */ + async function chooseCollection(scope: string, collection: string): Promise { + generation++; + store.getState().setCollection(collection); + await openCollection(scope, collection); + } + + /** Refresh: rebuild the collection's forest from nothing. */ + async function refresh(scope: string): Promise { + const s = store.getState(); + if (!s.collection) return loadCollections(scope); + generation++; + for (const k of [...indexSlices.keys()]) if (k.startsWith(`${s.collection}@`)) indexSlices.delete(k); + s.resetForest(); + await openCollection(scope, s.collection); + } + + return { loadCollections, openCollection, syncCollectionIndexes, loadSpine, loadSpines, chooseCollection, refresh }; +} + +export type AssetBrowserLoader = ReturnType; + +const LOADERS = new WeakMap(); + +/** One loader per store instance, so its slice cache and generation counter + * survive the tab being unmounted and remounted. */ +export function loaderFor(store: StoreLike, api: AssetsApiLike): AssetBrowserLoader { + let loader = LOADERS.get(store); + if (!loader) { + loader = createAssetBrowserLoader(store, api); + LOADERS.set(store, loader); + } + return loader; +} diff --git a/src/frontend/src/state/assetBrowserStore.ts b/src/frontend/src/state/assetBrowserStore.ts new file mode 100644 index 000000000..4fad3e38b --- /dev/null +++ b/src/frontend/src/state/assetBrowserStore.ts @@ -0,0 +1,189 @@ +// The asset browser's own state. Deliberately NOT the tree view's store: the +// Files tab's store is eager, file-rooted and singular (one `treeData`, one +// range index), and the Assets tab is lazy, union-merged and derived. Sharing a +// store would make one of them lie about its shape. +// +// What lives here is INPUT -- the forest, the index, the mode, what is expanded +// and fetched. Everything a row shows is derived from these by +// `@/assets/assetView`, never stored. + +import { create } from "zustand"; + +import { EMPTY_FOREST, mergeSpine, type Forest, type SpineMerge } from "@/assets/merge"; +import type { AssetIndex, AssetNode, ResolutionMode } from "@/assets/types"; + +export type AssetBrowserTab = "files" | "assets"; + +const EMPTY_SET: ReadonlySet = Object.freeze(new Set()); +const EMPTY_LOADED: ReadonlyMap = Object.freeze(new Map()); +const EMPTY_ERRORS: ReadonlyMap = Object.freeze(new Map()); + +export interface AssetBrowserState { + tab: AssetBrowserTab; + + /** The scope the data below came out of. A switch invalidates all of it. */ + scope: string | null; + /** Collections in the scope, from the scope-wide index; null = not asked yet. */ + collections: readonly string[] | null; + collection: string | null; + /** The chosen collection's folded index, with manifest summaries. */ + index: AssetIndex | null; + indexLoading: boolean; + indexError: string | null; + mode: ResolutionMode; + + /** Every row held, the spine that contributed each, and what was pruned. */ + forest: Forest; + /** Bumped on every forest change, so a memo over a 41k-entry map keys on a + * number instead of re-scanning the map each render. */ + forestVersion: number; + /** Collection-index revisions merged into the forest, ascending. */ + mergedIndexRevisions: readonly string[]; + /** SPINE ROOT -> the revision it was fetched at. Keyed by the spine, not the + * row that asked, so a second branch under one spine does not re-fetch it; + * and by revision, because a resolution change re-points the spine. */ + spineLoaded: ReadonlyMap; + spineLoading: ReadonlySet; + spineErrors: ReadonlyMap; + + expanded: ReadonlySet; + /** The focused row. One field on purpose: the tree is virtualised over ~41k + * rows, so selection is never per-row state. */ + selected: string | null; + searchTerm: string; + + setTab: (tab: AssetBrowserTab) => void; + setScope: (scope: string | null) => void; + setCollections: (collections: readonly string[]) => void; + setCollection: (collection: string | null) => void; + setIndex: (index: AssetIndex) => void; + setIndexLoading: (loading: boolean) => void; + setIndexError: (error: string | null) => void; + /** Deliberately leaves the tree alone: a mode is a lens over the same + * hierarchy, and re-collapsing on every switch would make comparing two + * publishes -- the reason the modes exist -- unusable. */ + setMode: (mode: ResolutionMode) => void; + mergeSlice: (nodes: readonly AssetNode[], merge: SpineMerge) => void; + setMergedIndexRevisions: (revisions: readonly string[]) => void; + beginSpine: (root: string) => void; + endSpine: (root: string, revision: string) => void; + failSpine: (root: string, error: string) => void; + toggleExpanded: (id: string) => void; + setExpanded: (id: string, on: boolean) => void; + select: (id: string | null) => void; + setSearchTerm: (term: string) => void; + /** Drop everything drawn from the current collection, keep the choice of it. + * What Refresh does: a union cannot express deletion, so rebuilding from + * nothing is the one way to see a tree with nothing stale in it. */ + resetForest: () => void; + /** A scope switch invalidates every datum; the tab and the mode survive, as + * preferences about how the user browses rather than facts about the data. */ + resetForScope: (scope: string | null) => void; +} + +const FOREST_RESET = { + forest: EMPTY_FOREST, + mergedIndexRevisions: [] as readonly string[], + spineLoaded: EMPTY_LOADED, + spineLoading: EMPTY_SET, + spineErrors: EMPTY_ERRORS, + expanded: EMPTY_SET, + selected: null, +}; + +export const useAssetBrowserStore = create((set) => ({ + tab: "files", + scope: null, + collections: null, + collection: null, + index: null, + indexLoading: false, + indexError: null, + mode: { kind: "latest" }, + forest: EMPTY_FOREST, + forestVersion: 0, + mergedIndexRevisions: [], + spineLoaded: EMPTY_LOADED, + spineLoading: EMPTY_SET, + spineErrors: EMPTY_ERRORS, + expanded: EMPTY_SET, + selected: null, + searchTerm: "", + + setTab: (tab) => set({ tab }), + setScope: (scope) => set({ scope }), + setCollections: (collections) => set({ collections }), + setCollection: (collection) => + set((s) => + s.collection === collection + ? s + : { collection, index: null, indexError: null, ...FOREST_RESET, forestVersion: s.forestVersion + 1 }, + ), + setIndex: (index) => set({ index, indexError: null }), + setIndexLoading: (indexLoading) => set({ indexLoading }), + setIndexError: (indexError) => set({ indexError }), + setMode: (mode) => set({ mode }), + + mergeSlice: (nodes, merge) => + set((s) => { + const forest = mergeSpine(s.forest, nodes, merge); + return forest === s.forest ? s : { forest, forestVersion: s.forestVersion + 1 }; + }), + setMergedIndexRevisions: (mergedIndexRevisions) => set({ mergedIndexRevisions }), + + beginSpine: (root) => + set((s) => { + const loading = new Set(s.spineLoading); + loading.add(root); + const errors = new Map(s.spineErrors); + errors.delete(root); + return { spineLoading: loading, spineErrors: errors }; + }), + endSpine: (root, revision) => + set((s) => { + const loading = new Set(s.spineLoading); + loading.delete(root); + const loaded = new Map(s.spineLoaded); + loaded.set(root, revision); + return { spineLoading: loading, spineLoaded: loaded }; + }), + failSpine: (root, error) => + set((s) => { + const loading = new Set(s.spineLoading); + loading.delete(root); + const errors = new Map(s.spineErrors); + errors.set(root, error); + return { spineLoading: loading, spineErrors: errors }; + }), + + toggleExpanded: (id) => + set((s) => { + const next = new Set(s.expanded); + if (!next.delete(id)) next.add(id); + return { expanded: next }; + }), + setExpanded: (id, on) => + set((s) => { + if (s.expanded.has(id) === on) return s; + const next = new Set(s.expanded); + if (on) next.add(id); + else next.delete(id); + return { expanded: next }; + }), + select: (selected) => set({ selected }), + setSearchTerm: (searchTerm) => set({ searchTerm }), + + resetForest: () => set((s) => ({ ...FOREST_RESET, forestVersion: s.forestVersion + 1 })), + resetForScope: (scope) => + set((s) => ({ + scope, + collections: null, + collection: null, + index: null, + indexLoading: false, + indexError: null, + ...FOREST_RESET, + forestVersion: s.forestVersion + 1, + searchTerm: "", + })), +})); diff --git a/tests/comms/rest/test_asset_routes.py b/tests/comms/rest/test_asset_routes.py index 97769276c..426449b18 100644 --- a/tests/comms/rest/test_asset_routes.py +++ b/tests/comms/rest/test_asset_routes.py @@ -94,6 +94,42 @@ def test_index_folds_without_listing_the_whole_scope(client_and_revision): assert {"pump-a", "pump-b", "tank-c", "unit-1", "unit-2", "site", COLLECTION} == subjects pump = next(s for s in body["collections"][COLLECTION] if s["subject"] == "pump-a") assert pump["revisions"][0]["revision"] == revision + # Opt-in: without it the index is the plain fold, byte for byte as before. + assert "manifest" not in pump["revisions"][0] + + +def test_index_folds_manifest_summaries_per_revision(client_and_revision): + """The tab badges from delivery + hierarchy_revision without one fetch per subject.""" + client, revision = client_and_revision + r = client.get(_scope_url("index"), params={"collection": COLLECTION, "manifests": "true"}) + assert r.status_code == 200, r.text + by_subject = {s["subject"]: s["revisions"][0] for s in r.json()["collections"][COLLECTION]} + assert by_subject["pump-a"]["manifest"] == { + "provider": "fixture-lines", + "node": "pump-a", + "delivery": "mesh", + "produced_at": "2026-09-21T14:30:01Z", + } + assert by_subject["pump-b"]["manifest"]["delivery"] == "build" + assert by_subject["unit-1"]["manifest"]["delivery"] == "none" + # Opaque provider data stays out of the index. + assert "build" not in by_subject["pump-b"]["manifest"] + + +def test_index_reports_an_unreadable_manifest_on_its_revision(client_and_revision, tmp_path): + client, revision = client_and_revision + bad = tmp_path / "users" / "local-dev" / "assets" / COLLECTION / "tank-c" / revision / "asset.json" + bad.write_bytes(b'{"schema": "someone-else/manifest@9"}') + r = client.get(_scope_url("index"), params={"collection": COLLECTION, "manifests": "true"}) + tank = next(s for s in r.json()["collections"][COLLECTION] if s["subject"] == "tank-c") + assert "manifest" not in tank["revisions"][0] + assert "unknown manifest schema" in tank["revisions"][0]["manifest_error"] + + +def test_index_manifests_needs_a_collection(client_and_revision): + client, _ = client_and_revision + r = client.get(_scope_url("index"), params={"manifests": "true"}) + assert r.status_code == 400 def test_tree_returns_the_slice_in_cores_vocabulary(client_and_revision):