@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,50 @@
1
+ /**
2
+ * Public surface of the entity state machine (reactive automation engine
3
+ * §4). The internal `ENTITY_CHANGED` hook (`./hook`) is deliberately NOT
4
+ * re-exported — `defineEntity` is the only typed path that emits an
5
+ * entity-change event (§6.1).
6
+ */
7
+ export { entityExtensionPoint } from "./extension-point";
8
+ export type {
9
+ EntityExtensionPoint,
10
+ RegisterChangeDeriver,
11
+ } from "./extension-point";
12
+ export {
13
+ createChangeDeriverRegistry,
14
+ } from "./change-derivers";
15
+ export type {
16
+ ChangeDeriverRegistry,
17
+ EntityChangeDeriver,
18
+ EntityChangePayloadMapper,
19
+ } from "./change-derivers";
20
+ export {
21
+ createEntityChangedSubscriptions,
22
+ } from "./on-entity-changed";
23
+ export type {
24
+ EntityChangedSubscriptions,
25
+ OnEntityChanged,
26
+ OnEntityChangedInput,
27
+ EntityChangedHandler,
28
+ EntityChangedDelivery,
29
+ EntityChangedUnsubscribe,
30
+ } from "./on-entity-changed";
31
+ export type {
32
+ DefineEntity,
33
+ DefineEntityInput,
34
+ DeclareNonReactiveState,
35
+ DeclareNonReactiveStateInput,
36
+ EntityHandle,
37
+ EntityMutationOpts,
38
+ EntityRead,
39
+ MutateInput,
40
+ RemoveInput,
41
+ } from "./define-entity";
42
+ export { withEntityWrite, withEntityRemove } from "./with-entity-write";
43
+
44
+ // Internal wiring surface (consumed by automation-backend's index.ts).
45
+ export { createEntityRegistry } from "./registry";
46
+ export type { EntityRegistry } from "./registry";
47
+ export { createEntityStore } from "./entity-store";
48
+ export type { EntityTx } from "./entity-store";
49
+ export { createChangeEmitter } from "./change-emitter";
50
+ export type { ChangeEmitter } from "./change-emitter";
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Model B reactive wrapper — driven `mutate` / `remove` over a PLUGIN-OWNED
3
+ * store (reactive automation engine §4, reshaped).
4
+ *
5
+ * These tests use a FAKE plugin store (a plain in-memory map) as the `read`
6
+ * accessor + the `apply` write target — NOT `entity_state`. They prove the
7
+ * Model B invariants:
8
+ *
9
+ * - `mutate` emits on a real diff, no-ops on an equal write;
10
+ * - `remove` emits a tombstone (next = null);
11
+ * - a transition is appended on EVERY change, even though current state
12
+ * lives in a non-`entity_state` backing (durable platform history for a
13
+ * homeless/in-memory kind);
14
+ * - `prev` is snapshotted BEFORE `apply`, so a change is never missed;
15
+ * - the change event is emitted only AFTER the plugin write resolves — a
16
+ * throwing `apply` emits nothing and appends no transition;
17
+ * - run-originated writes are masked;
18
+ * - `get` / `getMany` route to the plugin `read`.
19
+ */
20
+ import { describe, it, expect } from "bun:test";
21
+ import { z } from "zod";
22
+ import { SYSTEM_ACTOR } from "@checkstack/common";
23
+ import type { EntityChanged } from "@checkstack/automation-common";
24
+
25
+ import { createEntityHandle } from "./create-handle";
26
+ import { createChangeEmitter, type ChangeEmitter } from "./change-emitter";
27
+ import { createFakeEntityStore } from "./fake-entity-store";
28
+ import { createRunSecretRegistry } from "../dispatch/run-secret-registry";
29
+
30
+ const satelliteSchema = z.object({
31
+ status: z.enum(["online", "offline"]),
32
+ region: z.string(),
33
+ });
34
+ type Satellite = z.infer<typeof satelliteSchema>;
35
+
36
+ /**
37
+ * A FAKE plugin store: an in-memory map standing in for a domain table or an
38
+ * in-memory connection map. `read` + the per-write `apply` operate on it; the
39
+ * framework `entity_state` table is NEVER touched.
40
+ */
41
+ function fakePluginStore() {
42
+ const rows = new Map<string, Satellite>();
43
+ return {
44
+ rows,
45
+ read: async (ids: ReadonlyArray<string>) => {
46
+ const out: Record<string, Satellite> = {};
47
+ for (const id of ids) {
48
+ const row = rows.get(id);
49
+ if (row) out[id] = row;
50
+ }
51
+ return out;
52
+ },
53
+ /** Write current state in the plugin store (mirrors `apply`'s job). */
54
+ put: (id: string, state: Satellite) => {
55
+ rows.set(id, state);
56
+ return state;
57
+ },
58
+ del: (id: string) => {
59
+ rows.delete(id);
60
+ },
61
+ };
62
+ }
63
+
64
+ function setup(opts?: { secretValues?: string[]; runId?: string }) {
65
+ const store = createFakeEntityStore();
66
+ const events: EntityChanged[] = [];
67
+ const emitter: ChangeEmitter = createChangeEmitter();
68
+ void emitter.wire(async (payload) => {
69
+ events.push(payload);
70
+ });
71
+ const secretRegistry = createRunSecretRegistry();
72
+ if (opts?.runId && opts.secretValues) {
73
+ secretRegistry.register(opts.runId, opts.secretValues);
74
+ }
75
+ const plugin = fakePluginStore();
76
+ // PLUGIN-BACKED kind: `read` points at the plugin's own map.
77
+ const handle = createEntityHandle<Satellite>({
78
+ kind: "satellite-connection",
79
+ schema: satelliteSchema,
80
+ store,
81
+ emitter,
82
+ secretRegistry,
83
+ read: plugin.read,
84
+ });
85
+ return { store, events, handle, plugin, secretRegistry };
86
+ }
87
+
88
+ describe("Model B mutate — emit on diff / no-op on equal", () => {
89
+ it("emits a create (prev null) and appends transitions, with NO entity_state row", async () => {
90
+ const { store, events, handle, plugin } = setup();
91
+ const next = await handle.mutate({
92
+ id: "sat-1",
93
+ // PLUGIN-BACKED `apply` takes NO framework tx — the plugin owns its own
94
+ // write/tx. It just returns the post-write state.
95
+ apply: () =>
96
+ Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
97
+ });
98
+ expect(next).toEqual({ status: "online", region: "eu" });
99
+ // The current state lives ONLY in the plugin map — entity_state untouched.
100
+ expect(store.rows.size).toBe(0);
101
+ // But a durable transition is recorded for every change (history).
102
+ expect(store.transitions.map((t) => t.field).sort()).toEqual([
103
+ "region",
104
+ "status",
105
+ ]);
106
+ expect(events).toHaveLength(1);
107
+ const ev = events[0]!;
108
+ expect(ev.kind).toBe("satellite-connection");
109
+ expect(ev.id).toBe("sat-1");
110
+ expect(ev.prev).toBeNull();
111
+ expect(ev.next).toEqual({ status: "online", region: "eu" });
112
+ expect(ev.changedFields.sort()).toEqual(["region", "status"]);
113
+ expect(ev.actor).toEqual(SYSTEM_ACTOR);
114
+ });
115
+
116
+ it("emits only the changed-field delta on an update", async () => {
117
+ const { events, handle, plugin, store } = setup();
118
+ await handle.mutate({
119
+ id: "sat-1",
120
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
121
+ });
122
+ await handle.mutate({
123
+ id: "sat-1",
124
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
125
+ });
126
+ expect(events).toHaveLength(2);
127
+ expect(events[1]!.changedFields).toEqual(["status"]);
128
+ expect(events[1]!.delta).toEqual({ status: "offline" });
129
+ // One transition per change: 2 (create) + 1 (status flip).
130
+ expect(store.transitions).toHaveLength(3);
131
+ });
132
+
133
+ it("no-ops (no emit, no transition) when apply returns an equal state", async () => {
134
+ const { events, handle, plugin, store } = setup();
135
+ await handle.mutate({
136
+ id: "sat-1",
137
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
138
+ });
139
+ const before = store.transitions.length;
140
+ // A write that does not change the state (same values) → no emit.
141
+ const result = await handle.mutate({
142
+ id: "sat-1",
143
+ apply: () => Promise.resolve(plugin.put("sat-1", { region: "eu", status: "online" })),
144
+ });
145
+ expect(result).toEqual({ status: "online", region: "eu" });
146
+ expect(events).toHaveLength(1);
147
+ expect(store.transitions).toHaveLength(before);
148
+ });
149
+ });
150
+
151
+ describe("Model B mutate — prev snapshotted BEFORE apply", () => {
152
+ it("captures prev before the plugin write so a change is never missed", async () => {
153
+ const { events, handle, plugin } = setup();
154
+ plugin.put("sat-1", { status: "online", region: "eu" });
155
+ await handle.mutate({
156
+ id: "sat-1",
157
+ // `apply` mutates the SAME map `read` reads. If prev were re-read AFTER
158
+ // the write, prev would equal next and the change would vanish.
159
+ apply: () =>
160
+ Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
161
+ });
162
+ expect(events).toHaveLength(1);
163
+ expect(events[0]!.prev).toEqual({ status: "online", region: "eu" });
164
+ expect(events[0]!.next).toEqual({ status: "offline", region: "eu" });
165
+ expect(events[0]!.changedFields).toEqual(["status"]);
166
+ });
167
+ });
168
+
169
+ describe("Model B mutate — post-commit emit (no emit on rollback)", () => {
170
+ it("emits nothing and appends no transition when apply throws", async () => {
171
+ const { events, handle, store, plugin } = setup();
172
+ plugin.put("sat-1", { status: "online", region: "eu" });
173
+ await expect(
174
+ handle.mutate({
175
+ id: "sat-1",
176
+ apply: () => {
177
+ // The plugin write fails (in the plugin's own tx) → nothing else runs.
178
+ throw new Error("write blew up");
179
+ },
180
+ }),
181
+ ).rejects.toThrow(/write blew up/);
182
+ expect(events).toHaveLength(0);
183
+ expect(store.transitions).toHaveLength(0);
184
+ });
185
+ });
186
+
187
+ describe("Model B mutate — cross-plugin tx boundary", () => {
188
+ it("runs the plugin write FIRST, then appends transitions in a SEPARATE framework tx", async () => {
189
+ const { handle, plugin, store } = setup();
190
+ const order: string[] = [];
191
+ // Observe the framework transaction boundary: the plugin write must have
192
+ // already happened (the row is in the plugin map) by the time the
193
+ // framework opens its tx to append the transition.
194
+ store.onTransaction(() => {
195
+ order.push(
196
+ plugin.rows.has("sat-1") ? "framework-tx-after-write" : "framework-tx-before-write",
197
+ );
198
+ });
199
+ await handle.mutate({
200
+ id: "sat-1",
201
+ apply: () => {
202
+ order.push("plugin-write");
203
+ return Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" }));
204
+ },
205
+ });
206
+ // Plugin write happens BEFORE the framework opens its transition-append tx,
207
+ // and that tx sees the already-committed plugin state.
208
+ expect(order).toEqual(["plugin-write", "framework-tx-after-write"]);
209
+ expect(store.transitions).toHaveLength(2);
210
+ });
211
+
212
+ it("does NOT open a framework tx when the plugin write throws", async () => {
213
+ const { handle, plugin, store } = setup();
214
+ let frameworkTxOpened = false;
215
+ store.onTransaction(() => {
216
+ frameworkTxOpened = true;
217
+ });
218
+ plugin.put("sat-1", { status: "online", region: "eu" });
219
+ await expect(
220
+ handle.mutate({
221
+ id: "sat-1",
222
+ apply: () => {
223
+ throw new Error("boom");
224
+ },
225
+ }),
226
+ ).rejects.toThrow(/boom/);
227
+ // The transition-append tx is opened ONLY after a committed plugin write.
228
+ expect(frameworkTxOpened).toBe(false);
229
+ });
230
+ });
231
+
232
+ describe("Model B remove — tombstone", () => {
233
+ it("emits a tombstone (next null) from the plugin delete", async () => {
234
+ const { events, handle, plugin, store } = setup();
235
+ await handle.mutate({
236
+ id: "sat-1",
237
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
238
+ });
239
+ await handle.remove({
240
+ id: "sat-1",
241
+ apply: async () => {
242
+ plugin.del("sat-1");
243
+ },
244
+ });
245
+ expect(events).toHaveLength(2);
246
+ const tombstone = events[1]!;
247
+ expect(tombstone.next).toBeNull();
248
+ expect(tombstone.prev).toEqual({ status: "online", region: "eu" });
249
+ expect(tombstone.delta).toEqual({});
250
+ expect(store.rows.size).toBe(0);
251
+ });
252
+
253
+ it("removing an absent entity is a no-op (no event)", async () => {
254
+ const { events, handle } = setup();
255
+ await handle.remove({
256
+ id: "ghost",
257
+ apply: async () => {
258
+ /* nothing to delete */
259
+ },
260
+ });
261
+ expect(events).toHaveLength(0);
262
+ });
263
+ });
264
+
265
+ describe("Model B — masking run-originated writes", () => {
266
+ it("masks secret values in the emitted next when runId is set", async () => {
267
+ const { events, handle, plugin } = setup({
268
+ runId: "run-1",
269
+ secretValues: ["s3cr3t"],
270
+ });
271
+ await handle.mutate({
272
+ id: "sat-1",
273
+ opts: { runId: "run-1" },
274
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "s3cr3t" })),
275
+ });
276
+ expect(events).toHaveLength(1);
277
+ expect(JSON.stringify(events[0]!.next)).not.toContain("s3cr3t");
278
+ });
279
+
280
+ it("does not mask when the write is not run-originated", async () => {
281
+ const { events, handle, plugin } = setup({
282
+ runId: "run-1",
283
+ secretValues: ["s3cr3t"],
284
+ });
285
+ await handle.mutate({
286
+ id: "sat-1",
287
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "s3cr3t" })),
288
+ });
289
+ expect(JSON.stringify(events[0]!.next)).toContain("s3cr3t");
290
+ });
291
+
292
+ // Fix 3 (security) regression: catalog `metadata` is `z.record(z.unknown())`
293
+ // — a nested record, the only reactive catalog field that can carry an
294
+ // arbitrary run-resolved secret string. A run-originated write (runId set)
295
+ // MUST mask that secret in BOTH the emitted `ENTITY_CHANGED` and the durable
296
+ // `entity_transitions` rows, even when nested inside the metadata object.
297
+ it("masks a secret nested inside a record/metadata field (emit + transitions)", async () => {
298
+ const metadataSchema = z.object({
299
+ name: z.string(),
300
+ metadata: z.record(z.string(), z.unknown()),
301
+ });
302
+ type WithMetadata = z.infer<typeof metadataSchema>;
303
+ const store = createFakeEntityStore();
304
+ const events: EntityChanged[] = [];
305
+ const emitter = createChangeEmitter();
306
+ void emitter.wire(async (payload) => {
307
+ events.push(payload);
308
+ });
309
+ const secretRegistry = createRunSecretRegistry();
310
+ secretRegistry.register("run-1", ["s3cr3t"]);
311
+ const rows = new Map<string, WithMetadata>();
312
+ const handle = createEntityHandle<WithMetadata>({
313
+ kind: "catalog-system",
314
+ schema: metadataSchema,
315
+ store,
316
+ emitter,
317
+ secretRegistry,
318
+ read: async (ids) => {
319
+ const out: Record<string, WithMetadata> = {};
320
+ for (const id of ids) {
321
+ const row = rows.get(id);
322
+ if (row) out[id] = row;
323
+ }
324
+ return out;
325
+ },
326
+ });
327
+
328
+ const returned = await handle.mutate({
329
+ id: "sys-1",
330
+ opts: { runId: "run-1" },
331
+ apply: () => {
332
+ const next: WithMetadata = {
333
+ name: "API",
334
+ metadata: { token: "s3cr3t" },
335
+ };
336
+ rows.set("sys-1", next);
337
+ return Promise.resolve(next);
338
+ },
339
+ });
340
+
341
+ // The plugin gets the REAL value back...
342
+ expect(returned).toEqual({ name: "API", metadata: { token: "s3cr3t" } });
343
+ // ...but the emitted change masks the nested secret...
344
+ expect(events).toHaveLength(1);
345
+ expect(JSON.stringify(events[0]!.next)).not.toContain("s3cr3t");
346
+ // ...and the durable transition rows mask it too.
347
+ expect(store.transitions.some((t) => t.toValue.includes("s3cr3t"))).toBe(
348
+ false,
349
+ );
350
+ });
351
+
352
+ it("mutate RETURNS the unmasked state even though the emitted next is masked", async () => {
353
+ // Masking is an emission/persistence concern: the EMITTED payload + the
354
+ // transition rows are masked, but `mutate`'s return is the real resulting
355
+ // state (the contract is "returns the resulting state"). The calling
356
+ // plugin must get its actual value back, not a redaction token.
357
+ const { events, handle, plugin, store } = setup({
358
+ runId: "run-1",
359
+ secretValues: ["s3cr3t"],
360
+ });
361
+ const returned = await handle.mutate({
362
+ id: "sat-1",
363
+ opts: { runId: "run-1" },
364
+ apply: () =>
365
+ Promise.resolve(plugin.put("sat-1", { status: "online", region: "s3cr3t" })),
366
+ });
367
+ // Return value is UNMASKED.
368
+ expect(returned).toEqual({ status: "online", region: "s3cr3t" });
369
+ // ...but the emitted change event masks the secret value.
370
+ expect(JSON.stringify(events[0]!.next)).not.toContain("s3cr3t");
371
+ // ...and the durable transition rows also mask it.
372
+ expect(store.transitions.some((t) => t.toValue.includes("s3cr3t"))).toBe(
373
+ false,
374
+ );
375
+ });
376
+ });
377
+
378
+ describe("Model B — changeId for dispatch dedup", () => {
379
+ it("stamps a distinct changeId on each emitted change", async () => {
380
+ const { events, handle, plugin } = setup();
381
+ await handle.mutate({
382
+ id: "sat-1",
383
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
384
+ });
385
+ await handle.mutate({
386
+ id: "sat-1",
387
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
388
+ });
389
+ expect(events).toHaveLength(2);
390
+ // Each distinct change carries a changeId...
391
+ expect(typeof events[0]!.changeId).toBe("string");
392
+ expect(events[0]!.changeId).not.toHaveLength(0);
393
+ // ...and two distinct changes get distinct ids, even back-to-back (where
394
+ // `occurredAt` can collide at millisecond granularity).
395
+ expect(events[0]!.changeId).not.toBe(events[1]!.changeId);
396
+ });
397
+ });
398
+
399
+ describe("Model B — get / getMany route to plugin read", () => {
400
+ it("get / getMany read from the plugin store", async () => {
401
+ const { handle, plugin, store } = setup();
402
+ plugin.put("sat-1", { status: "online", region: "eu" });
403
+ plugin.put("sat-2", { status: "offline", region: "us" });
404
+ expect(await handle.get("sat-1")).toEqual({ status: "online", region: "eu" });
405
+ expect(await handle.get("missing")).toBeUndefined();
406
+ const many = await handle.getMany(["sat-1", "sat-2", "missing"]);
407
+ expect(Object.keys(many).sort()).toEqual(["sat-1", "sat-2"]);
408
+ // Reads never touch entity_state.
409
+ expect(store.rows.size).toBe(0);
410
+ });
411
+ });
412
+
413
+ describe("Model B mutate — validation + actor", () => {
414
+ it("hard-fails an invalid state via zod (apply returns a bad shape)", async () => {
415
+ const { handle, plugin } = setup();
416
+ await expect(
417
+ handle.mutate({
418
+ id: "sat-1",
419
+ apply: () =>
420
+ Promise.resolve(
421
+ // @ts-expect-error — deliberately invalid status
422
+ plugin.put("sat-1", { status: "nope", region: "eu" }),
423
+ ),
424
+ }),
425
+ ).rejects.toThrow();
426
+ });
427
+
428
+ it("carries an explicit actor when provided", async () => {
429
+ const { events, handle, plugin } = setup();
430
+ const actor = { type: "user" as const, id: "u-1", name: "Alice" };
431
+ await handle.mutate({
432
+ id: "sat-1",
433
+ opts: { actor },
434
+ apply: () =>
435
+ Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
436
+ });
437
+ expect(events[0]!.actor).toEqual(actor);
438
+ });
439
+ });
440
+
441
+ describe("Model B — transition helpers over plugin-backed state", () => {
442
+ it("inStateSince matches the plugin store's current value", async () => {
443
+ const { handle, plugin, store } = setup();
444
+ store.setClock(() => new Date("2026-01-01T00:00:00.000Z"));
445
+ await handle.mutate({
446
+ id: "sat-1",
447
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
448
+ });
449
+ store.setClock(() => new Date("2026-01-01T01:00:00.000Z"));
450
+ await handle.mutate({
451
+ id: "sat-1",
452
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
453
+ });
454
+ const since = await handle.inStateSince("sat-1", "status");
455
+ expect(since?.toISOString()).toBe("2026-01-01T01:00:00.000Z");
456
+ });
457
+
458
+ it("inStateForMs reflects the elapsed time since the latest transition", async () => {
459
+ const { handle, plugin, store } = setup();
460
+ const start = new Date(Date.now() - 5_000);
461
+ store.setClock(() => start);
462
+ await handle.mutate({
463
+ id: "sat-1",
464
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
465
+ });
466
+ const ms = await handle.inStateForMs("sat-1", "status");
467
+ expect(ms).toBeGreaterThanOrEqual(4_000);
468
+ });
469
+
470
+ it("transitionCount counts transitions of a field in the window", async () => {
471
+ const { handle, plugin, store } = setup();
472
+ const now = Date.now();
473
+ let t = now - 3 * 60_000;
474
+ store.setClock(() => new Date(t));
475
+ await handle.mutate({
476
+ id: "sat-1",
477
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
478
+ });
479
+ t += 60_000;
480
+ await handle.mutate({
481
+ id: "sat-1",
482
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
483
+ });
484
+ t += 60_000;
485
+ await handle.mutate({
486
+ id: "sat-1",
487
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
488
+ });
489
+ const count = await handle.transitionCount({
490
+ id: "sat-1",
491
+ field: "status",
492
+ windowMs: 10 * 60_000,
493
+ });
494
+ expect(count).toBe(3);
495
+ });
496
+
497
+ it("transitionCount excludes transitions outside the window", async () => {
498
+ const { handle, plugin, store } = setup();
499
+ const now = Date.now();
500
+ store.setClock(() => new Date(now - 60 * 60_000)); // 1h ago
501
+ await handle.mutate({
502
+ id: "sat-1",
503
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "online", region: "eu" })),
504
+ });
505
+ store.setClock(() => new Date(now - 30_000)); // 30s ago
506
+ await handle.mutate({
507
+ id: "sat-1",
508
+ apply: () => Promise.resolve(plugin.put("sat-1", { status: "offline", region: "eu" })),
509
+ });
510
+ const count = await handle.transitionCount({
511
+ id: "sat-1",
512
+ field: "status",
513
+ windowMs: 5 * 60_000, // only the last 5 min
514
+ });
515
+ expect(count).toBe(1);
516
+ });
517
+ });