@checkstack/satellite-backend 0.4.0 → 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 +153 -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 +20 -13
- package/src/automations.ts +65 -24
- 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 +102 -71
- package/src/hooks.ts +9 -39
- package/src/index.ts +168 -9
- 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 +242 -49
- 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 +15 -0
- package/src/automations.test.ts +0 -54
package/src/service.test.ts
CHANGED
|
@@ -289,4 +289,278 @@ describe("SatelliteService", () => {
|
|
|
289
289
|
expect(ids).toEqual(["online-1"]);
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
|
+
|
|
293
|
+
describe("getManyConnectionStates (durable, compute-on-read entity read)", () => {
|
|
294
|
+
it("computes online status from a recent durable lastHeartbeatAt", async () => {
|
|
295
|
+
const recent = new Date(Date.now() - 5_000);
|
|
296
|
+
let whereArg: unknown;
|
|
297
|
+
const db = {
|
|
298
|
+
select: mock(() => ({
|
|
299
|
+
from: mock(() => ({
|
|
300
|
+
where: mock((arg: unknown) => {
|
|
301
|
+
whereArg = arg;
|
|
302
|
+
return Promise.resolve([
|
|
303
|
+
{
|
|
304
|
+
id: "sat-1",
|
|
305
|
+
name: "edge-eu",
|
|
306
|
+
region: "eu",
|
|
307
|
+
lastHeartbeatAt: recent,
|
|
308
|
+
lastConnectionEvent: "connected",
|
|
309
|
+
},
|
|
310
|
+
]);
|
|
311
|
+
}),
|
|
312
|
+
})),
|
|
313
|
+
})),
|
|
314
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
315
|
+
|
|
316
|
+
const service = new SatelliteService(db);
|
|
317
|
+
const out = await service.getManyConnectionStates(["sat-1", "sat-2"]);
|
|
318
|
+
|
|
319
|
+
expect(whereArg).toBeDefined();
|
|
320
|
+
expect(out).toEqual({
|
|
321
|
+
"sat-1": {
|
|
322
|
+
status: "online",
|
|
323
|
+
name: "edge-eu",
|
|
324
|
+
region: "eu",
|
|
325
|
+
lastSeenAt: recent.toISOString(),
|
|
326
|
+
lastEvent: "connected",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
// A satellite absent from the result is simply omitted (prev === null).
|
|
330
|
+
expect(out["sat-2"]).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("self-heals an aged-out row to offline (crashed pod left it 'connected')", async () => {
|
|
334
|
+
// The horizontal-scale fix: a satellite whose heartbeat aged past the
|
|
335
|
+
// threshold reads OFFLINE even though its last edge is still `connected`,
|
|
336
|
+
// because status is computed, never a stuck stored copy.
|
|
337
|
+
const aged = new Date(Date.now() - OFFLINE_THRESHOLD_MS - 10_000);
|
|
338
|
+
const db = {
|
|
339
|
+
select: mock(() => ({
|
|
340
|
+
from: mock(() => ({
|
|
341
|
+
where: mock(() =>
|
|
342
|
+
Promise.resolve([
|
|
343
|
+
{
|
|
344
|
+
id: "sat-1",
|
|
345
|
+
name: "edge-eu",
|
|
346
|
+
region: "eu",
|
|
347
|
+
lastHeartbeatAt: aged,
|
|
348
|
+
lastConnectionEvent: "connected",
|
|
349
|
+
},
|
|
350
|
+
]),
|
|
351
|
+
),
|
|
352
|
+
})),
|
|
353
|
+
})),
|
|
354
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
355
|
+
|
|
356
|
+
const service = new SatelliteService(db);
|
|
357
|
+
const out = await service.getManyConnectionStates(["sat-1"]);
|
|
358
|
+
expect(out["sat-1"]!.status).toBe("offline");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("omits never-connected satellites (null lastConnectionEvent)", async () => {
|
|
362
|
+
const db = {
|
|
363
|
+
select: mock(() => ({
|
|
364
|
+
from: mock(() => ({
|
|
365
|
+
where: mock(() =>
|
|
366
|
+
Promise.resolve([
|
|
367
|
+
{
|
|
368
|
+
id: "sat-1",
|
|
369
|
+
name: "edge-eu",
|
|
370
|
+
region: "eu",
|
|
371
|
+
lastHeartbeatAt: null,
|
|
372
|
+
lastConnectionEvent: null,
|
|
373
|
+
},
|
|
374
|
+
]),
|
|
375
|
+
),
|
|
376
|
+
})),
|
|
377
|
+
})),
|
|
378
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
379
|
+
|
|
380
|
+
const service = new SatelliteService(db);
|
|
381
|
+
expect(await service.getManyConnectionStates(["sat-1"])).toEqual({});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("short-circuits an empty id list without touching the db", async () => {
|
|
385
|
+
const select = mock(() => ({ from: mock() }));
|
|
386
|
+
const db = { select } as unknown as ConstructorParameters<
|
|
387
|
+
typeof SatelliteService
|
|
388
|
+
>[0];
|
|
389
|
+
const service = new SatelliteService(db);
|
|
390
|
+
expect(await service.getManyConnectionStates([])).toEqual({});
|
|
391
|
+
expect(select).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("applyConnectionState (durable lifecycle write)", () => {
|
|
396
|
+
it("connect: sets lastHeartbeatAt=now + connected and returns an online view", async () => {
|
|
397
|
+
const now = new Date(Date.now() - 1_000);
|
|
398
|
+
let setArg:
|
|
399
|
+
| { lastConnectionEvent?: string; lastHeartbeatAt?: Date | null }
|
|
400
|
+
| undefined;
|
|
401
|
+
const db = {
|
|
402
|
+
update: mock(() => ({
|
|
403
|
+
set: mock((arg: typeof setArg) => {
|
|
404
|
+
setArg = arg;
|
|
405
|
+
return {
|
|
406
|
+
where: mock(() => ({
|
|
407
|
+
returning: mock(() =>
|
|
408
|
+
Promise.resolve([
|
|
409
|
+
{
|
|
410
|
+
id: "sat-1",
|
|
411
|
+
name: "edge-eu",
|
|
412
|
+
region: "eu",
|
|
413
|
+
lastConnectionEvent: "connected",
|
|
414
|
+
tags: {},
|
|
415
|
+
tokenHash: "h",
|
|
416
|
+
lastHeartbeatAt: now,
|
|
417
|
+
version: null,
|
|
418
|
+
createdAt: new Date(),
|
|
419
|
+
},
|
|
420
|
+
]),
|
|
421
|
+
),
|
|
422
|
+
})),
|
|
423
|
+
};
|
|
424
|
+
}),
|
|
425
|
+
})),
|
|
426
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
427
|
+
|
|
428
|
+
const service = new SatelliteService(db);
|
|
429
|
+
const next = await service.applyConnectionState({
|
|
430
|
+
satelliteId: "sat-1",
|
|
431
|
+
lastEvent: "connected",
|
|
432
|
+
lastHeartbeatAt: now,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(setArg).toEqual({
|
|
436
|
+
lastConnectionEvent: "connected",
|
|
437
|
+
lastHeartbeatAt: now,
|
|
438
|
+
});
|
|
439
|
+
expect(next).toEqual({
|
|
440
|
+
status: "online",
|
|
441
|
+
name: "edge-eu",
|
|
442
|
+
region: "eu",
|
|
443
|
+
lastSeenAt: now.toISOString(),
|
|
444
|
+
lastEvent: "connected",
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("clean disconnect: clears lastHeartbeatAt (null) so the view is offline immediately", async () => {
|
|
449
|
+
let setArg:
|
|
450
|
+
| { lastConnectionEvent?: string; lastHeartbeatAt?: Date | null }
|
|
451
|
+
| undefined;
|
|
452
|
+
const db = {
|
|
453
|
+
update: mock(() => ({
|
|
454
|
+
set: mock((arg: typeof setArg) => {
|
|
455
|
+
setArg = arg;
|
|
456
|
+
return {
|
|
457
|
+
where: mock(() => ({
|
|
458
|
+
returning: mock(() =>
|
|
459
|
+
Promise.resolve([
|
|
460
|
+
{
|
|
461
|
+
id: "sat-1",
|
|
462
|
+
name: "edge-eu",
|
|
463
|
+
region: "eu",
|
|
464
|
+
lastConnectionEvent: "disconnected",
|
|
465
|
+
tags: {},
|
|
466
|
+
tokenHash: "h",
|
|
467
|
+
lastHeartbeatAt: null,
|
|
468
|
+
version: null,
|
|
469
|
+
createdAt: new Date(),
|
|
470
|
+
},
|
|
471
|
+
]),
|
|
472
|
+
),
|
|
473
|
+
})),
|
|
474
|
+
};
|
|
475
|
+
}),
|
|
476
|
+
})),
|
|
477
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
478
|
+
|
|
479
|
+
const service = new SatelliteService(db);
|
|
480
|
+
const next = await service.applyConnectionState({
|
|
481
|
+
satelliteId: "sat-1",
|
|
482
|
+
lastEvent: "disconnected",
|
|
483
|
+
lastHeartbeatAt: null,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
expect(setArg).toEqual({
|
|
487
|
+
lastConnectionEvent: "disconnected",
|
|
488
|
+
lastHeartbeatAt: null,
|
|
489
|
+
});
|
|
490
|
+
expect(next).toEqual({
|
|
491
|
+
status: "offline",
|
|
492
|
+
name: "edge-eu",
|
|
493
|
+
region: "eu",
|
|
494
|
+
lastSeenAt: null,
|
|
495
|
+
lastEvent: "disconnected",
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("heartbeat_lost: flips ONLY lastConnectionEvent, leaving the aged heartbeat untouched", async () => {
|
|
500
|
+
const aged = new Date(Date.now() - OFFLINE_THRESHOLD_MS - 10_000);
|
|
501
|
+
let setArg:
|
|
502
|
+
| { lastConnectionEvent?: string; lastHeartbeatAt?: Date | null }
|
|
503
|
+
| undefined;
|
|
504
|
+
const db = {
|
|
505
|
+
update: mock(() => ({
|
|
506
|
+
set: mock((arg: typeof setArg) => {
|
|
507
|
+
setArg = arg;
|
|
508
|
+
return {
|
|
509
|
+
where: mock(() => ({
|
|
510
|
+
returning: mock(() =>
|
|
511
|
+
Promise.resolve([
|
|
512
|
+
{
|
|
513
|
+
id: "sat-1",
|
|
514
|
+
name: "edge-eu",
|
|
515
|
+
region: "eu",
|
|
516
|
+
lastConnectionEvent: "heartbeat_lost",
|
|
517
|
+
tags: {},
|
|
518
|
+
tokenHash: "h",
|
|
519
|
+
lastHeartbeatAt: aged,
|
|
520
|
+
version: null,
|
|
521
|
+
createdAt: new Date(),
|
|
522
|
+
},
|
|
523
|
+
]),
|
|
524
|
+
),
|
|
525
|
+
})),
|
|
526
|
+
};
|
|
527
|
+
}),
|
|
528
|
+
})),
|
|
529
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
530
|
+
|
|
531
|
+
const service = new SatelliteService(db);
|
|
532
|
+
const next = await service.applyConnectionState({
|
|
533
|
+
satelliteId: "sat-1",
|
|
534
|
+
lastEvent: "heartbeat_lost",
|
|
535
|
+
// lastHeartbeatAt omitted ⇒ left unchanged.
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Only the event column was written — no lastHeartbeatAt key.
|
|
539
|
+
expect(setArg).toEqual({ lastConnectionEvent: "heartbeat_lost" });
|
|
540
|
+
expect(next.status).toBe("offline");
|
|
541
|
+
expect(next.lastEvent).toBe("heartbeat_lost");
|
|
542
|
+
expect(next.lastSeenAt).toBe(aged.toISOString());
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("throws when the satellite no longer exists", async () => {
|
|
546
|
+
const db = {
|
|
547
|
+
update: mock(() => ({
|
|
548
|
+
set: mock(() => ({
|
|
549
|
+
where: mock(() => ({
|
|
550
|
+
returning: mock(() => Promise.resolve([])),
|
|
551
|
+
})),
|
|
552
|
+
})),
|
|
553
|
+
})),
|
|
554
|
+
} as unknown as ConstructorParameters<typeof SatelliteService>[0];
|
|
555
|
+
|
|
556
|
+
const service = new SatelliteService(db);
|
|
557
|
+
await expect(
|
|
558
|
+
service.applyConnectionState({
|
|
559
|
+
satelliteId: "gone",
|
|
560
|
+
lastEvent: "connected",
|
|
561
|
+
lastHeartbeatAt: new Date(),
|
|
562
|
+
}),
|
|
563
|
+
).rejects.toThrow(/not found/);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
292
566
|
});
|
package/src/service.ts
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
1
|
+
import { eq, inArray } from "drizzle-orm";
|
|
2
2
|
import { satellites } from "./schema";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
4
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
5
|
-
import type {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import type { SatelliteWithStatus } from "@checkstack/satellite-common";
|
|
6
|
+
import {
|
|
7
|
+
toSatelliteConnectionState,
|
|
8
|
+
type SatelliteConnectionEvent,
|
|
9
|
+
type SatelliteConnectionState,
|
|
10
|
+
} from "./entity";
|
|
11
|
+
import { computeStatus } from "./status";
|
|
10
12
|
|
|
11
13
|
// Drizzle type helper
|
|
12
14
|
type Db = SafeDatabase<typeof schema>;
|
|
13
15
|
|
|
14
|
-
/**
|
|
15
|
-
* Compute satellite status from lastHeartbeatAt timestamp.
|
|
16
|
-
*/
|
|
17
|
-
function computeStatus(lastHeartbeatAt: Date | null): SatelliteStatus {
|
|
18
|
-
if (!lastHeartbeatAt) return "offline";
|
|
19
|
-
const elapsed = Date.now() - lastHeartbeatAt.getTime();
|
|
20
|
-
return elapsed <= OFFLINE_THRESHOLD_MS ? "online" : "offline";
|
|
21
|
-
}
|
|
22
|
-
|
|
23
16
|
/**
|
|
24
17
|
* Service for managing satellite records.
|
|
25
18
|
*/
|
|
@@ -217,6 +210,131 @@ export class SatelliteService {
|
|
|
217
210
|
.map((row) => row.id);
|
|
218
211
|
}
|
|
219
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Batched durable read for the `satellite-connection` entity (Model B
|
|
215
|
+
* plugin-backed `read` accessor). Given satellite ids, return the reactive
|
|
216
|
+
* `SatelliteConnectionState` for each that exists AND has connected at least
|
|
217
|
+
* once (missing / never-connected ids omitted). The reactive `status` is
|
|
218
|
+
* COMPUTED on read from the durable `lastHeartbeatAt` column (the single
|
|
219
|
+
* liveness source of truth, shared by every pod) — never read from a stored
|
|
220
|
+
* status copy — so any pod sees the same answer AND a stale row self-heals to
|
|
221
|
+
* `offline` once the heartbeat ages out, even after a pod crash. Only
|
|
222
|
+
* `lastConnectionEvent` is read additionally (the deriver's discriminator).
|
|
223
|
+
* This is the single source of truth `handle.mutate` snapshots `prev` from and
|
|
224
|
+
* `get` / `getMany` / scope enrichment / `wait_until` re-eval route through.
|
|
225
|
+
*/
|
|
226
|
+
async getManyConnectionStates(
|
|
227
|
+
ids: ReadonlyArray<string>,
|
|
228
|
+
): Promise<Record<string, SatelliteConnectionState>> {
|
|
229
|
+
if (ids.length === 0) return {};
|
|
230
|
+
|
|
231
|
+
const rows = await this.db
|
|
232
|
+
.select({
|
|
233
|
+
id: satellites.id,
|
|
234
|
+
name: satellites.name,
|
|
235
|
+
region: satellites.region,
|
|
236
|
+
lastHeartbeatAt: satellites.lastHeartbeatAt,
|
|
237
|
+
lastConnectionEvent: satellites.lastConnectionEvent,
|
|
238
|
+
})
|
|
239
|
+
.from(satellites)
|
|
240
|
+
.where(inArray(satellites.id, [...ids]));
|
|
241
|
+
|
|
242
|
+
const out: Record<string, SatelliteConnectionState> = {};
|
|
243
|
+
for (const row of rows) {
|
|
244
|
+
const state = toSatelliteConnectionState({
|
|
245
|
+
name: row.name,
|
|
246
|
+
region: row.region,
|
|
247
|
+
lastHeartbeatAt: row.lastHeartbeatAt,
|
|
248
|
+
lastConnectionEvent: row.lastConnectionEvent,
|
|
249
|
+
});
|
|
250
|
+
if (state) out[row.id] = state;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Read every satellite's durable liveness inputs `(lastHeartbeatAt,
|
|
257
|
+
* lastConnectionEvent)`. Used by the heartbeat monitor to detect the
|
|
258
|
+
* online→offline (heartbeat-lost) edge from DURABLE state alone — no pod-local
|
|
259
|
+
* baseline — so detection works on ANY pod and is idempotent across pods /
|
|
260
|
+
* redelivery. The monitor computes status from `lastHeartbeatAt` itself.
|
|
261
|
+
*/
|
|
262
|
+
async listConnectionLiveness(): Promise<
|
|
263
|
+
Array<{
|
|
264
|
+
id: string;
|
|
265
|
+
name: string;
|
|
266
|
+
region: string;
|
|
267
|
+
lastHeartbeatAt: Date | null;
|
|
268
|
+
lastConnectionEvent: SatelliteConnectionEvent | null;
|
|
269
|
+
}>
|
|
270
|
+
> {
|
|
271
|
+
return this.db
|
|
272
|
+
.select({
|
|
273
|
+
id: satellites.id,
|
|
274
|
+
name: satellites.name,
|
|
275
|
+
region: satellites.region,
|
|
276
|
+
lastHeartbeatAt: satellites.lastHeartbeatAt,
|
|
277
|
+
lastConnectionEvent: satellites.lastConnectionEvent,
|
|
278
|
+
})
|
|
279
|
+
.from(satellites);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Durable write for a `satellite-connection` lifecycle edge (the `apply`
|
|
284
|
+
* body of `handle.mutate`). UPDATEs the satellite's liveness columns in the
|
|
285
|
+
* shared `satellites` table and returns the resulting reactive view (`next`),
|
|
286
|
+
* whose `status` is COMPUTED from the just-written `lastHeartbeatAt`. The
|
|
287
|
+
* caller passes the new `lastHeartbeatAt` explicitly so each edge controls
|
|
288
|
+
* liveness directly:
|
|
289
|
+
* - connect / reconnect → `now` (computes online),
|
|
290
|
+
* - clean disconnect → `null` (computes offline immediately),
|
|
291
|
+
* - heartbeat-lost → unchanged (`undefined` leaves the column as the
|
|
292
|
+
* already-aged value, which still computes offline) — only
|
|
293
|
+
* `lastConnectionEvent` flips, which is what makes monitor detection
|
|
294
|
+
* idempotent.
|
|
295
|
+
* The pod that owns the socket (or any pod, for heartbeat-lost) is the writer;
|
|
296
|
+
* every other pod reads the new state via {@link getManyConnectionStates}.
|
|
297
|
+
* Throws when the satellite no longer exists (a write against a deleted
|
|
298
|
+
* satellite is a no-op caller error).
|
|
299
|
+
*/
|
|
300
|
+
async applyConnectionState(props: {
|
|
301
|
+
satelliteId: string;
|
|
302
|
+
lastEvent: SatelliteConnectionEvent;
|
|
303
|
+
/** New heartbeat timestamp, or `null` to clear it. Omit to leave unchanged. */
|
|
304
|
+
lastHeartbeatAt?: Date | null;
|
|
305
|
+
}): Promise<SatelliteConnectionState> {
|
|
306
|
+
const { satelliteId, lastEvent, lastHeartbeatAt } = props;
|
|
307
|
+
|
|
308
|
+
const updates: Partial<typeof satellites.$inferInsert> = {
|
|
309
|
+
lastConnectionEvent: lastEvent,
|
|
310
|
+
};
|
|
311
|
+
if (lastHeartbeatAt !== undefined) {
|
|
312
|
+
updates.lastHeartbeatAt = lastHeartbeatAt;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const [row] = await this.db
|
|
316
|
+
.update(satellites)
|
|
317
|
+
.set(updates)
|
|
318
|
+
.where(eq(satellites.id, satelliteId))
|
|
319
|
+
.returning();
|
|
320
|
+
|
|
321
|
+
if (!row) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Cannot apply connection state: satellite ${satelliteId} not found`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
status: computeStatus(row.lastHeartbeatAt),
|
|
329
|
+
name: row.name,
|
|
330
|
+
region: row.region,
|
|
331
|
+
lastSeenAt: row.lastHeartbeatAt
|
|
332
|
+
? row.lastHeartbeatAt.toISOString()
|
|
333
|
+
: null,
|
|
334
|
+
lastEvent,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
220
338
|
/**
|
|
221
339
|
* Map a database row to SatelliteWithStatus (excludes tokenHash).
|
|
222
340
|
*/
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SatelliteStatus } from "@checkstack/satellite-common";
|
|
2
|
+
import { OFFLINE_THRESHOLD_MS } from "@checkstack/satellite-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute satellite liveness status from its `lastHeartbeatAt` timestamp — the
|
|
6
|
+
* SINGLE source of truth for presence. A satellite is `online` only while its
|
|
7
|
+
* most recent heartbeat is within {@link OFFLINE_THRESHOLD_MS} of now; a missing
|
|
8
|
+
* timestamp (never connected / cleanly disconnected) or an aged one is
|
|
9
|
+
* `offline`. Because this reads only durable, globally-shared state and a
|
|
10
|
+
* wall-clock comparison, every pod computes the SAME answer and a stale row
|
|
11
|
+
* self-heals to `offline` once the heartbeat ages out — no pod-local baseline,
|
|
12
|
+
* no status that can get stuck `online` after a pod crash.
|
|
13
|
+
*/
|
|
14
|
+
export function computeStatus(lastHeartbeatAt: Date | null): SatelliteStatus {
|
|
15
|
+
if (!lastHeartbeatAt) return "offline";
|
|
16
|
+
const elapsed = Date.now() - lastHeartbeatAt.getTime();
|
|
17
|
+
return elapsed <= OFFLINE_THRESHOLD_MS ? "online" : "offline";
|
|
18
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
{
|
|
8
8
|
"path": "../automation-backend"
|
|
9
9
|
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../automation-common"
|
|
12
|
+
},
|
|
10
13
|
{
|
|
11
14
|
"path": "../backend-api"
|
|
12
15
|
},
|
|
@@ -31,6 +34,18 @@
|
|
|
31
34
|
{
|
|
32
35
|
"path": "../satellite-common"
|
|
33
36
|
},
|
|
37
|
+
{
|
|
38
|
+
"path": "../script-packages-backend"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "../script-packages-common"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "../secrets-backend"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "../secrets-common"
|
|
48
|
+
},
|
|
34
49
|
{
|
|
35
50
|
"path": "../signal-common"
|
|
36
51
|
},
|
package/src/automations.test.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Behaviour tests for the satellite automation triggers.
|
|
3
|
-
* No mutation actions in this chunk — connection lifecycle is observed
|
|
4
|
-
* only, not commanded.
|
|
5
|
-
*/
|
|
6
|
-
import { describe, expect, it } from "bun:test";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
satelliteConnectedTrigger,
|
|
10
|
-
satelliteDisconnectedTrigger,
|
|
11
|
-
satelliteHeartbeatLostTrigger,
|
|
12
|
-
satelliteTriggers,
|
|
13
|
-
} from "./automations";
|
|
14
|
-
import { satelliteHooks } from "./hooks";
|
|
15
|
-
|
|
16
|
-
describe("satellite triggers", () => {
|
|
17
|
-
it("exposes three triggers in a stable order", () => {
|
|
18
|
-
expect(satelliteTriggers).toHaveLength(3);
|
|
19
|
-
expect(satelliteTriggers[0]).toBe(
|
|
20
|
-
satelliteConnectedTrigger as (typeof satelliteTriggers)[number],
|
|
21
|
-
);
|
|
22
|
-
expect(satelliteTriggers[1]).toBe(
|
|
23
|
-
satelliteDisconnectedTrigger as (typeof satelliteTriggers)[number],
|
|
24
|
-
);
|
|
25
|
-
expect(satelliteTriggers[2]).toBe(
|
|
26
|
-
satelliteHeartbeatLostTrigger as (typeof satelliteTriggers)[number],
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("binds each trigger to the matching hook", () => {
|
|
31
|
-
expect(satelliteConnectedTrigger.hook).toBe(satelliteHooks.connected);
|
|
32
|
-
expect(satelliteDisconnectedTrigger.hook).toBe(satelliteHooks.disconnected);
|
|
33
|
-
expect(satelliteHeartbeatLostTrigger.hook).toBe(satelliteHooks.heartbeatLost);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("extracts satelliteId as the contextKey on all three", () => {
|
|
37
|
-
const payload = {
|
|
38
|
-
satelliteId: "sat-1",
|
|
39
|
-
name: "EU-Central",
|
|
40
|
-
region: "eu-central-1",
|
|
41
|
-
timestamp: "2026-05-29T11:00:00Z",
|
|
42
|
-
};
|
|
43
|
-
expect(satelliteConnectedTrigger.contextKey?.(payload)).toBe("sat-1");
|
|
44
|
-
expect(satelliteDisconnectedTrigger.contextKey?.(payload)).toBe("sat-1");
|
|
45
|
-
expect(satelliteHeartbeatLostTrigger.contextKey?.(payload)).toBe("sat-1");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("rejects payloads missing required fields", () => {
|
|
49
|
-
const bad = satelliteConnectedTrigger.payloadSchema.safeParse({
|
|
50
|
-
satelliteId: "sat-1",
|
|
51
|
-
});
|
|
52
|
-
expect(bad.success).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
});
|