Repository navigation
fix(metadata-protocol): close() terminates watch iterators instead of emitting a drain event - #11136
Conversation
… emitting a drain event (#11021) `SysMetadataRepository.close()` modelled shutdown as a metadata event — `{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }` broadcast through the same dispatch closure real events pass, then `watchers.clear()`. Both of that closure's guards reject it: `matchesFilter` drops it for any subscription naming an org (the synthetic ref's org is the empty string), a type other than `view`, or a name; and `evt.seq <= since` holds for -1 against every real seq. Dropped and then unsubscribed, nothing could settle the parked promise and the consumer's `for await` never returned. The subscriptions that passed both guards were no better off: they received the synthetic event as a real one — a `view` named `_close`, deleted, at seq -1, which MetadataManager turns into a cache invalidation and re-emits to Studio's HMR stream — and hung on the next pull anyway, because delivering an event does not end an iterator. The watcher registry now holds each subscription's terminator next to its event sink, and `close()` runs the terminator — the same routine `iterator.return()` runs. Invariant 8 in metadata-core's repository.ts states the contract that was unstated, and records FileSystemRepository's non-conformance (#11127). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e4287723823872204fbdea54646d09489f6e68f6 && git checkout e4287723823872204fbdea54646d09489f6e68f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin afe1c4e0a1794a56817acbfd5f4f6e957ea023ce 2314ff6f97e00a893091215c8cc83b745875b228 && git checkout -B drift-repro afe1c4e0a1794a56817acbfd5f4f6e957ea023ce && git merge --no-ff 2314ff6f97e00a893091215c8cc83b745875b228
node scripts/docs-audit/affected-docs.mjs --json afe1c4e0a1794a56817acbfd5f4f6e957ea023ce
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32596788489 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
… that decided them (stage 13 of objectstack-ai#20595) (objectstack-ai#21640) Part of objectstack-ai#20595 Clause-②: no ## What changed Stage 13 of the `domain:engine` lane of the dead-citation sweep: `packages/metadata-fs/**`, comment and docblock prose only, per the claim (`5973452558`). Stages 1 to 11 landed as `a7d9768ec`, `d150c3039`, `4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`, `48fa7a381`, `c205b6c35`, `c98a72d69`, `fd5a1cd59` and `f97660cdd`; stage 12 (`packages/formula`) is PR objectstack-ai#21635, in review, with a disjoint file surface. objectstack-ai#20595 stays open: this PR does not touch `formula`, nor the test-string sites the card carries for a widened stage, and the seat decides the card's close-out. Every comment or docblock site in the package that cited a tracker number answering 404 is rewritten in ruling C+D's form C (record `5749154545` on objectstack-ai#19123): the ADR when one records the decision, otherwise the commit in this repository's history that made it. That is **3 sites on 3 lines in 3 files, all citing one number (objectstack-ai#11021), all re-anchored to one commit, `7d81c889f`**: - **2 census sites** (2 lines: `src/repository.ts:230` and `src/sync.ts:41`): the whole `allocated-but-absent` population of the gate's own census in this package at the base; - **1 test-comment site** (`test/close-terminates-watch.test.ts:103`), outside the census glob (`test/` is not under `src/`, and the census defers test files anyway). Same number; - **outside the census glob, inside the claimed surface: none.** `README.md`, `tsconfig.test.json` and `vitest.config.ts` carry 10 citations between them, and all 10 resolve on the enumerated board (below); `package.json`, `tsconfig.json` and `tsup.config.ts` carry none; - **dead comment ids: none.** The package carries no ten-digit comment id and no `issuecomment` or `discussion_r` link (git grep exit 1). **Anchors: 1 number, by commit; 0 by ADR, 0 by repository qualifier; 1 sha.** `7d81c889f` is the anchor stage 1 (`a7d9768ec`) and stage 9 (`c98a72d69`) already chose for the same number; it is reused here and re-proven below for this package's sentences. Only comments changed. All three files keep their line counts (3 lines out, 3 in, plus the changeset), so no line citation into any of them moves. No code token moves (the guard below). All 6 changed lines under `packages/` open with a comment marker. **No citation number is added**: the only tracker number on a `+` line is `objectstack-ai#11127` on `sync.ts:41`, carried over unchanged from the `-` line, and it resolves (a closed issue). The only new nine-hex span is `7d81c889f`, 3 times. **A `patch` changeset**: 1 of the 2 rewritten non-test lines is in the published `dist` (the `FileSystemRepository.close()` docblock, in the declarations and in the JavaScript esbuild emits), and `dist` is not byte-identical with the base text (see Changeset). ## H0: the package and its size The gate's own `node scripts/check-issue-citations.mjs --census --json` at base `0fc80878f` (the before run below), `allocated-but-absent` per remaining `domain:engine` package (stage 12's H0 list): | package | before | after this stage | |---|---|---| | `metadata-fs` | **2** | **0** | | `formula` | 4 | 4 (removed by stage 12, PR objectstack-ai#21635, not yet on `main`) | | `drivers/driver-mongodb`, `drivers/driver-turso`, `metadata-core`, `core`, `metadata-protocol`, `objectql`, `metadata`, `drivers/driver-sql`, `drivers/driver-memory`, `drivers/driver-sqlite-wasm`, `plugins/plugin-pinyin-search`, `platform-objects` | 0 each | 0 each | `metadata-fs` reads 2, both objectstack-ai#11021, at `repository.ts:230` and `sync.ts:41`, exactly as stage 12's head census (`b89eb86cb`) read it. So the stage went ahead. On this branch's tree the lane total goes 6 to 4; with stage 12's PR it is 0. ## Census: `metadata-fs`, before and after **Instrument.** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count is its `allocated-but-absent` findings under `packages/metadata-fs/`. | reading | tree | board | whole-repo `allocated-but-absent` | sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `0fc80878f`, run 21:04:24Z to 21:07:44Z | enumerated, 195 pages, frontier objectstack-ai#21636, 19,457 records | 119 | **2** | 2 | 2 | 1 | | after | `67f182575`, run 21:14:26Z to 21:17:43Z | enumerated, 195 pages, frontier objectstack-ai#21637, 19,458 records | 117 | **0** | 0 | 0 | 0 | | head | `32de59091`, run 21:37:42Z to 21:40:56Z | enumerated, 195 pages, frontier objectstack-ai#21639, 19,460 records | 117 | **0** | 0 | 0 | 0 | The whole-repo drop is 2, and the before and after finding sets differ by exactly the 2 rows of this package, removed; none was added. `resolves` (35,763), `resolves-as-pull-request` (2,383) and `cross-repo-unjudged` (1,254) did not move: `objectstack-ai#11127` stays on `sync.ts:41` and still resolves. The head's only later commit is the changeset; the head run's finding set is identical to the after run's, line numbers included. **Supplementary instrument, the whole package.** The census reads neither test files nor strings nor files outside `src`. A second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) over every tracked file in the package (24) and classifies each citation with the gate's `classifyCitation` against one board enumerated by the gate's `enumerateBoard` (195 pages, frontier objectstack-ai#21636, 19,457 records, read from 21:09:16Z to 21:12:31Z), the same board for both readings. | reading | citations | dead | src comment | test comment | test string | other files | changelog | |---|---|---|---|---|---|---|---| | before, `0fc80878f` | 137 | **5** | 2 | 1 | 0 | 0 | 2 | | after, `67f182575` | 134 | **2** | 0 | 0 | 0 | 0 | 2 | The citation count drops by 3, the 3 rewritten sites; no respelling stays a citation. The live counts did not move (src comment: 39 resolve as issues, 2 as pull requests; test comment: 44 and 9; test string: 10 and 0; files outside `src` and `test`: 8 and 2; changelog: 16 and 2). A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it, `CHANGELOG.md` aside) counts 117 before and 114 after: also a drop of 3. **Single reads** over the issues endpoint: objectstack-ai#11021 answers 404; objectstack-ai#11127 answers 200 (a closed issue: `FileSystemRepository.close()` never reaching its broker); objectstack-ai#11136 answers 200 (the pull request whose squash is `7d81c889f`). ## Per-number table `src` counts census sites, `test` the test-comment sites. | number | src | test | anchor | kind | source | what it decided | |---|---|---|---|---|---|---| | `objectstack-ai#11021` | 2 | 1 | `7d81c889f` | commit | reused (stage 1 `a7d9768ec`, stage 9 `c98a72d69`), re-proven here | `close()` terminates watch iterators instead of emitting a drain event: it measured why a synthetic drain event is the wrong shape (the filter and the numeric `since` drop it, and delivering an event never ends an iterator), fixed that defect in `SysMetadataRepository`, and wrote invariant 8 in `metadata-core`'s `repository.ts` (the squash of PR objectstack-ai#11136) | Each sentence, judged against the decision it describes rather than the number: - **`repository.ts:230`** says a synthetic drain event 「would be the wrong shape and was measured to be so」. The measurement is `7d81c889f`'s: its message and its invariant 8 text record both halves (the filters drop it; delivery never ends an iterator). The `metadata-fs` fix that wrote this docblock, `46644e25a` (objectstack-ai#11127), cites objectstack-ai#11021 as that earlier decision. - **`sync.ts:41`** sends the reader to invariant 8 in `metadata-core`'s `repository.ts` and names the pair `(objectstack-ai#11021, objectstack-ai#11127)`. `git blame` at the base puts invariant 8's statement (`repository.ts` :53 to :76) on `7d81c889f` (its line :63 since re-anchored by stage 9), and its conformance rows for `FileSystemRepository` and `InMemoryRepository` (:77, :79 to :88) on `46644e25a`, the objectstack-ai#11127 fix. So the dead half of the pair becomes `commit 7d81c88`, and the live `objectstack-ai#11127` stays. - **`close-terminates-watch.test.ts:103`** contrasts this package's hang with 「the sibling defect」: the filter-dependent drain-event drop in `SysMetadataRepository`, which is the defect `7d81c889f` fixed. The proof, per the earlier stages' standard: - `git rev-parse --disambiguate=7d81c889f` matches exactly one commit, `7d81c889f1f190a273e59ee584b7522ca6a792fa`. - `git merge-base --is-ancestor` puts it under the base `0fc80878f`, under `origin/main` `a1ca156da` at the time, and under `main` `5b5e83f44` fetched later (exit 0 each; exit 0 is self-proving, and the repository is not shallow). - It names objectstack-ai#11021 in its message (the subject) and in its diff (7 diff lines). - `git blame` at the base puts all 3 changed lines on `46644e25a`, which cites objectstack-ai#11021 as the sibling decision; that decision is `7d81c889f`'s diff, as the three bullets above set out. - No ADR names objectstack-ai#11021 or the drain-event decision (git grep over `docs/adr` for the number, 「drain event」 and 「Shutdown terminates」, exit 1). ## Wordings to check All 3 rewrites swap a tag in place, in forms the earlier stages already use: - `(objectstack-ai#11021)` became `(commit 7d81c88)` at `repository.ts:230` (stage 1's `(#N)` form). - `(objectstack-ai#11021, objectstack-ai#11127)` became `(commit 7d81c88, objectstack-ai#11127)` at `sync.ts:41`. The mixed commit-and-issue pair already stands in `packages/cli` (`(commit 44813ba, objectstack-ai#14554)`). - 「unlike the sibling defect in objectstack-ai#11021」 became 「unlike the sibling defect commit 7d81c88 fixed」 at `close-terminates-watch.test.ts:103`, the form `packages/cli/src/commands/generate-multiple-json-column.pin.test.ts:57` uses (「the defect commit ee370d3 fixed」). **No reflow.** No line was reflowed, so `repository.ts:230` and `close-terminates-watch.test.ts:103` are now longer than their block's wrap. `eslint.config.mjs` declares no line-length rule, and a reflow would move neighbouring lines. No file cites a line of any of the three touched files (git grep exit 1), and neither changed phrase is quoted elsewhere. ## Sites left - **In comments (src, test, outside the glob): none.** - **String literals: none.** The package's 10 test-string citations all resolve. - **Outside `src` and `test`:** the release-owned `CHANGELOG.md` names objectstack-ai#13112 (line 47) and objectstack-ai#11021 (line 196), both answering 404; left. ## Mechanical guard: no code token moves The guard compares base `0fc80878f` against the tree over all three touched files, with TypeScript 6.0.3, to the earlier stages' two-reading specification. The earlier guard scripts were scratch files, so it was rewritten here to that specification and proven with the controls below. - **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk. Comments are trivia there, and JSDoc is never visited. A leaf that is not itself a token is re-scanned with trivia skipped. - **Reading 2**: the full token stream in parser context, from a `getChildren` walk, with JSDoc nodes skipped. String, template and numeric literals are compared in full on both readings. Results, at `67f182575` (the later commit touches none of the three files): - Real run: 6,075 base tokens (reading 2), **0 files with a token change** (exit 0). - Comment controls: 「awaiting the watcher,」 to 「… the WATCHER,」 (`repository.ts`), 「Best-effort cleanup:」 to 「Best-effort CLEANUP:」 (`sync.ts`) and 「bites on its own.」 to 「bites ON its own.」 (the test file). 0 files changed (exit 0 each). - Positive control, an identifier (`export function createBroker(` to `createBrokerX(`, `sync.ts`): DIFFER on both readings (exit 1). - Positive control, a string literal (the word 「no」 in the 「empty filter, no since」 row label, to 「NO」, the test file): DIFFER on both readings (exit 1). - Positive control, a template literal (the `@` separator in the sweep-failure key template, to `#`, `repository.ts`): DIFFER on both readings (exit 1). - Positive control, a numeric literal (`SETTLE_MS = 2_000` to `2_001`, the test file): DIFFER on both readings (exit 1). Each mutation went through `scripts/ablation-replace.mjs` (wrap mode; the anchor hit 1 before and 0 after, and the blob changed). It ran under a shell trap that restores by absolute path from `HEAD`. Each restore was proven equal to its `HEAD` blob (`888f39203432`, `122a45dcb516`, `0d266189d60c`), and afterwards `git diff HEAD` was empty and the tree clean. ## Changeset: `patch` (`dist` measured) `files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is not private. One script ran under the shared verify lock (VERDICT command-exit 0, held 79s, shared-box seconds), at `67f182575`. It built the dependency closure first (`pnpm --workspace-concurrency=2 --filter '@objectstack/metadata-fs^...' build`, exit 0), then ran the package's own `build` (tsup and `check-dts-emitted`) three times, exit 0 each: - **Leg 1**, the head text: 6 `dist` files hashed (`index.js`, `index.cjs`, their sourcemaps, `index.d.ts`, `index.d.cts`). 1 of the 2 rewritten non-test lines appears verbatim in `dist`: the `close()` docblock line (`repository.ts:230`), in `index.d.ts`, `index.d.cts`, `index.js` and `index.cjs` (esbuild keeps that docblock). The other (`sync.ts:41`) sits on an interface that is erased from the JavaScript and never reaches the declarations (grep exit 1). `scripts/ablation-dist-preflight.mjs` finds the head marker 「was measured to be so (commit 7d81c88)」 in those 4 files with a clean tree (exit 0). - **Leg 2**, the base text put back in `repository.ts` and `sync.ts` (proven equal to their base blobs `d551426a7545` and `48798bcf7661`, written to the tree only, 0 paths staged): 4 of the 6 files differ from leg 1 (`index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`); the two sourcemaps do not. The preflight finds the base marker 「was measured to be so (objectstack-ai#11021)」 in the same 4 files (exit 0). - **Leg 3**, after the proven restore (equal to the `HEAD` blobs `888f39203432` and `122a45dcb516`, `git diff HEAD` empty, porcelain empty): all 6 files are byte-identical to leg 1. The preflight's `--absent` reading of the base marker exits 0 with a clean tree. So the build is deterministic, and the difference is the rewrite. So the rewrite ships. `.changeset/20595-metadata-fs-provenance-anchors.md` declares a `patch` for `@objectstack/metadata-fs`, comment text only, with the claim's `Clause-②: no` line. The anchor is a commit, so the changeset names no ADR, repository qualifier or bracketed substitution. It says which published files carry the reworded text, as measured above. The changeset commit touches no file under `packages/metadata-fs`. ## Gates (head `32de59091`) - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `32de59091` (4 paths against merge base `0fc80878f`) derived 61 commands. All 61 ran (21:25:07Z to 21:36:22Z, after the workspace build), each exit code captured before any pipe: 61 exit 0. `--ran` reports 「61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0. - The dispatch's lead derivation (47 commands, one path) is a subset. The extra 14 are: - the eight families the `.changeset/` path adds: the ADR-0087 registration and empty-changeset pairs, `check:objectui-changeset`, `check:pm-changeset-deadline-census` and two release self-tests; - `check:type-check-coverage` and `check:type-check-debt`; - four gates whose sources name the touched files: `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure` and `check:where-matcher`. - **Named readings:** - `node scripts/check-issue-citations.mjs` exits 0 (1 citation judged across 2 files: the carried-over `objectstack-ai#11127`, which resolves). - `pnpm check:issue-citations` exits 0 (its self-test, 173 cases, 9 batteries). - `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id baseline holds, no growth). - `pnpm check:nul-bytes` exits 0 (10,001 files, no raw control bytes), and a control-byte grep over the 4 changed files finds none (exit 1). - The changeset gates exit 0: `check-adr-0087-registration` (「1 non-breaking changeset(s) seen」), `check-empty-changeset` (「1 declaring changeset(s) added」), `check-changeset-no-major` (「no `major` bump」), and `check:changeset-gate-self-tests`. The Clause-② level axis of `check-changeset-no-major` reads the pull request body, so it does not apply to a local run; that reading is CI's. - **Build, tests and typecheck, under the verify lock**, at `32de59091`: - The workspace build (`turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`): VERDICT command-exit 0, held 198s, shared-box seconds; 71 of 71 tasks, 20 cached. - The tests and typecheck: held 33s. `pnpm --filter @objectstack/metadata-fs test`: 10 test files pass, 70 tests pass (exit 0). `pnpm --filter @objectstack/metadata-fs typecheck` (`tsc --noEmit` and `tsc --noEmit -p tsconfig.test.json`) exits 0. - `tsc --listFilesOnly` puts every touched file in a program: `repository.ts` and `sync.ts` in both configs, and `close-terminates-watch.test.ts` in `tsconfig.test.json`, whose program holds all 10 test files. - No importing package owes a run, because the declaration files change only in comment text. - **Lint, as a proven narrowing, at `32de59091`:** - eslint ran with inline config disabled (`--format json`) over the 3 touched files plus `dist/index.js` as the control. - 4 results: 0 errors, and 1 warning, which is the control's ignore notice. No touched file is reported ignored, and `--print-config` resolves a config for each. - `eslint.config.mjs` never enables type-aware linting (its lines 327 and 328 say so; `--print-config` shows no `parserOptions.project` and no `projectService`), so a comment edit cannot move the verdict on an untouched file. - The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **Base, and no merge.** The dispatch read `origin/main` at `0fc80878f`, and the worktree was cut there. Every reading above is on this branch's own tree. Before this PR was opened, `main` moved 4 commits, to `5b5e83f44` (`objectql`, `driver-sql` and `service-analytics`, `spec`). - None of them touches `packages/metadata-fs`, `check-issue-citations.mjs` or `dispatch-gates.mjs`. - A local `git merge-tree` of the head with `5b5e83f44` is clean (exit 0), and the anchor `7d81c889f` is under it too (`--is-ancestor`, exit 0). - The gate's diff mode, run in a scratch checkout of `5b5e83f44` against `0fc80878f`, judges the 26 citations those 4 commits add: all 26 resolve. So they add no dead citation to the lane's packages. - The branch is not merged with them: the gate derivation reads the three-dot change set against the merge base `0fc80878f`, and CI and the merge queue run on the merged ref. - **History.** The repository is not shallow (`git rev-parse --is-shallow-repository` answers false), so no deepening was needed before the blame, ancestry and history readings. - **The same dead numbers outside this package's comments**, each left to its own carrier: `CHANGELOG.md` (objectstack-ai#13112 and objectstack-ai#11021, release-owned). - **Wording only:** no line without the number was changed. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11021
SysMetadataRepository.close()modelled shutdown as a metadata event, and an event is exactly what a filtered or numeric-sincesubscriber is entitled to drop. It broadcast a synthetic{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }through the samedispatchclosure real events pass, then cleared the watcher registry. Both of that closure's guards reject it, and once the registry is cleared nothing else can settle the parked promise.The design fork, decided by measurement
Triage recommended shape 2 (
close()resolves pending iterators withdone: truedirectly) as a route to verify, with an explicit stop-condition: if a real consumer depends on receiving the synthetic drain event, shape 1 (exempt it from the filters) is the compatible answer instead. Nothing depends on receiving it:_closesentinelsys-metadata-repository.ts:1228. No consumer names it, counts it, or branches on it.MetadataManager.startRepositoryWatch()(repo.watch({}), the drained subscriber)applyRepoEvent(), which cannot tell it from a real one:invalidateForForeignWrite('view', '_close'), thennotifyWatchers()re-emits it downstream as adeletedviewnamed_closeatseq: -1— into the HMR SSE route and Studio's status badge. It is misread, not depended on. Its loop condition iswhile (!this.repoWatchClosed), which the event does not change, so receiving it does not end the loop either.MetadataCache.start()applyEvent()invalidates the cache key for a ref that never existed, then loops. Same shape.InMemoryRepository's iteratorclose()andmetadata-fs'screateWatchIterableclose()both settle the waiter with{ value: undefined, done: true }. There is no synthetic event anywhere else in the codebase.LayeredRepository.multiplexWatchdone: trueas "that layer finished" and ends when all children are done — shape 2 composes through it; shape 1 would forward the phantom_closeto a multiplexed consumer wearing a layer label.So shape 2, for the reason triage gave: giving shutdown a
seqof-1is what makes it collide with thesincecomparison in the first place.The matrix, measured before and after
Pinned in
sys-metadata-repository.contract.test.ts. "before" is the same three cases run against unmodifiedclose().watch({org:'system'}, a.seq)next()still unsettled 500ms afterclose(){ value: undefined, done: true }watch({org:'system'})— nosinceat all{ value: undefined, done: true }watch({}){ done: false, value: { seq: -1, op: 'delete', ref: { org: '', type: 'view', name: '_close' }, source: 'sys-metadata-repo-close' } }— and the next pull then hung{ value: undefined, done: true }, and so does every later pull⭐ The middle row is the one that proves the org-filter half bites on its own: no
sinceis involved, and it is the shapeMetadataCache.start()takes for any non-emptywatchFilter. A fix tested only against thesincehalf would look complete and leave it hanging.What changed
The watcher registry holds each subscription's terminator next to its event sink (
WatchSubscription), andclose()runs the terminator — the identical routineiterator.return()runs. A consumer that breaks its own loop and a consumer whose repository shut down under it now observe the same thing, so neither has to special-case the other.On the "both dispatch paths" instruction — measured, and the premise refines.
:1077-1078(inreplayFromHistory) and:1144-1145(indispatch) do both apply thesincedrop andmatchesFilter, but only the second is on the drain event's path:replayFromHistoryfilters rows read out ofsys_metadata_history, and the synthetic event was never written there — it went straight fromclose()intodispatch. Under shape 2 the question dissolves, because there is no event to filter on any path. Both filter sites are deliberately untouched and still apply to real events, which the existing invariant-6 cases pin.The contract, now stated
Invariant 8 in
packages/metadata-core/src/repository.ts— "shutdown terminates; it does not emit" — says what a repository-levelclose()owes a pending iterator: end every live iterator withdone: trueand no value, the same observationreturn()produces; queued or unreplayed events MAY be dropped; shutdown MUST NOT be delivered as an event, with both measured reasons written down. It is conditional becauseclose()is not on theMetadataRepositoryinterface, and it records where each of today's three implementations stands.Out of scope, filed not fixed
#11127 —
FileSystemRepository.close()retires the chokidar watcher and the resync sweep but never reaches its event broker, so a parkedwatch()iterator stays parked. Same defect class, different package; filed with its confidence stated (code read, no runtime probe) rather than fixed here. Invariant 8 names it as the one measured non-conformance rather than quietly omitting it. Also left alone:listDraftsand the org-scope path in this file, which belong to the neighbour queued behind this card.Verification
All at
2314ff6f9, the final commit.pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata-core --filter @objectstack/metadata test—1859 passed | 10 skipped,165 passed,615 passed. The two consumer packages are here because this changes what their watch loops observe at shutdown.pnpm --filter @objectstack/metadata-core typecheck—Done.@objectstack/metadata-protocoland@objectstack/metadatadeclare notypecheckscript (a--filterthat matches no script exits 0 having run nothing); they are covered instead by the ratchet below.pnpm check:type-check-debton the built closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) —OK — 33 ledger entr(ies) re-measured in 338.6s, 1895 raw tsc error(s) total, none above its recorded number.@objectstack/metadata-protocolis a ledger entry (frozen at 63), so the new test code was re-measured, not assumed.pnpm lint(eslint . --no-inline-config, repo-wide) — clean, 124s. No narrowing claimed.node scripts/pm/dispatch-gates.mjsre-derived against the real changed paths, all exit 0:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:durability-log-level,check:objectui-changeset,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:nul-bytes,check-adr-0087-registration.mjs,check-changeset-no-major.mjs,check-ci-filter-parity.mjs,check-cross-package-test-inputs.mjs,check-empty-changeset.mjs,check-plugin-teardown-shape.mjs,docs-audit/check-affected-docs.mjs.Generated by Claude Code