@objectstack/metadata-fs 17.0.0 → 17.2.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 +111 -0
- package/dist/index.cjs +291 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +291 -17
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,116 @@
|
|
|
1
1
|
# @objectstack/metadata-fs
|
|
2
2
|
|
|
3
|
+
## 17.2.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 46644e2: `FileSystemRepository.close()` now terminates every live `watch()` iterator
|
|
8
|
+
instead of leaving it parked (#11127). A consumer holding a `for await` over
|
|
9
|
+
`watch()` at shutdown never saw its loop end — on a repository that was already
|
|
10
|
+
gone.
|
|
11
|
+
|
|
12
|
+
`close()` retired the chokidar watcher and the resync sweep and stopped there.
|
|
13
|
+
It never reached the event broker, and the broker had no teardown of its own:
|
|
14
|
+
`subscribe`/`unsubscribe` add to and delete from a plain `Set`, and nothing else
|
|
15
|
+
emptied it. Each iterator parks its pending `next()` on a `waiter` that only two
|
|
16
|
+
things can settle — a broker `push`, or the iterator's own terminator, which ran
|
|
17
|
+
from `iterator.return()`/`throw()` and from nowhere else. After `close()` the
|
|
18
|
+
chokidar source was gone so no `push` could arrive, and the subscriber was still
|
|
19
|
+
registered with nothing left to run its terminator.
|
|
20
|
+
|
|
21
|
+
Unlike the sibling defect in `SysMetadataRepository` (#11021) this was not
|
|
22
|
+
filter-dependent: there was no drain attempt at all, so every subscription shape
|
|
23
|
+
hung, `watch({})` included. Measured before the fix: nine cases —
|
|
24
|
+
`watch({org}, seq)`, `watch({org})`, `watch({})`, a ref-exact filter, a watcher
|
|
25
|
+
over the real chokidar watcher, a watcher with no pull outstanding, four
|
|
26
|
+
concurrent watchers, and the `return()`-symmetry comparison — were all still
|
|
27
|
+
unsettled 2s after `close()`. `MetadataManager.startRepositoryWatch()`, which
|
|
28
|
+
awaits `iter.next()` in a loop, is exactly the shape that hung.
|
|
29
|
+
|
|
30
|
+
The broker now holds each subscription's terminator next to its event sink, and
|
|
31
|
+
`close()` runs every terminator — the same routine the consumer's own
|
|
32
|
+
`iterator.return()` runs, so a parked `next()` settles with `{ done: true }` and
|
|
33
|
+
no value, and so does every later one. Shutdown is deliberately **not** delivered
|
|
34
|
+
as an event: a synthetic drain event is subject to the very filters `watch()`
|
|
35
|
+
applies to real ones, and delivering an event has never ended an iterator
|
|
36
|
+
(invariant 8, `@objectstack/metadata-core`'s `repository.ts`).
|
|
37
|
+
|
|
38
|
+
One narrower path is closed with it. `watch()` returns a deferred iterable whose
|
|
39
|
+
subscriber registers only once the eager log read resolves, so a `close()`
|
|
40
|
+
landing inside that window swept a broker the subscription had not yet joined —
|
|
41
|
+
the same forever-parked shape by a different route. `watch()` now carries the
|
|
42
|
+
close generation it was opened under, and a subscription that arrives after a
|
|
43
|
+
shutdown terminates on arrival.
|
|
44
|
+
|
|
45
|
+
Invariant 8 named `FileSystemRepository` as its one known non-conformance. With
|
|
46
|
+
this change the invariant has no declared exceptions, and its text says so.
|
|
47
|
+
- Updated dependencies [8cc8401]
|
|
48
|
+
- Updated dependencies [26f3588]
|
|
49
|
+
- Updated dependencies [05bc692]
|
|
50
|
+
- Updated dependencies [f334d66]
|
|
51
|
+
- Updated dependencies [2810695]
|
|
52
|
+
- @objectstack/metadata-core@17.2.0
|
|
53
|
+
|
|
54
|
+
## 17.1.0
|
|
55
|
+
|
|
56
|
+
### Patch Changes
|
|
57
|
+
|
|
58
|
+
- 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)
|
|
59
|
+
|
|
60
|
+
`FileSystemRepository`'s watcher gave an externally-written file **exactly one**
|
|
61
|
+
chance to be noticed, and losing it was permanent and silent. Under
|
|
62
|
+
`usePolling`, chokidar re-reads a directory only when its stat *strictly*
|
|
63
|
+
advances; an external write advances the type directory's mtime once, so poll
|
|
64
|
+
#2..#N compare an unchanged stat and can never rediscover the file. Measured on
|
|
65
|
+
#9339 with a fault-injection harness: with that single read suppressed, fifteen
|
|
66
|
+
further poll ticks never find the file — a 20s deadline and a 200s deadline buy
|
|
67
|
+
the same one attempt. That is the structural reason behind #7282's empirical
|
|
68
|
+
finding that the event is *"never delivered, not slow"*, and why widening the
|
|
69
|
+
deadline (#7208) and lowering `interval` were both spent before they were tried.
|
|
70
|
+
|
|
71
|
+
**At least six independent one-shot gates sit on that attempt**, spanning three
|
|
72
|
+
layers — the kernel timestamp (the directory mtime does not strictly advance),
|
|
73
|
+
chokidar's readdir throttle and readdir snapshot, and chokidar's emit gates
|
|
74
|
+
(`_throttle('add')`, a stale `_pendingWrites` entry, the `awaitWriteFinish`
|
|
75
|
+
ENOENT early return). Each produces a byte-identical observable: no event, ever,
|
|
76
|
+
for that path. They are indistinguishable at the point of failure, which is why
|
|
77
|
+
#7282's close — picked from that family — covered one member and reopened.
|
|
78
|
+
|
|
79
|
+
**The fix does not name a member.** A bounded, content-keyed reconciliation
|
|
80
|
+
sweep runs alongside the watcher and compares what is on disk against `heads`,
|
|
81
|
+
the index that already defines what the repository believes it holds, publishing
|
|
82
|
+
any divergence through the *same* handler the watcher feeds. Its only premise is
|
|
83
|
+
that the bytes on disk stopped matching the index, so it is robust across all six
|
|
84
|
+
by construction — and equally across a seventh nobody has found.
|
|
85
|
+
|
|
86
|
+
- **Cadence** — one pass over `<root>/<type>/*.json` every 2s (twice the poll
|
|
87
|
+
interval), the same walk `start()` already performs once. Sweeps are chained
|
|
88
|
+
rather than intervalled, so they can never overlap or stack behind a slow
|
|
89
|
+
disk; the timer is `unref`ed and is retired by `close()`; and it is armed only
|
|
90
|
+
alongside the watcher, so a `disableWatch` repository pays nothing.
|
|
91
|
+
- **Exactly-once is preserved.** Suppression stays content-keyed (`#7335`): the
|
|
92
|
+
sweep republishes nothing the watcher already delivered, and recognises this
|
|
93
|
+
repository's own `put()` by content rather than by a clock.
|
|
94
|
+
- **Events are indistinguishable from the fast path** — same `op`,
|
|
95
|
+
`parentHash`, `source: 'fs'` and actor, because they are produced by the same
|
|
96
|
+
code. A subscriber cannot be made to care which path noticed.
|
|
97
|
+
- **A recovered path is re-armed** with the watcher through the seam `put()`
|
|
98
|
+
already uses, so a loss upstream of chokidar's `_handleFile` does not leave
|
|
99
|
+
the file dependent on the sweep forever.
|
|
100
|
+
- `put()`'s existing direct registration (#7336) is unchanged, as are
|
|
101
|
+
`usePolling`, `interval`, and `awaitWriteFinish`.
|
|
102
|
+
|
|
103
|
+
⚠️ **Bound on the claim.** The six gates are *forced fault injections*, not the
|
|
104
|
+
CI mechanism, which was never identified and may be a seventh. What is measured
|
|
105
|
+
is that the fix converts **six of six** forced one-shot gates from permanent
|
|
106
|
+
loss to delivery (3/3 runs each), where all six returned an empty event list
|
|
107
|
+
before it. That is not the same statement as "the flake is fixed".
|
|
108
|
+
- Updated dependencies [b6c7690]
|
|
109
|
+
- Updated dependencies [845e164]
|
|
110
|
+
- Updated dependencies [1a7f907]
|
|
111
|
+
- Updated dependencies [7fc01db]
|
|
112
|
+
- @objectstack/metadata-core@17.1.0
|
|
113
|
+
|
|
3
114
|
## 17.0.0
|
|
4
115
|
|
|
5
116
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -144,6 +144,16 @@ function createBroker(matches) {
|
|
|
144
144
|
if (!matches(evt, s.filter)) continue;
|
|
145
145
|
s.push(evt);
|
|
146
146
|
}
|
|
147
|
+
},
|
|
148
|
+
terminateAll: () => {
|
|
149
|
+
const snapshot = Array.from(subs);
|
|
150
|
+
subs.clear();
|
|
151
|
+
for (const s of snapshot) {
|
|
152
|
+
try {
|
|
153
|
+
s.terminate();
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
}
|
|
147
157
|
}
|
|
148
158
|
};
|
|
149
159
|
}
|
|
@@ -158,6 +168,10 @@ function createWatchIterable(args) {
|
|
|
158
168
|
const subscriber = {
|
|
159
169
|
filter: args.filter,
|
|
160
170
|
closed: false,
|
|
171
|
+
// Assigned below, once `close` exists. Termination and the consumer's own
|
|
172
|
+
// `return()` are ONE routine, deliberately: invariant 8 requires shutdown
|
|
173
|
+
// to be indistinguishable from `iterator.return()`.
|
|
174
|
+
terminate: () => void 0,
|
|
161
175
|
push: (evt) => {
|
|
162
176
|
if (subscriber.closed) return;
|
|
163
177
|
const k = evtKey(evt);
|
|
@@ -206,6 +220,8 @@ function createWatchIterable(args) {
|
|
|
206
220
|
}
|
|
207
221
|
return { value: void 0, done: true };
|
|
208
222
|
};
|
|
223
|
+
subscriber.terminate = close;
|
|
224
|
+
if (args.arrivesClosed?.()) close();
|
|
209
225
|
const iterator = {
|
|
210
226
|
next: () => {
|
|
211
227
|
if (closed) return Promise.resolve({ value: void 0, done: true });
|
|
@@ -235,6 +251,8 @@ var matchRefFilter = (ref, filter) => {
|
|
|
235
251
|
return true;
|
|
236
252
|
};
|
|
237
253
|
var matchEvent = (evt, filter) => matchRefFilter(evt.ref, filter);
|
|
254
|
+
var RESYNC_INTERVAL_MS = 2e3;
|
|
255
|
+
var isEnoent = (err) => err?.code === "ENOENT";
|
|
238
256
|
var FileSystemRepository = class {
|
|
239
257
|
constructor(opts) {
|
|
240
258
|
this.mutex = new KeyedMutex();
|
|
@@ -245,6 +263,25 @@ var FileSystemRepository = class {
|
|
|
245
263
|
this.nextSeq = 1;
|
|
246
264
|
this.watcher = null;
|
|
247
265
|
this.started = false;
|
|
266
|
+
/** Pending reconciliation sweep (#9339). Chained, never overlapping. */
|
|
267
|
+
this.resyncTimer = null;
|
|
268
|
+
/** False before the watcher is armed and from `close()` onwards. */
|
|
269
|
+
this.resyncEnabled = false;
|
|
270
|
+
/**
|
|
271
|
+
* Sweep read faults already reported, keyed `CODE @ path`, so a standing
|
|
272
|
+
* fault is announced once rather than every 2s (AGENTS.md: say it once, at
|
|
273
|
+
* the first degradation). An entry is cleared when that path reads again.
|
|
274
|
+
*/
|
|
275
|
+
this.resyncFaults = /* @__PURE__ */ new Set();
|
|
276
|
+
/**
|
|
277
|
+
* Bumped by every `close()`. `watch()` reads it before its deferred log
|
|
278
|
+
* replay starts and hands the comparison to `createWatchIterable`, so a
|
|
279
|
+
* subscription that registers AFTER the shutdown sweep terminates on
|
|
280
|
+
* arrival instead of parking forever (#11127). A counter rather than a
|
|
281
|
+
* boolean because `start()` may follow `close()`: a repository restart must
|
|
282
|
+
* not poison the watchers opened after it.
|
|
283
|
+
*/
|
|
284
|
+
this.closeGeneration = 0;
|
|
248
285
|
this.org = opts.org;
|
|
249
286
|
this.fsActor = opts.fsActor ?? "fs";
|
|
250
287
|
this.disableWatch = opts.disableWatch ?? false;
|
|
@@ -290,7 +327,35 @@ var FileSystemRepository = class {
|
|
|
290
327
|
await import_promises2.default.mkdir(this.layout.root, { recursive: true });
|
|
291
328
|
if (this.started && !this.disableWatch && !this.watcher) this.startWatcher();
|
|
292
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Shut the repository down, ending every live `watch()` iterator.
|
|
332
|
+
*
|
|
333
|
+
* **Shutdown terminates; it does not emit** — invariant 8 in
|
|
334
|
+
* `@objectstack/metadata-core`'s `repository.ts`, and the reason this method
|
|
335
|
+
* reaches the broker at all. It used to retire the chokidar watcher and the
|
|
336
|
+
* resync sweep and stop there. The broker has no teardown of its own
|
|
337
|
+
* (`subscribe`/`unsubscribe` add to and delete from a plain `Set`), and each
|
|
338
|
+
* iterator parks its pending `next()` on a `waiter` that only a broker
|
|
339
|
+
* `push` or the iterator's own terminator can settle. After `close()` the
|
|
340
|
+
* chokidar source was gone, so no `push` could arrive; the subscriber was
|
|
341
|
+
* still registered, and nothing ran its terminator. A consumer holding a
|
|
342
|
+
* `for await` at shutdown — `MetadataManager.startRepositoryWatch()` is
|
|
343
|
+
* exactly that shape — therefore never saw its loop end, for EVERY
|
|
344
|
+
* subscription shape including `watch({})`.
|
|
345
|
+
*
|
|
346
|
+
* Termination is expressed as termination: each subscription's
|
|
347
|
+
* `terminate()`, which is the same routine the consumer's own
|
|
348
|
+
* `iterator.return()` runs, so no consumer has to tell "the repository shut
|
|
349
|
+
* down under me" apart from "I broke my own loop". A synthetic drain event
|
|
350
|
+
* would be the wrong shape and was measured to be so (#11021): the
|
|
351
|
+
* subscriptions most in need of draining are exactly the ones whose filter
|
|
352
|
+
* or numeric `since` drops it, and delivering an event has never ended an
|
|
353
|
+
* iterator.
|
|
354
|
+
*/
|
|
293
355
|
async close() {
|
|
356
|
+
this.stopResync();
|
|
357
|
+
this.closeGeneration++;
|
|
358
|
+
this.broker.terminateAll();
|
|
294
359
|
if (this.watcher) {
|
|
295
360
|
await this.watcher.close();
|
|
296
361
|
this.watcher = null;
|
|
@@ -365,6 +430,7 @@ var FileSystemRepository = class {
|
|
|
365
430
|
if (matchEvent(evt, filter)) replay.push(evt);
|
|
366
431
|
}
|
|
367
432
|
})();
|
|
433
|
+
const generation = this.closeGeneration;
|
|
368
434
|
return deferredIterable(promise.then(
|
|
369
435
|
() => createWatchIterable({
|
|
370
436
|
filter,
|
|
@@ -372,7 +438,8 @@ var FileSystemRepository = class {
|
|
|
372
438
|
replay,
|
|
373
439
|
broker: this.broker,
|
|
374
440
|
matches: matchEvent,
|
|
375
|
-
branchKeyOf: (e) => e.ref.org
|
|
441
|
+
branchKeyOf: (e) => e.ref.org,
|
|
442
|
+
arrivesClosed: () => this.closeGeneration !== generation
|
|
376
443
|
})
|
|
377
444
|
));
|
|
378
445
|
}
|
|
@@ -591,6 +658,228 @@ var FileSystemRepository = class {
|
|
|
591
658
|
w.on("change", (p) => void this.handleFsChange(p, "change"));
|
|
592
659
|
w.on("unlink", (p) => void this.handleFsChange(p, "unlink"));
|
|
593
660
|
this.watcher = w;
|
|
661
|
+
this.startResync();
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Publish the `delete` face of an externally-observed removal.
|
|
665
|
+
*
|
|
666
|
+
* Extracted from `handleFsChange` unchanged so the reconciliation sweep
|
|
667
|
+
* (#9339) can reuse it **verbatim** rather than growing a second copy of the
|
|
668
|
+
* event shape. The one-line invariant: the caller already holds the per-key
|
|
669
|
+
* mutex, and `!currentHead` is the content-keyed suppression that makes our
|
|
670
|
+
* own `delete()` a no-op here.
|
|
671
|
+
*/
|
|
672
|
+
async publishExternalDelete(ref, key) {
|
|
673
|
+
const currentHead = this.heads.get(key) ?? null;
|
|
674
|
+
if (!currentHead) return;
|
|
675
|
+
this.heads.delete(key);
|
|
676
|
+
const seq = this.nextSeq++;
|
|
677
|
+
const evt = {
|
|
678
|
+
seq,
|
|
679
|
+
op: "delete",
|
|
680
|
+
ref: { ...ref, version: void 0 },
|
|
681
|
+
hash: null,
|
|
682
|
+
parentHash: currentHead,
|
|
683
|
+
actor: this.fsActor,
|
|
684
|
+
ts: this.now().toISOString(),
|
|
685
|
+
source: "fs"
|
|
686
|
+
};
|
|
687
|
+
await this.log.append(evt);
|
|
688
|
+
this.broker.publish(evt);
|
|
689
|
+
}
|
|
690
|
+
startResync() {
|
|
691
|
+
this.resyncEnabled = true;
|
|
692
|
+
this.scheduleResync();
|
|
693
|
+
}
|
|
694
|
+
stopResync() {
|
|
695
|
+
this.resyncEnabled = false;
|
|
696
|
+
if (this.resyncTimer) {
|
|
697
|
+
clearTimeout(this.resyncTimer);
|
|
698
|
+
this.resyncTimer = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Schedule the next sweep — chained, never `setInterval` (#9339).
|
|
703
|
+
*
|
|
704
|
+
* A chained timeout cannot stack: the next sweep is armed only once the
|
|
705
|
+
* previous one has finished, so a saturated runner degrades to *fewer*
|
|
706
|
+
* sweeps instead of a growing backlog of overlapping tree walks. The timer
|
|
707
|
+
* is `unref`ed because a backstop must never be the reason a process stays
|
|
708
|
+
* alive.
|
|
709
|
+
*/
|
|
710
|
+
scheduleResync() {
|
|
711
|
+
if (!this.resyncEnabled || this.resyncTimer) return;
|
|
712
|
+
const timer = setTimeout(() => {
|
|
713
|
+
this.resyncTimer = null;
|
|
714
|
+
void this.resync().finally(() => this.scheduleResync());
|
|
715
|
+
}, RESYNC_INTERVAL_MS);
|
|
716
|
+
timer.unref?.();
|
|
717
|
+
this.resyncTimer = timer;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Announce a sweep read that could not run — the non-silence half of #8895's
|
|
721
|
+
* "discriminate or propagate".
|
|
722
|
+
*
|
|
723
|
+
* ## Why `error` and not `warn`
|
|
724
|
+
*
|
|
725
|
+
* AGENTS.md decides the level with one question: *after the degradation, does
|
|
726
|
+
* the system still look "normal" from the outside while something it claims
|
|
727
|
+
* is persisted has not actually landed?* Here it does. Nothing throws, the
|
|
728
|
+
* watcher stays armed, `getWatched()` stays populated, `start()` succeeded —
|
|
729
|
+
* and the repository's index quietly stops tracking what is on disk. That is
|
|
730
|
+
* the rule's second limb verbatim ("persisted state and runtime state
|
|
731
|
+
* disagree"), not the functional-degradation limb: no capability is visibly
|
|
732
|
+
* smaller, so nobody finds out by using the missing thing.
|
|
733
|
+
*
|
|
734
|
+
* The counter-argument — *this is only a backstop, the watcher is still the
|
|
735
|
+
* fast path* — is why the level is arguable, and it does not survive the
|
|
736
|
+
* failing errno. The sharp case is fd exhaustion: EMFILE/ENFILE break this
|
|
737
|
+
* `readdir` and chokidar's `fs.watchFile` polling **at the same time and for
|
|
738
|
+
* the same reason**, so the fast path is not an independent fallback under
|
|
739
|
+
* precisely the load that produces this fault. A backstop that is silently
|
|
740
|
+
* absent whenever it is most needed is a durability-shaped degradation.
|
|
741
|
+
*
|
|
742
|
+
* ⚠️ AGENTS.md also warns against over-applying `error`, and the discipline
|
|
743
|
+
* that answers it is the ledger, not a quieter level: an `error` owes the
|
|
744
|
+
* consequence and the fix, said **once** at the first degradation rather than
|
|
745
|
+
* once per failed read. A sweep runs every 2s forever, so an unlatched
|
|
746
|
+
* `console.error` here would be the mirror-image failure the same rule names.
|
|
747
|
+
*
|
|
748
|
+
* ⛔ It deliberately does NOT throw. This runs on a background timer; taking
|
|
749
|
+
* a process down on a transient EACCES would be worse than the bug. The bar
|
|
750
|
+
* met here is non-silence, not propagation.
|
|
751
|
+
*
|
|
752
|
+
* The channel is `console.error` because this class has no logger: nothing is
|
|
753
|
+
* injected through `FileSystemRepositoryOptions`, and widening that public
|
|
754
|
+
* surface to carry one is out of scope for this fix.
|
|
755
|
+
*/
|
|
756
|
+
reportResyncFault(target, err) {
|
|
757
|
+
const code = err?.code ?? "UNKNOWN";
|
|
758
|
+
const key = `${code} @ ${target}`;
|
|
759
|
+
if (this.resyncFaults.has(key)) return;
|
|
760
|
+
this.resyncFaults.add(key);
|
|
761
|
+
console.error(
|
|
762
|
+
`[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.`
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
/** Re-arm reporting for a path that reads again, so a recurrence is heard. */
|
|
766
|
+
clearResyncFault(target) {
|
|
767
|
+
if (this.resyncFaults.size === 0) return;
|
|
768
|
+
const suffix = ` @ ${target}`;
|
|
769
|
+
for (const key of this.resyncFaults) {
|
|
770
|
+
if (key.endsWith(suffix)) this.resyncFaults.delete(key);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Content-keyed reconciliation sweep — the backstop that makes external-edit
|
|
775
|
+
* detection a guarantee rather than a single chance (#9339, #7282).
|
|
776
|
+
*
|
|
777
|
+
* ## Why the watcher alone cannot be the guarantee
|
|
778
|
+
*
|
|
779
|
+
* An external write to `<root>/<type>/<name>.json` reaches a subscriber only
|
|
780
|
+
* if chokidar notices it, and under `usePolling` it gets **exactly one**
|
|
781
|
+
* opportunity to do so: the write advances the type directory's mtime once,
|
|
782
|
+
* and chokidar re-reads a directory only when its stat *strictly advances*,
|
|
783
|
+
* so every later poll compares an unchanged stat and can never rediscover
|
|
784
|
+
* the file. Measured on #9339 with a fault-injection harness: with the one
|
|
785
|
+
* read suppressed, fifteen further poll ticks never find the new file, and a
|
|
786
|
+
* 20s deadline and a 200s deadline buy the same single attempt. That is the
|
|
787
|
+
* structural reason behind #7282's empirical finding that the event is
|
|
788
|
+
* "never delivered, not slow", and why widening the deadline (#7208) and
|
|
789
|
+
* lowering `interval` were both spent before they were tried.
|
|
790
|
+
*
|
|
791
|
+
* At least six independent one-shot gates sit on that single attempt,
|
|
792
|
+
* spanning three layers — the kernel timestamp (the directory mtime does not
|
|
793
|
+
* strictly advance), chokidar's readdir throttle and readdir snapshot, and
|
|
794
|
+
* chokidar's emit gates (`_throttle('add')`, a stale `_pendingWrites` entry,
|
|
795
|
+
* the `awaitWriteFinish` ENOENT early return). Each one produces a
|
|
796
|
+
* byte-identical observable: no event, ever, for that path.
|
|
797
|
+
*
|
|
798
|
+
* ## Why this shape, and not a narrower one
|
|
799
|
+
*
|
|
800
|
+
* ⚠️ The six are indistinguishable at the point of failure, so **any fix
|
|
801
|
+
* that has to name which gate fired is a fix for one member of a family** —
|
|
802
|
+
* which is exactly how #7282 was closed and exactly why it reopened. This
|
|
803
|
+
* sweep never asks. It compares what is on disk against `heads`, the index
|
|
804
|
+
* that already defines what this repository believes it holds, and publishes
|
|
805
|
+
* the divergence through the same `handleFsChange` the watcher feeds. It is
|
|
806
|
+
* therefore robust across all six *by construction*, and equally across a
|
|
807
|
+
* seventh nobody has found: the only property it relies on is that the bytes
|
|
808
|
+
* on disk stopped matching the index.
|
|
809
|
+
*
|
|
810
|
+
* `put()` is unaffected and keeps its direct registration (`trackWrittenPath`
|
|
811
|
+
* calls `watcher.add` and bypasses the whole chain, which is why the `put()`
|
|
812
|
+
* half of this family was already closed by #7336 and the external-write half
|
|
813
|
+
* was not).
|
|
814
|
+
*
|
|
815
|
+
* ## Cost, and why it is bounded
|
|
816
|
+
*
|
|
817
|
+
* One pass over `<root>/<type>/*.json` per sweep — the same walk `start()`
|
|
818
|
+
* already performs once — with no retry loop inside it and no work at all
|
|
819
|
+
* when nothing diverged. Sweeps are chained, so they cannot overlap; the
|
|
820
|
+
* timer is `unref`ed and dies with `close()`; and it is armed only alongside
|
|
821
|
+
* the watcher, so a `disableWatch` repository pays nothing.
|
|
822
|
+
*
|
|
823
|
+
* Discovery is by content, never by stat: a stat pre-filter would reintroduce
|
|
824
|
+
* a time key of exactly the kind this replaces.
|
|
825
|
+
*/
|
|
826
|
+
async resync() {
|
|
827
|
+
const root = this.layout.root;
|
|
828
|
+
let entries = [];
|
|
829
|
+
try {
|
|
830
|
+
entries = await import_promises2.default.readdir(root, { withFileTypes: true });
|
|
831
|
+
this.clearResyncFault(root);
|
|
832
|
+
} catch (err) {
|
|
833
|
+
if (!isEnoent(err)) this.reportResyncFault(root, err);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const onDisk = /* @__PURE__ */ new Set();
|
|
837
|
+
const unreadableTypes = /* @__PURE__ */ new Set();
|
|
838
|
+
for (const entry of entries) {
|
|
839
|
+
if (!entry.isDirectory()) continue;
|
|
840
|
+
if (entry.name.startsWith(".")) continue;
|
|
841
|
+
const dir = import_node_path3.default.join(root, entry.name);
|
|
842
|
+
let files = [];
|
|
843
|
+
try {
|
|
844
|
+
files = await import_promises2.default.readdir(dir);
|
|
845
|
+
this.clearResyncFault(dir);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
if (!isEnoent(err)) {
|
|
848
|
+
this.reportResyncFault(dir, err);
|
|
849
|
+
unreadableTypes.add(entry.name);
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
for (const file of files) {
|
|
854
|
+
if (!file.endsWith(".json") || file.startsWith(".")) continue;
|
|
855
|
+
const abs = import_node_path3.default.join(dir, file);
|
|
856
|
+
const parsed = parseItemPath(this.layout, abs);
|
|
857
|
+
if (!parsed) continue;
|
|
858
|
+
const ref = {
|
|
859
|
+
org: this.org,
|
|
860
|
+
type: parsed.type,
|
|
861
|
+
name: parsed.name
|
|
862
|
+
};
|
|
863
|
+
const key = (0, import_metadata_core.refKey)(ref);
|
|
864
|
+
onDisk.add(key);
|
|
865
|
+
const before = this.heads.get(key);
|
|
866
|
+
await this.handleFsChange(abs, "add");
|
|
867
|
+
if (this.heads.get(key) !== before) {
|
|
868
|
+
this.trackWrittenPath(abs);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
for (const key of [...this.heads.keys()]) {
|
|
873
|
+
if (onDisk.has(key)) continue;
|
|
874
|
+
const ref = parseRefKey(key);
|
|
875
|
+
if (!ref) continue;
|
|
876
|
+
if (unreadableTypes.has(ref.type)) continue;
|
|
877
|
+
const file = itemPath(this.layout, ref.type, ref.name);
|
|
878
|
+
await this.mutex.run(key, async () => {
|
|
879
|
+
if ((0, import_node_fs2.existsSync)(file)) return;
|
|
880
|
+
await this.publishExternalDelete(ref, key);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
594
883
|
}
|
|
595
884
|
/**
|
|
596
885
|
* Translate a watcher event into a `MetadataEvent`, or drop it.
|
|
@@ -651,22 +940,7 @@ var FileSystemRepository = class {
|
|
|
651
940
|
const key = (0, import_metadata_core.refKey)(ref);
|
|
652
941
|
await this.mutex.run(key, async () => {
|
|
653
942
|
if (kind === "unlink") {
|
|
654
|
-
|
|
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);
|
|
943
|
+
await this.publishExternalDelete(ref, key);
|
|
670
944
|
return;
|
|
671
945
|
}
|
|
672
946
|
const body = await readJson(absPath);
|