@objectstack/metadata-fs 17.0.0-rc.6 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,309 @@
1
1
  # @objectstack/metadata-fs
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - bd2fc8b: fix(metadata-fs): an external write reaches subscribers even when the watcher's single delivery attempt is lost — content-keyed reconciliation behind the poll (#9339)
8
+
9
+ `FileSystemRepository`'s watcher gave an externally-written file **exactly one**
10
+ chance to be noticed, and losing it was permanent and silent. Under
11
+ `usePolling`, chokidar re-reads a directory only when its stat *strictly*
12
+ advances; an external write advances the type directory's mtime once, so poll
13
+ #2..#N compare an unchanged stat and can never rediscover the file. Measured on
14
+ #9339 with a fault-injection harness: with that single read suppressed, fifteen
15
+ further poll ticks never find the file — a 20s deadline and a 200s deadline buy
16
+ the same one attempt. That is the structural reason behind #7282's empirical
17
+ finding that the event is *"never delivered, not slow"*, and why widening the
18
+ deadline (#7208) and lowering `interval` were both spent before they were tried.
19
+
20
+ **At least six independent one-shot gates sit on that attempt**, spanning three
21
+ layers — the kernel timestamp (the directory mtime does not strictly advance),
22
+ chokidar's readdir throttle and readdir snapshot, and chokidar's emit gates
23
+ (`_throttle('add')`, a stale `_pendingWrites` entry, the `awaitWriteFinish`
24
+ ENOENT early return). Each produces a byte-identical observable: no event, ever,
25
+ for that path. They are indistinguishable at the point of failure, which is why
26
+ #7282's close — picked from that family — covered one member and reopened.
27
+
28
+ **The fix does not name a member.** A bounded, content-keyed reconciliation
29
+ sweep runs alongside the watcher and compares what is on disk against `heads`,
30
+ the index that already defines what the repository believes it holds, publishing
31
+ any divergence through the *same* handler the watcher feeds. Its only premise is
32
+ that the bytes on disk stopped matching the index, so it is robust across all six
33
+ by construction — and equally across a seventh nobody has found.
34
+
35
+ - **Cadence** — one pass over `<root>/<type>/*.json` every 2s (twice the poll
36
+ interval), the same walk `start()` already performs once. Sweeps are chained
37
+ rather than intervalled, so they can never overlap or stack behind a slow
38
+ disk; the timer is `unref`ed and is retired by `close()`; and it is armed only
39
+ alongside the watcher, so a `disableWatch` repository pays nothing.
40
+ - **Exactly-once is preserved.** Suppression stays content-keyed (`#7335`): the
41
+ sweep republishes nothing the watcher already delivered, and recognises this
42
+ repository's own `put()` by content rather than by a clock.
43
+ - **Events are indistinguishable from the fast path** — same `op`,
44
+ `parentHash`, `source: 'fs'` and actor, because they are produced by the same
45
+ code. A subscriber cannot be made to care which path noticed.
46
+ - **A recovered path is re-armed** with the watcher through the seam `put()`
47
+ already uses, so a loss upstream of chokidar's `_handleFile` does not leave
48
+ the file dependent on the sweep forever.
49
+ - `put()`'s existing direct registration (#7336) is unchanged, as are
50
+ `usePolling`, `interval`, and `awaitWriteFinish`.
51
+
52
+ ⚠️ **Bound on the claim.** The six gates are *forced fault injections*, not the
53
+ CI mechanism, which was never identified and may be a seventh. What is measured
54
+ is that the fix converts **six of six** forced one-shot gates from permanent
55
+ loss to delivery (3/3 runs each), where all six returned an empty event list
56
+ before it. That is not the same statement as "the flake is fixed".
57
+ - Updated dependencies [b6c7690]
58
+ - Updated dependencies [845e164]
59
+ - Updated dependencies [1a7f907]
60
+ - Updated dependencies [7fc01db]
61
+ - @objectstack/metadata-core@17.1.0
62
+
63
+ ## 17.0.0
64
+
65
+ ### Patch Changes
66
+
67
+ - 2e836de: chore(packaging): CHANGELOG.md ships in every npm tarball (#4261)
68
+
69
+ The AGENTS.md post-task checklist requires breaking changesets to carry their
70
+ FROM → TO migration because "this text ships to consumers as `CHANGELOG.md`
71
+ inside the npm package and is what an upgrading agent greps after the tombstone
72
+ error." That delivery path was severed for 68 of the 69 publishable packages:
73
+ npm packs `package.json` / `README*` / `LICENSE*` unconditionally but — unlike
74
+ older npm versions — not `CHANGELOG.md`, and the canonical
75
+ `"files": ["dist", "README.md"]` whitelist never named it. Measured on npm
76
+ 10.9.7: `npm pack --dry-run` on `@objectstack/types` shipped 3 files while its
77
+ 70KB `CHANGELOG.md` stayed behind. Only `@objectstack/spec` listed it
78
+ explicitly.
79
+
80
+ The tombstone-error scenario is precisely the one where the repo is out of
81
+ reach — the upgrading agent has `node_modules` and nothing else — so the
82
+ migration text has to ride in the tarball. Every publishable package now
83
+ declares `CHANGELOG.md` in `files`, and the canonical whitelist is
84
+ `["dist", "README.md", "CHANGELOG.md"]`.
85
+
86
+ The other half is the gate: `check:published-files` gains a fifth invariant,
87
+ COMPLETE — a whitelist that fails to cover `CHANGELOG.md` fails the
88
+ always-required lint job, so the next package cannot silently sever the path
89
+ again. `@objectstack/spec`'s per-package EXTRA_ENTRIES exemption dissolves
90
+ into the canonical set.
91
+
92
+ Consumer-visible change: one more file per install (the package's changelog,
93
+ e.g. 70.8KB for `@objectstack/types`), and `grep -r "removed key"
94
+ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was
95
+ promised.
96
+
97
+ - a1b66ef: `FileSystemRepository` no longer creates its root directory when it is attached — only when it first writes.
98
+
99
+ `start()` used to `mkdir` both `<root>` and `<root>/.objectstack/.log` unconditionally, so merely attaching a repository was a write. Because `MetadataPlugin` attaches one at `<project>/.objectstack/metadata` during every boot, a command that never writes metadata still brought a directory skeleton into existence. The loudest case is `os migrate plan`, a declared dry run: on a project that had never been started it left
100
+
101
+ ```
102
+ .objectstack/metadata/.objectstack/.log
103
+ ```
104
+
105
+ behind, which also destroyed the one signal — does `.objectstack/` exist? — by which the next command can tell a fresh project from a started one. This is the filesystem half of the same property the database half already covers: a dry run leaves nothing behind.
106
+
107
+ Attaching and reading a repository whose root does not exist is now explicitly supported and answers as an empty repository (`get`, `getByHash`, `list`, `history`, `watch`). The root, the type directories and the JSONL change log all appear on the first `put` / `delete`, and nothing about the boot's read-only character changes: no metadata is written that was not written before.
108
+
109
+ One behavioural note for direct users of the package: when the root is absent at `start()`, the chokidar watcher is armed by the first write instead, because chokidar cannot watch a path that does not yet exist. A root brought into existence by a third party while the process runs — with this repository never writing — is therefore not picked up until the next `start()`.
110
+
111
+ - c7e7900: fix(metadata-core,metadata-fs): hash the serialized form, so `put().version` identifies the bytes actually stored (#7856)
112
+
113
+ `hashSpec` canonicalised a `Date` to `{}`, because `canonicalize` walked a
114
+ value's own enumerable keys and a `Date` has none. `JSON.stringify` — what every
115
+ repository actually writes — turns the same `Date` into an ISO string. So the
116
+ hash of the in-memory spec and the hash of the bytes on disk were **different
117
+ hashes for the same item**, and the version handed back to a caller did not
118
+ identify what had been stored.
119
+
120
+ Measured on `main`, one spec carrying one `Date`:
121
+
122
+ ```
123
+ canonicalize(in-memory) : {"createdAt":{},"label":"Home"}
124
+ JSON.stringify (bytes) : {"label":"Home","createdAt":"2024-01-01T00:00:00.000Z"}
125
+ ```
126
+
127
+ `canonicalize` now honours `toJSON` exactly as `JSON.stringify` does —
128
+ consulted once per position, its result serialised as-is and never
129
+ re-consulted — which makes a new guarantee true by construction:
130
+
131
+ ```
132
+ canonicalize(x) === canonicalize(JSON.parse(JSON.stringify(x)))
133
+ ```
134
+
135
+ **Both repository implementations were wrong, in different places**, which is
136
+ why the fix is one function rather than two patches. `FileSystemRepository`
137
+ broke `put().version === get().hash`: it hashed the spec it was handed, wrote
138
+ `JSON.stringify` of it, and re-hashed the parse on the way back out.
139
+ `InMemoryRepository` broke the repository contract's invariant 4
140
+ (`item.hash === hashSpec(item.body)`): it stores `body` already serialised
141
+ (`clonePlain`) while hashing the in-memory spec, so the item it returns
142
+ disagreed with its own hash. `SysMetadataRepository` inherits the fix through
143
+ the same function.
144
+
145
+ Downstream, an incoherent version meant a repository could report an
146
+ `{op:'update', actor:'fs'}` for a file nothing outside the process had touched:
147
+ the head index held a hash the disk could never reproduce, so re-reading one's
148
+ own write looked like somebody else's edit. That surfaces without any watcher —
149
+ a restart rebuilds the index from disk and the version the caller was handed no
150
+ longer matches it.
151
+
152
+ **Ordinary specs hash exactly as before, and this is not a migration.** The new
153
+ path diverges only at a position carrying a callable `toJSON`; a graph without
154
+ one is byte-identical through `canonicalize`. Verified against this repository's
155
+ entire checked-in JSON corpus — 1973 files hashed under both the old and the new
156
+ implementation, **0 hashes changed** — and the `hashSpec({})` regression guard
157
+ in `metadata-core` is unmoved. Stored versions for ordinary specs keep their
158
+ meaning. Versions for `toJSON`-carrying specs do change, and those are exactly
159
+ the versions that never identified their stored bytes in the first place.
160
+
161
+ Also supported as a consequence: a class instance with a `toJSON` now hashes as
162
+ whatever it serialises to, rather than as its private fields. One without a
163
+ `toJSON` still hashes as its own enumerable keys — which is what
164
+ `JSON.stringify` writes for it.
165
+
166
+ The pin is table-driven and lives in the shared repository contract suite, so
167
+ every `MetadataRepository` implementation is held to it: `Date` at a key, `Date`
168
+ under an array index, a class whose `toJSON` yields a string, an object literal
169
+ carrying its own `toJSON`, a nested case, and a plain-JSON control row that
170
+ proves the fix did not simply change every hash.
171
+
172
+ - a1686f9: fix(metadata-fs): stop suppressing self-writes on a wall clock, so a poll tick can no longer swallow an external edit (#7335)
173
+
174
+ `FileSystemRepository` suppressed the watcher event its own `put()`/`delete()`
175
+ was about to produce by adding the path to a `selfWrites` Set and clearing it on
176
+ a fixed `setTimeout(…, 200)`. `handleFsChange` then dropped **any** event for a
177
+ path in that Set, without ever reading what the watcher had observed.
178
+
179
+ Under `usePolling: true, interval: 1000` chokidar compares state once per tick,
180
+ so our own write and an external edit landing between two ticks are delivered as
181
+ a **single** event carrying the _external_ content. Dropping that on a timer
182
+ destroyed the only notification the external edit would ever produce — the edit
183
+ was silently lost, and nothing later recovered it. The realistic trigger is a
184
+ `git checkout` or an editor save arriving while the process writes the same item:
185
+ the dev-mode authoring loop.
186
+
187
+ **Measured.** The filing recorded 0/360 instrumented iterations reaching the
188
+ window and called it derived rather than observed. That was a sampling artefact:
189
+ the delivery lag of a self-write event is
190
+ `(interval - (writeTime mod interval)) + awaitWriteFinish`, so a _fixed_
191
+ pre-edit sleep phase-locks the poll and pins the lag outside the window
192
+ (measured: 519–585 ms across 25 runs). Randomising the sleep so the lag samples
193
+ `[0, interval)` uniformly, 40 runs:
194
+
195
+ | delivery lag | runs | external edit |
196
+ | ------------ | ---- | ------------- |
197
+ | < 200 ms | 7 | **swallowed** |
198
+ | > 200 ms | 33 | delivered |
199
+
200
+ A perfect split on the wall-clock boundary — the mechanism, observed.
201
+
202
+ **The fix removes the pre-check rather than re-keying it**, because the
203
+ content-keyed suppression it was shadowing already existed one step further
204
+ down and needs no timer:
205
+
206
+ - `add`/`change` — `currentHead === hash` drops the event when the bytes on disk
207
+ are the bytes we last published. `put()` sets that head in the same
208
+ continuation as its `rename`, and `awaitWriteFinish` holds any event for a
209
+ further `stabilityThreshold`, so the index is never late.
210
+ - `unlink` — `!currentHead` drops the event when the index already agrees the
211
+ item is gone.
212
+
213
+ `delete()` additionally now retires the head **before** it unlinks rather than
214
+ after. `awaitWriteFinish` debounces only `add`/`change`, so that face gets no
215
+ stability cushion between the disk mutation and the event it produces; ordering
216
+ the index update first makes the downstream check a total suppression rather
217
+ than a race against the poll callback. A failed `unlink` restores the head
218
+ before rethrowing, so the error path is unchanged.
219
+
220
+ No API or configuration change; the repository publishes strictly more of the
221
+ external edits it was always meant to report.
222
+
223
+ One pre-existing limit is now documented rather than altered: identity is judged
224
+ on what round-trips through the file, so a spec whose in-memory form does not
225
+ (a `Date`, which canonicalises to `{}` in memory but to an ISO string once
226
+ written and re-read) is republished as an external `update`. Such a spec already
227
+ fails `put().version === get().hash` independently of the watcher, and the
228
+ 200 ms window never covered it either — it expired some 360 ms before the event
229
+ it would have had to catch.
230
+
231
+ - ab07b53: fix(metadata-fs): register every written path with the watcher, so an item created while chokidar is still scanning is not invisible forever (#7282)
232
+
233
+ `FileSystemRepository`'s watcher could go **permanently blind to a single
234
+ item** — external edits to that file produced no `MetadataEvent` for the whole
235
+ life of the process, and nothing recovered short of a restart. The window is a
236
+ race between chokidar's asynchronous initial scan and the repository's own
237
+ first write, and both `start()` (which arms the watcher, after which the caller
238
+ may `put()` on the next tick) and `ensureRoot()` (which arms it in the middle
239
+ of the very first write, #7000) can open it.
240
+
241
+ Measured on chokidar 5 with this repository's options (`usePolling`,
242
+ `interval: 1000`):
243
+
244
+ 1. chokidar reads `<root>/<type>/` and finds it EMPTY — the atomic `rename` in
245
+ `writeJsonAtomic` has not landed yet;
246
+ 2. the rename lands, changing the directory's mtime;
247
+ 3. chokidar calls `watchFile()` on that directory and libuv takes its polling
248
+ baseline stat, which already reflects step 2.
249
+
250
+ The directory's stat then never changes again, so no poll ever fires for it,
251
+ the directory is never re-read, the item file is never added to the watched
252
+ set, and no per-file watcher is created. `getWatched()` reports the type
253
+ directory as `[]` while the file sits in it, and neither `add` nor `change` is
254
+ ever emitted for that path.
255
+
256
+ The fix does not widen any timer. The only writer that can be inside that
257
+ window is the repository itself, so `put()` now tells the watcher explicitly
258
+ about the path it created instead of depending on a directory scan that may
259
+ never notice it. Registration is idempotent and emits nothing.
260
+
261
+ User-visible effect: `MetadataManager.subscribe()` (and every consumer of
262
+ `repo.watch()`) now reliably sees out-of-process edits — a hand edit, or a
263
+ `git checkout` bringing metadata JSON in — to items written earlier in the same
264
+ process. This was also the cause of four merge-queue ejections across three
265
+ PRs; the two time-based mitigations tried before it (a 20s/25s event deadline
266
+ and a wider pre-edit sleep) could not have worked, because the event was never
267
+ delivered rather than late.
268
+
269
+ - 684ab22: fix(metadata-fs): the `FileSystemRepository` watcher now sees external edits in the production layout
270
+
271
+ `MetadataPlugin` attaches the repository at `<project>/.objectstack/metadata`, and the
272
+ watcher's `ignored` matcher was a bare dotfile regex. chokidar applies that matcher to the
273
+ watched root path itself, not only to entries found underneath it, so the `.objectstack`
274
+ segment of the root matched and the entire watch was inert — `getWatched()` returned `{}`
275
+ and no event ever fired. Hand edits, a `git checkout` that brings metadata JSON in, and any
276
+ other out-of-process writer under `.objectstack/metadata/` were invisible until the next
277
+ `start()`, even though `MetadataManager.setRepository()` is wired to those events and uses
278
+ them to invalidate the registry and the `list()` cache.
279
+
280
+ The matcher is now evaluated against the path _relative_ to the watch root, so dot segments
281
+ belonging to the root itself are never considered while dotfiles under the root — including
282
+ the repository's own `.objectstack/` bookkeeping subtree — stay ignored as before.
283
+
284
+ - Updated dependencies [f5a4ef0]
285
+ - Updated dependencies [2e836de]
286
+ - Updated dependencies [121852d]
287
+ - Updated dependencies [db0d53c]
288
+ - Updated dependencies [c7e7900]
289
+ - Updated dependencies [72c3c86]
290
+ - Updated dependencies [3670cf9]
291
+ - Updated dependencies [2d8dba3]
292
+ - Updated dependencies [7372d46]
293
+ - Updated dependencies [5e247fd]
294
+ - Updated dependencies [1a53a02]
295
+ - Updated dependencies [a954634]
296
+ - Updated dependencies [fda61e4]
297
+ - Updated dependencies [db48ad5]
298
+ - Updated dependencies [65f184b]
299
+ - Updated dependencies [51a587d]
300
+ - Updated dependencies [c073b8c]
301
+ - Updated dependencies [946a131]
302
+ - Updated dependencies [ce92674]
303
+ - Updated dependencies [3d4c545]
304
+ - Updated dependencies [bb7cb41]
305
+ - @objectstack/metadata-core@17.0.0
306
+
3
307
  ## 17.0.0-rc.6
4
308
 
5
309
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -235,6 +235,8 @@ var matchRefFilter = (ref, filter) => {
235
235
  return true;
236
236
  };
237
237
  var matchEvent = (evt, filter) => matchRefFilter(evt.ref, filter);
238
+ var RESYNC_INTERVAL_MS = 2e3;
239
+ var isEnoent = (err) => err?.code === "ENOENT";
238
240
  var FileSystemRepository = class {
239
241
  constructor(opts) {
240
242
  this.mutex = new KeyedMutex();
@@ -243,10 +245,18 @@ var FileSystemRepository = class {
243
245
  this.heads = /* @__PURE__ */ new Map();
244
246
  /** Next seq counter, hydrated from the log on `start()`. */
245
247
  this.nextSeq = 1;
246
- /** Paths we wrote ourselves; suppress the resulting chokidar event. */
247
- this.selfWrites = /* @__PURE__ */ new Set();
248
248
  this.watcher = null;
249
249
  this.started = false;
250
+ /** Pending reconciliation sweep (#9339). Chained, never overlapping. */
251
+ this.resyncTimer = null;
252
+ /** False before the watcher is armed and from `close()` onwards. */
253
+ this.resyncEnabled = false;
254
+ /**
255
+ * Sweep read faults already reported, keyed `CODE @ path`, so a standing
256
+ * fault is announced once rather than every 2s (AGENTS.md: say it once, at
257
+ * the first degradation). An entry is cleared when that path reads again.
258
+ */
259
+ this.resyncFaults = /* @__PURE__ */ new Set();
250
260
  this.org = opts.org;
251
261
  this.fsActor = opts.fsActor ?? "fs";
252
262
  this.disableWatch = opts.disableWatch ?? false;
@@ -293,6 +303,7 @@ var FileSystemRepository = class {
293
303
  if (this.started && !this.disableWatch && !this.watcher) this.startWatcher();
294
304
  }
295
305
  async close() {
306
+ this.stopResync();
296
307
  if (this.watcher) {
297
308
  await this.watcher.close();
298
309
  this.watcher = null;
@@ -410,12 +421,7 @@ var FileSystemRepository = class {
410
421
  const file = itemPath(this.layout, ref.type, ref.name);
411
422
  await this.ensureRoot();
412
423
  await import_promises2.default.mkdir(typeDir(this.layout, ref.type), { recursive: true });
413
- this.selfWrites.add(file);
414
- try {
415
- await writeJsonAtomic(file, spec);
416
- } finally {
417
- setTimeout(() => this.selfWrites.delete(file), 200);
418
- }
424
+ await writeJsonAtomic(file, spec);
419
425
  this.trackWrittenPath(file);
420
426
  this.heads.set(key, hash);
421
427
  const evt = {
@@ -457,13 +463,13 @@ var FileSystemRepository = class {
457
463
  }
458
464
  const file = itemPath(this.layout, ref.type, ref.name);
459
465
  await this.ensureRoot();
460
- this.selfWrites.add(file);
466
+ this.heads.delete(key);
461
467
  try {
462
468
  if ((0, import_node_fs2.existsSync)(file)) await import_promises2.default.unlink(file);
463
- } finally {
464
- setTimeout(() => this.selfWrites.delete(file), 200);
469
+ } catch (err) {
470
+ if (currentHead !== null) this.heads.set(key, currentHead);
471
+ throw err;
465
472
  }
466
- this.heads.delete(key);
467
473
  const seq = this.nextSeq++;
468
474
  const ts = this.now().toISOString();
469
475
  const evt = {
@@ -598,9 +604,278 @@ var FileSystemRepository = class {
598
604
  w.on("change", (p) => void this.handleFsChange(p, "change"));
599
605
  w.on("unlink", (p) => void this.handleFsChange(p, "unlink"));
600
606
  this.watcher = w;
607
+ this.startResync();
608
+ }
609
+ /**
610
+ * Publish the `delete` face of an externally-observed removal.
611
+ *
612
+ * Extracted from `handleFsChange` unchanged so the reconciliation sweep
613
+ * (#9339) can reuse it **verbatim** rather than growing a second copy of the
614
+ * event shape. The one-line invariant: the caller already holds the per-key
615
+ * mutex, and `!currentHead` is the content-keyed suppression that makes our
616
+ * own `delete()` a no-op here.
617
+ */
618
+ async publishExternalDelete(ref, key) {
619
+ const currentHead = this.heads.get(key) ?? null;
620
+ if (!currentHead) return;
621
+ this.heads.delete(key);
622
+ const seq = this.nextSeq++;
623
+ const evt = {
624
+ seq,
625
+ op: "delete",
626
+ ref: { ...ref, version: void 0 },
627
+ hash: null,
628
+ parentHash: currentHead,
629
+ actor: this.fsActor,
630
+ ts: this.now().toISOString(),
631
+ source: "fs"
632
+ };
633
+ await this.log.append(evt);
634
+ this.broker.publish(evt);
635
+ }
636
+ startResync() {
637
+ this.resyncEnabled = true;
638
+ this.scheduleResync();
639
+ }
640
+ stopResync() {
641
+ this.resyncEnabled = false;
642
+ if (this.resyncTimer) {
643
+ clearTimeout(this.resyncTimer);
644
+ this.resyncTimer = null;
645
+ }
646
+ }
647
+ /**
648
+ * Schedule the next sweep — chained, never `setInterval` (#9339).
649
+ *
650
+ * A chained timeout cannot stack: the next sweep is armed only once the
651
+ * previous one has finished, so a saturated runner degrades to *fewer*
652
+ * sweeps instead of a growing backlog of overlapping tree walks. The timer
653
+ * is `unref`ed because a backstop must never be the reason a process stays
654
+ * alive.
655
+ */
656
+ scheduleResync() {
657
+ if (!this.resyncEnabled || this.resyncTimer) return;
658
+ const timer = setTimeout(() => {
659
+ this.resyncTimer = null;
660
+ void this.resync().finally(() => this.scheduleResync());
661
+ }, RESYNC_INTERVAL_MS);
662
+ timer.unref?.();
663
+ this.resyncTimer = timer;
664
+ }
665
+ /**
666
+ * Announce a sweep read that could not run — the non-silence half of #8895's
667
+ * "discriminate or propagate".
668
+ *
669
+ * ## Why `error` and not `warn`
670
+ *
671
+ * AGENTS.md decides the level with one question: *after the degradation, does
672
+ * the system still look "normal" from the outside while something it claims
673
+ * is persisted has not actually landed?* Here it does. Nothing throws, the
674
+ * watcher stays armed, `getWatched()` stays populated, `start()` succeeded —
675
+ * and the repository's index quietly stops tracking what is on disk. That is
676
+ * the rule's second limb verbatim ("persisted state and runtime state
677
+ * disagree"), not the functional-degradation limb: no capability is visibly
678
+ * smaller, so nobody finds out by using the missing thing.
679
+ *
680
+ * The counter-argument — *this is only a backstop, the watcher is still the
681
+ * fast path* — is why the level is arguable, and it does not survive the
682
+ * failing errno. The sharp case is fd exhaustion: EMFILE/ENFILE break this
683
+ * `readdir` and chokidar's `fs.watchFile` polling **at the same time and for
684
+ * the same reason**, so the fast path is not an independent fallback under
685
+ * precisely the load that produces this fault. A backstop that is silently
686
+ * absent whenever it is most needed is a durability-shaped degradation.
687
+ *
688
+ * ⚠️ AGENTS.md also warns against over-applying `error`, and the discipline
689
+ * that answers it is the ledger, not a quieter level: an `error` owes the
690
+ * consequence and the fix, said **once** at the first degradation rather than
691
+ * once per failed read. A sweep runs every 2s forever, so an unlatched
692
+ * `console.error` here would be the mirror-image failure the same rule names.
693
+ *
694
+ * ⛔ It deliberately does NOT throw. This runs on a background timer; taking
695
+ * a process down on a transient EACCES would be worse than the bug. The bar
696
+ * met here is non-silence, not propagation.
697
+ *
698
+ * The channel is `console.error` because this class has no logger: nothing is
699
+ * injected through `FileSystemRepositoryOptions`, and widening that public
700
+ * surface to carry one is out of scope for this fix.
701
+ */
702
+ reportResyncFault(target, err) {
703
+ const code = err?.code ?? "UNKNOWN";
704
+ const key = `${code} @ ${target}`;
705
+ if (this.resyncFaults.has(key)) return;
706
+ this.resyncFaults.add(key);
707
+ console.error(
708
+ `[FileSystemRepository] metadata reconciliation sweep could not read ${target} (${code}). CONSEQUENCE: external edits under this path are no longer reconciled, so this repository's index and its watch() subscribers can drift from what is on disk while everything keeps reporting healthy. The chokidar watcher is not an independent fallback here \u2014 fd exhaustion degrades both. FIX: restore read access to the path; the sweep recovers by itself on the first successful read. Reported once per path and error code.`
709
+ );
710
+ }
711
+ /** Re-arm reporting for a path that reads again, so a recurrence is heard. */
712
+ clearResyncFault(target) {
713
+ if (this.resyncFaults.size === 0) return;
714
+ const suffix = ` @ ${target}`;
715
+ for (const key of this.resyncFaults) {
716
+ if (key.endsWith(suffix)) this.resyncFaults.delete(key);
717
+ }
601
718
  }
