Skip to content

Commit 62c90d6

Browse files
committed
refactor(metadata-protocol): move the post-persistence mutation fan-out out of protocol.ts into protocol-mutation-fan-out.ts (#25467 cut 25)
Move-only, shape B. runMutationProjector, notifyMutationListenersLocal, publishMetadataMutation and emitMetaItemPublished move, unedited, into one module. Each class member keeps its name, docblock and signature as a stub that hands the fields it reads over as getters. The callers stay in the class, so the recovery-doors call-site counts over protocol.ts do not move. Claude-Session: https://claude.ai/code/session_016SP1spj613tyGCgxoD1An6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f3d068 commit 62c90d6

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)