@checkstack/automation-backend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,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
+ );