@objectstack/metadata-fs 17.0.0 → 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,65 @@
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
+
3
63
  ## 17.0.0
4
64
 
5
65
  ### 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();
@@ -245,6 +247,16 @@ var FileSystemRepository = class {
245
247
  this.nextSeq = 1;
246
248
  this.watcher = null;
247
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();
248
260
  this.org = opts.org;
249
261
  this.fsActor = opts.fsActor ?? "fs";
250
262
  this.disableWatch = opts.disableWatch ?? false;
@@ -291,6 +303,7 @@ var FileSystemRepository = class {
291
303
  if (this.started && !this.disableWatch && !this.watcher) this.startWatcher();
292
304
  }
293
305
  async close() {
306
+ this.stopResync();
294
307
  if (this.watcher) {
295
308
  await this.watcher.close();
296
309
  this.watcher = null;
@@ -591,6 +604,228 @@ var FileSystemRepository = class {
591
604
  w.on("change", (p) => void this.handleFsChange(p, "change"));
592
605
  w.on("unlink", (p) => void this.handleFsChange(p, "unlink"));
593
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
+ }
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
+ }
594
829
  }
595
830
  /**
596
831
  * Translate a watcher event into a `MetadataEvent`, or drop it.
@@ -651,22 +886,7 @@ var FileSystemRepository = class {
651
886
  const key = (0, import_metadata_core.refKey)(ref);
652
887
  await this.mutex.run(key, async () => {
653
888
  if (kind === "unlink") {
654
- const currentHead2 = this.heads.get(key) ?? null;
655
- if (!currentHead2) return;
656
- this.heads.delete(key);
657
- const seq2 = this.nextSeq++;
658
- const evt2 = {
659
- seq: seq2,
660
- op: "delete",
661
- ref: { ...ref, version: void 0 },
662
- hash: null,
663
- parentHash: currentHead2,
664
- actor: this.fsActor,
665
- ts: this.now().toISOString(),
666
- source: "fs"
667
- };
668
- await this.log.append(evt2);
669
- this.broker.publish(evt2);
889
+ await this.publishExternalDelete(ref, key);
670
890
  return;
671
891
  }
672
892
  const body = await readJson(absPath);