@objectstack/metadata-fs 17.1.0 → 17.3.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,191 @@
1
1
  # @objectstack/metadata-fs
2
2
 
3
+ ## 17.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - e7191ce: fix(build): give each `exports` condition its own `types` target in the 28 dual-build packages (#13112)
8
+
9
+ **Published-surface change, zero runtime change.** No emitted byte moves; what
10
+ moves is which declaration file a resolver READS. Maintainer ruling 2026-08-29
11
+ (decision batch #3, verbatim 「同意」) chose declaring the files over deleting
12
+ them.
13
+
14
+ ## What was wrong
15
+
16
+ These 28 packages are `"type": "module"` and dual-built, and each spelled one
17
+ `types` condition as a **sibling** of `import`/`require`:
18
+
19
+ ```json
20
+ "exports": { ".": {
21
+ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs"
22
+ } }
23
+ ```
24
+
25
+ A sibling `types` answers for **both** conditions, so a CommonJS consumer was
26
+ handed `dist/index.d.ts` — an ES-module declaration, because the package is
27
+ `"type": "module"` — for an entry point it reaches with `require`. Measured with
28
+ `tsc --traceResolution` on a `"type": "commonjs"` fixture at `moduleResolution:
29
+ node16`:
30
+
31
+ ```
32
+ error TS1479: The current file is a CommonJS module whose imports will produce
33
+ 'require' calls; however, the referenced file is an ECMAScript module and cannot
34
+ be imported with 'require'.
35
+ ```
36
+
37
+ The JavaScript at `dist/index.cjs` loads perfectly (`check:dual-build-cjs-loads`
38
+ has asserted that for months). It is the **types** that told the consumer the
39
+ supported `require` entry point could not be required. The `dist/index.d.cts`
40
+ twin tsup emits beside it — 36 files, 5,517,701 B on this build — was named by
41
+ no condition at all and shipped in every tarball unreachable.
42
+
43
+ ## What changed
44
+
45
+ Each condition now names its own declaration, the shape TypeScript documents:
46
+
47
+ ```json
48
+ "exports": { ".": {
49
+ "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
50
+ "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
51
+ } }
52
+ ```
53
+
54
+ 33 entry points across 27 packages, subpaths included. The root `types` field is
55
+ untouched, so `node10` resolvers are unaffected; the `import` condition resolves
56
+ exactly what it resolved before, measured as an unchanged control in the same
57
+ run.
58
+
59
+ ## `@objectstack/core` is deliberately NOT changed
60
+
61
+ Splitting a declaration in two makes TypeScript compare it nominally, and
62
+ `ObjectKernel` carries a `private plugins` member that reaches every plugin
63
+ through `PluginContext.getKernel()`. With core split, whole-repo `pnpm build`
64
+ fails in `@objectstack/verify` with 5 × TS2345 ("Types have separate
65
+ declarations of a private property 'plugins'"); with core held back and the
66
+ other 27 split, 71/71 tasks pass. So core keeps the sibling-`types` shape and
67
+ its two `.d.cts` files (220,854 B) stay unreachable, declared as such in
68
+ `check:dual-build-cjs-loads`. Splitting it needs a decision about core's public
69
+ types, not about an exports map.
70
+
71
+ ## For consumers
72
+
73
+ - **ESM consumers: nothing changes.** Same declaration file, byte for byte.
74
+ - **CJS consumers under `node16`/`nodenext`: TS1479 goes away** and the
75
+ declarations they get are the ones built for CommonJS.
76
+ - **`node10` / `moduleResolution: node` consumers: nothing changes** — they never
77
+ read `exports`.
78
+ - Nothing is removed: every path that resolved before still resolves.
79
+
80
+ Packages that are CJS-first (`require` → `./dist/index.js`, no `"type": "module"`)
81
+ were already correct and are untouched — their `dist/index.d.ts` really is the
82
+ CommonJS declaration. Their ESM mirror (an unreachable `.d.mts` under the
83
+ `import` condition) is a separate, larger population and is filed separately per
84
+ the ruling, not fixed here.
85
+
86
+ `check:dual-build-cjs-loads` grew a fourth invariant (TYPED) that reds on the old
87
+ shape, so the drift cannot return silently.
88
+ - a07a831: fix(metadata-fs): declare `startWatcher()`'s chokidar `atomic` option explicitly (#12696)
89
+
90
+ `FileSystemRepository.startWatcher()` constructed its chokidar watcher with
91
+ `usePolling: true` but never passed `atomic`, leaving it to inherit chokidar's
92
+ default. That default is unconditionally `true` in the installed version
93
+ (chokidar 5.0.0): the defaults literal assigns `atomic: true` *before* the
94
+ caller's options are spread in, so chokidar's own default-correction
95
+ (`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can never
96
+ fire — it only runs when `atomic` is literally `undefined` after the merge,
97
+ which it never is. The comment beside that correction ("Editor atomic write
98
+ normalization enabled by default with fs.watch") reads as "off under
99
+ polling"; the actual resolved behaviour was on regardless.
100
+
101
+ This change passes `atomic: true` explicitly at the call site, with a comment
102
+ explaining why. **Patch, not a behaviour change**: verified at runtime
103
+ (constructing a watcher the way `startWatcher()` does and reading back
104
+ `watcher.options.atomic`) that the resolved value is identical before and
105
+ after — `true` either way, today. The only thing that changes is that the
106
+ value is now DECLARED rather than inherited from an upstream branch that
107
+ cannot execute, so a future chokidar release that fixes the ordering (making
108
+ the correction real) cannot silently flip this repository's watcher to
109
+ `atomic: false` under polling and change behaviour with no diff to review.
110
+
111
+ Not addressed here (see #12696): whether `atomic: true` (the 100ms
112
+ unlink-coalescing deferral and the `DOT_RE` editor-temp-file matcher it turns
113
+ on) is actually the right value. No evidence surfaced that either has ever
114
+ affected a run; flipping it to `false` is a deliberate behaviour change to a
115
+ live delivery path that needs its own reverse verification, and is out of
116
+ scope for this card.
117
+ - 3e8f5b0: The file watcher no longer publishes a `delete` for an item that is still on disk.
118
+
119
+ A watcher `unlink` is a claim of absence, not absence: chokidar reaches its removal path from failed stats as well as from real removals, so under filesystem pressure it can retire a file that is still there. `FileSystemRepository` published those claims straight through as `delete` events — appended to the change log and broadcast to every subscriber, which drops the item from the metadata registry and the `list()` cache — and the reconciliation sweep then republished the untouched file as a `create`. A failed stat therefore produced a durable delete/create pair for an item nobody removed, with a window in between where live metadata had disappeared.
120
+
121
+ The removal face now confirms the absence against the disk under the same per-key lock the reconciliation sweep already used for this, and an `unlink` for a path that still exists falls through to the content comparison — so a spurious unlink that arrived alongside a real external edit surfaces as the `update` it always was. Genuine external removals are unaffected and are still published on the first delivery.
122
+ - Updated dependencies [54e2d36]
123
+ - Updated dependencies [b745157]
124
+ - Updated dependencies [d23ebb9]
125
+ - Updated dependencies [fa5d137]
126
+ - Updated dependencies [e7191ce]
127
+ - Updated dependencies [0fd4899]
128
+ - Updated dependencies [00d8f65]
129
+ - Updated dependencies [200d255]
130
+ - Updated dependencies [2852acc]
131
+ - Updated dependencies [15d55fb]
132
+ - Updated dependencies [15eb2c9]
133
+ - Updated dependencies [1272f0a]
134
+ - Updated dependencies [d41d166]
135
+ - Updated dependencies [5d16379]
136
+ - @objectstack/metadata-core@17.3.0
137
+
138
+ ## 17.2.0
139
+
140
+ ### Patch Changes
141
+
142
+ - 46644e2: `FileSystemRepository.close()` now terminates every live `watch()` iterator
143
+ instead of leaving it parked (#11127). A consumer holding a `for await` over
144
+ `watch()` at shutdown never saw its loop end — on a repository that was already
145
+ gone.
146
+
147
+ `close()` retired the chokidar watcher and the resync sweep and stopped there.
148
+ It never reached the event broker, and the broker had no teardown of its own:
149
+ `subscribe`/`unsubscribe` add to and delete from a plain `Set`, and nothing else
150
+ emptied it. Each iterator parks its pending `next()` on a `waiter` that only two
151
+ things can settle — a broker `push`, or the iterator's own terminator, which ran
152
+ from `iterator.return()`/`throw()` and from nowhere else. After `close()` the
153
+ chokidar source was gone so no `push` could arrive, and the subscriber was still
154
+ registered with nothing left to run its terminator.
155
+
156
+ Unlike the sibling defect in `SysMetadataRepository` (#11021) this was not
157
+ filter-dependent: there was no drain attempt at all, so every subscription shape
158
+ hung, `watch({})` included. Measured before the fix: nine cases —
159
+ `watch({org}, seq)`, `watch({org})`, `watch({})`, a ref-exact filter, a watcher
160
+ over the real chokidar watcher, a watcher with no pull outstanding, four
161
+ concurrent watchers, and the `return()`-symmetry comparison — were all still
162
+ unsettled 2s after `close()`. `MetadataManager.startRepositoryWatch()`, which
163
+ awaits `iter.next()` in a loop, is exactly the shape that hung.
164
+
165
+ The broker now holds each subscription's terminator next to its event sink, and
166
+ `close()` runs every terminator — the same routine the consumer's own
167
+ `iterator.return()` runs, so a parked `next()` settles with `{ done: true }` and
168
+ no value, and so does every later one. Shutdown is deliberately **not** delivered
169
+ as an event: a synthetic drain event is subject to the very filters `watch()`
170
+ applies to real ones, and delivering an event has never ended an iterator
171
+ (invariant 8, `@objectstack/metadata-core`'s `repository.ts`).
172
+
173
+ One narrower path is closed with it. `watch()` returns a deferred iterable whose
174
+ subscriber registers only once the eager log read resolves, so a `close()`
175
+ landing inside that window swept a broker the subscription had not yet joined —
176
+ the same forever-parked shape by a different route. `watch()` now carries the
177
+ close generation it was opened under, and a subscription that arrives after a
178
+ shutdown terminates on arrival.
179
+
180
+ Invariant 8 named `FileSystemRepository` as its one known non-conformance. With
181
+ this change the invariant has no declared exceptions, and its text says so.
182
+ - Updated dependencies [8cc8401]
183
+ - Updated dependencies [26f3588]
184
+ - Updated dependencies [05bc692]
185
+ - Updated dependencies [f334d66]
186
+ - Updated dependencies [2810695]
187
+ - @objectstack/metadata-core@17.2.0
188
+
3
189
  ## 17.1.0
4
190
 
5
191
  ### 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 });
@@ -257,6 +273,15 @@ var FileSystemRepository = class {
257
273
  * the first degradation). An entry is cleared when that path reads again.
258
274
  */
259
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;
260
285
  this.org = opts.org;
261
286
  this.fsActor = opts.fsActor ?? "fs";
262
287
  this.disableWatch = opts.disableWatch ?? false;
@@ -302,8 +327,35 @@ var FileSystemRepository = class {
302
327
  await import_promises2.default.mkdir(this.layout.root, { recursive: true });
303
328
  if (this.started && !this.disableWatch && !this.watcher) this.startWatcher();
304
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
+ */
305
355
  async close() {
306
356
  this.stopResync();
357
+ this.closeGeneration++;
358
+ this.broker.terminateAll();
307
359
  if (this.watcher) {
308
360
  await this.watcher.close();
309
361
  this.watcher = null;
@@ -378,6 +430,7 @@ var FileSystemRepository = class {
378
430
  if (matchEvent(evt, filter)) replay.push(evt);
379
431
  }
380
432
  })();
433
+ const generation = this.closeGeneration;
381
434
  return deferredIterable(promise.then(
382
435
  () => createWatchIterable({
383
436
  filter,
@@ -385,7 +438,8 @@ var FileSystemRepository = class {
385
438
  replay,
386
439
  broker: this.broker,
387
440
  matches: matchEvent,
388
- branchKeyOf: (e) => e.ref.org
441
+ branchKeyOf: (e) => e.ref.org,
442
+ arrivesClosed: () => this.closeGeneration !== generation
389
443
  })
390
444
  ));
391
445
  }
@@ -598,7 +652,24 @@ var FileSystemRepository = class {
598
652
  // the entire customization tree.
599
653
  usePolling: true,
600
654
  interval: 1e3,
601
- binaryInterval: 2e3
655
+ binaryInterval: 2e3,
656
+ // Declared explicitly, not inherited. chokidar's own default-correction
657
+ // (`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can
658
+ // only fire when the caller omits `atomic`, but its defaults literal
659
+ // already assigns `atomic: true` *before* the caller's options are
660
+ // spread in — so leaving `atomic` unset here does not mean "off under
661
+ // polling" the way the correction's own comment claims, it silently
662
+ // resolves to `true` regardless of `usePolling`. That has been this
663
+ // repository's actual runtime behaviour all along (verified by reading
664
+ // back the resolved option from a real watcher instance, #12696): every
665
+ // `unlink` gets chokidar's 100ms editor-atomic-write deferral, and
666
+ // `DOT_RE` (vim swap files, `~`, sublime tmp) is folded into
667
+ // `_isIgnored` on top of this repository's own `isIgnoredWatchPath`
668
+ // (#7150). `atomic: true` here keeps that behaviour byte-for-byte —
669
+ // this is a declaration, not a change. Flipping it to `false` would
670
+ // remove both behaviours from a live delivery path and needs its own
671
+ // reverse verification; see #12696 for the analysis.
672
+ atomic: true
602
673
  });
603
674
  w.on("add", (p) => void this.handleFsChange(p, "add"));
604
675
  w.on("change", (p) => void this.handleFsChange(p, "change"));
@@ -864,9 +935,36 @@ var FileSystemRepository = class {
864
935
  * - `unlink` — `!currentHead` drops the event when the index already
865
936
  * agrees the item is gone. `delete()` retires the head *before* it
866
937
  * unlinks, precisely because this face gets no `awaitWriteFinish` delay.
938
+ * This is the whole of the *self-write* answer on this face, and it is
939
+ * not the whole of the face — see the section below it.
867
940
  *
868
941
  * Both faces are pinned together in `test/self-write-suppression.test.ts`.
869
942
  *
943
+ * ## A removal is confirmed against the disk before it is published (#7369)
944
+ *
945
+ * Those two checks answer "is this event OURS". Neither answers "did this
946
+ * happen at all", and the `unlink` face needs that second question asked
947
+ * because its input is a third party's inference: chokidar decides a file is
948
+ * gone from a *stat that failed*, not only from a file that went away, and
949
+ * `!currentHead` cannot tell the two apart because a spurious unlink leaves
950
+ * the index exactly as valid as it was.
951
+ *
952
+ * The cost of getting it wrong is not a dropped notification, which the
953
+ * sweep would repair. A `delete` is appended to the change log and broadcast
954
+ * to every subscriber, and `MetadataManager` drops the item from the
955
+ * registry and the `list()` cache on receipt. The sweep then finds the file
956
+ * still on disk and republishes it as a `create` — so a failed stat becomes
957
+ * a durable, permanently recorded delete/create pair for an item that never
958
+ * changed, and every consumer sees the item disappear in between. That is
959
+ * the shape ADR-0008's log is least able to walk back.
960
+ *
961
+ * So `existsSync` under the same per-key lock the sweep uses, and the same
962
+ * decision it makes: absent ⇒ publish the removal; present ⇒ this was a
963
+ * change, answered by the content path below. Genuine removals pay nothing —
964
+ * `delete()` is still suppressed by `!currentHead`, and an external `rm` is
965
+ * still published on the first delivery, because for those the file really
966
+ * is gone. Pinned in `test/external-delete-requires-absence.test.ts`.
967
+ *
870
968
  * Note the deliberate limit: identity is judged on what round-trips through
871
969
  * the file, so a spec whose in-memory form does not (a `Date`, which
872
970
  * canonicalises to `{}` in memory but to an ISO string once written and
@@ -885,7 +983,7 @@ var FileSystemRepository = class {
885
983
  };
886
984
  const key = (0, import_metadata_core.refKey)(ref);
887
985
  await this.mutex.run(key, async () => {
888
- if (kind === "unlink") {
986
+ if (kind === "unlink" && !(0, import_node_fs2.existsSync)(absPath)) {
889
987
  await this.publishExternalDelete(ref, key);
890
988
  return;
891
989
  }