fix(metadata-fs): attaching a FileSystemRepository no longer creates its root (#7000) - #7152
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
ACCEPT — PM step-7 review ( CI on My dispatch contained a factual error, and the dev corrected itI wrote that #6743's database half "landed (PR #6997)". It has not. PR #6997 is an open draft, The consequence was handled correctly rather than papered over: the pin asserts the metadata paths specifically rather than the absence of My assumption 1 was right in direction and incomplete in a way that matteredI predicted create-on-write and said "if a read path needs the directory, say so". No read path did — but the watch path did, and that is the interesting part: chokidar 5.0.0 cannot watch a path that does not exist yet (measured: a root created after Without that compensation this fix would have silently killed external-edit detection — a dry-run cleanliness fix quietly trading away a feature. And it was not merely asserted: variant 2 of the reverse verification is the naive fix (create-on-write kept, watcher compensation deleted), predicted to fail case 4 alone, measured Also worth noting the file had moved further than triage's line-level pointer: it is Reverse verification: two categories handled exactly right
Case 1's red listed the four paths byte-identical to the issue's The out-of-scope finding is the bigger news, and the restraint on it is right#7150: the Filed unlabeled, with both the observation-class and concrete-defect readings written out, because the dev did not measure whether anything edits files under Pre-existing on Generated by Claude Code |
Fixes #7000
What was wrong
MetadataPlugin.start()attaches aFileSystemRepositoryrooted at.objectstack/metadataon every boot that is notbootstrap: 'artifact-only'(
packages/metadata/src/plugin.ts:386-398), and the repository'sstart()used to
mkdirboth its root and its.objectstack/.logsubdirectoryunconditionally. Attaching was therefore a write, and any command that boots
the stack without ever writing metadata left a skeleton behind. The loudest
one is
os migrate plan, a declared dry run, on a project that has never beenstarted:
Reproduced at the seam rather than through the CLI, and the reproduction is
byte-identical to the card's observation. With the fix reverted, the new pin
prints exactly that tree as the diff of its residue sweep:
This is the filesystem half of the property #6743 ruled on — a dry run leaves
nothing behind. One correction to the card's framing: the database half is
pushed but not merged. PR #6997 is still an open draft, its commit
b70ca0b0cis not an ancestor oforigin/main, andsqliteAbsentFiledoesnot exist on
main. So onmaintodayos migrate planstill creates.objectstack/data/as well; this PR removes the metadata tree only, and thepin asserts the metadata paths specifically rather than the absence of
.objectstack/as a whole. The two halves stay independent and landindependently.
What changed
The behavioural change is confined to
packages/metadata-fs/src/repository.ts— the attach/
start()seam. No CLI file is touched (triage's attribution held:migrate planperforms nomkdirof its own), and the rest of the diff istests, the package README and a changeset.
start()no longer creates anything. It scans heads, hydratesnextSeqandarms the watcher, all of which already tolerate an absent root:
scanHeadsswallows the
readdirENOENT,JsonlLog.readAll/highestSeqguard onexistsSync, andgetguards onexistsSync.ensureRoot()— the single seam where the root now appears —called by
put()anddelete()immediately before they touch the disk. Itis create-on-write, exactly the shape the dispatch predicted; no read path
needed the directory, so no
ensureRoot()on the read side was required.a path that does not exist yet. Measured on chokidar 5.0.0 with
usePolling(the repository's own options): a root created after
watch()produces noevents at all, ever. So
start()arms the watcher only when the root exists,and
ensureRoot()arms it at the moment the root appears. Without this,dropping the
mkdirwould have silently killed external-edit detection forthe life of the process — a regression the pin catches (see reverse
verification variant 2).
case: a root brought into existence by a third party while the process runs,
with the repository itself never writing, is not picked up until the next
start().Deviations from the declared file surface, file by file:
packages/metadata/src/plugin.ts— not modified. The fix is entirely inthe repository; the plugin needed no change once attaching stopped writing.
packages/metadata-fs/src/repository.ts— the file the card named aspackages/metadata/src/repository.ts. The package split moved it; samesymbol, same two lines (
start()'s twomkdirs).packages/metadata-fs/README.md— added, documenting the contract change inthe package that ships it.
What the fix covers
The property, not the command. Every boot that attaches this repository
without writing metadata is covered by the same change, because the fix is at
the repository, not at any call site. On
mainthat is every command routedthrough
bootSchemaStack(migrate plan,migrate apply,migrate meta,migrate value-shapes,migrate recorded-by,migrate resume,migrate summary-nulls,migrate files-to-references,meta resync) plusserve/devthroughcreateStandaloneStack— the write-performing onesamong them simply create the root when they write, as before. So it does not
fix one command by accident;
os migrate planis the loudest instance, notthe scope.
The boot is not weakened (dispatch assumption 4)
Nothing about the read-only character of the boot changed: no DDL, no seed
rows, no metadata written that was not written before. The only behaviour
removed is the
mkdir. The control cases in both new files assert the positiveside directly — the repository is still attached (
plugin.repositoryisdefined, the object the
[MetadataPlugin] FileSystemRepository attachedlogline names), still readable, and a write through it still materializes the
root, the body file and the JSONL log.
packages/metadata's 591 tests and themetadata-corerepository contract suite are unchanged and green.Tests
packages/metadata-fs/test/no-root-on-attach.test.ts— 4 cases: attachcreates neither the root nor
.objectstack/.log(residue swept over thewhole fixture, not just the two known paths); an absent root is attachable
and reads as an empty repository through
get/getByHash/list/history; the first write materializes root + body + log and a fresh attachreads it back with
seqnumbering intact; and the watcher-arming guard.packages/metadata/src/plugin-no-metadata-root-on-boot.test.ts— 3 cases atthe boot seam: a read-only boot creates nothing under the project directory
(
watch: falseandwatch: true), and the control that the attachedrepository is still usable.
Pin shape deliberately matches #6743's (
schema-migrate.readonly-probe.integration.test.ts):assert the ABSENCE on a fresh fixture, and carry a control that proves the
fixture is genuine, so the absence can never be the uninteresting kind
produced by a boot that failed early.
Reverse verification
Direction predicted before running, recorded first, and reported with every
category preserved.
Variant 1 — the whole fix taken out of
repository.ts(viagit checkout 3e8e669c0 -- ..., my own base, not a movingorigin/main;metadata-fs'sdistrebuilt from the reverted source, because the plugintest imports the built package and the first attempt at this variant was a
false green for exactly that reason):
start()creates neither the root nor.objectstack/.logThe two missed predictions and their cause. Cases 3 and 4 each open with
an
expect(existsSync(root)).toBe(false)precondition immediately afterstart()— a line that pins the new behaviour too, which I did not account forwhen predicting. They went red on that precondition, at line 108 and line 150,
before reaching the assertions the prediction was about. That means their
substantive halves were left unmeasured by variant 1, not passed. So I
measured them: with the two precondition lines removed and the fix still out,
both cases pass. The prediction was right about the substance and wrong about
the file — cases 3 and 4 are guards, not evidence; cases 1, 2, 5 and 6 are
the evidence.
Variant 2 — the naive shape of the fix: keep create-on-write, delete only
the watcher compensation (
start()arms unconditionally again,ensureRoot()stops arming). Predicted: case 4 RED, and
fs-behavior.test.ts's existingexternal-edit case GREEN (its root exists at
start(), so the compensation isirrelevant to it). Measured: exactly that — 1 failed, 27 passed, the failure
being case 4 alone. This is what makes case 4 load-bearing despite being a
guard in variant 1.
Local results
@objectstack/metadatahas notypecheckscript (it is acheck:type-check-debtledger entry). Measured
tsc --noEmitover the package instead: 89 errorswith the new test file, and 0 of them in it — the first draft did add one
(TS2835, the extensionless
./pluginimport the neighbouringplugin.test.tsuses), fixed by importing
./plugin.js. No ledger drift.Out of scope, filed separately
Dispatch assumption 3 asked whether the
.objectstack/metadata/.objectstack/.lognesting is a second defect. The nesting itself is the documented layout — the
repository always keeps its log at
.objectstack/.logrelative to its ownroot, so a root that happens to live under the project's
.objectstack/justreads oddly. But chasing it surfaced a real one, which cost this PR a lap: the
repository's chokidar
ignoredoption is[/(^|[\\/])\../], and chokidarapplies it to the watched root path itself. Measured on chokidar 5.0.0, side by
side: a root at
metadata/yieldsgetWatched()keys and fires add/changeevents; the same tree at
.objectstack/metadata/yieldsgetWatched() == {}and zero events. The plugin's root is the second shape, so the
FileSystemRepositorywatcher — and with itMetadataManager'srepo.watch({})re-emit loop — has never fired in the production layout. It ispre-existing on
origin/mainand unchanged by this PR (both before and after,that watcher is inert for that root). Filed as #7150.
Generated by Claude Code