@loomwork/projection-runtime 0.0.0-stage → 0.1.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.
@@ -0,0 +1,384 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import type { Projection, ProjectionFoldMap, ProjectionRuntime } from "./types";
3
+
4
+ /**
5
+ * The runtime's normalized view of a registered projection. An eager projection has its
6
+ * `handlers` resolved up front; a lazy one gates loading on its `eventTypes` and buffers
7
+ * matching events until its fold code is `import()`-ed (event-type-gated on-demand loading).
8
+ */
9
+ interface ActiveProjection {
10
+ id: string;
11
+ /** ADR 0095 — the declared semantics of the fold, as given at registration. */
12
+ version?: string;
13
+ initialState: unknown;
14
+ handlers?: ProjectionFoldMap<unknown>;
15
+ // Lazy state — cleared to undefined once resolved into `handlers`.
16
+ eventTypes?: Set<string> | undefined;
17
+ load?: () => Promise<ProjectionFoldMap<unknown>>;
18
+ loading: boolean;
19
+ buffer: EventEnvelope[];
20
+ // Set once a load rejects permanently (a missing/broken chunk is not transient): the
21
+ // projection is abandoned so it can't leak an unbounded buffer or retry-storm the import.
22
+ loadFailed?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Cap on how many matching events a lazy projection buffers while its fold code loads. A local
27
+ * `import()` resolves in well under this window; exceeding it means the load is stuck or broken,
28
+ * so we stop buffering (the projection is a read-model, rebuildable from the log) rather than
29
+ * grow memory without bound. Generous so normal bursts never hit it.
30
+ */
31
+ const LAZY_BUFFER_LIMIT = 10_000;
32
+
33
+ /**
34
+ * How many least-recently-touched executions eviction will step over looking for an
35
+ * unsubscribed one. Bounds the per-event cost of `touch`; see its comment for why giving up
36
+ * (and sitting one entry over the cap) is the right failure direction.
37
+ */
38
+ const EVICTION_SCAN_LIMIT = 64;
39
+
40
+ /**
41
+ * A registered selector-subscription (see {@link InMemoryProjectionRuntime.subscribeSelector}).
42
+ * `last` holds the previously-seen slice so we can fire only on change; it is seeded from
43
+ * current state at subscribe time (change-only semantics — no initial call).
44
+ */
45
+ interface SelectorEntry {
46
+ selector: (state: unknown) => unknown;
47
+ isEqual: (a: unknown, b: unknown) => boolean;
48
+ handler: (slice: unknown) => void;
49
+ last: unknown;
50
+ }
51
+
52
+ export interface InMemoryProjectionRuntimeConfig {
53
+ /**
54
+ * Coalesce subscriber notifications. When true, instead of notifying
55
+ * synchronously on every event, dirty keys are collected and flushed once per
56
+ * microtask with each projection's LATEST state. A streaming burst of N events
57
+ * (e.g. one LLM token per event) collapses from N subscriber calls to 1 per
58
+ * affected key, cutting downstream re-renders without changing final state.
59
+ *
60
+ * Off by default: notifications stay synchronous, preserving exact current
61
+ * semantics and adding zero overhead unless explicitly enabled.
62
+ */
63
+ coalesceNotifications?: boolean;
64
+ /**
65
+ * Bound how many distinct executions' projection states are retained (LRU by last touch).
66
+ * Prevents unbounded growth in a long-lived server: an execution evicted here reverts to
67
+ * `initialState` on the next `getState` (graceful). Default 10_000; set higher/lower to taste.
68
+ */
69
+ maxExecutions?: number;
70
+ }
71
+
72
+ export class InMemoryProjectionRuntime implements ProjectionRuntime {
73
+ private readonly projections = new Map<string, ActiveProjection>();
74
+ private readonly states = new Map<string, unknown>();
75
+ private readonly subscribers = new Map<string, Set<(state: unknown) => void>>();
76
+ // Selector-subscribers, keyed like `subscribers`. Kept separate so whole-state
77
+ // subscription stays a zero-overhead fast path when no selector-subscribers exist.
78
+ private readonly selectorSubscribers = new Map<string, Set<SelectorEntry>>();
79
+
80
+ private readonly coalesce: boolean;
81
+ private readonly dirtyKeys = new Set<string>();
82
+ private flushScheduled = false;
83
+
84
+ // Insertion order = LRU order over distinct executions, for the retention bound.
85
+ private readonly maxExecutions: number;
86
+ private readonly execOrder = new Map<string, true>();
87
+
88
+ constructor(config?: InMemoryProjectionRuntimeConfig) {
89
+ this.coalesce = config?.coalesceNotifications ?? false;
90
+ this.maxExecutions = config?.maxExecutions ?? 10_000;
91
+ }
92
+
93
+ /** Drop all retained state + subscribers for an execution (e.g. after it completes). */
94
+ forget(executionId: string): void {
95
+ for (const projectionId of this.projections.keys()) {
96
+ const key = this.stateKey(projectionId, executionId);
97
+ this.states.delete(key);
98
+ this.subscribers.delete(key);
99
+ this.selectorSubscribers.delete(key);
100
+ }
101
+ this.execOrder.delete(executionId);
102
+ }
103
+
104
+ /** True if anything is currently subscribed to any projection of this execution. */
105
+ private hasSubscribers(executionId: string): boolean {
106
+ for (const projectionId of this.projections.keys()) {
107
+ const key = this.stateKey(projectionId, executionId);
108
+ if (this.subscribers.has(key) || this.selectorSubscribers.has(key)) return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Drop an execution's retained STATE only — never its subscriber registries.
115
+ *
116
+ * The distinction is the whole point. `forget` is an explicit "this execution is done"
117
+ * from a caller who knows it; eviction is a cache decision the caller never made, and
118
+ * dropping a live subscriber's registry there does not degrade — it disconnects. The
119
+ * handler set is captured by closure in `subscribe`, so once the map entry is gone
120
+ * `notify` looks up a key that no longer exists and that subscriber never fires again.
121
+ */
122
+ private evictState(executionId: string): void {
123
+ for (const projectionId of this.projections.keys()) {
124
+ this.states.delete(this.stateKey(projectionId, executionId));
125
+ }
126
+ this.execOrder.delete(executionId);
127
+ }
128
+
129
+ private touch(executionId: string): void {
130
+ this.execOrder.delete(executionId);
131
+ this.execOrder.set(executionId, true);
132
+ if (this.execOrder.size <= this.maxExecutions) return;
133
+ // Evict least-recently-touched first, but SKIP anything with a live subscriber:
134
+ // evicting a state someone is folding into resets it to `initialState`, which is
135
+ // corruption rather than a cache miss (the same rule `AgentSurfaceRuntime` follows).
136
+ //
137
+ // The scan is bounded rather than exhaustive: if the oldest entries are all subscribed,
138
+ // giving up costs one over-cap entry, while scanning on would make `touch` — which runs
139
+ // once per event — O(retained). Over the cap is the safe direction; `forget` is the
140
+ // explicit remedy.
141
+ let examined = 0;
142
+ for (const candidate of this.execOrder.keys()) {
143
+ if (examined++ >= EVICTION_SCAN_LIMIT) return;
144
+ if (this.hasSubscribers(candidate)) continue;
145
+ this.evictState(candidate);
146
+ return;
147
+ }
148
+ }
149
+
150
+ private stateKey(projectionId: string, executionId: string): string {
151
+ return `${projectionId}::${executionId}`;
152
+ }
153
+
154
+ register<TState>(projection: Projection<TState>): void {
155
+ const active: ActiveProjection = {
156
+ id: projection.id,
157
+ initialState: projection.initialState,
158
+ ...(projection.version !== undefined && { version: projection.version }),
159
+ loading: false,
160
+ buffer: [],
161
+ ...(projection.handlers !== undefined && {
162
+ handlers: projection.handlers as ProjectionFoldMap<unknown>,
163
+ }),
164
+ ...(projection.lazy !== undefined && {
165
+ eventTypes: new Set(projection.lazy.eventTypes),
166
+ load: projection.lazy.load as () => Promise<ProjectionFoldMap<unknown>>,
167
+ }),
168
+ };
169
+ this.projections.set(projection.id, active);
170
+ }
171
+
172
+ getState<TState>(projectionId: string, executionId: string): TState {
173
+ const key = this.stateKey(projectionId, executionId);
174
+ if (this.states.has(key)) {
175
+ return this.states.get(key) as TState;
176
+ }
177
+ const projection = this.projections.get(projectionId);
178
+ if (!projection) {
179
+ throw new Error(`Projection "${projectionId}" is not registered`);
180
+ }
181
+ return projection.initialState as TState;
182
+ }
183
+
184
+ subscribe<TState>(
185
+ projectionId: string,
186
+ executionId: string,
187
+ handler: (state: TState) => void
188
+ ): () => void {
189
+ const key = this.stateKey(projectionId, executionId);
190
+ if (!this.subscribers.has(key)) {
191
+ this.subscribers.set(key, new Set());
192
+ }
193
+ const handlers = this.subscribers.get(key)!;
194
+ const wrapper = (s: unknown) => handler(s as TState);
195
+ handlers.add(wrapper);
196
+ return () => {
197
+ handlers.delete(wrapper);
198
+ // Only retire the map entry if it is still OUR set. After an explicit `forget`, a later
199
+ // subscriber installs a fresh set under the same key; without this identity check a
200
+ // stale unsubscribe would empty its own orphaned set and then delete the live one,
201
+ // silently disconnecting a subscriber that did nothing wrong.
202
+ if (handlers.size === 0 && this.subscribers.get(key) === handlers) {
203
+ this.subscribers.delete(key);
204
+ }
205
+ };
206
+ }
207
+
208
+ subscribeSelector<TState, TSlice>(
209
+ projectionId: string,
210
+ executionId: string,
211
+ selector: (state: TState) => TSlice,
212
+ handler: (slice: TSlice) => void,
213
+ isEqual: (a: TSlice, b: TSlice) => boolean = Object.is
214
+ ): () => void {
215
+ const key = this.stateKey(projectionId, executionId);
216
+ let set = this.selectorSubscribers.get(key);
217
+ if (!set) {
218
+ set = new Set();
219
+ this.selectorSubscribers.set(key, set);
220
+ }
221
+ const sel = selector as (state: unknown) => unknown;
222
+ const entry: SelectorEntry = {
223
+ selector: sel,
224
+ isEqual: isEqual as (a: unknown, b: unknown) => boolean,
225
+ handler: handler as (slice: unknown) => void,
226
+ // Seed from current state so change-only holds from the first event on.
227
+ last: sel(this.peekState(projectionId, executionId)),
228
+ };
229
+ set.add(entry);
230
+ return () => {
231
+ set!.delete(entry);
232
+ // Same identity check as `subscribe` — never retire a set that is no longer ours.
233
+ if (set!.size === 0 && this.selectorSubscribers.get(key) === set) {
234
+ this.selectorSubscribers.delete(key);
235
+ }
236
+ };
237
+ }
238
+
239
+ // Current state without throwing for an unregistered/unseen projection (unlike
240
+ // getState) — used to seed a selector-subscription's `last`. resolve-by-path
241
+ // selectors are null-safe, so an absent state seeds cleanly to `undefined`.
242
+ private peekState(projectionId: string, executionId: string): unknown {
243
+ const key = this.stateKey(projectionId, executionId);
244
+ if (this.states.has(key)) return this.states.get(key);
245
+ return this.projections.get(projectionId)?.initialState;
246
+ }
247
+
248
+ handleEvent(event: EventEnvelope): void {
249
+ for (const [, projection] of this.projections) {
250
+ if (projection.handlers !== undefined) {
251
+ // Eager (or an already-loaded lazy) projection — fold synchronously as before.
252
+ this.apply(projection, projection.handlers, event);
253
+ continue;
254
+ }
255
+ // Lazy projection: gate on the compiler-recorded event types WITHOUT loading the
256
+ // fold code. A non-matching event never touches this projection (0 code loaded).
257
+ // A projection whose load already failed permanently is skipped (no buffer, no retry).
258
+ if (projection.loadFailed === true) continue;
259
+ if (projection.eventTypes?.has(event.eventType) !== true) continue;
260
+
261
+ // First matching event: buffer it (and any that arrive during load), kick the
262
+ // one-time import, and drain the buffer in arrival order once the code is ready.
263
+ // Bounded so a stuck/broken load can't grow the buffer without limit.
264
+ if (projection.buffer.length < LAZY_BUFFER_LIMIT) projection.buffer.push(event);
265
+ if (!projection.loading) {
266
+ projection.loading = true;
267
+ projection.load!()
268
+ .then((handlers) => {
269
+ projection.handlers = handlers;
270
+ projection.eventTypes = undefined; // resolved — the eager path takes over
271
+ projection.loading = false;
272
+ const buffered = projection.buffer;
273
+ projection.buffer = [];
274
+ for (const buf of buffered) this.apply(projection, handlers, buf);
275
+ })
276
+ .catch((err) => {
277
+ // A chunk load failure is not transient (missing/broken module). Abandon the
278
+ // projection: free the buffer and stop retrying, rather than leak memory and
279
+ // re-import on every matching event. The read-model can be rebuilt from the log.
280
+ projection.loading = false;
281
+ projection.loadFailed = true;
282
+ projection.buffer = [];
283
+ console.warn(
284
+ `[loom] projection "${projection.id}" failed to load its fold code — it will not update: ${String(err)}`
285
+ );
286
+ });
287
+ }
288
+ }
289
+ this.touch(event.executionId);
290
+ }
291
+
292
+ /** Fold one event into a projection's state via the matching handler, then notify. */
293
+ private apply(
294
+ projection: ActiveProjection,
295
+ handlers: ProjectionFoldMap<unknown>,
296
+ event: EventEnvelope
297
+ ): void {
298
+ // Own-key lookup: the fold map is user-authored and the event type is caller-controlled,
299
+ // so `handlers["__proto__"]` would resolve to `Object.prototype` — truthy, defeating the
300
+ // `!handler` guard, and then called as a function. Mirrors `getHandler` in @loomwork/replay-vm.
301
+ if (!Object.hasOwn(handlers, event.eventType)) return;
302
+ const handler = handlers[event.eventType];
303
+ if (typeof handler !== "function") return;
304
+ const key = this.stateKey(projection.id, event.executionId);
305
+ // One `get` on the hot path (this runs per event per projection); `has` is consulted only
306
+ // in the rare case where the retained state is itself `undefined`.
307
+ const retained = this.states.get(key);
308
+ const currentState =
309
+ retained !== undefined || this.states.has(key) ? retained : projection.initialState;
310
+ const nextState = handler(currentState, event);
311
+ this.states.set(key, nextState);
312
+ this.notify(key, nextState);
313
+ }
314
+
315
+ seedState(projectionId: string, executionId: string, state: unknown): void {
316
+ const key = this.stateKey(projectionId, executionId);
317
+ this.states.set(key, state);
318
+ this.touch(executionId);
319
+ }
320
+
321
+ /**
322
+ * ADR 0095 — the declared `version` of every registered projection that has one.
323
+ *
324
+ * Projections that declare nothing are ABSENT rather than present-and-empty: the caller
325
+ * compares this against what an artifact recorded, and a key mapped to `""` would compare
326
+ * equal to another empty declaration and read as a verified match.
327
+ */
328
+ pins(): Readonly<Record<string, string>> {
329
+ // No prototype: a projection registered as `__proto__` would otherwise set one instead
330
+ // of adding a key, and vanish from every comparison without a finding.
331
+ const pins: Record<string, string> = Object.create(null);
332
+ for (const [id, projection] of this.projections) {
333
+ if (projection.version !== undefined) pins[id] = projection.version;
334
+ }
335
+ return pins;
336
+ }
337
+
338
+ private notify(key: string, state: unknown): void {
339
+ if (!this.coalesce) {
340
+ this.emit(key, state);
341
+ return;
342
+ }
343
+ // Defer: mark dirty and flush once per microtask with the latest state.
344
+ this.dirtyKeys.add(key);
345
+ if (!this.flushScheduled) {
346
+ this.flushScheduled = true;
347
+ queueMicrotask(() => this.flush());
348
+ }
349
+ }
350
+
351
+ private flush(): void {
352
+ this.flushScheduled = false;
353
+ const keys = Array.from(this.dirtyKeys);
354
+ this.dirtyKeys.clear();
355
+ for (const key of keys) {
356
+ // Read the current state so subscribers always see the latest value even
357
+ // when many events for the same key arrived within the batch.
358
+ this.emit(key, this.states.get(key));
359
+ }
360
+ }
361
+
362
+ private emit(key: string, state: unknown): void {
363
+ const handlers = this.subscribers.get(key);
364
+ if (handlers) {
365
+ for (const handler of handlers) {
366
+ handler(state);
367
+ }
368
+ }
369
+ // Selector-subscribers: recompute each slice against the latest state and fire
370
+ // only those whose slice actually changed — so an unrelated slice's update never
371
+ // wakes this subscriber. Snapshot the set so an unsubscribe during iteration
372
+ // (a handler tearing itself down) can't mutate the live set mid-loop.
373
+ const selectors = this.selectorSubscribers.get(key);
374
+ if (selectors) {
375
+ for (const entry of [...selectors]) {
376
+ const next = entry.selector(state);
377
+ if (!entry.isEqual(entry.last, next)) {
378
+ entry.last = next;
379
+ entry.handler(next);
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ InMemoryProjectionRuntime,
3
+ type InMemoryProjectionRuntimeConfig,
4
+ } from "./InMemoryProjectionRuntime";
5
+ export type { Projection, ProjectionRuntime } from "./types";
@@ -0,0 +1,146 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+ import { bench, describe } from "vitest";
3
+ import { InMemoryProjectionRuntime } from "./InMemoryProjectionRuntime";
4
+
5
+ // ── Projection storm baseline ─────────────────────────────────────────────────
6
+ // Streaming (e.g. LLM token-by-token) produces one event per token. Today each
7
+ // event synchronously notifies subscribers, so a subscriber fires once per event.
8
+ // This bench captures the pre-coalescing baseline: wall time AND subscriber-call
9
+ // count for a burst of N events. Phase 4 coalescing should keep this wall time
10
+ // flat (or better) while collapsing subscriber calls from O(events) to
11
+ // O(microtask flushes).
12
+
13
+ const EXECUTION_ID = "exec-1";
14
+ const PROJECTION_ID = "counter";
15
+
16
+ function makeRuntime(coalesce = false) {
17
+ const runtime = new InMemoryProjectionRuntime({ coalesceNotifications: coalesce });
18
+ runtime.register<{ count: number }>({
19
+ id: PROJECTION_ID,
20
+ initialState: { count: 0 },
21
+ handlers: {
22
+ TICK: (state) => ({ count: state.count + 1 }),
23
+ },
24
+ });
25
+ return runtime;
26
+ }
27
+
28
+ function makeEvent(sequence: number): EventEnvelope {
29
+ return {
30
+ id: `e${sequence}`,
31
+ workflowId: "wf",
32
+ executionId: EXECUTION_ID,
33
+ eventType: "TICK",
34
+ sequence,
35
+ timestamp: 0,
36
+ payload: null,
37
+ };
38
+ }
39
+
40
+ for (const burst of [100, 1000]) {
41
+ describe(`projection storm / ${burst} events`, () => {
42
+ const events = Array.from({ length: burst }, (_unused, i) => makeEvent(i));
43
+
44
+ bench("handleEvent burst — synchronous notify (baseline)", () => {
45
+ const runtime = makeRuntime(false);
46
+ let notifications = 0;
47
+ runtime.subscribe(PROJECTION_ID, EXECUTION_ID, () => {
48
+ notifications++;
49
+ });
50
+ for (const event of events) runtime.handleEvent(event);
51
+ // notifications === burst (per-event notify).
52
+ void notifications;
53
+ });
54
+
55
+ bench("handleEvent burst — coalesced notify", async () => {
56
+ const runtime = makeRuntime(true);
57
+ let notifications = 0;
58
+ runtime.subscribe(PROJECTION_ID, EXECUTION_ID, () => {
59
+ notifications++;
60
+ });
61
+ for (const event of events) runtime.handleEvent(event);
62
+ // Flush the coalesced microtask; notifications collapses to 1.
63
+ await Promise.resolve();
64
+ void notifications;
65
+ });
66
+ });
67
+ }
68
+
69
+ // ── Selector fan-out ──────────────────────────────────────────────────────────
70
+ // A dashboard with W widgets bound to distinct slices of one projection. When a
71
+ // single slice changes, whole-state `subscribe` invokes ALL W handlers (each then
72
+ // diffs its own slice DOWNSTREAM — e.g. a React getSnapshot + scheduler ping);
73
+ // slice-scoped `subscribeSelector` invokes only the ONE whose slice changed.
74
+ //
75
+ // Honest reading of this bench: it measures RUNTIME dispatch cost only (setup is
76
+ // hoisted out of the timed fn). At the runtime level the selector path is not
77
+ // cheaper — it evaluates W selectors + W equality checks to decide whom to wake,
78
+ // versus whole-state calling W (here no-op) handlers — so expect roughly comparable
79
+ // (often modestly slower) wall time. The actual payoff is the DOWNSTREAM handler-
80
+ // invocation count, W → (changed slices), which this micro-bench cannot see and the
81
+ // unit tests assert directly. This bench exists to prove the filtering overhead
82
+ // stays bounded, not to claim a runtime speedup.
83
+
84
+ const WIDE_PROJECTION = "wide";
85
+
86
+ function makeWideRuntime(widgetCount: number) {
87
+ const runtime = new InMemoryProjectionRuntime();
88
+ const initialState: Record<string, number> = {};
89
+ for (let i = 0; i < widgetCount; i++) initialState[`w${i}`] = 0;
90
+ runtime.register<Record<string, number>>({
91
+ id: WIDE_PROJECTION,
92
+ initialState,
93
+ // BUMP increments exactly one field, named in the event payload.
94
+ handlers: {
95
+ BUMP: (state, event) => {
96
+ const field = (event.payload as { field: string }).field;
97
+ return { ...state, [field]: (state[field] ?? 0) + 1 };
98
+ },
99
+ },
100
+ });
101
+ return runtime;
102
+ }
103
+
104
+ function bumpEvent(sequence: number, field: string): EventEnvelope {
105
+ return {
106
+ id: `b${sequence}`,
107
+ workflowId: "wf",
108
+ executionId: EXECUTION_ID,
109
+ eventType: "BUMP",
110
+ sequence,
111
+ timestamp: 0,
112
+ payload: { field },
113
+ };
114
+ }
115
+
116
+ for (const widgets of [100, 1000]) {
117
+ describe(`selector fan-out / ${widgets} widgets — dispatch cost (setup hoisted)`, () => {
118
+ // Setup once at collection time — NOT inside the timed fn — so the bench reflects
119
+ // per-dispatch cost, not the O(W) cost of wiring up subscriptions.
120
+ const wholeStateRuntime = makeWideRuntime(widgets);
121
+ for (let i = 0; i < widgets; i++) {
122
+ wholeStateRuntime.subscribe(WIDE_PROJECTION, EXECUTION_ID, () => {});
123
+ }
124
+
125
+ const selectorRuntime = makeWideRuntime(widgets);
126
+ for (let i = 0; i < widgets; i++) {
127
+ const field = `w${i}`;
128
+ selectorRuntime.subscribeSelector<Record<string, number>, number>(
129
+ WIDE_PROJECTION,
130
+ EXECUTION_ID,
131
+ (state) => state[field] ?? 0,
132
+ () => {}
133
+ );
134
+ }
135
+
136
+ let seq = 0;
137
+
138
+ bench("whole-state subscribe — invokes all W handlers per dispatch", () => {
139
+ wholeStateRuntime.handleEvent(bumpEvent(seq++, "w0"));
140
+ });
141
+
142
+ bench("subscribeSelector — invokes only the changed handler per dispatch", () => {
143
+ selectorRuntime.handleEvent(bumpEvent(seq++, "w0"));
144
+ });
145
+ });
146
+ }
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { EventEnvelope } from "@loomwork/core";
2
+
3
+ export type ProjectionFoldMap<TState> = {
4
+ [eventType: string]: (state: TState, event: EventEnvelope) => TState;
5
+ };
6
+
7
+ export interface Projection<TState> {
8
+ id: string;
9
+ /**
10
+ * ADR 0095 — the declared semantics of this fold. An artifact that binds to `id` (a
11
+ * blueprint's `DataBinding`, a saved report) records this string, and `checkVersionPins`
12
+ * compares what was recorded against what is registered: a changed query, filter,
13
+ * aggregation or window then reads as a finding instead of a silently different number.
14
+ *
15
+ * Bump it when the fold's MEANING changes — a refactor producing the same state is not a
16
+ * new version. Declared rather than derived, for the reason `flowContentHash` excludes
17
+ * handler code: a fold closure has no stable fingerprint.
18
+ */
19
+ version?: string;
20
+ initialState: TState;
21
+ /** The eager fold map — present when handlers are inline / already resolved. */
22
+ handlers?: ProjectionFoldMap<TState>;
23
+ /**
24
+ * Event-type-gated lazy form: the fold code stays unloaded until an event whose type
25
+ * is in `eventTypes` first arrives, at which point `load()` is `import()`-ed once (its
26
+ * events buffered until ready). A projection whose event types never fire never loads
27
+ * its code. Exactly one of `handlers` / `lazy` should be set.
28
+ */
29
+ lazy?: {
30
+ eventTypes: string[];
31
+ load: () => Promise<ProjectionFoldMap<TState>>;
32
+ };
33
+ }
34
+
35
+ export interface ProjectionRuntime {
36
+ register<TState>(projection: Projection<TState>): void;
37
+ getState<TState>(projectionId: string, executionId: string): TState;
38
+ subscribe<TState>(
39
+ projectionId: string,
40
+ executionId: string,
41
+ handler: (state: TState) => void
42
+ ): () => void;
43
+ /**
44
+ * Fine-grained ("selector") subscription: invoke `handler` only when
45
+ * `selector(state)` changes — by `isEqual`, default `Object.is` — rather than on
46
+ * every projection event. The framework-agnostic building block for live rendering
47
+ * surfaces (terminal, editor webview, TUI, …): each derives a slice-granular,
48
+ * memoized subscription from the shared read-model without reimplementing the diff.
49
+ *
50
+ * Change-only, exactly like {@link subscribe}: it does NOT fire on subscribe. The
51
+ * last slice is seeded from current state at subscribe time, so the first `handler`
52
+ * call is the first genuine change. Read the initial value via `getState` + selector.
53
+ * Participates in `coalesceNotifications` — a burst collapses to one call per changed
54
+ * slice per microtask.
55
+ */
56
+ subscribeSelector<TState, TSlice>(
57
+ projectionId: string,
58
+ executionId: string,
59
+ selector: (state: TState) => TSlice,
60
+ handler: (slice: TSlice) => void,
61
+ isEqual?: (a: TSlice, b: TSlice) => boolean
62
+ ): () => void;
63
+ handleEvent(event: EventEnvelope): void;
64
+ seedState(projectionId: string, executionId: string, state: unknown): void;
65
+ /**
66
+ * ADR 0095 — the declared `version` of every registered projection that has one, by id.
67
+ *
68
+ * OPTIONAL on the interface so an existing implementation stays valid; a runtime that
69
+ * omits it simply cannot answer the question, and `checkVersionPins` reports that as
70
+ * `unpinned` rather than as a pass.
71
+ */
72
+ pins?(): Readonly<Record<string, string>>;
73
+ }