@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,189 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import type { HookEventMeta } from "@checkstack/backend-api";
|
|
4
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEntityChangedSubscriptions,
|
|
8
|
+
type OnHookFn,
|
|
9
|
+
} from "./on-entity-changed";
|
|
10
|
+
import { ENTITY_CHANGED_HOOK } from "./hook";
|
|
11
|
+
|
|
12
|
+
const noopLogger = {
|
|
13
|
+
debug: () => {},
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
} as unknown as Parameters<
|
|
18
|
+
ReturnType<typeof createEntityChangedSubscriptions>["wire"]
|
|
19
|
+
>[0]["logger"];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A fake `onHook` that records each subscription with its options and lets
|
|
23
|
+
* a test emit a payload to the matching hook id.
|
|
24
|
+
*/
|
|
25
|
+
function fakeOnHook() {
|
|
26
|
+
const subs: Array<{
|
|
27
|
+
hookId: string;
|
|
28
|
+
listener: (payload: unknown, meta?: HookEventMeta) => Promise<void>;
|
|
29
|
+
options: unknown;
|
|
30
|
+
active: boolean;
|
|
31
|
+
}> = [];
|
|
32
|
+
const onHook = ((hook, listener, options) => {
|
|
33
|
+
const entry = {
|
|
34
|
+
hookId: hook.id,
|
|
35
|
+
listener: listener as (
|
|
36
|
+
payload: unknown,
|
|
37
|
+
meta?: HookEventMeta,
|
|
38
|
+
) => Promise<void>,
|
|
39
|
+
options,
|
|
40
|
+
active: true,
|
|
41
|
+
};
|
|
42
|
+
subs.push(entry);
|
|
43
|
+
return async () => {
|
|
44
|
+
entry.active = false;
|
|
45
|
+
};
|
|
46
|
+
}) as OnHookFn;
|
|
47
|
+
const emit = async (payload: EntityChanged) => {
|
|
48
|
+
for (const s of subs) {
|
|
49
|
+
if (s.active && s.hookId === ENTITY_CHANGED_HOOK.id) {
|
|
50
|
+
await s.listener(payload, { actor: SYSTEM_ACTOR });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return { onHook, subs, emit };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
|
|
58
|
+
return {
|
|
59
|
+
kind: "health",
|
|
60
|
+
id: "sys-1",
|
|
61
|
+
prev: { status: "healthy" },
|
|
62
|
+
next: { status: "degraded" },
|
|
63
|
+
delta: { status: "degraded" },
|
|
64
|
+
changedFields: ["status"],
|
|
65
|
+
actor: SYSTEM_ACTOR,
|
|
66
|
+
occurredAt: new Date().toISOString(),
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("onEntityChanged", () => {
|
|
72
|
+
it("delivers matching-kind changes to the handler after wiring", async () => {
|
|
73
|
+
const svc = createEntityChangedSubscriptions();
|
|
74
|
+
const got: EntityChanged[] = [];
|
|
75
|
+
svc.onEntityChanged({ kind: "health", handler: (c) => void got.push(c) });
|
|
76
|
+
|
|
77
|
+
const fake = fakeOnHook();
|
|
78
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
79
|
+
|
|
80
|
+
await fake.emit(change());
|
|
81
|
+
expect(got).toHaveLength(1);
|
|
82
|
+
expect(got[0]?.id).toBe("sys-1");
|
|
83
|
+
expect(got[0]?.delta).toEqual({ status: "degraded" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("filters out changes of a different kind", async () => {
|
|
87
|
+
const svc = createEntityChangedSubscriptions();
|
|
88
|
+
const got: EntityChanged[] = [];
|
|
89
|
+
svc.onEntityChanged({ kind: "incident", handler: (c) => void got.push(c) });
|
|
90
|
+
const fake = fakeOnHook();
|
|
91
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
92
|
+
|
|
93
|
+
await fake.emit(change({ kind: "health" }));
|
|
94
|
+
expect(got).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("defaults to broadcast delivery", () => {
|
|
98
|
+
const svc = createEntityChangedSubscriptions();
|
|
99
|
+
svc.onEntityChanged({ kind: "health", handler: () => {} });
|
|
100
|
+
const fake = fakeOnHook();
|
|
101
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
102
|
+
expect(fake.subs[0]?.options).toEqual({ mode: "broadcast" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("honours work-queue delivery with a worker group", () => {
|
|
106
|
+
const svc = createEntityChangedSubscriptions();
|
|
107
|
+
svc.onEntityChanged({
|
|
108
|
+
kind: "incident",
|
|
109
|
+
handler: () => {},
|
|
110
|
+
delivery: { mode: "work-queue", workerGroup: "slo-react", maxRetries: 2 },
|
|
111
|
+
});
|
|
112
|
+
const fake = fakeOnHook();
|
|
113
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
114
|
+
expect(fake.subs[0]?.options).toEqual({
|
|
115
|
+
mode: "work-queue",
|
|
116
|
+
workerGroup: "slo-react",
|
|
117
|
+
maxRetries: 2,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("rejects work-queue delivery without a worker group", () => {
|
|
122
|
+
const svc = createEntityChangedSubscriptions();
|
|
123
|
+
expect(() =>
|
|
124
|
+
svc.onEntityChanged({
|
|
125
|
+
kind: "incident",
|
|
126
|
+
handler: () => {},
|
|
127
|
+
// @ts-expect-error — intentionally omit the required workerGroup
|
|
128
|
+
delivery: { mode: "work-queue" },
|
|
129
|
+
}),
|
|
130
|
+
).toThrow(/workerGroup/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("rejects an empty kind", () => {
|
|
134
|
+
const svc = createEntityChangedSubscriptions();
|
|
135
|
+
expect(() =>
|
|
136
|
+
svc.onEntityChanged({ kind: " ", handler: () => {} }),
|
|
137
|
+
).toThrow(/kind/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("buffers a subscription registered before wiring", async () => {
|
|
141
|
+
const svc = createEntityChangedSubscriptions();
|
|
142
|
+
const got: EntityChanged[] = [];
|
|
143
|
+
// Register BEFORE wire().
|
|
144
|
+
svc.onEntityChanged({ kind: "health", handler: (c) => void got.push(c) });
|
|
145
|
+
const fake = fakeOnHook();
|
|
146
|
+
// No subscription wired yet.
|
|
147
|
+
expect(fake.subs).toHaveLength(0);
|
|
148
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
149
|
+
expect(fake.subs).toHaveLength(1);
|
|
150
|
+
await fake.emit(change());
|
|
151
|
+
expect(got).toHaveLength(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stops delivering after unsubscribe", async () => {
|
|
155
|
+
const svc = createEntityChangedSubscriptions();
|
|
156
|
+
const got: EntityChanged[] = [];
|
|
157
|
+
const unsub = svc.onEntityChanged({
|
|
158
|
+
kind: "health",
|
|
159
|
+
handler: (c) => void got.push(c),
|
|
160
|
+
});
|
|
161
|
+
const fake = fakeOnHook();
|
|
162
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
163
|
+
await fake.emit(change());
|
|
164
|
+
await unsub();
|
|
165
|
+
await fake.emit(change());
|
|
166
|
+
expect(got).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("isolates a throwing handler", async () => {
|
|
170
|
+
const svc = createEntityChangedSubscriptions();
|
|
171
|
+
let secondRan = false;
|
|
172
|
+
svc.onEntityChanged({
|
|
173
|
+
kind: "health",
|
|
174
|
+
handler: () => {
|
|
175
|
+
throw new Error("boom");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
svc.onEntityChanged({
|
|
179
|
+
kind: "health",
|
|
180
|
+
handler: () => {
|
|
181
|
+
secondRan = true;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const fake = fakeOnHook();
|
|
185
|
+
svc.wire({ onHook: fake.onHook, logger: noopLogger });
|
|
186
|
+
await fake.emit(change());
|
|
187
|
+
expect(secondRan).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public cross-plugin entity-change subscription service (reactive
|
|
3
|
+
* automation engine §6.1 design decision).
|
|
4
|
+
*
|
|
5
|
+
* Other plugins (slo-backend, dependency-backend, …) must react to ANOTHER
|
|
6
|
+
* domain's entity changes WITHOUT touching the internal `ENTITY_CHANGED`
|
|
7
|
+
* hook, which stays unexported so `defineEntity` remains the only typed path
|
|
8
|
+
* that EMITS a change. This service is the typed, validated path that
|
|
9
|
+
* CONSUMES changes: a subscriber names the `kind` it cares about and gets a
|
|
10
|
+
* handler called with `{ kind, id, prev, next, delta, changedFields, actor }`
|
|
11
|
+
* for every matching change.
|
|
12
|
+
*
|
|
13
|
+
* ## Delivery semantics
|
|
14
|
+
*
|
|
15
|
+
* Two modes, picked by the subscriber:
|
|
16
|
+
*
|
|
17
|
+
* - `"broadcast"` (DEFAULT): every instance's handler runs for every
|
|
18
|
+
* change. Correct for reactors that maintain PER-INSTANCE state — an
|
|
19
|
+
* in-memory cache to invalidate, a local fan-out, a websocket push. A
|
|
20
|
+
* cleanup reactor that must touch each instance wants this.
|
|
21
|
+
* - `"work-queue"`: exactly one instance in the cluster runs the handler
|
|
22
|
+
* per change (load-balanced, retried). Correct for "do this side-effect
|
|
23
|
+
* once per change" work (write a derived row, enqueue a notification).
|
|
24
|
+
* Requires a `workerGroup` so distinct subscribers don't share a claim.
|
|
25
|
+
*
|
|
26
|
+
* The default is broadcast because the safe failure mode of "every instance
|
|
27
|
+
* reacts" is redundant work, whereas a wrong work-queue grouping silently
|
|
28
|
+
* DROPS a reactor's delivery on all but one instance — a far worse default
|
|
29
|
+
* for a cross-plugin API whose callers can't see the engine's internals.
|
|
30
|
+
*
|
|
31
|
+
* Subscriptions are registered eagerly (during another plugin's
|
|
32
|
+
* `register`/`init`) and the actual `onHook` wiring is deferred until
|
|
33
|
+
* automation-backend's `afterPluginsReady` (the only place `onHook` is
|
|
34
|
+
* injected, §3.7) — exactly like the change emitter buffers emits.
|
|
35
|
+
*/
|
|
36
|
+
import {
|
|
37
|
+
EntityChangedSchema,
|
|
38
|
+
type EntityChanged,
|
|
39
|
+
} from "@checkstack/automation-common";
|
|
40
|
+
import type { HookEventMeta, Logger } from "@checkstack/backend-api";
|
|
41
|
+
|
|
42
|
+
import { ENTITY_CHANGED_HOOK } from "./hook";
|
|
43
|
+
|
|
44
|
+
/** Payload delivered to an `onEntityChanged` handler (the validated change). */
|
|
45
|
+
export type EntityChangedDelivery = EntityChanged;
|
|
46
|
+
|
|
47
|
+
/** Handler invoked for each matching entity change. */
|
|
48
|
+
export type EntityChangedHandler = (
|
|
49
|
+
change: EntityChangedDelivery,
|
|
50
|
+
) => Promise<void> | void;
|
|
51
|
+
|
|
52
|
+
export interface OnEntityChangedInput {
|
|
53
|
+
/** Entity kind to subscribe to (e.g. `"health"`, `"incident"`). */
|
|
54
|
+
kind: string;
|
|
55
|
+
/** Called for every change of `kind`. */
|
|
56
|
+
handler: EntityChangedHandler;
|
|
57
|
+
/**
|
|
58
|
+
* Delivery semantics. Defaults to `"broadcast"` (every instance). Use
|
|
59
|
+
* `"work-queue"` for exactly-once-per-cluster work; a `workerGroup` is
|
|
60
|
+
* then required so distinct subscribers don't compete for one claim.
|
|
61
|
+
*/
|
|
62
|
+
delivery?:
|
|
63
|
+
| { mode?: "broadcast" }
|
|
64
|
+
| { mode: "work-queue"; workerGroup: string; maxRetries?: number };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Unsubscribe handle. Idempotent. */
|
|
68
|
+
export type EntityChangedUnsubscribe = () => Promise<void>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The public surface registered on the entity extension point. Callable
|
|
72
|
+
* from another plugin's `register`/`init`; the underlying hook subscription
|
|
73
|
+
* is wired when automation-backend reaches `afterPluginsReady`.
|
|
74
|
+
*/
|
|
75
|
+
export type OnEntityChanged = (
|
|
76
|
+
input: OnEntityChangedInput,
|
|
77
|
+
) => EntityChangedUnsubscribe;
|
|
78
|
+
|
|
79
|
+
/** Type of the `onHook` fn injected in afterPluginsReady (mirrors backend-api). */
|
|
80
|
+
export type OnHookFn = <T>(
|
|
81
|
+
hook: { id: string; _type?: T },
|
|
82
|
+
listener: (payload: T, meta?: HookEventMeta) => Promise<void>,
|
|
83
|
+
options?:
|
|
84
|
+
| { mode?: "broadcast"; maxRetries?: number }
|
|
85
|
+
| { mode: "work-queue"; workerGroup: string; maxRetries?: number }
|
|
86
|
+
| { mode: "instance-local" },
|
|
87
|
+
) => () => Promise<void>;
|
|
88
|
+
|
|
89
|
+
interface PendingSubscription {
|
|
90
|
+
input: OnEntityChangedInput;
|
|
91
|
+
/** Set once wired; calling it unsubscribes the live hook. */
|
|
92
|
+
liveUnsub?: () => Promise<void>;
|
|
93
|
+
/** True once the caller unsubscribed before wiring completed. */
|
|
94
|
+
cancelled: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EntityChangedSubscriptions {
|
|
98
|
+
/** The `onEntityChanged` impl exposed on the entity extension point. */
|
|
99
|
+
readonly onEntityChanged: OnEntityChanged;
|
|
100
|
+
/**
|
|
101
|
+
* Wire every pending (and future) subscription to the real `onHook`.
|
|
102
|
+
* Called once from automation-backend's `afterPluginsReady`.
|
|
103
|
+
*/
|
|
104
|
+
wire(args: { onHook: OnHookFn; logger: Logger }): void;
|
|
105
|
+
/** Tear down every live subscription (cleanup). */
|
|
106
|
+
disposeAll(): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createEntityChangedSubscriptions(): EntityChangedSubscriptions {
|
|
110
|
+
const pending: PendingSubscription[] = [];
|
|
111
|
+
let wiring:
|
|
112
|
+
| { onHook: OnHookFn; logger: Logger }
|
|
113
|
+
| undefined;
|
|
114
|
+
|
|
115
|
+
/** Build the listener that validates + filters by kind + invokes handler. */
|
|
116
|
+
function makeListener(
|
|
117
|
+
sub: PendingSubscription,
|
|
118
|
+
logger: Logger,
|
|
119
|
+
): (payload: EntityChanged, meta?: HookEventMeta) => Promise<void> {
|
|
120
|
+
return async (payload) => {
|
|
121
|
+
// Validate the wire payload (defensive — the hook is internal, but the
|
|
122
|
+
// public API contract is the validated shape).
|
|
123
|
+
const parsed = EntityChangedSchema.safeParse(payload);
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
logger.warn(
|
|
126
|
+
`onEntityChanged(${sub.input.kind}): dropping malformed ENTITY_CHANGED payload`,
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (parsed.data.kind !== sub.input.kind) return;
|
|
131
|
+
try {
|
|
132
|
+
await sub.input.handler(parsed.data);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.warn(
|
|
135
|
+
`onEntityChanged(${sub.input.kind}) handler threw: ${(error as Error).message}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Wire one subscription to the live hook. */
|
|
142
|
+
function wireOne(
|
|
143
|
+
sub: PendingSubscription,
|
|
144
|
+
onHook: OnHookFn,
|
|
145
|
+
logger: Logger,
|
|
146
|
+
): void {
|
|
147
|
+
if (sub.cancelled) return;
|
|
148
|
+
const delivery = sub.input.delivery;
|
|
149
|
+
const listener = makeListener(sub, logger);
|
|
150
|
+
const unsub =
|
|
151
|
+
delivery && delivery.mode === "work-queue"
|
|
152
|
+
? onHook(ENTITY_CHANGED_HOOK, listener, {
|
|
153
|
+
mode: "work-queue",
|
|
154
|
+
workerGroup: delivery.workerGroup,
|
|
155
|
+
maxRetries: delivery.maxRetries,
|
|
156
|
+
})
|
|
157
|
+
: onHook(ENTITY_CHANGED_HOOK, listener, { mode: "broadcast" });
|
|
158
|
+
sub.liveUnsub = unsub;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const onEntityChanged: OnEntityChanged = (input) => {
|
|
162
|
+
if (typeof input.kind !== "string" || input.kind.trim().length === 0) {
|
|
163
|
+
throw new Error("onEntityChanged: `kind` must be a non-empty string");
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
input.delivery &&
|
|
167
|
+
input.delivery.mode === "work-queue" &&
|
|
168
|
+
(!input.delivery.workerGroup ||
|
|
169
|
+
input.delivery.workerGroup.trim().length === 0)
|
|
170
|
+
) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"onEntityChanged: work-queue delivery requires a non-empty `workerGroup`",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sub: PendingSubscription = { input, cancelled: false };
|
|
177
|
+
pending.push(sub);
|
|
178
|
+
// If wiring already happened, wire immediately.
|
|
179
|
+
if (wiring) wireOne(sub, wiring.onHook, wiring.logger);
|
|
180
|
+
|
|
181
|
+
return async () => {
|
|
182
|
+
sub.cancelled = true;
|
|
183
|
+
if (sub.liveUnsub) {
|
|
184
|
+
const unsub = sub.liveUnsub;
|
|
185
|
+
sub.liveUnsub = undefined;
|
|
186
|
+
await unsub();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
onEntityChanged,
|
|
193
|
+
wire({ onHook, logger }) {
|
|
194
|
+
wiring = { onHook, logger };
|
|
195
|
+
for (const sub of pending) {
|
|
196
|
+
if (!sub.liveUnsub) wireOne(sub, onHook, logger);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
async disposeAll() {
|
|
200
|
+
for (const sub of pending) {
|
|
201
|
+
sub.cancelled = true;
|
|
202
|
+
if (sub.liveUnsub) {
|
|
203
|
+
const unsub = sub.liveUnsub;
|
|
204
|
+
sub.liveUnsub = undefined;
|
|
205
|
+
try {
|
|
206
|
+
await unsub();
|
|
207
|
+
} catch {
|
|
208
|
+
// best-effort cleanup
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { createEntityRegistry } from "./registry";
|
|
5
|
+
import { createChangeEmitter } from "./change-emitter";
|
|
6
|
+
import { createFakeEntityStore } from "./fake-entity-store";
|
|
7
|
+
import { createRunSecretRegistry } from "../dispatch/run-secret-registry";
|
|
8
|
+
|
|
9
|
+
function makeRegistry() {
|
|
10
|
+
const secretRegistry = createRunSecretRegistry();
|
|
11
|
+
const emitter = createChangeEmitter();
|
|
12
|
+
return createEntityRegistry({ secretRegistry, emitter });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const stateSchema = z.object({ status: z.string(), region: z.string() });
|
|
16
|
+
/** A trivial plugin `read` returning nothing — every kind needs one (Model B). */
|
|
17
|
+
const emptyRead = async () => ({});
|
|
18
|
+
|
|
19
|
+
describe("entity registry — validation (§6.3)", () => {
|
|
20
|
+
it("rejects a missing/empty kind", () => {
|
|
21
|
+
const reg = makeRegistry();
|
|
22
|
+
expect(() =>
|
|
23
|
+
reg.defineEntity({ kind: " ", state: stateSchema, read: emptyRead }),
|
|
24
|
+
).toThrow(/non-empty string/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a non-z.object state (hard fail)", () => {
|
|
28
|
+
const reg = makeRegistry();
|
|
29
|
+
expect(() =>
|
|
30
|
+
reg.defineEntity({
|
|
31
|
+
kind: "bad",
|
|
32
|
+
// @ts-expect-error — deliberately not a z.object
|
|
33
|
+
state: z.string(),
|
|
34
|
+
read: emptyRead,
|
|
35
|
+
}),
|
|
36
|
+
).toThrow(/must be a z\.object/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a duplicate kind (globally unique)", () => {
|
|
40
|
+
const reg = makeRegistry();
|
|
41
|
+
reg.defineEntity({ kind: "incident", state: stateSchema, read: emptyRead });
|
|
42
|
+
expect(() =>
|
|
43
|
+
reg.defineEntity({
|
|
44
|
+
kind: "incident",
|
|
45
|
+
state: stateSchema,
|
|
46
|
+
read: emptyRead,
|
|
47
|
+
}),
|
|
48
|
+
).toThrow(/duplicate kind/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects a missing `read` (Model B requires a plugin read accessor)", () => {
|
|
52
|
+
const reg = makeRegistry();
|
|
53
|
+
expect(() =>
|
|
54
|
+
// @ts-expect-error — `read` is required in Model B
|
|
55
|
+
reg.defineEntity({ kind: "incident", state: stateSchema }),
|
|
56
|
+
).toThrow(/`read` must be a function/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("tracks registered kinds in order", () => {
|
|
60
|
+
const reg = makeRegistry();
|
|
61
|
+
reg.defineEntity({ kind: "a", state: stateSchema, read: emptyRead });
|
|
62
|
+
reg.defineEntity({ kind: "b", state: stateSchema, read: emptyRead });
|
|
63
|
+
expect(reg.getKinds()).toEqual(["a", "b"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("entity registry — declareNonReactiveState", () => {
|
|
68
|
+
it("records declarations for the lint rule", () => {
|
|
69
|
+
const reg = makeRegistry();
|
|
70
|
+
reg.declareNonReactiveState({
|
|
71
|
+
table: "health_check_runs",
|
|
72
|
+
reason: "raw-sample",
|
|
73
|
+
note: "firehose; the aggregate is the entity",
|
|
74
|
+
});
|
|
75
|
+
const decls = reg.getNonReactiveDeclarations();
|
|
76
|
+
expect(decls).toHaveLength(1);
|
|
77
|
+
expect(decls[0]).toEqual({
|
|
78
|
+
table: "health_check_runs",
|
|
79
|
+
reason: "raw-sample",
|
|
80
|
+
note: "firehose; the aggregate is the entity",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("entity registry — store binding", () => {
|
|
86
|
+
it("throws a clear error if a handle mutates before the store is bound", async () => {
|
|
87
|
+
const reg = makeRegistry();
|
|
88
|
+
const store = createFakeEntityStore();
|
|
89
|
+
const handle = reg.defineEntity({
|
|
90
|
+
kind: "incident",
|
|
91
|
+
state: stateSchema,
|
|
92
|
+
read: store.readFor("incident"),
|
|
93
|
+
});
|
|
94
|
+
expect(reg.hasStore).toBe(false);
|
|
95
|
+
await expect(
|
|
96
|
+
handle.mutate({
|
|
97
|
+
id: "inc-1",
|
|
98
|
+
apply: async () => {
|
|
99
|
+
store.rows.set("incident:inc-1", { status: "open", region: "eu" });
|
|
100
|
+
return { status: "open", region: "eu" };
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
).rejects.toThrow(/store not initialized/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("works once the store is bound", async () => {
|
|
107
|
+
const reg = makeRegistry();
|
|
108
|
+
const store = createFakeEntityStore();
|
|
109
|
+
const handle = reg.defineEntity({
|
|
110
|
+
kind: "incident",
|
|
111
|
+
state: stateSchema,
|
|
112
|
+
read: store.readFor("incident"),
|
|
113
|
+
});
|
|
114
|
+
reg.setStore({ store });
|
|
115
|
+
expect(reg.hasStore).toBe(true);
|
|
116
|
+
await handle.mutate({
|
|
117
|
+
id: "inc-1",
|
|
118
|
+
apply: async () => {
|
|
119
|
+
store.rows.set("incident:inc-1", { status: "open", region: "eu" });
|
|
120
|
+
return { status: "open", region: "eu" };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(store.rows.get("incident:inc-1")).toEqual({
|
|
124
|
+
status: "open",
|
|
125
|
+
region: "eu",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("entity registry — Model B read + entityResolverFor", () => {
|
|
131
|
+
const incidentSchema = z.object({ status: z.string(), severity: z.string() });
|
|
132
|
+
|
|
133
|
+
it("rejects a `read` that is not a function", () => {
|
|
134
|
+
const reg = makeRegistry();
|
|
135
|
+
expect(() =>
|
|
136
|
+
reg.defineEntity({
|
|
137
|
+
kind: "incident",
|
|
138
|
+
state: incidentSchema,
|
|
139
|
+
// @ts-expect-error — deliberately not a function
|
|
140
|
+
read: 42,
|
|
141
|
+
}),
|
|
142
|
+
).toThrow(/`read` must be a function/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("entityResolverFor routes a kind to its plugin `read`", async () => {
|
|
146
|
+
const reg = makeRegistry();
|
|
147
|
+
const read = async (ids: ReadonlyArray<string>) => {
|
|
148
|
+
const out: Record<string, { status: string; severity: string }> = {};
|
|
149
|
+
for (const id of ids) out[id] = { status: "open", severity: "high" };
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
reg.defineEntity({ kind: "incident", state: incidentSchema, read });
|
|
153
|
+
const resolver = reg.entityResolverFor("incident");
|
|
154
|
+
expect(resolver).toBeDefined();
|
|
155
|
+
expect(await resolver!(["inc-1"])).toEqual({
|
|
156
|
+
"inc-1": { status: "open", severity: "high" },
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("entityResolverFor routes a plugin-backed kind through its `read` over a backing map", async () => {
|
|
161
|
+
const reg = makeRegistry();
|
|
162
|
+
const store = createFakeEntityStore();
|
|
163
|
+
reg.defineEntity({
|
|
164
|
+
kind: "health",
|
|
165
|
+
state: incidentSchema,
|
|
166
|
+
read: store.readFor("health"),
|
|
167
|
+
});
|
|
168
|
+
reg.setStore({ store });
|
|
169
|
+
store.rows.set("health:sys-1", { status: "open", severity: "low" });
|
|
170
|
+
const resolver = reg.entityResolverFor("health");
|
|
171
|
+
expect(resolver).toBeDefined();
|
|
172
|
+
expect(await resolver!(["sys-1"])).toEqual({
|
|
173
|
+
"sys-1": { status: "open", severity: "low" },
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("entityResolverFor returns undefined for an unknown kind", () => {
|
|
178
|
+
const reg = makeRegistry();
|
|
179
|
+
expect(reg.entityResolverFor("nope")).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|