@checkstack/satellite-backend 0.3.6 → 0.5.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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Unit tests for the satellite-connection reactive-entity mapping (reactive
3
+ * automation engine §10.6, §9.1). Proves the deriver returns the EXACT
4
+ * qualified trigger event ids existing automations match — including the
5
+ * three-way connected / disconnected / heartbeat_lost distinction carried
6
+ * by the `lastEvent` discriminator.
7
+ */
8
+ import { describe, expect, it } from "bun:test";
9
+ import { SYSTEM_ACTOR } from "@checkstack/common";
10
+ import { OFFLINE_THRESHOLD_MS } from "@checkstack/satellite-common";
11
+ import type { EntityChanged } from "@checkstack/automation-common";
12
+
13
+ import {
14
+ SATELLITE_CONNECTED_EVENT,
15
+ SATELLITE_DISCONNECTED_EVENT,
16
+ SATELLITE_HEARTBEAT_LOST_EVENT,
17
+ createSatelliteConnectionRead,
18
+ deriveSatelliteConnectionEvents,
19
+ satelliteChangeToPayload,
20
+ satelliteConnectionStateSchema,
21
+ toSatelliteConnectionState,
22
+ type SatelliteConnectionState,
23
+ } from "./entity";
24
+ import {
25
+ satelliteConnectedTrigger,
26
+ satelliteDisconnectedTrigger,
27
+ satelliteHeartbeatLostTrigger,
28
+ } from "./automations";
29
+ import type { SatelliteService } from "./service";
30
+
31
+ function makeChange(over: Partial<EntityChanged>): EntityChanged {
32
+ return {
33
+ kind: "satellite-connection",
34
+ id: "sat-1",
35
+ prev: null,
36
+ next: {
37
+ status: "online",
38
+ name: "edge-eu",
39
+ region: "eu",
40
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
41
+ lastEvent: "connected",
42
+ },
43
+ delta: {},
44
+ changedFields: [],
45
+ actor: SYSTEM_ACTOR,
46
+ occurredAt: "2026-05-31T00:00:00.000Z",
47
+ ...over,
48
+ };
49
+ }
50
+
51
+ describe("satelliteChangeToPayload — payloadSchema parity", () => {
52
+ it("a connect payload validates against the connected trigger's payloadSchema", () => {
53
+ const parsed = satelliteConnectedTrigger.payloadSchema.parse(
54
+ satelliteChangeToPayload(makeChange({})),
55
+ );
56
+ expect(parsed.satelliteId).toBe("sat-1");
57
+ expect(parsed.name).toBe("edge-eu");
58
+ expect(parsed.region).toBe("eu");
59
+ expect(parsed.status).toBe("online");
60
+ expect(parsed.lastSeenAt).toBe("2026-05-31T00:00:00.000Z");
61
+ });
62
+
63
+ it("a disconnect payload validates (lastSeenAt null after clean disconnect)", () => {
64
+ const change = makeChange({
65
+ prev: {
66
+ status: "online",
67
+ name: "edge-eu",
68
+ region: "eu",
69
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
70
+ lastEvent: "connected",
71
+ },
72
+ next: {
73
+ status: "offline",
74
+ name: "edge-eu",
75
+ region: "eu",
76
+ lastSeenAt: null,
77
+ lastEvent: "disconnected",
78
+ },
79
+ });
80
+ const parsed = satelliteDisconnectedTrigger.payloadSchema.parse(
81
+ satelliteChangeToPayload(change),
82
+ );
83
+ expect(parsed.satelliteId).toBe("sat-1");
84
+ expect(parsed.status).toBe("offline");
85
+ expect(parsed.lastSeenAt).toBeNull();
86
+ });
87
+
88
+ it("a heartbeat-lost payload validates against the heartbeat_lost trigger's payloadSchema", () => {
89
+ const change = makeChange({
90
+ prev: {
91
+ status: "online",
92
+ name: "edge-eu",
93
+ region: "eu",
94
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
95
+ lastEvent: "connected",
96
+ },
97
+ next: {
98
+ status: "offline",
99
+ name: "edge-eu",
100
+ region: "eu",
101
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
102
+ lastEvent: "heartbeat_lost",
103
+ },
104
+ });
105
+ const parsed = satelliteHeartbeatLostTrigger.payloadSchema.parse(
106
+ satelliteChangeToPayload(change),
107
+ );
108
+ expect(parsed.satelliteId).toBe("sat-1");
109
+ expect(parsed.status).toBe("offline");
110
+ });
111
+ });
112
+
113
+ describe("deriveSatelliteConnectionEvents", () => {
114
+ it("maps lastEvent='connected' to satellite.connected", () => {
115
+ expect(deriveSatelliteConnectionEvents(makeChange({}))).toEqual([
116
+ SATELLITE_CONNECTED_EVENT,
117
+ ]);
118
+ });
119
+
120
+ it("maps lastEvent='disconnected' to satellite.disconnected", () => {
121
+ const change = makeChange({
122
+ prev: {
123
+ status: "online",
124
+ name: "edge-eu",
125
+ region: "eu",
126
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
127
+ lastEvent: "connected",
128
+ },
129
+ next: {
130
+ status: "offline",
131
+ name: "edge-eu",
132
+ region: "eu",
133
+ lastSeenAt: "2026-05-31T00:01:00.000Z",
134
+ lastEvent: "disconnected",
135
+ },
136
+ });
137
+ expect(deriveSatelliteConnectionEvents(change)).toEqual([
138
+ SATELLITE_DISCONNECTED_EVENT,
139
+ ]);
140
+ });
141
+
142
+ it("maps lastEvent='heartbeat_lost' to satellite.heartbeat_lost (distinct from disconnected)", () => {
143
+ const change = makeChange({
144
+ prev: {
145
+ status: "online",
146
+ name: "edge-eu",
147
+ region: "eu",
148
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
149
+ lastEvent: "connected",
150
+ },
151
+ next: {
152
+ status: "offline",
153
+ name: "edge-eu",
154
+ region: "eu",
155
+ lastSeenAt: "2026-05-31T00:05:00.000Z",
156
+ lastEvent: "heartbeat_lost",
157
+ },
158
+ });
159
+ expect(deriveSatelliteConnectionEvents(change)).toEqual([
160
+ SATELLITE_HEARTBEAT_LOST_EVENT,
161
+ ]);
162
+ });
163
+
164
+ it("fires nothing on a tombstone (next === null)", () => {
165
+ const change = makeChange({
166
+ prev: {
167
+ status: "online",
168
+ name: "edge-eu",
169
+ region: "eu",
170
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
171
+ lastEvent: "connected",
172
+ },
173
+ next: null,
174
+ });
175
+ expect(deriveSatelliteConnectionEvents(change)).toEqual([]);
176
+ });
177
+
178
+ it("fires nothing when lastEvent is missing/invalid", () => {
179
+ const change = makeChange({
180
+ next: {
181
+ status: "online",
182
+ name: "edge-eu",
183
+ region: "eu",
184
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
185
+ // lastEvent intentionally absent
186
+ },
187
+ });
188
+ expect(deriveSatelliteConnectionEvents(change)).toEqual([]);
189
+ });
190
+
191
+ it("returns event ids that exactly equal the qualified trigger ids", () => {
192
+ expect(SATELLITE_CONNECTED_EVENT).toBe("satellite.connected");
193
+ expect(SATELLITE_DISCONNECTED_EVENT).toBe("satellite.disconnected");
194
+ expect(SATELLITE_HEARTBEAT_LOST_EVENT).toBe("satellite.heartbeat_lost");
195
+ });
196
+ });
197
+
198
+ describe("satelliteConnectionStateSchema", () => {
199
+ it("accepts the canonical state shape", () => {
200
+ const ok = satelliteConnectionStateSchema.safeParse({
201
+ status: "offline",
202
+ name: "edge-eu",
203
+ region: "eu",
204
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
205
+ lastEvent: "heartbeat_lost",
206
+ });
207
+ expect(ok.success).toBe(true);
208
+ });
209
+
210
+ it("rejects an unknown status", () => {
211
+ const bad = satelliteConnectionStateSchema.safeParse({
212
+ status: "degraded",
213
+ name: "edge-eu",
214
+ region: "eu",
215
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
216
+ lastEvent: "connected",
217
+ });
218
+ expect(bad.success).toBe(false);
219
+ });
220
+ });
221
+
222
+ describe("toSatelliteConnectionState", () => {
223
+ it("computes online status from a recent lastHeartbeatAt", () => {
224
+ const recent = new Date(Date.now() - 5_000);
225
+ const state = toSatelliteConnectionState({
226
+ name: "edge-eu",
227
+ region: "eu",
228
+ lastHeartbeatAt: recent,
229
+ lastConnectionEvent: "connected",
230
+ });
231
+ expect(state).toEqual({
232
+ status: "online",
233
+ name: "edge-eu",
234
+ region: "eu",
235
+ lastSeenAt: recent.toISOString(),
236
+ lastEvent: "connected",
237
+ });
238
+ });
239
+
240
+ it("computes offline (self-heals) when lastHeartbeatAt has aged past the threshold", () => {
241
+ // This is the crash-recovery property: a row left marked `connected` by a
242
+ // crashed pod reads `offline` purely because its heartbeat aged out — the
243
+ // status is computed, never a stuck stored copy.
244
+ const aged = new Date(Date.now() - OFFLINE_THRESHOLD_MS - 10_000);
245
+ const state = toSatelliteConnectionState({
246
+ name: "edge-eu",
247
+ region: "eu",
248
+ lastHeartbeatAt: aged,
249
+ lastConnectionEvent: "connected",
250
+ });
251
+ expect(state!.status).toBe("offline");
252
+ expect(state!.lastSeenAt).toBe(aged.toISOString());
253
+ expect(state!.lastEvent).toBe("connected");
254
+ });
255
+
256
+ it("computes offline with null lastSeenAt after a clean disconnect (lastHeartbeatAt cleared)", () => {
257
+ const state = toSatelliteConnectionState({
258
+ name: "edge-eu",
259
+ region: "eu",
260
+ lastHeartbeatAt: null,
261
+ lastConnectionEvent: "disconnected",
262
+ });
263
+ expect(state).toEqual({
264
+ status: "offline",
265
+ name: "edge-eu",
266
+ region: "eu",
267
+ lastSeenAt: null,
268
+ lastEvent: "disconnected",
269
+ });
270
+ });
271
+
272
+ it("returns null for a never-connected satellite (no last edge yet)", () => {
273
+ // A satellite created but never connected has null lastConnectionEvent — it
274
+ // has no entity state, so the read omits it and the framework sees the first
275
+ // connect as a create (prev === null).
276
+ expect(
277
+ toSatelliteConnectionState({
278
+ name: "edge-eu",
279
+ region: "eu",
280
+ lastHeartbeatAt: null,
281
+ lastConnectionEvent: null,
282
+ }),
283
+ ).toBeNull();
284
+ });
285
+ });
286
+
287
+ describe("createSatelliteConnectionRead", () => {
288
+ it("routes the batched read straight to the durable service read", async () => {
289
+ // Proves the entity `read` resolves from the service (the durable
290
+ // `satellites` table) — so a fresh service instance, i.e. ANOTHER POD,
291
+ // sees the SAME state. This is the horizontal-scaling fix.
292
+ const seen: ReadonlyArray<string>[] = [];
293
+ const durableState: SatelliteConnectionState = {
294
+ status: "online",
295
+ name: "edge-eu",
296
+ region: "eu",
297
+ lastSeenAt: "2026-05-31T00:00:00.000Z",
298
+ lastEvent: "connected",
299
+ };
300
+ const service = {
301
+ async getManyConnectionStates(ids: ReadonlyArray<string>) {
302
+ seen.push(ids);
303
+ return { "sat-1": durableState };
304
+ },
305
+ } as unknown as SatelliteService;
306
+
307
+ const read = createSatelliteConnectionRead(service);
308
+ const out = await read(["sat-1", "sat-2"]);
309
+ expect(seen).toEqual([["sat-1", "sat-2"]]);
310
+ expect(out["sat-1"]).toEqual(durableState);
311
+ expect(out["sat-2"]).toBeUndefined();
312
+ });
313
+ });
package/src/entity.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Satellite connection reactive entity (reactive automation engine §10.6,
3
+ * §9.1).
4
+ *
5
+ * Satellite connection state is genuinely an entity: the WS handler's
6
+ * connection lifecycle and the heartbeat monitor's online→offline transition
7
+ * ARE state with diffs. The `satellite-connection` entity is PLUGIN-BACKED
8
+ * (Model B) and COMPUTE-ON-READ: its `status` is DERIVED on read from the
9
+ * durable `satellites.lastHeartbeatAt` column (via `computeStatus` /
10
+ * `OFFLINE_THRESHOLD_MS` — the SAME single liveness source of truth the admin
11
+ * list uses), so there is no second stored copy of the status to drift, and the
12
+ * read is globally consistent from any pod AND self-healing: a stale row reads
13
+ * `offline` once `lastHeartbeatAt` ages past the offline threshold, even if the
14
+ * pod that owned the socket crashed without writing offline. The only extra
15
+ * durable column is `lastConnectionEvent` (the deriver's event discriminator).
16
+ * There is NO framework `entity_state` mirror. This fixes a horizontal-scaling
17
+ * bug twice over: the original design stored status in a process-local in-memory
18
+ * map (invisible across pods), and the prior fix's stored `connectionStatus`
19
+ * still got stuck `online` forever after a pod crash because the heartbeat-lost
20
+ * EDGE was detected pod-locally. Computing status on read removes both.
21
+ *
22
+ * The three lifecycle sites that used to emit the `satellite.connected` /
23
+ * `.disconnected` / `.heartbeat_lost` hooks now drive `handle.mutate`, whose
24
+ * `apply` UPDATEs the satellite row's connection columns (the pod that owns the
25
+ * socket is the writer) and returns the view; the framework still records full
26
+ * transition HISTORY in `entity_transitions` (durable current state AND durable
27
+ * platform history). The persisted `satellites.lastHeartbeatAt` column stays as
28
+ * escape-hatched bookkeeping (declared non-reactive).
29
+ *
30
+ * The three hooks are removed; the change-deriver below maps an entity
31
+ * change back to the SAME qualified trigger event ids existing automations
32
+ * match. Because `disconnected` (socket drop) and `heartbeat_lost`
33
+ * (online→offline edge) BOTH move the connection toward "offline", a plain
34
+ * `status` diff cannot tell them apart. The entity therefore carries an
35
+ * explicit `lastEvent` discriminator naming the lifecycle edge that
36
+ * produced the change; the deriver reads it to preserve the three-way
37
+ * distinction the original triggers had.
38
+ */
39
+ import { z } from "zod";
40
+ import type { EntityChanged } from "@checkstack/automation-common";
41
+ import type {
42
+ EntityChangePayloadMapper,
43
+ EntityRead,
44
+ } from "@checkstack/automation-backend";
45
+ import type { SatelliteService } from "./service";
46
+ import { computeStatus } from "./status";
47
+
48
+ /** Globally-unique entity kind for a satellite connection. */
49
+ export const SATELLITE_CONNECTION_ENTITY_KIND = "satellite-connection";
50
+
51
+ /**
52
+ * Qualified trigger event ids the deriver maps to — `${pluginId}.${triggerId}`
53
+ * for the (now-removed) connection triggers. Automations reference these
54
+ * strings in `definition.triggers[].event`, so the deriver MUST return them
55
+ * verbatim for Stage-1 routing to match.
56
+ */
57
+ export const SATELLITE_CONNECTED_EVENT = "satellite.connected";
58
+ export const SATELLITE_DISCONNECTED_EVENT = "satellite.disconnected";
59
+ export const SATELLITE_HEARTBEAT_LOST_EVENT = "satellite.heartbeat_lost";
60
+
61
+ /**
62
+ * The lifecycle edge that produced a connection-state change. Preserves the
63
+ * distinction between a socket drop (`disconnected`) and the heartbeat-lost
64
+ * offline edge (`heartbeat_lost`), which a bare `status` diff cannot encode.
65
+ */
66
+ export const satelliteConnectionEventEnum = z.enum([
67
+ "connected",
68
+ "disconnected",
69
+ "heartbeat_lost",
70
+ ]);
71
+
72
+ export type SatelliteConnectionEvent = z.infer<
73
+ typeof satelliteConnectionEventEnum
74
+ >;
75
+
76
+ /** Reactive state of a satellite connection (reactive automation engine §9.1). */
77
+ export const satelliteConnectionStateSchema = z.object({
78
+ /**
79
+ * COMPUTED on read from `lastHeartbeatAt` (the single liveness source of
80
+ * truth). Never stored — a stale row reads `offline` once the heartbeat ages
81
+ * past the offline threshold, so presence self-heals after a pod crash.
82
+ */
83
+ status: z.enum(["online", "offline"]),
84
+ name: z.string(),
85
+ region: z.string(),
86
+ /**
87
+ * ISO timestamp the satellite was last seen alive (its `lastHeartbeatAt`), or
88
+ * `null` after a clean disconnect (when `lastHeartbeatAt` is cleared so the
89
+ * status flips offline immediately). Derived, not separately stored.
90
+ */
91
+ lastSeenAt: z.string().nullable(),
92
+ /** Which lifecycle edge produced the latest change (see above). */
93
+ lastEvent: satelliteConnectionEventEnum,
94
+ });
95
+
96
+ export type SatelliteConnectionState = z.infer<
97
+ typeof satelliteConnectionStateSchema
98
+ >;
99
+
100
+ /**
101
+ * Map a `satellite-connection` entity change to the qualified trigger event
102
+ * id(s) it should fire (reactive automation engine §7, §10.6). The
103
+ * `lastEvent` discriminator on the NEW state names the lifecycle edge,
104
+ * so the mapping is exact and preserves the three-way distinction:
105
+ *
106
+ * - `connected` → `satellite.connected`
107
+ * - `disconnected` → `satellite.disconnected`
108
+ * - `heartbeat_lost` → `satellite.heartbeat_lost`
109
+ *
110
+ * A tombstone (`next === null`, e.g. an entity removed when the satellite is
111
+ * deleted) fires nothing — satellite deletion has its own `satellite.removed`
112
+ * hook (kept), not a connection-lifecycle event.
113
+ */
114
+ export function deriveSatelliteConnectionEvents(
115
+ changed: EntityChanged,
116
+ ): ReadonlyArray<string> {
117
+ if (changed.next === null) return [];
118
+ const parsed = satelliteConnectionEventEnum.safeParse(
119
+ changed.next["lastEvent"],
120
+ );
121
+ if (!parsed.success) return [];
122
+ switch (parsed.data) {
123
+ case "connected": {
124
+ return [SATELLITE_CONNECTED_EVENT];
125
+ }
126
+ case "disconnected": {
127
+ return [SATELLITE_DISCONNECTED_EVENT];
128
+ }
129
+ case "heartbeat_lost": {
130
+ return [SATELLITE_HEARTBEAT_LOST_EVENT];
131
+ }
132
+ }
133
+ }
134
+
135
+ function readString(
136
+ state: Record<string, unknown> | null,
137
+ field: string,
138
+ ): string | undefined {
139
+ if (state === null) return undefined;
140
+ const value = state[field];
141
+ return typeof value === "string" ? value : undefined;
142
+ }
143
+
144
+ /**
145
+ * Map a `satellite-connection` entity change to the domain-named
146
+ * `trigger.payload` the connection triggers declare via `payloadSchema`
147
+ * (`satelliteId`, `name`, `region`, `status`, `lastSeenAt`). Restores the keys
148
+ * operators read (`trigger.payload.satelliteId`, `.name`, …) that the generic
149
+ * change shape omits, so an entity-driven connection trigger sees the same
150
+ * documented payload the four migrated lifecycle domains do.
151
+ *
152
+ * `satelliteId` is the entity id; the remaining fields read off `next` (a
153
+ * tombstone fires no event, so `next` is always present when a trigger fires).
154
+ * `lastSeenAt` is nullable (`null` after a clean disconnect).
155
+ */
156
+ export const satelliteChangeToPayload: EntityChangePayloadMapper = (changed) => {
157
+ const next = changed.next;
158
+ const lastSeenAt = next === null ? null : next["lastSeenAt"];
159
+ return {
160
+ satelliteId: changed.id,
161
+ name: readString(next, "name"),
162
+ region: readString(next, "region"),
163
+ status: readString(next, "status"),
164
+ lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
165
+ };
166
+ };
167
+
168
+ /**
169
+ * The durable connection columns of a satellite row, as read from the shared
170
+ * `satellites` table. This is the raw shape the service returns for the entity
171
+ * `read` accessor; {@link toSatelliteConnectionState} projects it onto the
172
+ * reactive view by COMPUTING `status` from `lastHeartbeatAt`. A satellite that
173
+ * has never connected has `lastConnectionEvent === null` (no recorded edge yet).
174
+ */
175
+ export interface SatelliteConnectionRow {
176
+ name: string;
177
+ region: string;
178
+ /** The single durable liveness source of truth; `status` is computed from it. */
179
+ lastHeartbeatAt: Date | null;
180
+ lastConnectionEvent: SatelliteConnectionEvent | null;
181
+ }
182
+
183
+ /**
184
+ * Project a durable `satellites` connection row onto the reactive
185
+ * `SatelliteConnectionState` view (the exact shape the deriver + change events
186
+ * consume). `status` is COMPUTED on read from `lastHeartbeatAt` via
187
+ * {@link computeStatus} — never read from a stored copy — so it is globally
188
+ * consistent and self-heals to `offline` once the heartbeat ages out. A
189
+ * satellite with no recorded lifecycle edge yet (never connected,
190
+ * `lastConnectionEvent === null`) has no entity state, so this returns `null`
191
+ * and the `read` accessor omits the id — exactly the `prev === null` (create)
192
+ * signal the framework needs on the first connect.
193
+ */
194
+ export function toSatelliteConnectionState(
195
+ row: SatelliteConnectionRow,
196
+ ): SatelliteConnectionState | null {
197
+ if (row.lastConnectionEvent === null) {
198
+ return null;
199
+ }
200
+ return {
201
+ status: computeStatus(row.lastHeartbeatAt),
202
+ name: row.name,
203
+ region: row.region,
204
+ lastSeenAt: row.lastHeartbeatAt ? row.lastHeartbeatAt.toISOString() : null,
205
+ lastEvent: row.lastConnectionEvent,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Build the PLUGIN-BACKED `read` accessor for the `satellite-connection`
211
+ * entity. Routes straight to the service's batched durable read of the
212
+ * `satellites` connection columns (no framework storage), so the current state
213
+ * is the SAME for every pod — this is what makes the entity globally consistent
214
+ * and is the single source of truth `handle.mutate` snapshots `prev` from and
215
+ * `get` / `getMany` / scope enrichment / `wait_until` re-eval route through.
216
+ */
217
+ export function createSatelliteConnectionRead(
218
+ service: SatelliteService,
219
+ ): EntityRead<SatelliteConnectionState> {
220
+ return (ids) => service.getManyConnectionStates(ids);
221
+ }