Skip to content

Commit 288cb53

Browse files
refactor(metadata-protocol): move the post-persistence mutation fan-out out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25) (#26807)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X7JKgiM29GUcitRvoGGj5BU)_ Part of #25467 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Cut 25 of the `protocol.ts` split (#24586 ruling A, recorded in `6270097840`). Cuts 1–24 landed as #25494, #25518, #25538, #25589, #25601, #25623, #25642, #25719, #25731, #25740, #25797, #25939, #25972, #26014, #26104, #26168, #26264, #26317, #26350, #26366, #26404, #26449, #26654 and #26704. The claim is `6446194706`. It takes the cut 25 proposal of the cut 24 report (`6443136164`), a themed group. All four members passed the AST screen and the read-seam census, and all four moved unedited. Four `ObjectStackProtocolImplementation` members move unedited into one new module, `packages/metadata-protocol/src/protocol-mutation-fan-out.ts` (#23050 shape B). Together they are the post-persistence mutation fan-out: - `runMutationProjector`: the registered projector for a just-persisted mutation (ADR-0094). It is awaited and never throws. - `notifyMutationListenersLocal`: the local half of `emitMetadataMutation` (#2588, #13331). Each listener is isolated. - `publishMetadataMutation`: the cluster half of `emitMetadataMutation` (#13331). It publishes the mutation's address to peers, fire-and-forget, and skips drafts. - `emitMetaItemPublished`: the per-item publish listeners (#10219), awaited and isolated. `protocol.ts` loses 25 net lines (29,152 to 29,127). The cut is move-only, and the card stays open for later cuts. Branch `claude/project-thread-ybjm3h` is the harness-pinned branch for this thread. It was restarted from `main` `0f3d068cf1` (after cut 24's merge). It names no issue, so the claim comment is the identity record. It holds the move commit `62c90d6199` and one merge of `main`, `02bc68c682` (13 commits). The merge touches no file of `packages/metadata-protocol/`. The package's `src/` tree is `91361684` at the move commit and at the merge. ## Serial with the file's in-flight PRs Measured just before this PR was opened, against the merged head `02bc68c682`: of the 26 open PRs, **none edits `protocol.ts`**, and none names any of the four members, `MutationFanOutHost` or the new module. Four touch other files of `packages/metadata-protocol/src/`: - **#26769**: one test file. - **#26782**: nine `migrations/` files. - **#26786**: one test file. - **#26799**: one test file. Each one's `git merge-tree` is clean against this head. `main` moved three commits after the merge: #26787, #26788 (dogfood tests) and #26796 (`create-objectstack`). None touches this package, `git merge-tree` with this head is clean, and the derivation found nothing stale, so `main` was not merged again. ## The order's screen (private statics, `new Error(`, the read-seam census, the call-site counts) All four members passed: - **No private-static read.** The AST walk found no access on the `ObjectStackProtocolImplementation` identifier and none on `this.constructor`. - **No error constructed.** No body has `new Error(`, so no `PRODUCERS` row is owed. - **Read-seam census.** `check-durability-degradation-log-level --list` was taken on the base and again on the cut. Both read `88 read seam(s)`; the base count has grown from 82 since cut 24, through `main`. The lists are identical once `[@:][0-9]+` is stripped. The full readings are under "The durability `--list`". - **The recovery-doors call-site counts hold.** `protocol.recovery-doors-emit-mutation.test.ts` and `protocol.recovery-doors-mutation-projector.test.ts` read `protocol.ts` by path and count its text. The counts at base and head: | Pattern counted in `protocol.ts` | Base | Head | |---|---|---| | `this.emitMetadataMutation(` | 7 | 7 | | `private emitMetadataMutation(` | 1 | 1 | | `this.notifyMutationListenersLocal(` | 2 | 2 | | `private notifyMutationListenersLocal(` | 1 | 1 | | `this.runMutationProjector(` | 7 | 7 | | `private async runMutationProjector(` | 1 | 1 | The callers that are counted stay in the class: `emitMetadataMutation` (local, then cluster), `applyRemoteMetadataMutation` (local only), the seven projecting doors and the per-item publish door. That test file also slices `applyRemoteMetadataMutation`'s body by text, and that body does not move. Both files pass at both ends, in the package suite. ## What moves | Member | Base line | Visibility | Body lines | AST tokens (params + return + body) | Reads | |---|---|---|---|---|---| | `runMutationProjector` | `:7493` | private async | 12 | 113 | `mutationProjectors` (field) | | `notifyMutationListenersLocal` | `:7563` | private | 10 | 66 | `metadataMutationListeners` (field) | | `publishMetadataMutation` | `:7675` | private | 16 | 115 | `clusterPubSub`, `clusterNodeId` (fields) | | `emitMetaItemPublished` | `:7864` | private async | 15 | 80 | `metaItemPublishedListeners` (field) | `protocol-mutation-fan-out.ts` has 152 lines. ## The shape (#23050 shape B, as cuts 1–24 apply it) - **The class keeps all four members.** Each keeps its name, docblock, signature and position. Each body now delegates, marked `[#25467 cut 25]`. - **Every member read is a field, so each is handed over as a getter.** `MutationFanOutHost` types each field as the class member itself (`ObjectStackProtocolImplementation['…']`). Each function types its `this` as the `Pick` of what it reads, and each stub annotates its literal as `ThisParameterType<typeof …>`. - The getters read the protocol's own listener arrays, projector map and cluster wiring at call time. - `attachMetadataMutationPubSub`/`detachMetadataMutationPubSub` reassign `clusterPubSub` and `clusterNodeId`, and the getter sees the current values, exactly as the body did. This is the field hand-over cuts 20, 23 and 24 used. - No body reassigns a field; the listener arrays and the projector map are only iterated or read. - **None of the four reads another**, so no closure is owed. - **No member changes visibility.** - **Every caller still calls the class method.** The sites (base) are: - `this.runMutationProjector(` at `:21364`, `:22976`, `:26599`, `:26718`, `:27073`, `:27923` and `:28121`. - `this.notifyMutationListenersLocal(` at `:7550` and `:7787`. - `this.publishMetadataMutation(` at `:7551`. - `this.emitMetaItemPublished(` at `:22394`. None of them changes. ## Module-level declarations, by reader Measured by an AST walk over each body's identifiers, against `protocol.ts`'s top-level declarations and import bindings (property names excluded): | Name | Where `protocol.ts` has it | Other readers in `protocol.ts` | What this cut does | |---|---|---|---| | `METADATA_MUTATION_CLUSTER_CHANNEL` | exported `const`, `:5926` | yes (`attachMetadataMutationPubSub`, and docblocks) | none (already exported); the new module imports it from `./protocol.js` | | `ClusterMetadataMutationPayload` | exported interface | yes (`attachMetadataMutationPubSub`) | none; imported as a type | | `MetadataMutationEvent`, `MutationProjectionOutcome`, `MetaItemPublishedEvent` | exported interfaces | yes (the stubs' declared signatures and others) | none; imported as types | No import of `protocol.ts` loses its last reader, and no declaration gains `export`. ## The published `.d.ts`: one emitter trap, met and avoided The series bar is ruling `6348891160`: sorted-union identity, measured first, with raw identity reported when it holds. **Both hold for this cut, as pushed.** **Disclosed: the first layout of this cut failed the bar, and only through comments.** The cut's first import comment was 16 characters longer. With it, the head `.d.ts` moved to `0105198c6b324117…` (782,358 bytes; normalised `c211e183dfe9c5e8…`). The diff against base was one hunk: an inferred type from `@objectstack/spec` printed as `generatedBy /** … */ ? /** … */ : string | undefined` instead of `generatedBy?: string | undefined;`. Both comments were `protocol.ts`'s own `:967` docblock ("Zod schemas used to validate overlay items…"), which has nothing to do with that type. - The two files print identically with comments stripped (TypeScript printer, `removeComments`; sha `99dc518ed3a640f9…` for both). So no type moved. - The mechanism, as measured: tsc's declaration emitter reuses a node whose source is another file, but reads its comment ranges from `protocol.ts`'s text at the same offset. The cut's added import comment had shifted that docblock to start at exactly that offset (50,150). - Shortening the cut's own import comment by 16 characters moved the docblock off that offset, and raw identity returned (below). The bundles' JS does not change with comment text (`index.js` `8ef90e4aa9cbf4b4…` both ways). - The trap is latent on `main`: any edit that moves the docblock onto that offset would trigger it. Diff the `.d.ts` hunk before blaming a change for it. Base `0f3d068cf1` and head `62c90d6199` were built with `pnpm --filter @objectstack/metadata-protocol build` against one dependency tree. The closure was built at head by a full `pnpm build` (116/116). For the base build, the package's `src/` was swapped tree-only to base (the new module removed) and restored from `HEAD` under a trap. Afterwards `git diff HEAD` was empty, `git status` was clean, and both touched blobs equalled `HEAD`'s. - **Normalised sha256** (the ruling's bar): `310d157f1455a7b76c3d9a60e905ac5e07debb23ec4a6a615b510870341bdbec` at both ends, on `dist/index.d.ts` and `dist/index.d.cts`. The normaliser sorted 80 string-literal union runs. **Sorted-union identity holds.** - **Raw sha256**: `724eaa784128c37a7e4b59d85e21212ba6378a5b07279853c97828cd8368e932` at both ends, 777,143 bytes. **Raw identity holds.** It is also the hash cut 24 measured, since the base is cut 24's merge. - **Again on the merged tree** `02bc68c682` (closure rebuilt by a full `pnpm build`, 116/116): the same raw and normalised sha256, and the same `index.js`/`index.cjs` hashes, as the pre-merge head. - **The two builds differ where they should.** - `index.js` hashes `0cb9bc1754636851…` at base and `8ef90e4aa9cbf4b4…` at head (`index.cjs` `40c568bf03d0672b…` → `65ffd5926831fb5d…`). - Each of the four `….call(host` spellings occurs 0 times in the base bundles and once in the head bundles (ESM and CJS alike). - Each `function …(` declaration goes 0 → 1. - **Positive control** on the head tree. `private` was dropped from `runMutationProjector` on disk (restored from `HEAD` under a trap) and the package rebuilt. Both `.d.ts` files move to `c996c4705a6c30f8…` (normalised `5db8ebd53a1498a3…`, which differs from the bar's `310d157f…`). So the bar can see a change on these members. - **Export tables.** The ESM and CJS bundles, loaded at runtime from the package, each export the same 78 names at base and head. The sorted list hashes to `9f18a0177ac736ed…` on all of them. None of the four functions, and not `MutationFanOutHost`, appears. No changeset: `skip-changeset`, per ruling `6348891160`. ## The durability `--list` `check-durability-degradation-log-level` exits 0 at both ends with identical output: `88 read seam(s)` at both ends. Its `--list` is **identical once `[@:][0-9]+` is stripped** (164 lines, and no entry changes file). - Three of the bodies have a `try` (`runMutationProjector`, and the two listener loops). Each guards a callback, not a call in the gate's read vocabulary (`find`/`findOne`/`count`). - Each catch logs at `warn`, as before. No seam enters or leaves the census. - On the merged tree the verdict still reads `88 read seam(s)`. ## Proof of move-only **Bodies, by AST token.** The compare reads the TypeScript compiler's AST leaf tokens, with JSDoc skipped, over the parameters (less the added `this`), the return type and the body. It compares base `protocol.ts` at `0f3d068cf1` against the head module: ``` runMutationProjector: base tokens 113, head tokens 113, differ 0, text-equal(ws-collapsed, comments incl.) true notifyMutationListenersLocal: base tokens 66, head tokens 66, differ 0, text-equal(ws-collapsed, comments incl.) true publishMetadataMutation: base tokens 115, head tokens 115, differ 0, text-equal(ws-collapsed, comments incl.) true emitMetaItemPublished: base tokens 80, head tokens 80, differ 0, text-equal(ws-collapsed, comments incl.) true TOTAL tokens=374 differ=0 members=4 CLASS members base=301 head=301 order-same=true byte-identical=297 stubs-with-identical-docblock+signature=4 other=[] TOP base=254 head=255 byte-identical=254 ``` **Negative controls**, one per member. Each changed one token in an in-memory copy of the head module, and only the mutated member moved (differ 1; the other three stayed 0): ``` "return { success: true };" -> "return { success: false };" runMutationProjector differ 1 "listener(evt);" -> "listener(e);" notifyMutationListenersLocal differ 1 "if (evt.state === 'draft') return;" -> "if (evt.state !== 'draft') return;" publishMetadataMutation differ 1 "await listener(evt);" -> "void listener(evt);" emitMetaItemPublished differ 1 ``` - **Class side.** There are 301 members at base and at head, in the same order. The 297 that did not move are byte-identical to base, leading comments included. All four stubs sit at their base positions and keep their docblocks and signatures byte-identical. - **Top level of `protocol.ts`.** All 254 base statements are byte-identical, leading comments included. Head adds one statement, the new module's import. - **Size.** +179 / −52 over 2 files, 231 changed lines (the proposal's estimate was 200–260). ## Readers of the moved lines The tests and scripts that read `protocol.ts` by path were grepped for the four member names, the fields they read, and the bodies' log text: `mutation projector failed`, `metadata-mutation listener failed`, `cluster metadata-mutation publish failed`, `post-publish re-bind announce FAILED`, `partitionKey`. These are the files that name `protocol.ts` and call `readFileSync`. So were the test files in `packages/`, `docs/qa/` and the `@objectstack/spec` liveness ledgers. - **By path, two readers name the members, and neither reads a moved body.** They are the two recovery-doors tests above. They count call sites and declarations, which stay, and their table holds at both ends. The emit-mutation test also names `publishMetadataMutation` in a comment. - **Behaviour through the class method, no edit owed.** These drive the four members through the protocol: - `protocol.cluster-mutation-fanout.test.ts`, `mutation-listeners.test.ts` and both recovery-doors tests (metadata-protocol). - `plugin-publish-announce.test.ts`, `plugin-authored-hooks.test.ts` and `plugin-authored-actions.test.ts` (objectql). The metadata-protocol ones pass at both ends, in the package suite. - **The liveness ledgers.** No row points at any of the four. Carried forward: `manifest.json`'s `dependencies` row → `protocol.ts#resolveWritePackageScope` (cut 19), and `seed.json` → `protocol.ts#applySeedBodies` (cut 14). `check:liveness` checks only that the `#symbol` is named in the file. Re-pointing them is a `packages/spec/**` edit, outside this card. - **ADR anchors.** `check-adr-anchors` exits 0 at both ends. - The decision-letter count is unchanged at 1,898. No moved body cites an ADR letter. - The citation count goes 59,968 → 59,969 and the file count 8,393 → 8,394, from the new module's header (ADR-0094). - **Engine-double contract** (`check-engine-double-contract`): exits 0 at both ends. - The `updateData`/`deleteData` seams keep their entries at new line numbers (13863 → 13838, 13888 → 13863). - `productionFiles` is 3,798 at base and 3,800 at head. The +2 are the new file and the gitignored `packages/spec/src/migrations/registry.ts` that the spec build writes in the head tree, as in cuts 20–24. - **Identical output at both ends:** `check-adr-symbol-anchors`, `check-system-context-census`, `check-tenant-audit-census`, `check-query-options-erasure-ratchet`, `check-filter-alias-parity` and `check-durability-degradation-log-level`. `check-nul-bytes` differs only in the scanned file count, +1. The base-end readings were taken in a clean detached worktree at `0f3d068cf1`. ## Verification - `tsc --noEmit` in `packages/metadata-protocol` exits 0 at head and on the merged tree. - `pnpm exec eslint --no-inline-config packages/metadata-protocol` exits 0 with no output (the whole touched package, with the root `lint` script's flags). - **`packages/metadata-protocol` suite**, base and head, compared per test name and status. Each leg ran vitest `--maxWorkers=2` with the JSON reporter under the shared verify lock. - Base `0f3d068cf1`: 21,635 tests (21,616 passed, 0 failed, 19 skipped), 272 files. - Head `62c90d6199`: 21,635 tests (21,616 passed, 0 failed, 19 skipped), 272 files. - None lost, none added, none changed status. The base leg is the package `src/` swapped tree-only from `0f3d068cf1` (the new module removed) and restored from `HEAD` under a trap. - The merged head `02bc68c682` gives the same 21,635 tests, per test identical to the pre-merge head (0 lost, 0 added, 0 changed). - `check-platform-checklist --changed-from origin/main` (merged tree): OK. 1 anchored file is in the change set, 29 anchors were judged, and 0 were lost. - **Governed surface.** `check-governed-merges --branch claude/project-thread-ybjm3h` finds 0 of 2 paths in the register (13 surfaces), so the PR is NOT governed. It has 231 changed lines, under the 3,000-line human-merge bar. - **Derived gates.** `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 46 gates on 2 changed paths from `02bc68c682`, with no stale-tree finding. All 46 exit 0 after the full `pnpm build` (116/116). `dispatch-gates --ran` reports 46 derived, 46 run, 0 NOT-MEASURED and 0 UNRUN, and exits 0. - `check-durability-degradation-log-level --list` on `02bc68c682`: `88 read seam(s)`. ## Carry-forward These stay in `protocol.ts`: - **The read-seam members** (cut 21). A member named in a `protocol.ts` read-seam entry of `check-durability-degradation-log-level --list`, as the guarded wrapper or the guarding member, stays until the gate follows imported wrappers or a ruling accepts the loss. - This is the census-negative set that cuts 22–23 measured: `findEnvironmentRows`, `packagelessAddress`, `findServedOverlayRow`, `foldStoredCollection`, `applyObjectRegistryMutation`, `unmanagedImportCollisions`, `rollBackUnmanagedImport`, `readRevertableCommit` and `storedFlowBindingAgrees`. - `rethrowUnlessMetadataStoreUnprovisioned` cannot move at all. - This cut re-trialled only its own members and the cut 26 candidates. - Public members whose return type is inferred. - **The bodies kept by a by-path reader:** - `probeRecord`, `getMetaItemCached`, `findReferencesToMeta`, `refuseUnmintableMetaType` and `metaTypeNamespaceExists`. - `viewContainerNameCollisionRefusal` with `overlaidShippedContainerViewNames`, and `reportPageRequiresAbsentAtLoad`. - The 7 `applyRegistryWriteThrough` callers (among them `applyRemoteMetadataMutation`), `readCodeLayerForCarryForward` and `ensureObjectStorage`. - `emitMetadataMutation`, a counted call site. - The private-static readers: `refusePackagedBaseRemoval`, `packagedBaseRefusal`, `runPublishSideEffects`, `promoteDraftForPublish`, `publishPackageDrafts` and `discardPackageDrafts`. - The field writers: `warnClosureReadUnavailable` and `resolveSduiManifest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13dc413 commit 288cb53

2 files changed

Lines changed: 179 additions & 52 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25467 cut 25] The post-persistence mutation fan-out of
5+
* `ObjectStackProtocolImplementation`, moved out of `protocol.ts` whole: the
6+
* four members that tell the rest of the process — and, through the cluster
7+
* transport, the rest of the fleet — that a metadata write has landed.
8+
*
9+
* - `runMutationProjector` — the registered projector for a just-persisted
10+
* mutation (ADR-0094), awaited, never throwing.
11+
* - `notifyMutationListenersLocal` — the LOCAL half of
12+
* `emitMetadataMutation` (#2588, #13331): each listener isolated.
13+
* - `publishMetadataMutation` — the cluster half of `emitMetadataMutation`
14+
* (#13331): the mutation's address to peers, fire-and-forget, drafts
15+
* skipped.
16+
* - `emitMetaItemPublished` — the per-item publish listeners (#10219),
17+
* awaited and isolated.
18+
*
19+
* A MOVE, not a redesign. Each function below is the body of the
20+
* `ObjectStackProtocolImplementation` method of the same name as it stood,
21+
* re-indented one level and otherwise unedited: the same order, the same
22+
* isolation, the same log lines.
23+
*
24+
* ## How the moved bodies reach the class
25+
*
26+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
27+
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
28+
* The class keeps each method — the published `dist/*.d.ts` lists its name,
29+
* docblock and declared signature — and its body is one literal of exactly
30+
* the {@link MutationFanOutHost} members it reads, plus a `.call`. Every
31+
* member read is a field, so each is handed over as a getter: the body reads
32+
* the protocol's own listener arrays, projector map and cluster wiring at
33+
* call time — `attachMetadataMutationPubSub` and
34+
* `detachMetadataMutationPubSub` reassign `clusterPubSub` and
35+
* `clusterNodeId`, and the getters see the current values, as the bodies did.
36+
*
37+
* The callers stay in the class and keep calling the class methods:
38+
* `emitMetadataMutation` (local, then cluster), `applyRemoteMetadataMutation`
39+
* (local only), the seven projecting doors and the per-item publish door. So
40+
* the call-site counts that `protocol.recovery-doors-emit-mutation.test.ts`
41+
* and `protocol.recovery-doors-mutation-projector.test.ts` take over
42+
* `protocol.ts` do not move.
43+
*
44+
* What the bodies read from elsewhere, they import from where `protocol.ts`
45+
* declares it. Nothing re-exports this module, so the published surface does
46+
* not move.
47+
*/
48+
49+
import type {
50+
ClusterMetadataMutationPayload,
51+
MetadataMutationEvent,
52+
MetaItemPublishedEvent,
53+
MutationProjectionOutcome,
54+
ObjectStackProtocolImplementation,
55+
} from './protocol.js';
56+
import { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol.js';
57+
58+
/**
59+
* The `ObjectStackProtocolImplementation` members the functions below read —
60+
* every one of them, and no other; each function's `this` is the `Pick` of
61+
* the members it reads. Each is typed AS the class member (an indexed access
62+
* on the class), so a change to a member's type on the class is a change
63+
* here too, and naming a member the class does not have is a compile error.
64+
*/
65+
export interface MutationFanOutHost {
66+
readonly mutationProjectors: ObjectStackProtocolImplementation['mutationProjectors'];
67+
readonly metadataMutationListeners: ObjectStackProtocolImplementation['metadataMutationListeners'];
68+
readonly clusterPubSub: ObjectStackProtocolImplementation['clusterPubSub'];
69+
readonly clusterNodeId: ObjectStackProtocolImplementation['clusterNodeId'];
70+
readonly metaItemPublishedListeners: ObjectStackProtocolImplementation['metaItemPublishedListeners'];
71+
}
72+
73+
/** The body of `ObjectStackProtocolImplementation#runMutationProjector`; the method's docblock there is its contract. */
74+
export async function runMutationProjector(
75+
this: Pick<MutationFanOutHost, 'mutationProjectors'>,
76+
evt: MetadataMutationEvent & { body?: unknown },
77+
): Promise<MutationProjectionOutcome | undefined> {
78+
const projector = this.mutationProjectors.get(evt.type);
79+
if (!projector) return undefined;
80+
try {
81+
await projector(evt);
82+
return { success: true };
83+
} catch (e) {
84+
const error = e instanceof Error ? e.message : String(e);
85+
console.warn(
86+
`[Protocol] mutation projector failed for ${evt.type}/${evt.name} (state=${evt.state}): ${error}`,
87+
);
88+
return { success: false, error };
89+
}
90+
}
91+
92+
/** The body of `ObjectStackProtocolImplementation#notifyMutationListenersLocal`; the method's docblock there is its contract. */
93+
export function notifyMutationListenersLocal(
94+
this: Pick<MutationFanOutHost, 'metadataMutationListeners'>,
95+
evt: MetadataMutationEvent,
96+
): void {
97+
for (const listener of this.metadataMutationListeners) {
98+
try {
99+
listener(evt);
100+
} catch (e) {
101+
console.warn(
102+
`[Protocol] metadata-mutation listener failed for ${evt.type}/${evt.name}: `
103+
+ `${e instanceof Error ? e.message : String(e)}`,
104+
);
105+
}
106+
}
107+
}
108+
109+
/** The body of `ObjectStackProtocolImplementation#publishMetadataMutation`; the method's docblock there is its contract. */
110+
export function publishMetadataMutation(
111+
this: Pick<MutationFanOutHost, 'clusterPubSub' | 'clusterNodeId'>,
112+
evt: MetadataMutationEvent,
113+
): void {
114+
if (!this.clusterPubSub) return;
115+
if (evt.state === 'draft') return;
116+
const payload: ClusterMetadataMutationPayload = {
117+
originNode: this.clusterNodeId,
118+
event: evt,
119+
};
120+
void this.clusterPubSub
121+
.publish(METADATA_MUTATION_CLUSTER_CHANNEL, payload, {
122+
partitionKey: `${evt.type}:${evt.name}`,
123+
})
124+
.catch((err) => {
125+
console.warn(
126+
`[Protocol] cluster metadata-mutation publish failed for `
127+
+ `${evt.type}/${evt.name}: ${err instanceof Error ? err.message : String(err)}`,
128+
);
129+
});
130+
}
131+
132+
/** The body of `ObjectStackProtocolImplementation#emitMetaItemPublished`; the method's docblock there is its contract. */
133+
export async function emitMetaItemPublished(
134+
this: Pick<MutationFanOutHost, 'metaItemPublishedListeners'>,
135+
evt: MetaItemPublishedEvent,
136+
): Promise<void> {
137+
for (const listener of this.metaItemPublishedListeners) {
138+
try {
139+
await listener(evt);
140+
} catch (e) {
141+
console.warn(
142+
`[Protocol] the post-publish re-bind announce FAILED for ${evt.type}/${evt.name} — the item IS `
143+
+ `published and stored, but boot-cached consumers keep the PRE-publish view until this process `
144+
+ `restarts: a newly published record-triggered flow does not bind its trigger (it will not fire), `
145+
+ `an edited schedule-triggered flow keeps running its old definition, and authored hooks, actions `
146+
+ `and translations are not re-synced. Nothing retries this announce. Re-publish the item once the `
147+
+ `cause below is resolved (it is idempotent), or restart the process to rebuild every subscriber `
148+
+ `from storage. Cause: ${e instanceof Error ? e.message : String(e)}`,
149+
);
150+
}
151+
}
152+
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 27 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ import { convertStoredItem, convertStoredItemDetailed } from './protocol-stored-
417417
import { listLiveMetadataTypes } from './protocol-live-metadata-types.js';
418418
// [#25467 cut 24] The save door's 409 for a `ConflictError`, with its decision-audit row.
419419
import { saveConflict } from './protocol-save-conflict.js';
420+
// [#25467 cut 25] The post-persistence mutation fan-out: projector, local listeners, cluster publish, publish listeners.
421+
import { emitMetaItemPublished, notifyMutationListenersLocal, publishMetadataMutation, runMutationProjector } from './protocol-mutation-fan-out.js';
420422

421423
/**
422424
* Canonical Zod schema per metadata type lives in
@@ -7493,18 +7495,12 @@ export class ObjectStackProtocolImplementation implements
74937495
private async runMutationProjector(
74947496
evt: MetadataMutationEvent & { body?: unknown },
74957497
): Promise<MutationProjectionOutcome | undefined> {
7496-
const projector = this.mutationProjectors.get(evt.type);
7497-
if (!projector) return undefined;
7498-
try {
7499-
await projector(evt);
7500-
return { success: true };
7501-
} catch (e) {
7502-
const error = e instanceof Error ? e.message : String(e);
7503-
console.warn(
7504-
`[Protocol] mutation projector failed for ${evt.type}/${evt.name} (state=${evt.state}): ${error}`,
7505-
);
7506-
return { success: false, error };
7507-
}
7498+
// [#25467 cut 25] The body moved to `protocol-mutation-fan-out.ts` (see `MutationFanOutHost`).
7499+
const readMutationProjectors = () => this.mutationProjectors;
7500+
const host: ThisParameterType<typeof runMutationProjector> = {
7501+
get mutationProjectors() { return readMutationProjectors(); },
7502+
};
7503+
return runMutationProjector.call(host, evt);
75087504
}
75097505

75107506
/**
@@ -7561,16 +7557,12 @@ export class ObjectStackProtocolImplementation implements
75617557
* messages) could not stop the storm.
75627558
*/
75637559
private notifyMutationListenersLocal(evt: MetadataMutationEvent): void {
7564-
for (const listener of this.metadataMutationListeners) {
7565-
try {
7566-
listener(evt);
7567-
} catch (e) {
7568-
console.warn(
7569-
`[Protocol] metadata-mutation listener failed for ${evt.type}/${evt.name}: `
7570-
+ `${e instanceof Error ? e.message : String(e)}`,
7571-
);
7572-
}
7573-
}
7560+
// [#25467 cut 25] The body moved to `protocol-mutation-fan-out.ts` (see `MutationFanOutHost`).
7561+
const readMetadataMutationListeners = () => this.metadataMutationListeners;
7562+
const host: ThisParameterType<typeof notifyMutationListenersLocal> = {
7563+
get metadataMutationListeners() { return readMetadataMutationListeners(); },
7564+
};
7565+
return notifyMutationListenersLocal.call(host, evt);
75747566
}
75757567

75767568
// ── [#13331] Cross-node registry convergence ────────────────────────────
@@ -7673,22 +7665,14 @@ export class ObjectStackProtocolImplementation implements
76737665
* converge on a no-op.
76747666
*/
76757667
private publishMetadataMutation(evt: MetadataMutationEvent): void {
7676-
if (!this.clusterPubSub) return;
7677-
if (evt.state === 'draft') return;
7678-
const payload: ClusterMetadataMutationPayload = {
7679-
originNode: this.clusterNodeId,
7680-
event: evt,
7668+
// [#25467 cut 25] The body moved to `protocol-mutation-fan-out.ts` (see `MutationFanOutHost`).
7669+
const readClusterPubSub = () => this.clusterPubSub;
7670+
const readClusterNodeId = () => this.clusterNodeId;
7671+
const host: ThisParameterType<typeof publishMetadataMutation> = {
7672+
get clusterPubSub() { return readClusterPubSub(); },
7673+
get clusterNodeId() { return readClusterNodeId(); },
76817674
};
7682-
void this.clusterPubSub
7683-
.publish(METADATA_MUTATION_CLUSTER_CHANNEL, payload, {
7684-
partitionKey: `${evt.type}:${evt.name}`,
7685-
})
7686-
.catch((err) => {
7687-
console.warn(
7688-
`[Protocol] cluster metadata-mutation publish failed for `
7689-
+ `${evt.type}/${evt.name}: ${err instanceof Error ? err.message : String(err)}`,
7690-
);
7691-
});
7675+
return publishMetadataMutation.call(host, evt);
76927676
}
76937677

76947678
/**
@@ -7862,21 +7846,12 @@ export class ObjectStackProtocolImplementation implements
78627846
* this log.
78637847
*/
78647848
private async emitMetaItemPublished(evt: MetaItemPublishedEvent): Promise<void> {
7865-
for (const listener of this.metaItemPublishedListeners) {
7866-
try {
7867-
await listener(evt);
7868-
} catch (e) {
7869-
console.warn(
7870-
`[Protocol] the post-publish re-bind announce FAILED for ${evt.type}/${evt.name} — the item IS `
7871-
+ `published and stored, but boot-cached consumers keep the PRE-publish view until this process `
7872-
+ `restarts: a newly published record-triggered flow does not bind its trigger (it will not fire), `
7873-
+ `an edited schedule-triggered flow keeps running its old definition, and authored hooks, actions `
7874-
+ `and translations are not re-synced. Nothing retries this announce. Re-publish the item once the `
7875-
+ `cause below is resolved (it is idempotent), or restart the process to rebuild every subscriber `
7876-
+ `from storage. Cause: ${e instanceof Error ? e.message : String(e)}`,
7877-
);
7878-
}
7879-
}
7849+
// [#25467 cut 25] The body moved to `protocol-mutation-fan-out.ts` (see `MutationFanOutHost`).
7850+
const readMetaItemPublishedListeners = () => this.metaItemPublishedListeners;
7851+
const host: ThisParameterType<typeof emitMetaItemPublished> = {
7852+
get metaItemPublishedListeners() { return readMetaItemPublishedListeners(); },
7853+
};
7854+
return emitMetaItemPublished.call(host, evt);
78807855
}
78817856

78827857
/**

0 commit comments

Comments
 (0)