@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,200 @@
1
+ /**
2
+ * The entity registry — the runtime behind the `automation.entity`
3
+ * extension point. It produces `defineEntity` (and its sibling
4
+ * `declareNonReactiveState`), enforces the load-time validation rules
5
+ * (§6.3), tracks declarable expression indexes for init-time creation, and
6
+ * records escape-hatch declarations for the (later-phase) lint rule.
7
+ *
8
+ * Model B: `defineEntity` owns no current-state storage. Every kind declares
9
+ * a plugin `read` accessor pointing at wherever its state lives (its own
10
+ * table, an in-memory map, or a computed value).
11
+ *
12
+ * One registry instance per automation-backend process. `defineEntity` is
13
+ * callable from another plugin's `register`/`init` (Proxy-buffered until the
14
+ * impl registers), so the registry tolerates being driven before the DB is
15
+ * resolved: handles capture a lazily-set transition store via an indirection,
16
+ * and change events buffer via the emitter (see `change-emitter.ts`). The
17
+ * store/db are bound once at init.
18
+ */
19
+ import { z } from "zod";
20
+
21
+ import type {
22
+ DeclareNonReactiveStateInput,
23
+ DefineEntity,
24
+ DefineEntityInput,
25
+ EntityHandle,
26
+ EntityRead,
27
+ } from "./define-entity";
28
+ import type { EntityStore } from "./entity-store";
29
+ import { createEntityHandle } from "./create-handle";
30
+ import type { ChangeEmitter } from "./change-emitter";
31
+ import type { RunSecretRegistry } from "../dispatch/run-secret-registry";
32
+
33
+ /** A registered escape-hatch declaration (for the lint rule, later phase). */
34
+ export type NonReactiveDeclaration = DeclareNonReactiveStateInput;
35
+
36
+ /** A batched per-kind read resolver: `getMany(ids)` for one kind. */
37
+ export type EntityKindResolver = (
38
+ ids: ReadonlyArray<string>,
39
+ ) => Promise<Record<string, Record<string, unknown>>>;
40
+
41
+ export interface EntityRegistry {
42
+ /** The `defineEntity` impl exposed on the extension point. */
43
+ readonly defineEntity: DefineEntity;
44
+ /** The `declareNonReactiveState` impl exposed on the extension point. */
45
+ declareNonReactiveState(input: DeclareNonReactiveStateInput): void;
46
+
47
+ /**
48
+ * Bind the DB-backed transition store once init has resolved the database.
49
+ * The transition store owns the tx + transition log for every kind.
50
+ */
51
+ setStore(args: { store: EntityStore }): void;
52
+ /** Whether a store has been bound yet. */
53
+ readonly hasStore: boolean;
54
+
55
+ /** Every recorded escape-hatch declaration (for the lint rule). */
56
+ getNonReactiveDeclarations(): ReadonlyArray<NonReactiveDeclaration>;
57
+ /** Registered entity kinds, in registration order. */
58
+ getKinds(): ReadonlyArray<string>;
59
+ /**
60
+ * The kind-agnostic read resolver behind the reactive `wait_until` wake
61
+ * re-eval + scope enrichment (reactive automation engine §3.6, §8): routes
62
+ * each kind to its plugin `read` accessor. Returns `undefined` for an
63
+ * unknown kind (enrichment leaves it unresolved, fail-open).
64
+ */
65
+ entityResolverFor(kind: string): EntityKindResolver | undefined;
66
+ }
67
+
68
+ /** Hard-fail validation for a malformed registration (§6.3). */
69
+ function validateInput<TState extends Record<string, unknown>>(args: {
70
+ input: DefineEntityInput<TState>;
71
+ registeredKinds: ReadonlySet<string>;
72
+ }): void {
73
+ const { input, registeredKinds } = args;
74
+ const { kind, state, read } = input;
75
+
76
+ if (typeof kind !== "string" || kind.trim().length === 0) {
77
+ throw new Error("defineEntity: `kind` must be a non-empty string");
78
+ }
79
+ if (registeredKinds.has(kind)) {
80
+ throw new Error(
81
+ `defineEntity: duplicate kind "${kind}" — entity kinds must be globally unique`,
82
+ );
83
+ }
84
+ // Model B: every kind owns its state and exposes it through a plugin `read`
85
+ // accessor.
86
+ if (typeof read !== "function") {
87
+ throw new TypeError(
88
+ `defineEntity: kind "${kind}" — \`read\` must be a function`,
89
+ );
90
+ }
91
+ // The state schema MUST be a z.object: scope projection, UI introspection,
92
+ // and per-field transitions all rely on enumerable top-level fields. The
93
+ // static type says `state` is a ZodObject, but callers reaching us through
94
+ // the extension point are untyped, so this guard is a real runtime check —
95
+ // read through `unknown` since TS narrows the failing branch to `never`.
96
+ if (!(state instanceof z.ZodObject)) {
97
+ const received: unknown = state;
98
+ const describe =
99
+ received == null
100
+ ? String(received)
101
+ : ((received as { constructor?: { name?: string } }).constructor
102
+ ?.name ?? typeof received);
103
+ throw new Error(
104
+ `defineEntity: kind "${kind}" — \`state\` must be a z.object (got ${describe})`,
105
+ );
106
+ }
107
+ }
108
+
109
+ export function createEntityRegistry(args: {
110
+ secretRegistry: RunSecretRegistry;
111
+ emitter: ChangeEmitter;
112
+ }): EntityRegistry {
113
+ const { secretRegistry, emitter } = args;
114
+
115
+ const registeredKinds = new Set<string>();
116
+ const kindsInOrder: string[] = [];
117
+ const nonReactive: NonReactiveDeclaration[] = [];
118
+ // Per-kind plugin `read` accessor. Backs `entityResolverFor`.
119
+ const reads = new Map<string, EntityKindResolver>();
120
+
121
+ // The transition store is bound lazily at init. Handles created during
122
+ // `register` capture this mutable indirection so a mutation issued before
123
+ // init throws a clear error rather than a cryptic
124
+ // "undefined.runInTransaction" (mutations realistically only happen from
125
+ // `init` onward, by which point the store is bound).
126
+ let store: EntityStore | undefined;
127
+
128
+ function requireStore(): EntityStore {
129
+ if (!store) {
130
+ throw new Error(
131
+ "entity store not initialized yet — defineEntity handles can only mutate from init() onward",
132
+ );
133
+ }
134
+ return store;
135
+ }
136
+ const storeProxy: EntityStore = {
137
+ runInTransaction: (fn) => requireStore().runInTransaction(fn),
138
+ appendTransitions: (a) => requireStore().appendTransitions(a),
139
+ inStateSince: (a) => requireStore().inStateSince(a),
140
+ transitionCount: (a) => requireStore().transitionCount(a),
141
+ };
142
+
143
+ const defineEntity: DefineEntity = <
144
+ TState extends Record<string, unknown>,
145
+ >(
146
+ input: DefineEntityInput<TState>,
147
+ ): EntityHandle<TState> => {
148
+ validateInput({ input, registeredKinds });
149
+
150
+ registeredKinds.add(input.kind);
151
+ kindsInOrder.push(input.kind);
152
+
153
+ // Every kind exposes its current state through a plugin `read` accessor
154
+ // (its own table, an in-memory map, or a computed value). `defineEntity`
155
+ // owns no current-state storage.
156
+ const read: EntityRead<TState> = input.read;
157
+
158
+ // Register the read accessor so `entityResolverFor` can route scope
159
+ // enrichment + wake re-eval to it (cast: the resolver is untyped record).
160
+ reads.set(input.kind, (ids) =>
161
+ read(ids) as Promise<Record<string, Record<string, unknown>>>,
162
+ );
163
+
164
+ return createEntityHandle<TState>({
165
+ kind: input.kind,
166
+ schema: input.state,
167
+ store: storeProxy,
168
+ emitter,
169
+ secretRegistry,
170
+ read,
171
+ });
172
+ };
173
+
174
+ return {
175
+ defineEntity,
176
+
177
+ declareNonReactiveState(input) {
178
+ nonReactive.push({ ...input });
179
+ },
180
+
181
+ setStore({ store: nextStore }) {
182
+ store = nextStore;
183
+ },
184
+ get hasStore() {
185
+ return store !== undefined;
186
+ },
187
+
188
+ getNonReactiveDeclarations() {
189
+ return nonReactive;
190
+ },
191
+ getKinds() {
192
+ return kindsInOrder;
193
+ },
194
+
195
+ entityResolverFor(kind) {
196
+ // Plugin-backed kinds resolve through their own `read` immediately.
197
+ return reads.get(kind);
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { stableStringify, structurallyEqual } from "./stable-stringify";
3
+
4
+ describe("stableStringify", () => {
5
+ it("is key-order independent", () => {
6
+ expect(stableStringify({ a: 1, b: 2 })).toBe(
7
+ stableStringify({ b: 2, a: 1 }),
8
+ );
9
+ });
10
+
11
+ it("treats undefined as absent (matches JSON semantics)", () => {
12
+ expect(stableStringify({ a: 1, b: undefined })).toBe(
13
+ stableStringify({ a: 1 }),
14
+ );
15
+ });
16
+
17
+ it("serializes nested objects deterministically", () => {
18
+ const a = { outer: { z: 1, a: 2 }, list: [3, { y: 1, x: 2 }] };
19
+ const b = { list: [3, { x: 2, y: 1 }], outer: { a: 2, z: 1 } };
20
+ expect(stableStringify(a)).toBe(stableStringify(b));
21
+ });
22
+
23
+ it("distinguishes different values", () => {
24
+ expect(stableStringify({ a: 1 })).not.toBe(stableStringify({ a: 2 }));
25
+ });
26
+
27
+ it("distinguishes array order (arrays are ordered)", () => {
28
+ expect(stableStringify([1, 2])).not.toBe(stableStringify([2, 1]));
29
+ });
30
+
31
+ it("normalizes non-finite numbers to null", () => {
32
+ expect(stableStringify({ a: Number.NaN })).toBe(stableStringify({ a: null }));
33
+ expect(stableStringify({ a: Infinity })).toBe(stableStringify({ a: null }));
34
+ });
35
+
36
+ it("treats an explicit-null field as distinct from an absent field", () => {
37
+ // `{a: null}` is `{"a":null}`; `{a: undefined}` drops the key entirely,
38
+ // so they are NOT structurally equal — a field explicitly set to null
39
+ // is a real value, whereas undefined means absent.
40
+ expect(structurallyEqual({ a: null }, { a: undefined })).toBe(false);
41
+ expect(structurallyEqual({ a: undefined }, {})).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("structurallyEqual", () => {
46
+ it("returns true for structurally equal objects with different key order", () => {
47
+ expect(structurallyEqual({ a: 1, b: { c: 2 } }, { b: { c: 2 }, a: 1 })).toBe(
48
+ true,
49
+ );
50
+ });
51
+
52
+ it("returns false on a real difference", () => {
53
+ expect(structurallyEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Deterministic JSON stringify for structural equality.
3
+ *
4
+ * The entity store diffs a freshly-validated state object against the
5
+ * prior persisted row. A naive `JSON.stringify` is key-order-sensitive,
6
+ * so two structurally-equal objects with differently-ordered keys would
7
+ * read as a "change". This walker emits object keys in sorted order so
8
+ * the serialization is canonical: equal values ⇒ equal strings.
9
+ *
10
+ * `undefined` is treated as absent (matching JSON.stringify): an explicit
11
+ * `undefined` property and a missing property serialize identically, so a
12
+ * `set` that drops an optional field to `undefined` no-ops against a prior
13
+ * row that never had it.
14
+ */
15
+ export function stableStringify(value: unknown): string {
16
+ return serialize(value);
17
+ }
18
+
19
+ function serialize(value: unknown): string {
20
+ if (value === null) return "null";
21
+ if (value === undefined) return "null";
22
+ const type = typeof value;
23
+ if (type === "number") {
24
+ return Number.isFinite(value) ? String(value) : "null";
25
+ }
26
+ if (type === "boolean") return value ? "true" : "false";
27
+ if (type === "string") return JSON.stringify(value);
28
+ if (type === "bigint") return JSON.stringify((value as bigint).toString());
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map((v) => serialize(v)).join(",")}]`;
31
+ }
32
+ if (type === "object") {
33
+ const obj = value as Record<string, unknown>;
34
+ const keys = Object.keys(obj)
35
+ .filter((k) => obj[k] !== undefined)
36
+ .toSorted();
37
+ const body = keys
38
+ .map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`)
39
+ .join(",");
40
+ return `{${body}}`;
41
+ }
42
+ // Functions / symbols cannot appear in zod-parsed state; coerce to null.
43
+ return "null";
44
+ }
45
+
46
+ /** Structural equality via canonical serialization. */
47
+ export function structurallyEqual(a: unknown, b: unknown): boolean {
48
+ return stableStringify(a) === stableStringify(b);
49
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Integration test (real Postgres) for the wake-index arm race + lookup.
3
+ *
4
+ * Part of the surgical integration lane (plan §14.4 #3). It pins the two
5
+ * behaviours fakes cannot model against real Postgres:
6
+ *
7
+ * 1. The `(wait_lock_id, ref)` UNIQUE index + `ON CONFLICT DO NOTHING`
8
+ * arm: concurrent inserts of the SAME pair must leave EXACTLY ONE row
9
+ * (the arm race must not double-insert).
10
+ * 2. The key-intersection lookup join (§8.2) returns the owning
11
+ * `kind: "until"` wait lock for a changed `kind:id` ref, including the
12
+ * kind-level wildcard.
13
+ *
14
+ * Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it. The
15
+ * `integration` CI job sets the flag and provides a real Postgres service.
16
+ * Connection comes from `CHECKSTACK_IT_PG_URL` (defaulting to the
17
+ * `docker-compose-dev.yml` Postgres port). Each run isolates itself in a
18
+ * freshly created Postgres schema and cleans up after itself.
19
+ */
20
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
21
+ import { drizzle } from "drizzle-orm/node-postgres";
22
+ import { and, eq } from "drizzle-orm";
23
+ import { Pool } from "pg";
24
+
25
+ import {
26
+ automationRunState,
27
+ automationRunSteps,
28
+ automationRuns,
29
+ automationWaitLocks,
30
+ automationWakeIndex,
31
+ } from "../schema";
32
+ import { createRunStore } from "../dispatch/run-state";
33
+
34
+ const PG_URL =
35
+ process.env.CHECKSTACK_IT_PG_URL ??
36
+ "postgres://postgres:postgres@localhost:5432/postgres";
37
+
38
+ const SCHEMA = `it_wake_${crypto.randomUUID().replace(/-/g, "")}`;
39
+
40
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
41
+ "wake-index arm race + intersection lookup (real Postgres)",
42
+ () => {
43
+ let pool: Pool;
44
+
45
+ beforeAll(async () => {
46
+ const setupPool = new Pool({ connectionString: PG_URL });
47
+ try {
48
+ await setupPool.query(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA}"`);
49
+ await setupPool.query(`SET search_path TO "${SCHEMA}"`);
50
+ await setupPool.query(`
51
+ CREATE TABLE "${SCHEMA}".automations (
52
+ id text PRIMARY KEY,
53
+ name text NOT NULL,
54
+ status text NOT NULL DEFAULT 'enabled',
55
+ definition jsonb NOT NULL,
56
+ created_at timestamp NOT NULL DEFAULT now(),
57
+ updated_at timestamp NOT NULL DEFAULT now()
58
+ )
59
+ `);
60
+ await setupPool.query(`
61
+ CREATE TABLE "${SCHEMA}".automation_runs (
62
+ id text PRIMARY KEY,
63
+ automation_id text NOT NULL,
64
+ trigger_id text NOT NULL,
65
+ trigger_event_id text NOT NULL,
66
+ trigger_payload jsonb NOT NULL,
67
+ context_key text,
68
+ status text NOT NULL DEFAULT 'running',
69
+ error_message text,
70
+ started_at timestamp NOT NULL DEFAULT now(),
71
+ finished_at timestamp
72
+ )
73
+ `);
74
+ await setupPool.query(`
75
+ CREATE TABLE "${SCHEMA}".automation_wait_locks (
76
+ id text PRIMARY KEY,
77
+ run_id text NOT NULL
78
+ REFERENCES "${SCHEMA}".automation_runs(id) ON DELETE CASCADE,
79
+ action_path text NOT NULL,
80
+ kind text NOT NULL DEFAULT 'trigger',
81
+ event_id text NOT NULL,
82
+ context_key text,
83
+ filter_template text,
84
+ wait_config jsonb,
85
+ timeout_at timestamp,
86
+ created_at timestamp NOT NULL DEFAULT now()
87
+ )
88
+ `);
89
+ await setupPool.query(`
90
+ CREATE TABLE "${SCHEMA}".automation_wake_index (
91
+ id text PRIMARY KEY,
92
+ wait_lock_id text NOT NULL
93
+ REFERENCES "${SCHEMA}".automation_wait_locks(id) ON DELETE CASCADE,
94
+ ref text NOT NULL
95
+ )
96
+ `);
97
+ await setupPool.query(`
98
+ CREATE UNIQUE INDEX automation_wake_index_lock_ref_unique
99
+ ON "${SCHEMA}".automation_wake_index (wait_lock_id, ref)
100
+ `);
101
+ await setupPool.query(`
102
+ CREATE INDEX automation_wake_index_ref_idx
103
+ ON "${SCHEMA}".automation_wake_index (ref)
104
+ `);
105
+ } finally {
106
+ await setupPool.end();
107
+ }
108
+
109
+ pool = new Pool({
110
+ connectionString: PG_URL,
111
+ options: `-c search_path=${SCHEMA}`,
112
+ });
113
+ });
114
+
115
+ afterAll(async () => {
116
+ await pool.end();
117
+ const cleanupPool = new Pool({ connectionString: PG_URL });
118
+ try {
119
+ await cleanupPool.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
120
+ } finally {
121
+ await cleanupPool.end();
122
+ }
123
+ });
124
+
125
+ function makeStore() {
126
+ const db = drizzle({
127
+ client: pool,
128
+ schema: {
129
+ automationRuns,
130
+ automationRunSteps,
131
+ automationWaitLocks,
132
+ automationRunState,
133
+ automationWakeIndex,
134
+ },
135
+ });
136
+ return createRunStore(db);
137
+ }
138
+
139
+ async function seedRun(): Promise<string> {
140
+ const automationId = crypto.randomUUID();
141
+ await pool.query(
142
+ `INSERT INTO "${SCHEMA}".automations (id, name, definition)
143
+ VALUES ($1, $2, $3)`,
144
+ [automationId, "IT wake automation", JSON.stringify({})],
145
+ );
146
+ const store = makeStore();
147
+ return store.createRun({
148
+ automationId,
149
+ triggerId: "t",
150
+ triggerEventId: "test.event",
151
+ triggerPayload: {},
152
+ contextKey: "sys-1",
153
+ });
154
+ }
155
+
156
+ it("intersection lookup returns the owning until-lock for a concrete ref", async () => {
157
+ const store = makeStore();
158
+ const runId = await seedRun();
159
+ await store.createWaitLockWithWakeRefs({
160
+ runId,
161
+ actionPath: "actions[0]",
162
+ eventId: "@@until",
163
+ contextKey: "sys-1",
164
+ timeoutAt: null,
165
+ waitConfig: {
166
+ condition: "state.health['sys-1'].status == 'healthy'",
167
+ continueOnTimeout: true,
168
+ },
169
+ wakeRefs: ["health:sys-1", "incident:inc-9"],
170
+ });
171
+
172
+ const byExact = await store.findWaitLocksByWakeRef("health:sys-1");
173
+ expect(byExact).toHaveLength(1);
174
+ expect(byExact[0]?.runId).toBe(runId);
175
+
176
+ const byOther = await store.findWaitLocksByWakeRef("incident:inc-9");
177
+ expect(byOther).toHaveLength(1);
178
+
179
+ const noMatch = await store.findWaitLocksByWakeRef("health:other");
180
+ expect(noMatch).toHaveLength(0);
181
+ });
182
+
183
+ it("matches a kind-level wildcard wait", async () => {
184
+ const store = makeStore();
185
+ const runId = await seedRun();
186
+ await store.createWaitLockWithWakeRefs({
187
+ runId,
188
+ actionPath: "actions[0]",
189
+ eventId: "@@until",
190
+ contextKey: null,
191
+ timeoutAt: null,
192
+ waitConfig: {
193
+ condition: "state.slo[trigger.id].budget < 10",
194
+ continueOnTimeout: true,
195
+ },
196
+ wakeRefs: ["slo:*"],
197
+ });
198
+ const matched = await store.findWaitLocksByWakeRef("slo:obj-42");
199
+ expect(matched.some((l) => l.runId === runId)).toBe(true);
200
+ });
201
+
202
+ it("concurrent same-(lock, ref) inserts leave exactly one row", async () => {
203
+ const runId = await seedRun();
204
+ const store = makeStore();
205
+ // First, create the wait lock with one ref.
206
+ const lockId = await store.createWaitLockWithWakeRefs({
207
+ runId,
208
+ actionPath: "actions[0]",
209
+ eventId: "@@until",
210
+ contextKey: "sys-1",
211
+ timeoutAt: null,
212
+ waitConfig: {
213
+ condition: "state.health['sys-1'].status == 'healthy'",
214
+ continueOnTimeout: true,
215
+ },
216
+ wakeRefs: ["health:sys-1"],
217
+ });
218
+
219
+ // Now race many concurrent inserts of the SAME (lock, ref) pair under
220
+ // ON CONFLICT DO NOTHING — the unique index must collapse them to one.
221
+ const db = drizzle({
222
+ client: pool,
223
+ schema: { automationWakeIndex },
224
+ });
225
+ await Promise.all(
226
+ Array.from({ length: 8 }, () =>
227
+ db
228
+ .insert(automationWakeIndex)
229
+ .values({ waitLockId: lockId, ref: "health:sys-1" })
230
+ .onConflictDoNothing({
231
+ target: [
232
+ automationWakeIndex.waitLockId,
233
+ automationWakeIndex.ref,
234
+ ],
235
+ }),
236
+ ),
237
+ );
238
+
239
+ const rows = await db
240
+ .select()
241
+ .from(automationWakeIndex)
242
+ .where(
243
+ and(
244
+ eq(automationWakeIndex.waitLockId, lockId),
245
+ eq(automationWakeIndex.ref, "health:sys-1"),
246
+ ),
247
+ );
248
+ expect(rows).toHaveLength(1);
249
+ });
250
+ },
251
+ );
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { EntityHandle, EntityMutationOpts } from "./define-entity";
3
+ import { withEntityWrite, withEntityRemove } from "./with-entity-write";
4
+
5
+ interface TestState extends Record<string, unknown> {
6
+ value: string;
7
+ }
8
+
9
+ describe("withEntityWrite", () => {
10
+ it("routes the write through handle.mutate keyed by id (with opts)", async () => {
11
+ const calls: Array<{
12
+ id: string;
13
+ opts?: EntityMutationOpts;
14
+ next: TestState;
15
+ }> = [];
16
+ const handle = {
17
+ kind: "test",
18
+ async mutate(input: {
19
+ id: string;
20
+ opts?: EntityMutationOpts;
21
+ apply: () => Promise<TestState>;
22
+ }) {
23
+ const next = await input.apply();
24
+ calls.push({ id: input.id, opts: input.opts, next });
25
+ return next;
26
+ },
27
+ } as unknown as EntityHandle<TestState>;
28
+
29
+ let applied = false;
30
+ await withEntityWrite({
31
+ handle,
32
+ id: "e-1",
33
+ opts: { runId: "run-1" },
34
+ apply: async () => {
35
+ applied = true;
36
+ return { value: "next" };
37
+ },
38
+ });
39
+
40
+ expect(applied).toBe(true);
41
+ expect(calls).toEqual([
42
+ { id: "e-1", opts: { runId: "run-1" }, next: { value: "next" } },
43
+ ]);
44
+ });
45
+
46
+ it("runs the plugin write directly when no handle is wired", async () => {
47
+ let applied = false;
48
+ await withEntityWrite<TestState>({
49
+ handle: undefined,
50
+ id: "e-2",
51
+ apply: async () => {
52
+ applied = true;
53
+ return { value: "next" };
54
+ },
55
+ });
56
+ expect(applied).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("withEntityRemove", () => {
61
+ it("tombstones via handle.remove keyed by id (with opts)", async () => {
62
+ const removed: Array<{ id: string; opts?: EntityMutationOpts }> = [];
63
+ let deleted = false;
64
+ const handle = {
65
+ kind: "test",
66
+ async remove(input: {
67
+ id: string;
68
+ opts?: EntityMutationOpts;
69
+ apply: () => Promise<void>;
70
+ }) {
71
+ await input.apply();
72
+ removed.push({ id: input.id, opts: input.opts });
73
+ },
74
+ } as unknown as EntityHandle<TestState>;
75
+
76
+ await withEntityRemove<TestState>({
77
+ handle,
78
+ id: "e-9",
79
+ opts: { runId: "run-9" },
80
+ apply: async () => {
81
+ deleted = true;
82
+ },
83
+ });
84
+
85
+ expect(deleted).toBe(true);
86
+ expect(removed).toEqual([{ id: "e-9", opts: { runId: "run-9" } }]);
87
+ });
88
+
89
+ it("runs the delete directly when no handle is wired", async () => {
90
+ let deleted = false;
91
+ await withEntityRemove<TestState>({
92
+ handle: undefined,
93
+ id: "e-3",
94
+ apply: async () => {
95
+ deleted = true;
96
+ },
97
+ });
98
+ expect(deleted).toBe(true);
99
+ });
100
+ });