719
+ /**
720
+ * Content-keyed reconciliation sweep — the backstop that makes external-edit
721
+ * detection a guarantee rather than a single chance (#9339, #7282).
722
+ *
723
+ * ## Why the watcher alone cannot be the guarantee
724
+ *
725
+ * An external write to `<root>/<type>/<name>.json` reaches a subscriber only
726
+ * if chokidar notices it, and under `usePolling` it gets **exactly one**
727
+ * opportunity to do so: the write advances the type directory's mtime once,
728
+ * and chokidar re-reads a directory only when its stat *strictly advances*,
729
+ * so every later poll compares an unchanged stat and can never rediscover
730
+ * the file. Measured on #9339 with a fault-injection harness: with the one
731
+ * read suppressed, fifteen further poll ticks never find the new file, and a
732
+ * 20s deadline and a 200s deadline buy the same single attempt. That is the
733
+ * structural reason behind #7282's empirical finding that the event is
734
+ * "never delivered, not slow", and why widening the deadline (#7208) and
735
+ * lowering `interval` were both spent before they were tried.
736
+ *
737
+ * At least six independent one-shot gates sit on that single attempt,
738
+ * spanning three layers — the kernel timestamp (the directory mtime does not
739
+ * strictly advance), chokidar's readdir throttle and readdir snapshot, and
740
+ * chokidar's emit gates (`_throttle('add')`, a stale `_pendingWrites` entry,
741
+ * the `awaitWriteFinish` ENOENT early return). Each one produces a
742
+ * byte-identical observable: no event, ever, for that path.
743
+ *
744
+ * ## Why this shape, and not a narrower one
745
+ *
746
+ * ⚠️ The six are indistinguishable at the point of failure, so **any fix
747
+ * that has to name which gate fired is a fix for one member of a family** —
748
+ * which is exactly how #7282 was closed and exactly why it reopened. This
749
+ * sweep never asks. It compares what is on disk against `heads`, the index
750
+ * that already defines what this repository believes it holds, and publishes
751
+ * the divergence through the same `handleFsChange` the watcher feeds. It is
752
+ * therefore robust across all six *by construction*, and equally across a
753
+ * seventh nobody has found: the only property it relies on is that the bytes
754
+ * on disk stopped matching the index.
755
+ *
756
+ * `put()` is unaffected and keeps its direct registration (`trackWrittenPath`
757
+ * calls `watcher.add` and bypasses the whole chain, which is why the `put()`
758
+ * half of this family was already closed by #7336 and the external-write half
759
+ * was not).
760
+ *
761
+ * ## Cost, and why it is bounded
762
+ *
763
+ * One pass over `<root>/<type>/*.json` per sweep — the same walk `start()`
764
+ * already performs once — with no retry loop inside it and no work at all
765
+ * when nothing diverged. Sweeps are chained, so they cannot overlap; the
766
+ * timer is `unref`ed and dies with `close()`; and it is armed only alongside
767
+ * the watcher, so a `disableWatch` repository pays nothing.
768
+ *
769
+ * Discovery is by content, never by stat: a stat pre-filter would reintroduce
770
+ * a time key of exactly the kind this replaces.
771
+ */
772
+ async resync() {
773
+ const root = this.layout.root;
774
+ let entries = [];
775
+ try {
776
+ entries = await import_promises2.default.readdir(root, { withFileTypes: true });
777
+ this.clearResyncFault(root);
778
+ } catch (err) {
779
+ if (!isEnoent(err)) this.reportResyncFault(root, err);
780
+ return;
781
+ }
782
+ const onDisk = /* @__PURE__ */ new Set();
783
+ const unreadableTypes = /* @__PURE__ */ new Set();
784
+ for (const entry of entries) {
785
+ if (!entry.isDirectory()) continue;
786
+ if (entry.name.startsWith(".")) continue;
787
+ const dir = import_node_path3.default.join(root, entry.name);
788
+ let files = [];
789
+ try {
790
+ files = await import_promises2.default.readdir(dir);
791
+ this.clearResyncFault(dir);
792
+ } catch (err) {
793
+ if (!isEnoent(err)) {
794
+ this.reportResyncFault(dir, err);
795
+ unreadableTypes.add(entry.name);
796
+ }
797
+ continue;
798
+ }
799
+ for (const file of files) {
800
+ if (!file.endsWith(".json") || file.startsWith(".")) continue;
801
+ const abs = import_node_path3.default.join(dir, file);
802
+ const parsed = parseItemPath(this.layout, abs);
803
+ if (!parsed) continue;
804
+ const ref = {
805
+ org: this.org,
806
+ type: parsed.type,
807
+ name: parsed.name
808
+ };
809
+ const key = (0, import_metadata_core.refKey)(ref);
810
+ onDisk.add(key);
811
+ const before = this.heads.get(key);
812
+ await this.handleFsChange(abs, "add");
813
+ if (this.heads.get(key) !== before) {
814
+ this.trackWrittenPath(abs);
815
+ }
816
+ }
817
+ }
818
+ for (const key of [...this.heads.keys()]) {
819
+ if (onDisk.has(key)) continue;
820
+ const ref = parseRefKey(key);
821
+ if (!ref) continue;
822
+ if (unreadableTypes.has(ref.type)) continue;
823
+ const file = itemPath(this.layout, ref.type, ref.name);
824
+ await this.mutex.run(key, async () => {
825
+ if ((0, import_node_fs2.existsSync)(file)) return;
826
+ await this.publishExternalDelete(ref, key);
827
+ });
828
+ }
829
+ }
830
+ /**
831
+ * Translate a watcher event into a `MetadataEvent`, or drop it.
832
+ *
833
+ * ## Self-writes are suppressed by content identity, never by a clock (#7335)
834
+ *
835
+ * This used to open with `if (this.selfWrites.has(absPath)) return;` — a
836
+ * `Set` that `put()`/`delete()` added the path to and a `setTimeout(…, 200)`
837
+ * cleared. That check discarded **every** event for a recently-written path
838
+ * without ever looking at what the watcher had actually observed, which is
839
+ * the whole defect: with `usePolling`, chokidar compares state once per
840
+ * `interval`, so our write and an external edit landing between two ticks
841
+ * are delivered as **one** event carrying the *external* content. Dropping
842
+ * it on a wall clock destroyed the only notification that edit would ever
843
+ * produce.
844
+ *
845
+ * Measured on `origin/main` @ `69fde55`, 40 iterations, poll phase
846
+ * randomised so the delivery lag samples `[0, interval)` uniformly:
847
+ *
848
+ * delivery lag < 200ms → 7 runs → external edit SWALLOWED, every time
849
+ * delivery lag > 200ms → 33 runs → external edit delivered, every time
850
+ *
851
+ * A perfect split on the wall-clock boundary, and the reason earlier
852
+ * instrumentation saw 0/360: a *fixed* pre-edit sleep phase-locks the poll,
853
+ * pinning the lag (measured: 519–585ms across 25 runs) safely outside the
854
+ * window. Nothing about the window was rare — it was unsampled.
855
+ *
856
+ * What remains is the check that was already doing the real work one step
857
+ * down, and it needs no timer because it compares the content the watcher
858
+ * **read** against the index:
859
+ *
860
+ * - `add`/`change` — `currentHead === hash` drops the event when the bytes
861
+ * on disk are the bytes we last published. `put()` sets that head in the
862
+ * same continuation as its `rename`, and `awaitWriteFinish` holds the
863
+ * event for a further `stabilityThreshold`, so it is never late.
864
+ * - `unlink` — `!currentHead` drops the event when the index already
865
+ * agrees the item is gone. `delete()` retires the head *before* it
866
+ * unlinks, precisely because this face gets no `awaitWriteFinish` delay.
867
+ *
868
+ * Both faces are pinned together in `test/self-write-suppression.test.ts`.
869
+ *
870
+ * Note the deliberate limit: identity is judged on what round-trips through
871
+ * the file, so a spec whose in-memory form does not (a `Date`, which
872
+ * canonicalises to `{}` in memory but to an ISO string once written and
873
+ * re-read) is republished as an external `update`. That predates this change
874
+ * and is independent of it — such a spec already fails `put().version ===
875
+ * get().hash`, and the 200ms window never covered it either, expiring some
876
+ * 360ms before the event it would have had to catch.
877
+ */
602
878
  async handleFsChange(absPath, kind) {
603
- if (this.selfWrites.has(absPath)) return;
604
879
  const parsed = parseItemPath(this.layout, absPath);
605
880
  if (!parsed) return;
606
881
  const ref = {
@@ -611,22 +886,7 @@ var FileSystemRepository = class {
611
886
  const key = (0, import_metadata_core.refKey)(ref);
612
887
  await this.mutex.run(key, async () => {
613
888
  if (kind === "unlink") {
614
- const currentHead2 = this.heads.get(key) ?? null;
615
- if (!currentHead2) return;
616
- this.heads.delete(key);
617
- const seq2 = this.nextSeq++;
618
- const evt2 = {
619
- seq: seq2,
620
- op: "delete",
621
- ref: { ...ref, version: void 0 },
622
- hash: null,
623
- parentHash: currentHead2,
624
- actor: this.fsActor,
625
- ts: this.now().toISOString(),
626
- source: "fs"
627
- };
628
- await this.log.append(evt2);
629
- this.broker.publish(evt2);
889
+ await this.publishExternalDelete(ref, key);
630
890
  return;
631
891
  }
632
892
  const body = await readJson(absPath);