@checkstack/automation-backend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Builds an `EntityHandle` for one validated kind — the Model B reactive
3
+ * wrapper (reactive automation engine §4, reshaped).
4
+ *
5
+ * `defineEntity` owns NO current-state storage. The plugin owns its state
6
+ * and exposes it through a `read` accessor; this handle makes that state
7
+ * REACTIVE by funneling every write through one driven entry point:
8
+ *
9
+ * handle.mutate({ id, opts?, apply })
10
+ *
11
+ * The orchestration (`mutate` and `remove` share it):
12
+ *
13
+ * 1. Snapshot `prev` via `read([id])` BEFORE the write, so a change can
14
+ * never be missed (we never re-read prev AFTER the plugin has written).
15
+ * 2. Run the plugin's `apply()` — the ACTUAL write against its OWN storage,
16
+ * committed in the PLUGIN's own transaction. `apply` returns the
17
+ * resulting current state (`next`); `remove`'s `apply` returns void and
18
+ * `next` is `null` (tombstone). `apply` takes NO tx: a plugin-backed kind
19
+ * lives behind a different drizzle client than `entity_transitions`, so
20
+ * it cannot share the framework transaction.
21
+ * 3. AFTER the plugin write has committed: validate `next` (zod) and diff
22
+ * prev → next. On a real diff, append the field-level transition rows to
23
+ * `entity_transitions` in the FRAMEWORK's OWN transaction (a separate
24
+ * db/client from the plugin's write).
25
+ * 4. Emit the internal `ENTITY_CHANGED` event carrying the mutating actor.
26
+ * A rolled-back / throwing `apply` emits nothing and logs nothing — the
27
+ * plugin write is the source of truth.
28
+ *
29
+ * Masking boundary: run-originated secret masking is confined to the EMITTED
30
+ * `ENTITY_CHANGED` payload and the `entity_transitions` rows. `mutate` returns
31
+ * the UNMASKED, zod-validated resulting state — the contract is "returns the
32
+ * resulting state", and masking is an emission/persistence concern, not part
33
+ * of the value the calling plugin gets back.
34
+ *
35
+ * Cross-plugin transaction boundary (the deliberate tradeoff): the plugin
36
+ * write (step 2) and the transition append (step 3) are NOT in one shared db
37
+ * transaction — they target different schemas behind different clients. The
38
+ * plugin write is authoritative; the transition append is a post-commit
39
+ * framework write. A failure between them leaves correct plugin state with a
40
+ * missing history row (a gap, never a corruption). A plugin platform must NOT
41
+ * couple plugin writes to framework-internal tables, so this decoupling is
42
+ * intentional. See `define-entity.ts` for the full rationale.
43
+ *
44
+ * A structurally-unchanged write (returns an equal state) is a no-op: the
45
+ * plugin write still happened, but no transition is appended and no event is
46
+ * emitted.
47
+ */
48
+ import type { z } from "zod";
49
+ import { SYSTEM_ACTOR, type Actor } from "@checkstack/common";
50
+
51
+ import type {
52
+ EntityHandle,
53
+ EntityMutationOpts,
54
+ EntityRead,
55
+ MutateInput,
56
+ RemoveInput,
57
+ } from "./define-entity";
58
+ import type { EntityStore, TransitionAppend } from "./entity-store";
59
+ import { serializeFieldValue } from "./entity-store";
60
+ import { diffEntityState } from "./diff";
61
+ import type { ChangeEmitter } from "./change-emitter";
62
+ import type { RunSecretRegistry } from "../dispatch/run-secret-registry";
63
+
64
+ export interface CreateHandleArgs<TState extends Record<string, unknown>> {
65
+ kind: string;
66
+ schema: z.ZodType<TState>;
67
+ store: EntityStore;
68
+ emitter: ChangeEmitter;
69
+ secretRegistry: RunSecretRegistry;
70
+ /** Plugin-owned current-state accessor (the single read path). */
71
+ read: EntityRead<TState>;
72
+ }
73
+
74
+ /** Resolve the effective actor for a mutation (defaults to the system actor). */
75
+ function resolveActor(opts?: EntityMutationOpts): Actor {
76
+ return opts?.actor ?? SYSTEM_ACTOR;
77
+ }
78
+
79
+ /** Apply run-scoped secret masking to a payload when the write is run-originated. */
80
+ function maskForRun(args: {
81
+ registry: RunSecretRegistry;
82
+ runId: string | undefined;
83
+ value: Record<string, unknown>;
84
+ }): Record<string, unknown> {
85
+ const { registry, runId, value } = args;
86
+ if (!runId) return value;
87
+ // maskDeep returns the same JSON-shape with secret VALUES redacted.
88
+ const masked = registry.maskDeep(runId, value);
89
+ return masked as Record<string, unknown>;
90
+ }
91
+
92
+ export function createEntityHandle<TState extends Record<string, unknown>>(
93
+ args: CreateHandleArgs<TState>,
94
+ ): EntityHandle<TState> {
95
+ const { kind, schema, store, emitter, secretRegistry, read } = args;
96
+
97
+ /** Resolve the current state of one id via the plugin read accessor. */
98
+ async function readOne(id: string): Promise<TState | undefined> {
99
+ const map = await read([id]);
100
+ return map[id];
101
+ }
102
+
103
+ /** Build the transition rows for a changed-field set (prev/next views). */
104
+ function buildTransitions(args2: {
105
+ prev: Record<string, unknown> | null;
106
+ next: Record<string, unknown>;
107
+ changedFields: string[];
108
+ }): TransitionAppend[] {
109
+ const { prev, next, changedFields } = args2;
110
+ return changedFields.map((field) => ({
111
+ field,
112
+ fromValue: prev ? serializeFieldValue(prev[field]) : null,
113
+ toValue: serializeFieldValue(next[field]),
114
+ }));
115
+ }
116
+
117
+ /**
118
+ * Snapshot `prev` via the plugin `read` accessor STRICTLY BEFORE any write,
119
+ * masking run-originated reads. A change can never be missed because we
120
+ * never re-read prev after the write.
121
+ */
122
+ async function snapshotPrev(
123
+ id: string,
124
+ opts?: EntityMutationOpts,
125
+ ): Promise<Record<string, unknown> | null> {
126
+ const prevRaw = (await readOne(id)) ?? null;
127
+ return prevRaw === null
128
+ ? null
129
+ : maskForRun({
130
+ registry: secretRegistry,
131
+ runId: opts?.runId,
132
+ value: prevRaw as Record<string, unknown>,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Validate (zod) the post-write state WITHOUT masking. This is the state
138
+ * `mutate` returns to its caller — the contract is "returns the resulting
139
+ * state", and masking is purely an emission/persistence concern (it must
140
+ * not leak into the value the calling plugin gets back). A tombstone has no
141
+ * state.
142
+ */
143
+ function validateNext(applied: TState | null): Record<string, unknown> | null {
144
+ return applied === null
145
+ ? null
146
+ : (schema.parse(applied) as Record<string, unknown>);
147
+ }
148
+
149
+ /**
150
+ * Mask an already-validated state for the run-originated emit/transition
151
+ * path ONLY (a tombstone has none). Keeps secret VALUES out of the emitted
152
+ * `ENTITY_CHANGED` payload and the `entity_transitions` rows, while the
153
+ * unmasked validated state is what `mutate` returns.
154
+ */
155
+ function maskNext(
156
+ validated: Record<string, unknown> | null,
157
+ opts?: EntityMutationOpts,
158
+ ): Record<string, unknown> | null {
159
+ return validated === null
160
+ ? null
161
+ : maskForRun({
162
+ registry: secretRegistry,
163
+ runId: opts?.runId,
164
+ value: validated,
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Diff prev → next, append transitions, and emit `ENTITY_CHANGED` when
170
+ * there is a real change. `appendTransitions` performs the durable
171
+ * transition write for a non-tombstone diff in a fresh framework tx AFTER
172
+ * the plugin write has committed.
173
+ *
174
+ * Masking boundary (reactive automation engine §3.5, §12): `prev` and
175
+ * `maskedNext` are the run-masked views — they feed the diff, the
176
+ * `entity_transitions` rows, and the emitted payload, so secret VALUES never
177
+ * leak into history or change events. `returnNext` is the UNMASKED, validated
178
+ * state echoed back to `mutate` (the caller gets the real resulting state;
179
+ * masking is purely an emission/persistence concern).
180
+ */
181
+ async function diffAppendEmit(args2: {
182
+ id: string;
183
+ opts?: EntityMutationOpts;
184
+ prev: Record<string, unknown> | null;
185
+ maskedNext: Record<string, unknown> | null;
186
+ returnNext: Record<string, unknown> | null;
187
+ appendTransitions: (rows: TransitionAppend[]) => Promise<void>;
188
+ }): Promise<Record<string, unknown> | null> {
189
+ const { id, opts, prev, maskedNext, returnNext, appendTransitions } = args2;
190
+
191
+ const { changedFields, delta } = diffEntityState({ prev, next: maskedNext });
192
+
193
+ // Structurally unchanged ⇒ no transition, no emit (the write itself may
194
+ // still have touched non-state columns; that is the plugin's concern).
195
+ if (changedFields.length === 0) return returnNext;
196
+
197
+ // Append transitions for a real write (a tombstone records none, like the
198
+ // old `remove`). Built from the MASKED next so secret values stay out of
199
+ // the durable history.
200
+ if (maskedNext !== null) {
201
+ await appendTransitions(
202
+ buildTransitions({ prev, next: maskedNext, changedFields }),
203
+ );
204
+ }
205
+
206
+ // Tombstone wire shape (§13.2): `delta` is `{}` because the tombstone
207
+ // signal is `next === null`, not a per-field null delta. A real write
208
+ // carries the changed-field delta. `changedFields` is still the set of
209
+ // prev fields (drives the change-event `changedFields` list).
210
+ await emitter.emit({
211
+ kind,
212
+ id,
213
+ prev,
214
+ next: maskedNext,
215
+ delta: maskedNext === null ? {} : delta,
216
+ changedFields,
217
+ actor: resolveActor(opts),
218
+ occurredAt: new Date().toISOString(),
219
+ // Per-change identity, generated ONCE here so it travels with every
220
+ // at-least-once redelivery of THIS change. Two distinct changes within
221
+ // one millisecond share an `occurredAt`; the changeId keeps them
222
+ // distinct in the Stage-2 trigger jobId (§13.2).
223
+ changeId: crypto.randomUUID(),
224
+ });
225
+
226
+ return returnNext;
227
+ }
228
+
229
+ /**
230
+ * PLUGIN-BACKED driven pipeline for `mutate` / `remove`. The plugin's
231
+ * `apply()` runs FIRST and commits in the PLUGIN's own transaction (no
232
+ * framework tx is handed in — a plugin-backed kind lives behind a different
233
+ * client). Only AFTER that commit does the framework open its OWN
234
+ * transaction to append the transition log, then emit. The plugin write is
235
+ * authoritative; a throwing `apply` appends nothing and emits nothing.
236
+ */
237
+ async function drivePluginBacked(input: {
238
+ id: string;
239
+ opts?: EntityMutationOpts;
240
+ apply: () => Promise<TState | null>;
241
+ }): Promise<Record<string, unknown> | null> {
242
+ const { id, opts, apply } = input;
243
+
244
+ // 1. Snapshot prev STRICTLY BEFORE the plugin write (masked view, for the
245
+ // diff / transitions / emit).
246
+ const prev = await snapshotPrev(id, opts);
247
+
248
+ // 2. Run the plugin write, committed in the PLUGIN's own tx. A throw
249
+ // propagates here, so nothing below runs (no append, no emit). Validate
250
+ // once; `returnNext` is the UNMASKED state echoed to the caller and
251
+ // `maskedNext` is the run-masked view used only for emit/transitions.
252
+ const returnNext = validateNext(await apply());
253
+ const maskedNext = maskNext(returnNext, opts);
254
+
255
+ // 3 + 4. AFTER the plugin commit, append transitions in the FRAMEWORK's
256
+ // own tx, then emit. Not atomic with the plugin write (documented
257
+ // cross-plugin tx boundary): a failure here leaves correct plugin state
258
+ // with a missing history row, never a corrupted state.
259
+ return diffAppendEmit({
260
+ id,
261
+ opts,
262
+ prev,
263
+ maskedNext,
264
+ returnNext,
265
+ appendTransitions: (rows) =>
266
+ store.runInTransaction((tx) =>
267
+ store.appendTransitions({ tx, kind, entityId: id, transitions: rows }),
268
+ ),
269
+ });
270
+ }
271
+
272
+ /** Resolve "in this value since" for a field via the transition log. */
273
+ async function inStateSinceFor(
274
+ id: string,
275
+ field: string,
276
+ ): Promise<Date | null> {
277
+ const current = await readOne(id);
278
+ if (current === undefined) return null;
279
+ return store.inStateSince({
280
+ kind,
281
+ entityId: id,
282
+ field,
283
+ currentValue: current[field],
284
+ });
285
+ }
286
+
287
+ return {
288
+ kind,
289
+
290
+ async mutate(input: MutateInput<TState>): Promise<TState> {
291
+ const { id, opts, apply } = input;
292
+ // `apply` always returns a (non-null) state, so the pipeline resolves it
293
+ // regardless of whether the diff was a no-op.
294
+ const next = await drivePluginBacked({ id, opts, apply: () => apply() });
295
+ // Unreachable: `mutate`'s apply never yields null. Guarded for types.
296
+ if (next === null) {
297
+ throw new Error(
298
+ `EntityHandle.mutate: apply for kind "${kind}" id "${id}" resolved to no state`,
299
+ );
300
+ }
301
+ return next as TState;
302
+ },
303
+
304
+ async remove(input: RemoveInput): Promise<void> {
305
+ const { id, opts, apply } = input;
306
+ await drivePluginBacked({
307
+ id,
308
+ opts,
309
+ apply: async () => {
310
+ await apply();
311
+ return null;
312
+ },
313
+ });
314
+ },
315
+
316
+ async get(id) {
317
+ return readOne(id);
318
+ },
319
+
320
+ async getMany(ids) {
321
+ return read(ids);
322
+ },
323
+
324
+ async inStateSince(id, field) {
325
+ return inStateSinceFor(id, field);
326
+ },
327
+
328
+ async inStateForMs(id, field) {
329
+ const since = await inStateSinceFor(id, field);
330
+ if (since === null) return 0;
331
+ return Math.max(Date.now() - since.getTime(), 0);
332
+ },
333
+
334
+ async transitionCount({ id, field, windowMs }) {
335
+ return store.transitionCount({
336
+ kind,
337
+ entityId: id,
338
+ field,
339
+ windowMs,
340
+ now: new Date(),
341
+ });
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Integration test (real Postgres): cross-pod reactive-entity read consistency.
3
+ *
4
+ * This is the DETERMINISTIC backstop for `.agent/rules/state-and-scale.md`
5
+ * that the single-process unit suite structurally cannot provide: "Does a read
6
+ * return the same answer on every pod?". The unit suite runs in one process, so
7
+ * a value written in that process is trivially visible to the same process —
8
+ * green typecheck/lint/tests do NOT prove scale-correctness. This test catches
9
+ * the class of bug where a reactive entity's CURRENT state is stored
10
+ * process-local (pod-local) memory instead of shared durable storage.
11
+ *
12
+ * ## Two-pod model (faithful proxy, justified)
13
+ *
14
+ * A full second platform boot per case is unnecessary to expose the bug: the
15
+ * bug is purely about WHERE the `read` accessor resolves state from. We model
16
+ * TWO independent "pods" as two independent entity registries, each with its
17
+ * OWN `EntityStore` over its OWN `pg.Pool` connection, BOTH pointed at the SAME
18
+ * Postgres database + schema:
19
+ *
20
+ * - instance A — registry A + store A + pool A
21
+ * - instance B — registry B + store B + pool B
22
+ *
23
+ * Separate pools/registries = separate processes for the property under test
24
+ * (no shared JS heap between A and B), while one DB + schema = the shared
25
+ * durable substrate N pods share in production. A mutation driven on A's handle
26
+ * (the pod that "claims the dispatch job") must be visible to B's `read` (the
27
+ * pod that later runs scope enrichment / `wait_until` re-eval).
28
+ *
29
+ * ## What it asserts
30
+ *
31
+ * 1. DURABLE kind (`it-shared`): `read` resolves from a shared table. A write
32
+ * on A is visible to B's `get` AND B's `getMany` (scope-resolution shape).
33
+ * This is the property a correct reactive entity MUST have.
34
+ *
35
+ * 2. POD-LOCAL kind (`it-pod-local`, NEGATIVE CONTROL): `read` resolves from a
36
+ * per-instance in-memory Map. A write on A is INVISIBLE to B's read. We
37
+ * ASSERT the inconsistency, so the test documents the bug shape and proves
38
+ * it has teeth — a pod-local `read` for a real entity WOULD fail assertion
39
+ * (1). If durable storage ever silently aliased the in-memory map (e.g. a
40
+ * shared module singleton), this control would start failing.
41
+ *
42
+ * Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it.
43
+ * Connection comes from `CHECKSTACK_IT_PG_URL`. Each run isolates itself in a
44
+ * freshly created Postgres schema and cleans up after itself.
45
+ */
46
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
47
+ import { drizzle } from "drizzle-orm/node-postgres";
48
+ import { z } from "zod";
49
+ import { Pool } from "pg";
50
+
51
+ import type { SafeDatabase } from "@checkstack/backend-api";
52
+
53
+ import { entityTransitions } from "../schema";
54
+ import type { EntityHandle, EntityRead } from "./define-entity";
55
+ import { createEntityRegistry } from "./registry";
56
+ import { createEntityStore } from "./entity-store";
57
+ import { createChangeEmitter } from "./change-emitter";
58
+ import { createRunSecretRegistry } from "../dispatch/run-secret-registry";
59
+
60
+ const PG_URL =
61
+ process.env.CHECKSTACK_IT_PG_URL ??
62
+ "postgres://postgres:postgres@localhost:5432/postgres";
63
+
64
+ const SCHEMA = `it_crosspod_${crypto.randomUUID().replace(/-/g, "")}`;
65
+
66
+ /** The representative shared-table state shape. */
67
+ const sharedStateSchema = z.object({ status: z.string() });
68
+ type SharedState = z.infer<typeof sharedStateSchema>;
69
+
70
+ const podLocalStateSchema = z.object({ status: z.string() });
71
+ type PodLocalState = z.infer<typeof podLocalStateSchema>;
72
+
73
+ const SHARED_KIND = "it-shared";
74
+ const POD_LOCAL_KIND = "it-pod-local";
75
+
76
+ /** A `SafeDatabase` over a pool (drizzle's NodePgDatabase, query-API omitted). */
77
+ type StoreSchema = { entityTransitions: typeof entityTransitions };
78
+
79
+ /**
80
+ * One simulated pod: an independent registry + store + emitter over its own
81
+ * pool, plus its own pod-local in-memory map (the negative control's backing
82
+ * store). The DURABLE kind reads the shared `it_entities` table; the POD-LOCAL
83
+ * kind reads THIS pod's map.
84
+ */
85
+ interface Pod {
86
+ readonly pool: Pool;
87
+ readonly sharedHandle: EntityHandle<SharedState>;
88
+ readonly podLocalHandle: EntityHandle<PodLocalState>;
89
+ /** This pod's pod-local backing store (per-instance heap state). */
90
+ readonly localMap: Map<string, PodLocalState>;
91
+ end(): Promise<void>;
92
+ }
93
+
94
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
95
+ "cross-pod reactive-entity read consistency (real Postgres)",
96
+ () => {
97
+ const pods: Pod[] = [];
98
+
99
+ /** Build one independent "pod" over its own connection to the shared DB. */
100
+ function makePod(): Pod {
101
+ const pool = new Pool({
102
+ connectionString: PG_URL,
103
+ options: `-c search_path=${SCHEMA}`,
104
+ });
105
+ const db = drizzle({
106
+ client: pool,
107
+ schema: { entityTransitions },
108
+ }) as unknown as SafeDatabase<StoreSchema>;
109
+
110
+ const emitter = createChangeEmitter();
111
+ const secretRegistry = createRunSecretRegistry();
112
+ const registry = createEntityRegistry({ secretRegistry, emitter });
113
+ registry.setStore({ store: createEntityStore(db) });
114
+
115
+ // DURABLE kind: `read` resolves from the SHARED `it_entities` table, so a
116
+ // write on any pod is visible to every pod. This is the correct shape.
117
+ const sharedRead: EntityRead<SharedState> = async (ids) => {
118
+ if (ids.length === 0) return {};
119
+ const { rows } = await pool.query<{ id: string; status: string }>(
120
+ `SELECT id, status FROM "${SCHEMA}".it_entities WHERE id = ANY($1)`,
121
+ [ids as string[]],
122
+ );
123
+ const out: Record<string, SharedState> = {};
124
+ for (const row of rows) out[row.id] = { status: row.status };
125
+ return out;
126
+ };
127
+ const sharedHandle = registry.defineEntity<SharedState>({
128
+ kind: SHARED_KIND,
129
+ state: sharedStateSchema,
130
+ read: sharedRead,
131
+ });
132
+
133
+ // POD-LOCAL kind (NEGATIVE CONTROL): `read` resolves from THIS pod's
134
+ // in-memory map. A write on pod A's map is invisible to pod B — the
135
+ // horizontal-scale bug this whole guard exists to forbid.
136
+ const localMap = new Map<string, PodLocalState>();
137
+ const podLocalRead: EntityRead<PodLocalState> = async (ids) => {
138
+ const out: Record<string, PodLocalState> = {};
139
+ for (const id of ids) {
140
+ const value = localMap.get(id);
141
+ if (value) out[id] = value;
142
+ }
143
+ return out;
144
+ };
145
+ const podLocalHandle = registry.defineEntity<PodLocalState>({
146
+ kind: POD_LOCAL_KIND,
147
+ state: podLocalStateSchema,
148
+ read: podLocalRead,
149
+ });
150
+
151
+ return {
152
+ pool,
153
+ sharedHandle,
154
+ podLocalHandle,
155
+ localMap,
156
+ end: () => pool.end(),
157
+ };
158
+ }
159
+
160
+ let podA: Pod;
161
+ let podB: Pod;
162
+
163
+ beforeAll(async () => {
164
+ const setupPool = new Pool({ connectionString: PG_URL });
165
+ try {
166
+ await setupPool.query(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA}"`);
167
+ // The plugin-owned shared state table the DURABLE kind reads from.
168
+ await setupPool.query(`
169
+ CREATE TABLE "${SCHEMA}".it_entities (
170
+ id text PRIMARY KEY,
171
+ status text NOT NULL
172
+ )
173
+ `);
174
+ // The framework transition log every handle appends to (Model B).
175
+ await setupPool.query(`
176
+ CREATE TABLE "${SCHEMA}".entity_transitions (
177
+ id text PRIMARY KEY,
178
+ kind text NOT NULL,
179
+ entity_id text NOT NULL,
180
+ field text NOT NULL,
181
+ from_value text,
182
+ to_value text NOT NULL,
183
+ transitioned_at timestamp NOT NULL DEFAULT now()
184
+ )
185
+ `);
186
+ } finally {
187
+ await setupPool.end();
188
+ }
189
+
190
+ podA = makePod();
191
+ podB = makePod();
192
+ pods.push(podA, podB);
193
+ });
194
+
195
+ afterAll(async () => {
196
+ await Promise.all(pods.map((p) => p.end()));
197
+ const cleanupPool = new Pool({ connectionString: PG_URL });
198
+ try {
199
+ await cleanupPool.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
200
+ } finally {
201
+ await cleanupPool.end();
202
+ }
203
+ });
204
+
205
+ /** The plugin write for the DURABLE kind: UPSERT the shared table row. */
206
+ function writeShared(pod: Pod, id: string, status: string) {
207
+ return async (): Promise<SharedState> => {
208
+ await pod.pool.query(
209
+ `INSERT INTO "${SCHEMA}".it_entities (id, status) VALUES ($1, $2)
210
+ ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status`,
211
+ [id, status],
212
+ );
213
+ return { status };
214
+ };
215
+ }
216
+
217
+ /** The plugin "write" for the POD-LOCAL kind: mutate THIS pod's map only. */
218
+ function writePodLocal(pod: Pod, id: string, status: string) {
219
+ return async (): Promise<PodLocalState> => {
220
+ pod.localMap.set(id, { status });
221
+ return { status };
222
+ };
223
+ }
224
+
225
+ it("durable kind: a write on pod A is visible to pod B's read + getMany", async () => {
226
+ const id = `ent-${crypto.randomUUID()}`;
227
+
228
+ // Mutate via pod A's handle (the pod that claimed the dispatch).
229
+ await podA.sharedHandle.mutate({ id, apply: writeShared(podA, id, "open") });
230
+
231
+ // Pod A sees its own write (sanity).
232
+ expect(await podA.sharedHandle.get(id)).toEqual({ status: "open" });
233
+
234
+ // The property under test: pod B — a DIFFERENT registry over a DIFFERENT
235
+ // connection, no shared heap — sees the SAME value.
236
+ expect(await podB.sharedHandle.get(id)).toEqual({ status: "open" });
237
+
238
+ // Scope-resolution shape (`getMany`) is consistent across pods too.
239
+ const manyB = await podB.sharedHandle.getMany([id]);
240
+ expect(manyB[id]).toEqual({ status: "open" });
241
+
242
+ // A follow-up write on A is likewise visible on B (not just first-write).
243
+ await podA.sharedHandle.mutate({
244
+ id,
245
+ apply: writeShared(podA, id, "resolved"),
246
+ });
247
+ expect(await podB.sharedHandle.get(id)).toEqual({ status: "resolved" });
248
+ });
249
+
250
+ it("NEGATIVE CONTROL: a pod-local read does NOT see another pod's write (proves teeth)", async () => {
251
+ const id = `ent-${crypto.randomUUID()}`;
252
+
253
+ // Mutate the POD-LOCAL kind on pod A: only pod A's in-memory map changes.
254
+ await podA.podLocalHandle.mutate({
255
+ id,
256
+ apply: writePodLocal(podA, id, "open"),
257
+ });
258
+
259
+ // Pod A sees its own pod-local write.
260
+ expect(await podA.podLocalHandle.get(id)).toEqual({ status: "open" });
261
+
262
+ // Pod B's read resolves from ITS OWN (empty) map — the write is invisible.
263
+ // This is exactly the horizontal-scale bug the guard forbids. We assert
264
+ // the inconsistency so the test documents the failure shape WITHOUT
265
+ // itself failing: were `it-pod-local` a real reactive entity, the durable
266
+ // assertion above would FAIL for it.
267
+ expect(await podB.podLocalHandle.get(id)).toBeUndefined();
268
+ const manyB = await podB.podLocalHandle.getMany([id]);
269
+ expect(manyB[id]).toBeUndefined();
270
+
271
+ // Cross-check: the DURABLE kind would have been visible here — so the
272
+ // difference is purely WHERE state lives, not a test artifact.
273
+ const durableId = `ent-${crypto.randomUUID()}`;
274
+ await podA.sharedHandle.mutate({
275
+ id: durableId,
276
+ apply: writeShared(podA, durableId, "open"),
277
+ });
278
+ expect(await podB.sharedHandle.get(durableId)).toEqual({ status: "open" });
279
+ });
280
+ },
281
+ );