@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 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
- 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);
943
+ await this.publishExternalDelete(ref, key);
670
944
  return;
671
945
  }
672
946
  const body = await readJson(absPath);