fix(metadata): FilesystemLoader keys loader-held items by the name it can resolve - #14497
Conversation
… can resolve (#14341) `loadMany()` discarded the path it had just read, so `MetadataManager` fell back to keying by `body.name` and dropped every nameless body held by this loader — the #14205 defect, unrepaired here. `loadManyKeyed()` keys an item by this loader's own name-to-path derivation (the basename minus extension, the same one `list()` reports) ONLY where that derivation is a bijection for the file: directly under `ROOT/TYPE/`, with an extension `findFile()` tries. Every other shape keeps the pre-#14205 behaviour verbatim — keyed by `body.name` when it has one, dropped when it has none — because `list()` and `findFile()` disagree there, and a minted key that `get()`/`exists()` cannot open is what the card refused. Both methods now share one file walk, so the body `loadManyKeyed()` reports can never drift from the one `loadMany()` returns. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a87878a10fdeb5025812db499cae7e311011cfca && git checkout a87878a10fdeb5025812db499cae7e311011cfca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378 940b6c2579c73286a8f1438e1d11553b45b2d518 && git checkout -B drift-repro 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378 && git merge --no-ff 940b6c2579c73286a8f1438e1d11553b45b2d518
node scripts/docs-audit/affected-docs.mjs --json 9c7d9d4b343f5ded5b51b757cb4251d1f68cd378
|
|
Landing provenance (engine execution seat,
Generated by Claude Code |
Fixes #14341
FilesystemLoader.loadMany()globbed files and pushed bodies, throwing away the path it had just read, soMetadataManager.admitLoaderItems()fell back to keying bybody.nameand dropped every nameless body this loader held — the #14205 defect, unrepaired here. This implements the optionalloadManyKeyed()member PR #14340 added.The rule, in one sentence
An item is keyed by this loader's own name-to-path derivation — the basename minus extension, the same derivation
list()reports — only where that derivation is a bijection for the file (it sits directly underROOT/TYPE/and carries an extensionfindFile()tries, sofindFile(type, key)resolves back to that same file); every other shape keeps the pre-#14205 behaviour verbatim, keyed bybody.namewhen it has one and dropped when it has none.Why the rule stops there
The card asked which part of a path is the metadata key, and triage settled it "by consistency rather than invention: the key is whatever
findFile()/list()already treat as the metadata name for that path — if those two disagree with each other, that disagreement is the deliverable's first finding and a stop-and-report". Measured onorigin/main@253da34c4, they do disagree, in two shapesloadMany()reads today:list()reportsfindFile()resolvesROOT/TYPE/flat.json(anddotted.config.json)flat/dotted.configROOT/TYPE/crm/account.jsonaccountaccount; onlycrm/accountreaches itROOT/TYPE/extensionlessextensionlessThat was reported as the stop-and-report, and the PM ruled option D (comment 5507163442), knowingly over triage's "a nested path keeps whatever
list()reports for it today": keying the disagreeing shapes by their basename would mint namesget()/load()/exists()cannot open — the card's own fence, "keying items under names nothing else uses … is worse than today's honest drop" — and two directories holding one basename would collide in silence, sinceadmitLoaderItems()keeps the first and says nothing.The derivation defect itself is #14486 (one shared name-to-path function for
list(),findFile()andloadManyKeyed(), option C). It moveslistNames()output, so it is not attempted here. The nested-nameless drop is pinned as aRECORD:case precisely so #14486 inverts it deliberately, in a visible diff.remote-loader.tsis untouched: triage ruled it "explicitly out of scope and not filed as its own card" (the key is absent from the wire response), so the card closes on this PR rather than deferring a half.One consequence, deliberate
A flat file whose
body.namedisagrees with its basename is now keyed by the basename. That is #14205's rule — identity is the key the store holds an item under, notbody.name— applied to this loader, and it alignsMetadataManager.list()withlistNames()for that shape. Pinned askeys a flat file by its BASENAME even when body.name disagrees.Shape of the change
loadManyKeyed()is additive;loadMany()'s signature and answer are unchanged.loadManyEntries()returning file/data pairs), because the interface requiresloadManyKeyed()'sdatato be "the same bodyloadMany()would return for the item" and a second copy of the glob is how that quietly stops being true. ACONTROL:case pins thatloadMany()still answers with bodies only, envelope not leaked.nameFromFilename) now used by bothlist()and the key rule, andfindFile()'s extension list is the sharedRESOLVABLE_EXTENSIONS, so the resolvable set cannot drift from the set the key rule will mint from. Neitherlist()norfindFile()changes observable behaviour.MetadataKeyedItem[T]is{ readonly name: string; readonly data: T }, matchingDatabaseLoaderandMemoryLoader.Verification — union run on
940b6c257pnpm --filter '@objectstack/metadata^...' buildfirst (VERDICT command-exit 0), then:pnpm --filter @objectstack/metadata test— 43 files, 677 tests, all passed; the new pin file is 14/14.pnpm lint(full repo,eslint . --no-inline-config) — exit 0. Not narrowed.pnpm check:nul-bytes— OK, 7916 text files scanned, no raw control bytes.pnpm check:error-status-conformance— exit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(3 paths vs merge basee5812fa43): 35 commands, identical to the dispatched list. 31 exit 0; four exited 3 = NOT MEASURED in the gate's own words, each for an input this container cannot supply and CI does:check-test-completeness(wants a savedturbo run testlog),check:dual-build-cjs-loadsandcheck:type-check-debt(want a full-repopnpm build),check-half-states(wants GitHub API access; repo-scoped REST is 403 in this container). None is a finding.check:type-check-debtwas measured directly instead:tsc --noEmit -p packages/metadata/tsconfig.jsonstill reports exactly 89 errors, the number the ledger records, and neither of my two files appears in any of them;--listFilesconfirms both are in that program. Per [finding] @objectstack/metadata has no typecheck script, and its own tsconfig program does not compile: 89 pre-existing errors across 12 test files, checked by nothing #14342 that program is not green, so the test-layer type reading stays NOT MEASURED and vitest is the runtime evidence.Ablation
Predicted before the run: remove the
loadManyKeyedimplementation ⇒ the flat-nameless pin RED, the named pins GREEN.Mutation proved on disk before measuring (
async loadManyKeyed[Trenamed, removed-text count 1 → 0, injected-text count 1, file hash14fe5e4c→0c994970). Observed, manager-level describe: 2 failed / 2 passed — RED exactly ona flat nameless body reaches list() end to endandlistDiagnosed() counts it and stays complete-and-not-degraded, GREEN on the namedCONTROL:and the nested-namelessRECORD:. The predicted direction, on the discriminating layer. Whole file ablated: 10 failed / 4 passed — the loader-level cases callloadManyKeyed()directly and throw once it is gone, which is the ablation biting but not a discriminating signal.No rebuild leg applies: the pin imports
./filesystem-loader.jsand../metadata-manager.jsrelatively, so vitest compiles the mutated source — proved by the run turning red at all. Restore verified by bytes, not by exit code:git checkout HEAD -- ABSOLUTE_PATH(absolute-pathtrap … EXIT INT TERMarmed throughout),git hash-objectback to the HEAD blob14fe5e4c,git diff HEADempty, injected-text count 0.Changeset:
@objectstack/metadata: patch.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code
Generated by Claude Code