@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.
- package/CHANGELOG.md +204 -0
- package/drizzle/0001_tiresome_terror.sql +3 -0
- package/drizzle/0002_graceful_mac_gargan.sql +2 -0
- package/drizzle/meta/0001_snapshot.json +102 -0
- package/drizzle/meta/0002_snapshot.json +89 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +22 -13
- package/src/automations.ts +107 -0
- package/src/entity.test.ts +313 -0
- package/src/entity.ts +221 -0
- package/src/heartbeat-monitor.it.test.ts +232 -0
- package/src/heartbeat-monitor.test.ts +156 -83
- package/src/heartbeat-monitor.ts +106 -35
- package/src/hooks.ts +9 -2
- package/src/index.ts +180 -0
- package/src/run-secret-resolver.test.ts +121 -0
- package/src/run-secret-resolver.ts +66 -0
- package/src/satellite-ws-handler.test.ts +267 -0
- package/src/satellite-ws-handler.ts +266 -6
- package/src/schema.ts +22 -1
- package/src/service.test.ts +274 -0
- package/src/service.ts +133 -15
- package/src/status.ts +18 -0
- package/tsconfig.json +18 -0
|
@@ -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
|
+
}
|