@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,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle-backed implementation of `WindowStore` — the windowed-count / rate
|
|
3
|
+
* trigger occurrence log. Kept thin: each method maps almost 1:1 to a DB
|
|
4
|
+
* statement, mirroring `dwell-store.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The append log is the source of truth. `recordAndCount` does the proven
|
|
7
|
+
* "insert one row, then COUNT(*) within the trailing window" shape (relocated
|
|
8
|
+
* from the former healthcheck flapping detector) so the gate is durable and
|
|
9
|
+
* pod-independent: the INSERT runs once on the pod that claimed the emission
|
|
10
|
+
* from the work queue, and the COUNT is pure SQL so every pod computes the
|
|
11
|
+
* same total.
|
|
12
|
+
*/
|
|
13
|
+
import { and, eq, gte, isNull, lt, sql } from "drizzle-orm";
|
|
14
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
15
|
+
|
|
16
|
+
import { automationWindowEvents } from "../schema";
|
|
17
|
+
import type { RecordWindowInput, WindowStore } from "./types";
|
|
18
|
+
|
|
19
|
+
type Schema = { automationWindowEvents: typeof automationWindowEvents };
|
|
20
|
+
|
|
21
|
+
/** Build the `(automationId, triggerId, contextKey)` match predicate. */
|
|
22
|
+
function keyWhere(
|
|
23
|
+
automationId: string,
|
|
24
|
+
triggerId: string,
|
|
25
|
+
contextKey: string | null,
|
|
26
|
+
) {
|
|
27
|
+
return and(
|
|
28
|
+
eq(automationWindowEvents.automationId, automationId),
|
|
29
|
+
eq(automationWindowEvents.triggerId, triggerId),
|
|
30
|
+
contextKey === null
|
|
31
|
+
? isNull(automationWindowEvents.contextKey)
|
|
32
|
+
: eq(automationWindowEvents.contextKey, contextKey),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createWindowStore(db: SafeDatabase<Schema>): WindowStore {
|
|
37
|
+
return {
|
|
38
|
+
async recordAndCount(input: RecordWindowInput): Promise<boolean> {
|
|
39
|
+
const {
|
|
40
|
+
automationId,
|
|
41
|
+
triggerId,
|
|
42
|
+
eventId,
|
|
43
|
+
contextKey,
|
|
44
|
+
occurredAt,
|
|
45
|
+
windowMinutes,
|
|
46
|
+
threshold,
|
|
47
|
+
refire,
|
|
48
|
+
} = input;
|
|
49
|
+
|
|
50
|
+
// (1) Append the qualifying occurrence. One INSERT per claimed emission,
|
|
51
|
+
// so the count below can't double-count across pods.
|
|
52
|
+
await db.insert(automationWindowEvents).values({
|
|
53
|
+
automationId,
|
|
54
|
+
triggerId,
|
|
55
|
+
eventId,
|
|
56
|
+
contextKey,
|
|
57
|
+
occurredAt,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// (2) Count rows in the trailing window (inclusive of the row just
|
|
61
|
+
// inserted) — a pure DB read, identical on every pod.
|
|
62
|
+
const windowStart = new Date(
|
|
63
|
+
occurredAt.getTime() - windowMinutes * 60_000,
|
|
64
|
+
);
|
|
65
|
+
const result = await db
|
|
66
|
+
.select({ count: sql<number>`COUNT(*)::int` })
|
|
67
|
+
.from(automationWindowEvents)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
keyWhere(automationId, triggerId, contextKey),
|
|
71
|
+
gte(automationWindowEvents.occurredAt, windowStart),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
const newCount = result[0]?.count ?? 0;
|
|
75
|
+
|
|
76
|
+
// (3) Apply the re-fire policy.
|
|
77
|
+
// - `every`: fire on every occurrence at/over the threshold.
|
|
78
|
+
// - `once`: fire only on the crossing edge (newCount === threshold);
|
|
79
|
+
// re-arms naturally as old rows age out and the count re-crosses.
|
|
80
|
+
//
|
|
81
|
+
// At-least-once caveat: if the work queue redelivers an emission, the
|
|
82
|
+
// same logical occurrence inserts twice and the count can skip the exact
|
|
83
|
+
// `=== threshold` edge, so `once` may miss a fire. This is best-effort,
|
|
84
|
+
// fail-open (matching the dwell re-confirm posture) — `every` is
|
|
85
|
+
// redelivery-tolerant; pick `once` knowing the trade-off.
|
|
86
|
+
if (refire === "once") return newCount === threshold;
|
|
87
|
+
return newCount >= threshold;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async sweepExpired(cutoff: Date): Promise<void> {
|
|
91
|
+
await db
|
|
92
|
+
.delete(automationWindowEvents)
|
|
93
|
+
.where(lt(automationWindowEvents.occurredAt, cutoff));
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async deleteForAutomation(automationId: string): Promise<void> {
|
|
97
|
+
await db
|
|
98
|
+
.delete(automationWindowEvents)
|
|
99
|
+
.where(eq(automationWindowEvents.automationId, automationId));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
4
|
+
|
|
5
|
+
import { createChangeDeriverRegistry } from "./change-derivers";
|
|
6
|
+
|
|
7
|
+
function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
|
|
8
|
+
return {
|
|
9
|
+
kind: "fake",
|
|
10
|
+
id: "id-1",
|
|
11
|
+
prev: null,
|
|
12
|
+
next: { status: "open" },
|
|
13
|
+
delta: { status: "open" },
|
|
14
|
+
changedFields: ["status"],
|
|
15
|
+
actor: SYSTEM_ACTOR,
|
|
16
|
+
occurredAt: new Date().toISOString(),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("ChangeDeriverRegistry", () => {
|
|
22
|
+
it("returns [] for a kind with no registered deriver", () => {
|
|
23
|
+
const reg = createChangeDeriverRegistry();
|
|
24
|
+
expect(reg.derive(change())).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("routes a change through a registered per-kind deriver", () => {
|
|
28
|
+
const reg = createChangeDeriverRegistry();
|
|
29
|
+
reg.register({
|
|
30
|
+
kind: "fake",
|
|
31
|
+
derive: (c) =>
|
|
32
|
+
c.next?.status === "open" ? ["fake.opened"] : ["fake.closed"],
|
|
33
|
+
});
|
|
34
|
+
expect(reg.derive(change({ next: { status: "open" } }))).toEqual([
|
|
35
|
+
"fake.opened",
|
|
36
|
+
]);
|
|
37
|
+
expect(reg.derive(change({ next: { status: "done" } }))).toEqual([
|
|
38
|
+
"fake.closed",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("only invokes derivers for the matching kind", () => {
|
|
43
|
+
const reg = createChangeDeriverRegistry();
|
|
44
|
+
reg.register({ kind: "fake", derive: () => ["fake.evt"] });
|
|
45
|
+
expect(reg.derive(change({ kind: "other" }))).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("unions + de-duplicates across multiple derivers for one kind", () => {
|
|
49
|
+
const reg = createChangeDeriverRegistry();
|
|
50
|
+
reg.register({ kind: "fake", derive: () => ["a", "shared"] });
|
|
51
|
+
reg.register({ kind: "fake", derive: () => ["shared", "b"] });
|
|
52
|
+
expect(reg.derive(change())).toEqual(["a", "shared", "b"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("isolates a throwing deriver from the others", () => {
|
|
56
|
+
const reg = createChangeDeriverRegistry();
|
|
57
|
+
reg.register({
|
|
58
|
+
kind: "fake",
|
|
59
|
+
derive: () => {
|
|
60
|
+
throw new Error("boom");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
reg.register({ kind: "fake", derive: () => ["safe"] });
|
|
64
|
+
expect(reg.derive(change())).toEqual(["safe"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("ignores duplicate registration of the same deriver function", () => {
|
|
68
|
+
const reg = createChangeDeriverRegistry();
|
|
69
|
+
const derive = () => ["once"];
|
|
70
|
+
reg.register({ kind: "fake", derive });
|
|
71
|
+
reg.register({ kind: "fake", derive });
|
|
72
|
+
expect(reg.derive(change())).toEqual(["once"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects an empty kind", () => {
|
|
76
|
+
const reg = createChangeDeriverRegistry();
|
|
77
|
+
expect(() => reg.register({ kind: "", derive: () => [] })).toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("lists kinds with derivers", () => {
|
|
81
|
+
const reg = createChangeDeriverRegistry();
|
|
82
|
+
reg.register({ kind: "a", derive: () => [] });
|
|
83
|
+
reg.register({ kind: "b", derive: () => [] });
|
|
84
|
+
expect(reg.kinds().toSorted()).toEqual(["a", "b"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("ChangeDeriverRegistry — payload mapper", () => {
|
|
89
|
+
it("returns undefined when the kind has no registered mapper", () => {
|
|
90
|
+
const reg = createChangeDeriverRegistry();
|
|
91
|
+
reg.register({ kind: "fake", derive: () => ["fake.evt"] });
|
|
92
|
+
expect(reg.payload(change())).toBeUndefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("maps a change to the domain-named payload via the registered mapper", () => {
|
|
96
|
+
const reg = createChangeDeriverRegistry();
|
|
97
|
+
reg.register({
|
|
98
|
+
kind: "fake",
|
|
99
|
+
derive: () => ["fake.evt"],
|
|
100
|
+
toPayload: (c) => ({ fakeId: c.id, status: c.next?.["status"] }),
|
|
101
|
+
});
|
|
102
|
+
expect(reg.payload(change({ id: "x", next: { status: "open" } }))).toEqual({
|
|
103
|
+
fakeId: "x",
|
|
104
|
+
status: "open",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("only applies the mapper for the matching kind", () => {
|
|
109
|
+
const reg = createChangeDeriverRegistry();
|
|
110
|
+
reg.register({
|
|
111
|
+
kind: "fake",
|
|
112
|
+
derive: () => [],
|
|
113
|
+
toPayload: () => ({ mapped: true }),
|
|
114
|
+
});
|
|
115
|
+
expect(reg.payload(change({ kind: "other" }))).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("allows registering the deriver and mapper in one call", () => {
|
|
119
|
+
const reg = createChangeDeriverRegistry();
|
|
120
|
+
reg.register({
|
|
121
|
+
kind: "fake",
|
|
122
|
+
derive: () => ["fake.evt"],
|
|
123
|
+
toPayload: (c) => ({ id: c.id }),
|
|
124
|
+
});
|
|
125
|
+
expect(reg.derive(change())).toEqual(["fake.evt"]);
|
|
126
|
+
expect(reg.payload(change({ id: "id-1" }))).toEqual({ id: "id-1" });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("re-registering the SAME mapper for a kind is harmless", () => {
|
|
130
|
+
const reg = createChangeDeriverRegistry();
|
|
131
|
+
const toPayload = (c: EntityChanged) => ({ id: c.id });
|
|
132
|
+
reg.register({ kind: "fake", derive: () => [], toPayload });
|
|
133
|
+
reg.register({ kind: "fake", derive: () => [], toPayload });
|
|
134
|
+
expect(reg.payload(change({ id: "id-1" }))).toEqual({ id: "id-1" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("throws on a second DISTINCT mapper for the same kind", () => {
|
|
138
|
+
const reg = createChangeDeriverRegistry();
|
|
139
|
+
reg.register({ kind: "fake", derive: () => [], toPayload: () => ({ a: 1 }) });
|
|
140
|
+
expect(() =>
|
|
141
|
+
reg.register({
|
|
142
|
+
kind: "fake",
|
|
143
|
+
derive: () => [],
|
|
144
|
+
toPayload: () => ({ b: 2 }),
|
|
145
|
+
}),
|
|
146
|
+
).toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-change → trigger-event derivation registry (reactive automation
|
|
3
|
+
* engine §7, Stage-1 routing).
|
|
4
|
+
*
|
|
5
|
+
* Stage 1 turns an `ENTITY_CHANGED` into the set of qualified trigger event
|
|
6
|
+
* ids to route to fresh runs (via `findEnabledByTriggerEvent`). The mapping
|
|
7
|
+
* from "this kind changed like THIS" to "these trigger events fired" is
|
|
8
|
+
* DOMAIN knowledge — incident's `incident.created`/`.resolved`, health's
|
|
9
|
+
* `system.degraded`, etc. — so it can't live in the kind-agnostic engine.
|
|
10
|
+
*
|
|
11
|
+
* This is the generic registry the engine owns: domains register a per-kind
|
|
12
|
+
* deriver in Phase 4 (their migration). In Phase 5 no real domains are
|
|
13
|
+
* migrated, so this routes nothing in production yet — that is expected and
|
|
14
|
+
* correct. The engine calls every deriver registered for the changed kind
|
|
15
|
+
* and unions their results.
|
|
16
|
+
*
|
|
17
|
+
* A deriver receives the validated `EntityChanged` payload and returns the
|
|
18
|
+
* trigger event id(s) the change should fire (e.g. `["healthcheck.system.degraded"]`).
|
|
19
|
+
* It returns an empty array when the change fires nothing.
|
|
20
|
+
*/
|
|
21
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Derive the qualified trigger event id(s) a change of one entity kind
|
|
25
|
+
* should route to. Pure + synchronous. Returns `[]` for "no trigger event".
|
|
26
|
+
*/
|
|
27
|
+
export type EntityChangeDeriver = (
|
|
28
|
+
changed: EntityChanged,
|
|
29
|
+
) => ReadonlyArray<string>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map an entity change to the DOMAIN-named trigger payload (`trigger.payload`)
|
|
33
|
+
* a fresh run sees. Pure + synchronous.
|
|
34
|
+
*
|
|
35
|
+
* The generic `EntityChanged` shape (`{ kind, id, prev, next, delta, ... }`)
|
|
36
|
+
* is editor/engine-internal; operators author filters and templates against
|
|
37
|
+
* the domain-named `payloadSchema` a trigger declares (incident `incidentId`,
|
|
38
|
+
* health `systemId` / `previousStatus`, …). Without a mapper the runtime
|
|
39
|
+
* payload would NOT carry those documented keys and `trigger.payload.incidentId`
|
|
40
|
+
* etc. would silently resolve to `undefined` (a regression vs the legacy hook
|
|
41
|
+
* payloads). A domain registers `toPayload` alongside its deriver so the
|
|
42
|
+
* runtime payload matches its declared `payloadSchema`. Stage-2 falls back to
|
|
43
|
+
* the generic shape for kinds without a mapper.
|
|
44
|
+
*/
|
|
45
|
+
export type EntityChangePayloadMapper = (
|
|
46
|
+
changed: EntityChanged,
|
|
47
|
+
) => Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
export interface ChangeDeriverRegistry {
|
|
50
|
+
/**
|
|
51
|
+
* Register a deriver (and optional payload mapper) for an entity `kind`.
|
|
52
|
+
* Multiple derivers may be registered per kind (their outputs union);
|
|
53
|
+
* registering the same deriver twice is harmless.
|
|
54
|
+
*
|
|
55
|
+
* `toPayload`, when supplied, maps a change of this kind to the domain-named
|
|
56
|
+
* `trigger.payload` shape the kind's `payloadSchema` declares (see
|
|
57
|
+
* {@link EntityChangePayloadMapper}). At most one payload mapper may be
|
|
58
|
+
* registered per kind — a second distinct mapper throws (the payload shape
|
|
59
|
+
* for a kind must be unambiguous).
|
|
60
|
+
*/
|
|
61
|
+
register(args: {
|
|
62
|
+
kind: string;
|
|
63
|
+
derive: EntityChangeDeriver;
|
|
64
|
+
toPayload?: EntityChangePayloadMapper;
|
|
65
|
+
}): void;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derive the union of qualified trigger event ids for a change, across
|
|
69
|
+
* every deriver registered for `changed.kind`. De-duplicated, stable
|
|
70
|
+
* order (registration order, first-seen wins).
|
|
71
|
+
*/
|
|
72
|
+
derive(changed: EntityChanged): ReadonlyArray<string>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a change to its domain-named `trigger.payload`, using the payload
|
|
76
|
+
* mapper registered for `changed.kind`. Returns `undefined` when the kind
|
|
77
|
+
* has no registered mapper (the caller falls back to the generic payload
|
|
78
|
+
* shape).
|
|
79
|
+
*/
|
|
80
|
+
payload(changed: EntityChanged): Record<string, unknown> | undefined;
|
|
81
|
+
|
|
82
|
+
/** Kinds that have at least one registered deriver. */
|
|
83
|
+
kinds(): ReadonlyArray<string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createChangeDeriverRegistry(): ChangeDeriverRegistry {
|
|
87
|
+
const byKind = new Map<string, EntityChangeDeriver[]>();
|
|
88
|
+
const payloadByKind = new Map<string, EntityChangePayloadMapper>();
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
register({ kind, derive, toPayload }) {
|
|
92
|
+
if (typeof kind !== "string" || kind.trim().length === 0) {
|
|
93
|
+
throw new Error("registerChangeDeriver: `kind` must be a non-empty string");
|
|
94
|
+
}
|
|
95
|
+
const list = byKind.get(kind);
|
|
96
|
+
if (list) {
|
|
97
|
+
if (!list.includes(derive)) list.push(derive);
|
|
98
|
+
} else {
|
|
99
|
+
byKind.set(kind, [derive]);
|
|
100
|
+
}
|
|
101
|
+
if (toPayload) {
|
|
102
|
+
const existing = payloadByKind.get(kind);
|
|
103
|
+
if (existing && existing !== toPayload) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`registerChangeDeriver: a payload mapper is already registered for kind "${kind}"`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
payloadByKind.set(kind, toPayload);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
derive(changed) {
|
|
113
|
+
const derivers = byKind.get(changed.kind);
|
|
114
|
+
if (!derivers || derivers.length === 0) return [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
for (const derive of derivers) {
|
|
118
|
+
let ids: ReadonlyArray<string>;
|
|
119
|
+
try {
|
|
120
|
+
ids = derive(changed);
|
|
121
|
+
} catch {
|
|
122
|
+
// A misbehaving deriver must not wedge routing for the others.
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
if (typeof id !== "string" || id.length === 0 || seen.has(id)) continue;
|
|
127
|
+
seen.add(id);
|
|
128
|
+
out.push(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
payload(changed) {
|
|
135
|
+
const mapper = payloadByKind.get(changed.kind);
|
|
136
|
+
return mapper ? mapper(changed) : undefined;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
kinds() {
|
|
140
|
+
return [...byKind.keys()];
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
3
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
4
|
+
|
|
5
|
+
import { createChangeEmitter } from "./change-emitter";
|
|
6
|
+
|
|
7
|
+
function makeEvent(id: string): EntityChanged {
|
|
8
|
+
return {
|
|
9
|
+
kind: "incident",
|
|
10
|
+
id,
|
|
11
|
+
prev: null,
|
|
12
|
+
next: { status: "open" },
|
|
13
|
+
delta: { status: "open" },
|
|
14
|
+
changedFields: ["status"],
|
|
15
|
+
actor: SYSTEM_ACTOR,
|
|
16
|
+
occurredAt: new Date().toISOString(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("change emitter — deferred wiring window", () => {
|
|
21
|
+
it("buffers events emitted before the emitter is wired, then flushes in order", async () => {
|
|
22
|
+
const emitter = createChangeEmitter();
|
|
23
|
+
expect(emitter.isWired).toBe(false);
|
|
24
|
+
await emitter.emit(makeEvent("a"));
|
|
25
|
+
await emitter.emit(makeEvent("b"));
|
|
26
|
+
|
|
27
|
+
const received: string[] = [];
|
|
28
|
+
await emitter.wire(async (p) => {
|
|
29
|
+
received.push(p.id);
|
|
30
|
+
});
|
|
31
|
+
expect(emitter.isWired).toBe(true);
|
|
32
|
+
expect(received).toEqual(["a", "b"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("emits synchronously once wired", async () => {
|
|
36
|
+
const received: string[] = [];
|
|
37
|
+
const emitter = createChangeEmitter();
|
|
38
|
+
await emitter.wire(async (p) => {
|
|
39
|
+
received.push(p.id);
|
|
40
|
+
});
|
|
41
|
+
await emitter.emit(makeEvent("c"));
|
|
42
|
+
expect(received).toEqual(["c"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("drops the oldest when the buffer limit is exceeded (guarded, logged)", async () => {
|
|
46
|
+
const warnings: string[] = [];
|
|
47
|
+
const emitter = createChangeEmitter({
|
|
48
|
+
bufferLimit: 2,
|
|
49
|
+
logger: {
|
|
50
|
+
debug: () => {},
|
|
51
|
+
info: () => {},
|
|
52
|
+
warn: (m: string) => warnings.push(m),
|
|
53
|
+
error: () => {},
|
|
54
|
+
} as never,
|
|
55
|
+
});
|
|
56
|
+
await emitter.emit(makeEvent("1"));
|
|
57
|
+
await emitter.emit(makeEvent("2"));
|
|
58
|
+
await emitter.emit(makeEvent("3")); // evicts "1"
|
|
59
|
+
const received: string[] = [];
|
|
60
|
+
await emitter.wire(async (p) => {
|
|
61
|
+
received.push(p.id);
|
|
62
|
+
});
|
|
63
|
+
expect(received).toEqual(["2", "3"]);
|
|
64
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred `ENTITY_CHANGED` emitter.
|
|
3
|
+
*
|
|
4
|
+
* `emitHook` is only injected in `afterPluginsReady` (reactive automation
|
|
5
|
+
* engine §3.7). Plugins, however, call `defineEntity` in `init` and may
|
|
6
|
+
* mutate entities during `init`/`afterPluginsReady` before automation-
|
|
7
|
+
* backend's own `afterPluginsReady` runs and wires the emitter. To avoid a
|
|
8
|
+
* silent no-emit window, change events produced before the emitter is set
|
|
9
|
+
* are BUFFERED and flushed (in order) the moment it is wired.
|
|
10
|
+
*
|
|
11
|
+
* Persistence (the plugin write + transition append) is NOT deferred — only
|
|
12
|
+
* the change-event emission is, since the plugin store is always the source
|
|
13
|
+
* of truth and Stage-1 routing/wake (later phases) re-reads it.
|
|
14
|
+
*/
|
|
15
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
16
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
17
|
+
|
|
18
|
+
export type EmitEntityChanged = (payload: EntityChanged) => Promise<void>;
|
|
19
|
+
|
|
20
|
+
export interface ChangeEmitter {
|
|
21
|
+
/** Emit a change event now, or buffer it until the emitter is wired. */
|
|
22
|
+
emit(payload: EntityChanged): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Wire the real emitter (called from automation-backend's
|
|
25
|
+
* `afterPluginsReady`). Flushes any buffered events in order.
|
|
26
|
+
*/
|
|
27
|
+
wire(emit: EmitEntityChanged): Promise<void>;
|
|
28
|
+
/** Whether the real emitter has been wired. */
|
|
29
|
+
readonly isWired: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createChangeEmitter(args?: {
|
|
33
|
+
logger?: Logger;
|
|
34
|
+
/** Cap on buffered events before the emitter is wired (guards a runaway). */
|
|
35
|
+
bufferLimit?: number;
|
|
36
|
+
}): ChangeEmitter {
|
|
37
|
+
const logger = args?.logger;
|
|
38
|
+
const bufferLimit = args?.bufferLimit ?? 10_000;
|
|
39
|
+
let emit: EmitEntityChanged | undefined;
|
|
40
|
+
const buffer: EntityChanged[] = [];
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
get isWired() {
|
|
44
|
+
return emit !== undefined;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async emit(payload) {
|
|
48
|
+
if (emit) {
|
|
49
|
+
await emit(payload);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (buffer.length >= bufferLimit) {
|
|
53
|
+
logger?.warn(
|
|
54
|
+
`entity change buffer full (${bufferLimit}); dropping the oldest buffered ${payload.kind}:${payload.id} change event`,
|
|
55
|
+
);
|
|
56
|
+
buffer.shift();
|
|
57
|
+
}
|
|
58
|
+
buffer.push(payload);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async wire(realEmit) {
|
|
62
|
+
emit = realEmit;
|
|
63
|
+
if (buffer.length > 0) {
|
|
64
|
+
logger?.debug(
|
|
65
|
+
`flushing ${buffer.length} buffered entity change event(s) on emitter wire`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// Drain in arrival order; clearing as we go so a re-entrant emit
|
|
69
|
+
// (an emitter that itself mutates an entity) appends, not duplicates.
|
|
70
|
+
while (buffer.length > 0) {
|
|
71
|
+
const next = buffer.shift();
|
|
72
|
+
if (next) await realEmit(next);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|