@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,157 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Actor } from "@checkstack/common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plugin-owned read accessor (Model B). A kind declares where its CURRENT
|
|
6
|
+
* state lives by supplying a batched reader: given a set of ids, return the
|
|
7
|
+
* current state for each (missing ids omitted). The state may come from the
|
|
8
|
+
* plugin's own table, an in-memory map, or a computed value. `defineEntity`
|
|
9
|
+
* NEVER stores a copy of its own — it only makes the plugin's state reactive.
|
|
10
|
+
*
|
|
11
|
+
* This is the single source of truth for `get` / `getMany`, scope
|
|
12
|
+
* enrichment, the reactive `wait_until` wake re-eval, and the prev-snapshot
|
|
13
|
+
* taken by `handle.mutate` before each write.
|
|
14
|
+
*/
|
|
15
|
+
export type EntityRead<TState extends Record<string, unknown>> = (
|
|
16
|
+
ids: ReadonlyArray<string>,
|
|
17
|
+
) => Promise<Record<string, TState>>;
|
|
18
|
+
|
|
19
|
+
export interface DefineEntityInput<TState extends Record<string, unknown>> {
|
|
20
|
+
/** Globally-unique entity kind (e.g. "incident", "maintenance", "health"). */
|
|
21
|
+
kind: string;
|
|
22
|
+
/**
|
|
23
|
+
* zod = single source of truth: typing, validation, scope projection,
|
|
24
|
+
* UI/editor introspection, change-event shape. MUST be a z.object.
|
|
25
|
+
*/
|
|
26
|
+
state: z.ZodObject<z.ZodRawShape> & z.ZodType<TState>;
|
|
27
|
+
/**
|
|
28
|
+
* Plugin-owned current-state accessor (Model B). REQUIRED: `defineEntity`
|
|
29
|
+
* owns NO current-state storage; this reader is the only path to current
|
|
30
|
+
* state and the prev-snapshot for diffs.
|
|
31
|
+
*/
|
|
32
|
+
read: EntityRead<TState>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mutation context so change events carry the causing actor (§3.1). */
|
|
36
|
+
export interface EntityMutationOpts {
|
|
37
|
+
/** Defaults to the system actor when omitted. */
|
|
38
|
+
actor?: Actor;
|
|
39
|
+
/** Run id, when the mutation originates inside a dispatch run (masking). */
|
|
40
|
+
runId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The single driven mutation entry point (Model B). The handle:
|
|
45
|
+
*
|
|
46
|
+
* 1. snapshots `prev` via the kind's `read` accessor BEFORE the write
|
|
47
|
+
* (so a change is structurally impossible to miss),
|
|
48
|
+
* 2. runs `apply()` — the plugin's ACTUAL write against ITS OWN storage,
|
|
49
|
+
* committed in the PLUGIN's own transaction. `apply` returns the
|
|
50
|
+
* resulting current state (`next`),
|
|
51
|
+
* 3. AFTER the plugin write has committed, diffs prev → next and, on a real
|
|
52
|
+
* diff, appends the field-level transition rows to `entity_transitions`
|
|
53
|
+
* in the FRAMEWORK's own transaction (a separate db/client),
|
|
54
|
+
* 4. emits `ENTITY_CHANGED` AFTER the plugin write has committed (never on a
|
|
55
|
+
* rolled-back / throwing write).
|
|
56
|
+
*
|
|
57
|
+
* Cross-plugin transaction boundary (the deliberate Model B tradeoff): a
|
|
58
|
+
* plugin-backed kind keeps its state in its OWN schema, behind its OWN drizzle
|
|
59
|
+
* client — a DIFFERENT client than automation-backend's `entity_transitions`.
|
|
60
|
+
* Two different clients cannot share one transaction, so `apply` does NOT
|
|
61
|
+
* receive the framework tx; it owns its own. The framework appends the
|
|
62
|
+
* transition log in a SEPARATE transaction AFTER the plugin write commits.
|
|
63
|
+
*
|
|
64
|
+
* Consequences, by design:
|
|
65
|
+
* - The plugin write is the source of truth. If it throws, NOTHING is
|
|
66
|
+
* appended and NOTHING is emitted (the change never happened).
|
|
67
|
+
* - `prev` is snapshotted BEFORE `apply`, so the diff is always correct even
|
|
68
|
+
* when `read` and `apply` touch the same rows.
|
|
69
|
+
* - The transition-log append is best-effort-after-commit: if the framework
|
|
70
|
+
* append throws after a committed plugin write, the plugin state is still
|
|
71
|
+
* correct but that one transition row is missing (a history gap, never a
|
|
72
|
+
* state corruption). This is the accepted cost of decoupling plugin writes
|
|
73
|
+
* from framework-internal tables — a plugin platform must NOT couple a
|
|
74
|
+
* plugin's storage to a framework table's transaction.
|
|
75
|
+
*/
|
|
76
|
+
export interface MutateInput<TState extends Record<string, unknown>> {
|
|
77
|
+
id: string;
|
|
78
|
+
opts?: EntityMutationOpts;
|
|
79
|
+
/**
|
|
80
|
+
* The plugin's write, committed in the PLUGIN's own transaction. Takes no
|
|
81
|
+
* arguments: the plugin owns its db/tx and must NOT receive the framework
|
|
82
|
+
* tx (different client). MUST return the resulting current state (`next`)
|
|
83
|
+
* so the handle can diff without a second read.
|
|
84
|
+
*/
|
|
85
|
+
apply: () => Promise<TState>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Driven tombstone entry point. Same orchestration as {@link MutateInput},
|
|
90
|
+
* but `next` is `null` (the entity is removed). `apply` performs the plugin's
|
|
91
|
+
* delete in the plugin's own transaction; the handle records the tombstone
|
|
92
|
+
* transition (framework tx) and emits the tombstone change AFTER the delete
|
|
93
|
+
* has committed.
|
|
94
|
+
*/
|
|
95
|
+
export interface RemoveInput {
|
|
96
|
+
id: string;
|
|
97
|
+
opts?: EntityMutationOpts;
|
|
98
|
+
/** The plugin's delete, committed in the plugin's own transaction. */
|
|
99
|
+
apply: () => Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface EntityHandle<TState extends Record<string, unknown>> {
|
|
103
|
+
readonly kind: string;
|
|
104
|
+
/**
|
|
105
|
+
* The single driven mutation entry point (Model B). Snapshots prev via
|
|
106
|
+
* `read`, runs `apply()` (the plugin's own write, committed in the plugin's
|
|
107
|
+
* own tx), then appends transitions in the framework's own tx and emits
|
|
108
|
+
* `ENTITY_CHANGED` — both AFTER the plugin write commits. No-op (no emit,
|
|
109
|
+
* no transition) when `apply` returns a structurally-equal state. Returns
|
|
110
|
+
* the resulting state.
|
|
111
|
+
*/
|
|
112
|
+
mutate(input: MutateInput<TState>): Promise<TState>;
|
|
113
|
+
/**
|
|
114
|
+
* Driven tombstone (Model B). Snapshots prev via `read`, runs `apply()`
|
|
115
|
+
* (the plugin delete, committed in the plugin's own tx), records the
|
|
116
|
+
* tombstone transition (framework tx), emits a tombstone `ENTITY_CHANGED`
|
|
117
|
+
* (next = null) after the delete commits. No-op when the entity was already
|
|
118
|
+
* absent.
|
|
119
|
+
*/
|
|
120
|
+
remove(input: RemoveInput): Promise<void>;
|
|
121
|
+
/** Current state by id (routes to `read`). */
|
|
122
|
+
get(id: string): Promise<TState | undefined>;
|
|
123
|
+
/** Batched current-state read (routes to `read`). Missing ids omitted. */
|
|
124
|
+
getMany(ids: ReadonlyArray<string>): Promise<Record<string, TState>>;
|
|
125
|
+
/** Transition helpers — generalize Phase 13's health transitions to any entity. */
|
|
126
|
+
inStateSince(id: string, field: keyof TState & string): Promise<Date | null>;
|
|
127
|
+
inStateForMs(id: string, field: keyof TState & string): Promise<number>;
|
|
128
|
+
transitionCount(args: {
|
|
129
|
+
id: string;
|
|
130
|
+
field: keyof TState & string;
|
|
131
|
+
windowMs: number;
|
|
132
|
+
}): Promise<number>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type DefineEntity = <TState extends Record<string, unknown>>(
|
|
136
|
+
input: DefineEntityInput<TState>,
|
|
137
|
+
) => EntityHandle<TState>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Escape-hatch declaration — data that looks like state but is
|
|
141
|
+
* intentionally NOT a reactive entity (reactive automation engine §5,
|
|
142
|
+
* §15.6). Recorded in a registry; the lint rule (a later phase) consumes
|
|
143
|
+
* these declarations to suppress false positives on declared-non-reactive
|
|
144
|
+
* tables.
|
|
145
|
+
*/
|
|
146
|
+
export interface DeclareNonReactiveStateInput {
|
|
147
|
+
/** Drizzle table object name or table name the data lives in. */
|
|
148
|
+
table: string;
|
|
149
|
+
/** One of the §5 classes — forces the author to pick a reason. */
|
|
150
|
+
reason: "raw-sample" | "sensitive" | "externally-owned" | "bookkeeping";
|
|
151
|
+
/** Free-text justification surfaced in the lint message + docs. */
|
|
152
|
+
note: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type DeclareNonReactiveState = (
|
|
156
|
+
input: DeclareNonReactiveStateInput,
|
|
157
|
+
) => void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { diffEntityState } from "./diff";
|
|
3
|
+
|
|
4
|
+
describe("diffEntityState", () => {
|
|
5
|
+
it("reports added fields on create (prev null)", () => {
|
|
6
|
+
const { changedFields, delta } = diffEntityState({
|
|
7
|
+
prev: null,
|
|
8
|
+
next: { status: "open", severity: "high" },
|
|
9
|
+
});
|
|
10
|
+
expect(changedFields).toEqual(["severity", "status"]);
|
|
11
|
+
expect(delta).toEqual({ status: "open", severity: "high" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reports only the changed field on a value change", () => {
|
|
15
|
+
const { changedFields, delta } = diffEntityState({
|
|
16
|
+
prev: { status: "open", severity: "high" },
|
|
17
|
+
next: { status: "resolved", severity: "high" },
|
|
18
|
+
});
|
|
19
|
+
expect(changedFields).toEqual(["status"]);
|
|
20
|
+
expect(delta).toEqual({ status: "resolved" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reports a dropped field as null in the delta", () => {
|
|
24
|
+
const { changedFields, delta } = diffEntityState({
|
|
25
|
+
prev: { status: "open", note: "x" },
|
|
26
|
+
next: { status: "open" },
|
|
27
|
+
});
|
|
28
|
+
expect(changedFields).toEqual(["note"]);
|
|
29
|
+
expect(delta).toEqual({ note: null });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns empty when structurally unchanged (key order, nesting)", () => {
|
|
33
|
+
const { changedFields, delta } = diffEntityState({
|
|
34
|
+
prev: { a: 1, nested: { x: 1, y: 2 } },
|
|
35
|
+
next: { nested: { y: 2, x: 1 }, a: 1 },
|
|
36
|
+
});
|
|
37
|
+
expect(changedFields).toEqual([]);
|
|
38
|
+
expect(delta).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("detects nested object changes", () => {
|
|
42
|
+
const { changedFields, delta } = diffEntityState({
|
|
43
|
+
prev: { window: { startAt: "1", endAt: "2" } },
|
|
44
|
+
next: { window: { startAt: "1", endAt: "3" } },
|
|
45
|
+
});
|
|
46
|
+
expect(changedFields).toEqual(["window"]);
|
|
47
|
+
expect(delta).toEqual({ window: { startAt: "1", endAt: "3" } });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("detects array changes", () => {
|
|
51
|
+
const { changedFields } = diffEntityState({
|
|
52
|
+
prev: { systemIds: ["a", "b"] },
|
|
53
|
+
next: { systemIds: ["a", "b", "c"] },
|
|
54
|
+
});
|
|
55
|
+
expect(changedFields).toEqual(["systemIds"]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { structurallyEqual } from "./stable-stringify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-field structural diff between a prior and next entity state.
|
|
5
|
+
*
|
|
6
|
+
* Fields are the top-level keys of the (zod-object) state. A field is
|
|
7
|
+
* "changed" when its value is not structurally equal across prev/next —
|
|
8
|
+
* this covers added keys (prev absent), removed keys (next absent, value
|
|
9
|
+
* becomes `null` in the delta), and value changes (including nested object
|
|
10
|
+
* / array mutations compared structurally).
|
|
11
|
+
*
|
|
12
|
+
* `delta` carries only the changed fields' NEXT values (or `null` for a
|
|
13
|
+
* field that was dropped), mirroring the `EntityChangedSchema.delta` shape
|
|
14
|
+
* ("changed fields only").
|
|
15
|
+
*/
|
|
16
|
+
export interface EntityDiff {
|
|
17
|
+
/** Sorted list of changed top-level field names. */
|
|
18
|
+
changedFields: string[];
|
|
19
|
+
/** Changed fields only → their next value (or `null` when removed). */
|
|
20
|
+
delta: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function diffEntityState(args: {
|
|
24
|
+
prev: Record<string, unknown> | null;
|
|
25
|
+
next: Record<string, unknown> | null;
|
|
26
|
+
}): EntityDiff {
|
|
27
|
+
const { prev, next } = args;
|
|
28
|
+
const prevObj = prev ?? {};
|
|
29
|
+
const nextObj = next ?? {};
|
|
30
|
+
|
|
31
|
+
const keys = new Set<string>([
|
|
32
|
+
...Object.keys(prevObj),
|
|
33
|
+
...Object.keys(nextObj),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const changedFields: string[] = [];
|
|
37
|
+
const delta: Record<string, unknown> = {};
|
|
38
|
+
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
const prevHas = Object.prototype.hasOwnProperty.call(prevObj, key);
|
|
41
|
+
const nextHas = Object.prototype.hasOwnProperty.call(nextObj, key);
|
|
42
|
+
const prevVal = prevHas ? prevObj[key] : undefined;
|
|
43
|
+
const nextVal = nextHas ? nextObj[key] : undefined;
|
|
44
|
+
|
|
45
|
+
if (structurallyEqual(prevVal, nextVal)) continue;
|
|
46
|
+
|
|
47
|
+
changedFields.push(key);
|
|
48
|
+
// A dropped field surfaces as `null` so the delta is JSON-serializable
|
|
49
|
+
// and the change event records the removal explicitly.
|
|
50
|
+
delta[key] = nextHas ? nextVal : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { changedFields: changedFields.toSorted(), delta };
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { serializeFieldValue } from "./entity-store";
|
|
3
|
+
|
|
4
|
+
// The drizzle-backed `createEntityStore` is thin glue over the query
|
|
5
|
+
// builder (each method maps ~1:1 to a statement); its observable behavior
|
|
6
|
+
// is exercised end-to-end through the in-memory `FakeEntityStore` + handle
|
|
7
|
+
// tests, and pinned against real Postgres by the Phase-1 integration lane.
|
|
8
|
+
// `serializeFieldValue` carries the only standalone logic — the transition
|
|
9
|
+
// log's value normalization — so it is tested directly here.
|
|
10
|
+
describe("serializeFieldValue", () => {
|
|
11
|
+
it("passes strings through verbatim (no JSON quotes)", () => {
|
|
12
|
+
expect(serializeFieldValue("open")).toBe("open");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders null / undefined as the literal 'null'", () => {
|
|
16
|
+
expect(serializeFieldValue(null)).toBe("null");
|
|
17
|
+
expect(serializeFieldValue(undefined)).toBe("null");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("JSON-encodes non-string scalars and structures", () => {
|
|
21
|
+
expect(serializeFieldValue(42)).toBe("42");
|
|
22
|
+
expect(serializeFieldValue(true)).toBe("true");
|
|
23
|
+
expect(serializeFieldValue(["a", "b"])).toBe('["a","b"]');
|
|
24
|
+
expect(serializeFieldValue({ x: 1 })).toBe('{"x":1}');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is stable for the same string value (powers inStateSince matching)", () => {
|
|
28
|
+
expect(serializeFieldValue("resolved")).toBe(serializeFieldValue("resolved"));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework transition store — the kind-agnostic, plugin-storage-agnostic
|
|
3
|
+
* persistence layer behind every `defineEntity` handle (Model B, reactive
|
|
4
|
+
* automation engine §15.1 reshaped).
|
|
5
|
+
*
|
|
6
|
+
* In Model B `defineEntity` owns NO current-state storage of its own. The
|
|
7
|
+
* plugin owns its state and reads it through a `read` accessor; this store
|
|
8
|
+
* owns only two universal concerns:
|
|
9
|
+
*
|
|
10
|
+
* 1. The framework TRANSACTION used to append the `entity_transitions`
|
|
11
|
+
* rows. This wraps ONLY the post-commit transition append — the plugin's
|
|
12
|
+
* `apply` write is NOT driven inside it. The plugin's reactive-state
|
|
13
|
+
* write is a different schema behind a different drizzle client, so it
|
|
14
|
+
* cannot share this transaction; the handle runs `apply` FIRST (it
|
|
15
|
+
* commits in the plugin's own tx), then opens this framework tx solely to
|
|
16
|
+
* append the transition rows. The plugin write and the transition append
|
|
17
|
+
* therefore do NOT commit atomically together — a deliberate cross-plugin
|
|
18
|
+
* non-atomic boundary: the plugin write is authoritative, and a failure
|
|
19
|
+
* between the two leaves correct plugin state with at most a missing
|
|
20
|
+
* history row (a gap, never a corruption). Full rationale in
|
|
21
|
+
* `define-entity.ts` (the "Cross-plugin transaction boundary" note).
|
|
22
|
+
* 2. The transition-LOG read helpers (`inStateSince` / `transitionCount`)
|
|
23
|
+
* that power `inStateSince` / `inStateForMs` / `transitionCount`.
|
|
24
|
+
*
|
|
25
|
+
* Every kind owns its current-state storage and exposes it through a `read`
|
|
26
|
+
* accessor; this store touches only `entity_transitions`.
|
|
27
|
+
*/
|
|
28
|
+
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
|
29
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
30
|
+
|
|
31
|
+
import { entityTransitions } from "../schema";
|
|
32
|
+
|
|
33
|
+
type Schema = {
|
|
34
|
+
entityTransitions: typeof entityTransitions;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The transaction handle the store opens for the framework's transition
|
|
39
|
+
* append. It is the drizzle transaction object for the automation-backend
|
|
40
|
+
* schema, used ONLY to append `entity_transitions` rows after the plugin's
|
|
41
|
+
* `apply` has already committed (Model B). It is NOT passed to the plugin's
|
|
42
|
+
* `apply`; the plugin write runs in its own client/tx and does not share this
|
|
43
|
+
* one.
|
|
44
|
+
*/
|
|
45
|
+
export type EntityTx = Parameters<
|
|
46
|
+
Parameters<SafeDatabase<Schema>["transaction"]>[0]
|
|
47
|
+
>[0];
|
|
48
|
+
|
|
49
|
+
/** A transition row to append when a tracked field changes. */
|
|
50
|
+
export interface TransitionAppend {
|
|
51
|
+
field: string;
|
|
52
|
+
fromValue: string | null;
|
|
53
|
+
toValue: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The kind-agnostic transition store. A `defineEntity` handle binds a single
|
|
58
|
+
* `kind` and forwards to these methods.
|
|
59
|
+
*/
|
|
60
|
+
export interface EntityStore {
|
|
61
|
+
/**
|
|
62
|
+
* Run `fn` inside ONE framework database transaction, passing it the
|
|
63
|
+
* transaction handle. The handle uses this ONLY to append the
|
|
64
|
+
* `entity_transitions` rows AFTER the plugin's `apply` has already committed
|
|
65
|
+
* (in the plugin's own tx). The plugin write is NOT driven inside this
|
|
66
|
+
* transaction — it cannot be, since it lives behind a different client (the
|
|
67
|
+
* non-atomic cross-plugin boundary; see the file docblock and
|
|
68
|
+
* `define-entity.ts`). The post-commit change emit is done by the handle
|
|
69
|
+
* AFTER this resolves.
|
|
70
|
+
*/
|
|
71
|
+
runInTransaction<R>(fn: (tx: EntityTx) => Promise<R>): Promise<R>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append transition rows on the framework transaction opened by
|
|
75
|
+
* `runInTransaction`. This is a post-commit framework write: the plugin's
|
|
76
|
+
* reactive-state write has ALREADY committed separately, so these rows do
|
|
77
|
+
* NOT commit atomically with it (the deliberate non-atomic boundary above).
|
|
78
|
+
*/
|
|
79
|
+
appendTransitions(args: {
|
|
80
|
+
tx: EntityTx;
|
|
81
|
+
kind: string;
|
|
82
|
+
entityId: string;
|
|
83
|
+
transitions: ReadonlyArray<TransitionAppend>;
|
|
84
|
+
}): Promise<void>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Most-recent transition INTO `currentValue` for `field`, i.e. "in this
|
|
88
|
+
* value since" — the timestamp of the latest row whose `toValue` matches
|
|
89
|
+
* the entity's CURRENT field value (resolved by the handle via `read`).
|
|
90
|
+
* Null when there is no such transition (e.g. the field never changed
|
|
91
|
+
* since creation, or the entity is absent and the handle passes no value).
|
|
92
|
+
*/
|
|
93
|
+
inStateSince(args: {
|
|
94
|
+
kind: string;
|
|
95
|
+
entityId: string;
|
|
96
|
+
field: string;
|
|
97
|
+
/** The entity's current value of `field`, resolved by the handle. */
|
|
98
|
+
currentValue: unknown;
|
|
99
|
+
}): Promise<Date | null>;
|
|
100
|
+
|
|
101
|
+
/** Count transitions of `field` within the trailing `windowMs`. */
|
|
102
|
+
transitionCount(args: {
|
|
103
|
+
kind: string;
|
|
104
|
+
entityId: string;
|
|
105
|
+
field: string;
|
|
106
|
+
windowMs: number;
|
|
107
|
+
now: Date;
|
|
108
|
+
}): Promise<number>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Serialize a state field value to the transition log's text column. */
|
|
112
|
+
export function serializeFieldValue(value: unknown): string {
|
|
113
|
+
if (value === null || value === undefined) return "null";
|
|
114
|
+
if (typeof value === "string") return value;
|
|
115
|
+
return JSON.stringify(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createEntityStore(db: SafeDatabase<Schema>): EntityStore {
|
|
119
|
+
return {
|
|
120
|
+
async runInTransaction(fn) {
|
|
121
|
+
return db.transaction(async (tx) => fn(tx));
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async appendTransitions({ tx, kind, entityId, transitions }) {
|
|
125
|
+
if (transitions.length === 0) return;
|
|
126
|
+
await tx.insert(entityTransitions).values(
|
|
127
|
+
transitions.map((t) => ({
|
|
128
|
+
kind,
|
|
129
|
+
entityId,
|
|
130
|
+
field: t.field,
|
|
131
|
+
fromValue: t.fromValue,
|
|
132
|
+
toValue: t.toValue,
|
|
133
|
+
})),
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async inStateSince({ kind, entityId, field, currentValue }) {
|
|
138
|
+
const value = serializeFieldValue(currentValue);
|
|
139
|
+
const [row] = await db
|
|
140
|
+
.select({ transitionedAt: entityTransitions.transitionedAt })
|
|
141
|
+
.from(entityTransitions)
|
|
142
|
+
.where(
|
|
143
|
+
and(
|
|
144
|
+
eq(entityTransitions.kind, kind),
|
|
145
|
+
eq(entityTransitions.entityId, entityId),
|
|
146
|
+
eq(entityTransitions.field, field),
|
|
147
|
+
eq(entityTransitions.toValue, value),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
.orderBy(desc(entityTransitions.transitionedAt))
|
|
151
|
+
.limit(1);
|
|
152
|
+
return row?.transitionedAt ?? null;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async transitionCount({ kind, entityId, field, windowMs, now }) {
|
|
156
|
+
const since = new Date(now.getTime() - windowMs);
|
|
157
|
+
const [row] = await db
|
|
158
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
159
|
+
.from(entityTransitions)
|
|
160
|
+
.where(
|
|
161
|
+
and(
|
|
162
|
+
eq(entityTransitions.kind, kind),
|
|
163
|
+
eq(entityTransitions.entityId, entityId),
|
|
164
|
+
eq(entityTransitions.field, field),
|
|
165
|
+
gte(entityTransitions.transitionedAt, since),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
return row?.count ?? 0;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createExtensionPoint } from "@checkstack/backend-api";
|
|
2
|
+
|
|
3
|
+
import type { DeclareNonReactiveState, DefineEntity } from "./define-entity";
|
|
4
|
+
import type {
|
|
5
|
+
EntityChangeDeriver,
|
|
6
|
+
EntityChangePayloadMapper,
|
|
7
|
+
} from "./change-derivers";
|
|
8
|
+
import type { OnEntityChanged } from "./on-entity-changed";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register a per-kind trigger-event deriver (reactive automation engine §7,
|
|
12
|
+
* Stage-1 routing). A domain (Phase 4) maps "this entity kind changed like
|
|
13
|
+
* THIS" → the qualified trigger event id(s) it should fire; Stage 1 unions
|
|
14
|
+
* the results and fans out to the enabled automations referencing them.
|
|
15
|
+
*
|
|
16
|
+
* `toPayload`, when supplied, maps a change of this kind to the DOMAIN-named
|
|
17
|
+
* `trigger.payload` shape the kind's triggers declare via `payloadSchema`
|
|
18
|
+
* (incident `incidentId`, health `systemId` / `previousStatus`, …). Stage-2
|
|
19
|
+
* uses it so operator filters/templates reading `trigger.payload.incidentId`
|
|
20
|
+
* etc. resolve correctly; without it Stage-2 falls back to the generic
|
|
21
|
+
* `{ kind, id, prev, next, delta, ...next }` shape. At most one mapper per
|
|
22
|
+
* kind (a second distinct mapper throws).
|
|
23
|
+
*/
|
|
24
|
+
export type RegisterChangeDeriver = (input: {
|
|
25
|
+
kind: string;
|
|
26
|
+
derive: EntityChangeDeriver;
|
|
27
|
+
toPayload?: EntityChangePayloadMapper;
|
|
28
|
+
}) => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `automation.entity` extension point — the single typed path to
|
|
32
|
+
* reactive entity state (reactive automation engine §4.2). automation-
|
|
33
|
+
* backend owns the entity store, scope projection, and wake-index, so it
|
|
34
|
+
* registers this impl in Phase 1 (`register`); other plugins resolve it via
|
|
35
|
+
* `env.getExtensionPoint(entityExtensionPoint)` and call `defineEntity` in
|
|
36
|
+
* their own `register`/`init`. Cross-plugin calls are Proxy-buffered until
|
|
37
|
+
* automation-backend registers the impl.
|
|
38
|
+
*
|
|
39
|
+
* - `defineEntity` — declare an entity kind + get its typed mutation handle.
|
|
40
|
+
* - `declareNonReactiveState` — annotate intentionally non-reactive data
|
|
41
|
+
* (§5, §15.6) so enforcement can be strict on everything unmarked.
|
|
42
|
+
* - `onEntityChanged` — subscribe to ANOTHER domain's entity changes without
|
|
43
|
+
* touching the internal `ENTITY_CHANGED` hook (§6.1 design decision).
|
|
44
|
+
* - `registerChangeDeriver` — map a kind's change to the trigger event id(s)
|
|
45
|
+
* Stage-1 routing fans out (Phase 4 supplies the per-domain derivers).
|
|
46
|
+
*/
|
|
47
|
+
export interface EntityExtensionPoint {
|
|
48
|
+
defineEntity: DefineEntity;
|
|
49
|
+
declareNonReactiveState: DeclareNonReactiveState;
|
|
50
|
+
onEntityChanged: OnEntityChanged;
|
|
51
|
+
registerChangeDeriver: RegisterChangeDeriver;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const entityExtensionPoint = createExtensionPoint<EntityExtensionPoint>(
|
|
55
|
+
"automation.entity",
|
|
56
|
+
);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory test doubles for the Model B entity machine.
|
|
3
|
+
*
|
|
4
|
+
* `createFakeEntityStore()` returns a combined fake that plays two roles a
|
|
5
|
+
* unit test needs:
|
|
6
|
+
*
|
|
7
|
+
* - the framework TRANSITION store (`EntityStore`): a fake transaction +
|
|
8
|
+
* an in-memory transition log + the `inStateSince` / `transitionCount`
|
|
9
|
+
* reads. The fake `runInTransaction` just runs the callback (no real tx);
|
|
10
|
+
* `tx` is a sentinel `appendTransitions` recognizes.
|
|
11
|
+
* - a plugin `read` accessor over `rows` (`readFor`), so a test can drive a
|
|
12
|
+
* kind end-to-end via `handle.mutate` and keep `rows` inspectable.
|
|
13
|
+
*
|
|
14
|
+
* For PLUGIN-BACKED kinds a test supplies its OWN `read` + `apply` (e.g. an
|
|
15
|
+
* in-memory domain map) and only uses this fake for the transition store; see
|
|
16
|
+
* `mutate-handle.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
import type { EntityStore, EntityTx, TransitionAppend } from "./entity-store";
|
|
19
|
+
import { serializeFieldValue } from "./entity-store";
|
|
20
|
+
|
|
21
|
+
interface TransitionRow {
|
|
22
|
+
kind: string;
|
|
23
|
+
entityId: string;
|
|
24
|
+
field: string;
|
|
25
|
+
fromValue: string | null;
|
|
26
|
+
toValue: string;
|
|
27
|
+
transitionedAt: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A sentinel "transaction" the fake passes to `apply` / `appendTransitions`. */
|
|
31
|
+
export const FAKE_TX: EntityTx = {} as unknown as EntityTx;
|
|
32
|
+
|
|
33
|
+
export interface FakeEntityStore extends EntityStore {
|
|
34
|
+
/** Direct access to the persisted state rows (kind:id -> state). */
|
|
35
|
+
readonly rows: Map<string, Record<string, unknown>>;
|
|
36
|
+
/** Direct access to the transition log. */
|
|
37
|
+
readonly transitions: TransitionRow[];
|
|
38
|
+
/** Override the clock used for transition timestamps (defaults to Date.now). */
|
|
39
|
+
setClock(fn: () => Date): void;
|
|
40
|
+
/**
|
|
41
|
+
* Register an observer invoked each time `runInTransaction` opens the fake
|
|
42
|
+
* framework transaction. Lets a test assert the cross-plugin ordering: the
|
|
43
|
+
* plugin-backed path opens the framework (transition-append) tx ONLY after
|
|
44
|
+
* the plugin write has committed.
|
|
45
|
+
*/
|
|
46
|
+
onTransaction(fn: () => void): void;
|
|
47
|
+
/** A plugin `read` accessor over `rows` for a given kind. */
|
|
48
|
+
readFor<TState extends Record<string, unknown>>(
|
|
49
|
+
kind: string,
|
|
50
|
+
): (ids: ReadonlyArray<string>) => Promise<Record<string, TState>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const key = (kind: string, id: string) => `${kind}:${id}`;
|
|
54
|
+
const defaultClock = (): Date => new Date();
|
|
55
|
+
|
|
56
|
+
export function createFakeEntityStore(): FakeEntityStore {
|
|
57
|
+
const rows = new Map<string, Record<string, unknown>>();
|
|
58
|
+
const transitions: TransitionRow[] = [];
|
|
59
|
+
let clock: () => Date = defaultClock;
|
|
60
|
+
let onTx: (() => void) | undefined;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
rows,
|
|
64
|
+
transitions,
|
|
65
|
+
setClock(fn) {
|
|
66
|
+
clock = fn;
|
|
67
|
+
},
|
|
68
|
+
onTransaction(fn) {
|
|
69
|
+
onTx = fn;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async runInTransaction(fn) {
|
|
73
|
+
// No real transaction in the fake — just run with the sentinel tx.
|
|
74
|
+
onTx?.();
|
|
75
|
+
return fn(FAKE_TX);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async appendTransitions({ kind, entityId, transitions: appends }) {
|
|
79
|
+
const at = clock();
|
|
80
|
+
for (const t of appends as ReadonlyArray<TransitionAppend>) {
|
|
81
|
+
transitions.push({
|
|
82
|
+
kind,
|
|
83
|
+
entityId,
|
|
84
|
+
field: t.field,
|
|
85
|
+
fromValue: t.fromValue,
|
|
86
|
+
toValue: t.toValue,
|
|
87
|
+
transitionedAt: at,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async inStateSince({ kind, entityId, field, currentValue }) {
|
|
93
|
+
const value = serializeFieldValue(currentValue);
|
|
94
|
+
const matching = transitions
|
|
95
|
+
.filter(
|
|
96
|
+
(t) =>
|
|
97
|
+
t.kind === kind &&
|
|
98
|
+
t.entityId === entityId &&
|
|
99
|
+
t.field === field &&
|
|
100
|
+
t.toValue === value,
|
|
101
|
+
)
|
|
102
|
+
.toSorted(
|
|
103
|
+
(a, b) => b.transitionedAt.getTime() - a.transitionedAt.getTime(),
|
|
104
|
+
);
|
|
105
|
+
return matching[0]?.transitionedAt ?? null;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async transitionCount({ kind, entityId, field, windowMs, now }) {
|
|
109
|
+
const since = now.getTime() - windowMs;
|
|
110
|
+
return transitions.filter(
|
|
111
|
+
(t) =>
|
|
112
|
+
t.kind === kind &&
|
|
113
|
+
t.entityId === entityId &&
|
|
114
|
+
t.field === field &&
|
|
115
|
+
t.transitionedAt.getTime() >= since,
|
|
116
|
+
).length;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
readFor<TState extends Record<string, unknown>>(kind: string) {
|
|
120
|
+
return async (ids: ReadonlyArray<string>) => {
|
|
121
|
+
const out: Record<string, TState> = {};
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
const row = rows.get(key(kind, id));
|
|
124
|
+
if (row) out[id] = row as TState;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHook } from "@checkstack/backend-api";
|
|
2
|
+
import type { EntityChanged } from "@checkstack/automation-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* INTERNAL entity-change hook (reactive automation engine §6.1).
|
|
6
|
+
*
|
|
7
|
+
* Created and emitted ONLY inside automation-backend; it is deliberately
|
|
8
|
+
* NOT re-exported from the package's public surface so the only typed path
|
|
9
|
+
* that emits an entity-change event is `defineEntity`. Off-pattern entity
|
|
10
|
+
* state is non-reactive by construction — Stage-1 routing and scope
|
|
11
|
+
* projection only ever read the framework store / this hook.
|
|
12
|
+
*
|
|
13
|
+
* Payload is the `EntityChangedSchema` shape (zod source of truth lives in
|
|
14
|
+
* `@checkstack/automation-common`). Emitted in `mode: "work-queue"` for
|
|
15
|
+
* Stage-1 routing in a later phase; this phase only emits it.
|
|
16
|
+
*/
|
|
17
|
+
export const ENTITY_CHANGED_HOOK = createHook<EntityChanged>(
|
|
18
|
+
"automation.entity.changed",
|
|
19
|
+
);
|