@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.
- package/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- 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
|
+
);
|