@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 CHANGED
@@ -1,5 +1,158 @@
1
1
  # @checkstack/satellite-backend
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b995afb: Make `satellite-connection` a plugin-backed, COMPUTE-ON-READ reactive entity over the DURABLE, globally-readable `satellites` table via the Model-B entity state machine.
8
+
9
+ Satellite defines a `satellite-connection` entity `{ status: "online" | "offline", name, region, lastSeenAt, lastEvent }` keyed by satellite id. Its `status` is COMPUTED on read from the durable `last_heartbeat_at` column (via `computeStatus` / `OFFLINE_THRESHOLD_MS` - the SAME single liveness source of truth the admin satellite list uses), not stored. The only extra durable connection column is `last_connection_event` (`connected` / `disconnected` / `heartbeat_lost`), which the change-deriver reads as `lastEvent`. `lastSeenAt` is derived from `last_heartbeat_at` (null after a clean disconnect). There is no `entity_state` mirror and no stored status copy. `defineEntity({ kind: "satellite-connection", read })` resolves via `SatelliteService.getManyConnectionStates`. The three lifecycle sites - WS authentication (sets `last_heartbeat_at = now`, event `connected`), WS socket close (clears `last_heartbeat_at = null` so status flips offline immediately, event `disconnected`), and the heartbeat monitor's online->offline edge (flips only the event to `heartbeat_lost`) - drive `handle.mutate({ id: satelliteId, apply })`, where `apply` UPDATEs the satellite row's liveness columns and returns the computed view. The platform still records full transition HISTORY in `entity_transitions` for every change. `last_heartbeat_at` is the reactive liveness input now, so the `declareNonReactiveState` escape-hatch is retained for raw bookkeeping but the status it drives is the entity's.
10
+
11
+ This fixes a horizontal-scaling defect twice over. (a) Connection state originally lived in a process-local in-memory map, so a satellite connected to pod A was invisible to pod B's reads. (b) A prior fix made the STATUS durable but left the online->offline (`heartbeat_lost`) transition DETECTION pod-local: the heartbeat-check job runs under one consumer group claimed by a VARYING pod, and a pod with an empty in-memory `previousStatuses` map never observed the satellite online, so it never fired `heartbeat_lost` - leaving the stored `connection_status` stuck `online` forever after a pod crash. Computing status on read removes the stored copy that could get stuck (a stale row self-heals to `offline` once `last_heartbeat_at` ages past the threshold, from any pod), and the heartbeat monitor now detects the lost edge from DURABLE state alone - any pod, idempotent across pods + redelivery: it reads every satellite's `(last_heartbeat_at, last_connection_event)`, computes status, and for any that is `offline` while still marked `connected` drives the `heartbeat_lost` mutate; once `last_connection_event = "heartbeat_lost"`, re-runs are no-ops (a small in-memory set is kept ONLY as a per-pod broadcast-dedup optimisation, never as the source of truth). A new env-gated cross-pod IT (`heartbeat-monitor.it.test.ts`) proves a `heartbeat_lost` detected by a fresh "pod" that never saw the satellite online, so the pod-local-baseline bug cannot recur. `toSatelliteWithStatus` / `getOnlineSatelliteIds` and the entity now derive from the same `last_heartbeat_at` - one source of truth, no disagreement.
12
+
13
+ A registered change-deriver maps these entity changes back to the `satellite.connected` / `satellite.disconnected` / `satellite.heartbeat_lost` trigger events, so existing automations keep firing. The three-way distinction is preserved by an explicit `lastEvent` discriminator on the entity state: a bare `status` diff cannot tell a socket drop (`disconnected`) apart from the heartbeat-lost offline edge (`heartbeat_lost`), so the deriver reads `lastEvent` to fire the exact original event. The old connection hooks are removed in favor of the reactive entity.
14
+
15
+ BREAKING CHANGES:
16
+
17
+ - DROPPED the `satellites.connection_status` and `satellites.last_seen_at` columns added by the prior fix (migration `0002_graceful_mac_gargan.sql`, forward-only). Status is now computed on read from `last_heartbeat_at` (no stored copy to drift or get stuck), and `lastSeenAt` is derived from `last_heartbeat_at`. The `last_connection_event` column is KEPT (the deriver's event discriminator + the monitor's idempotency key). Existing rows with a non-null `last_connection_event` keep reading their derived status; rows that never connected (null `last_connection_event`) report no current state until their next lifecycle edge.
18
+ - Removed the `satellite.connected`, `satellite.disconnected`, and `satellite.heartbeat_lost` hooks (`createHook`). Use the `satellite-connection` entity's auto-emitted change events (subscribe via the `automation.entity` extension point's `onEntityChanged`, or author automations against the derived trigger events). The `satellite.removed` deletion/cleanup hook is unaffected and stays.
19
+ - The `connected` / `disconnected` / `heartbeat_lost` automation triggers are now ENTITY-DRIVEN instead of hook-backed: they are fired by the `satellite-connection` entity change-deriver (Stage-1 routing) rather than a `createHook`, but stay REGISTERED in the automation editor's trigger catalog (a no-op `setup` via `makeEntityDrivenTriggerSetup`), so they remain offered as picker entries and payload-introspectable. Already-authored automations referencing them continue to fire. A registered `toPayload` mapper keeps the runtime `trigger.payload` matching each trigger's declared `payloadSchema` (`satelliteId`, `name`, `region`, `status`, `lastSeenAt` (nullable - `null` after a clean disconnect)), rather than degrading to the generic entity-change shape.
20
+ - The `satellite-connection` entity's current state is COMPUTED on read from the durable `satellites.last_heartbeat_at` (+ `last_connection_event`), NOT a framework `entity_state` row and NOT a stored status column. Any code reading current connection state must read through the entity `read` accessor / `handle.get` / `getMany`. Durable history in `entity_transitions` is unaffected.
21
+
22
+ - 270ef29: Core-side satellite script-package distribution.
23
+
24
+ - `satellite-backend`: the WS handler now carries the desired script-package
25
+ lockfile hash in `authenticated` / `config_updated` payloads (the durable
26
+ backstop), exposes `pushRefreshScriptPackagesToAll` (wired to the
27
+ `script-packages.changed` broadcast hook in `mode: "broadcast"`, so each
28
+ core instance fans the refresh out to its own connected satellites), and
29
+ persists `script_package_sync_state` reports from satellites.
30
+ - `script-packages-*`: new `reportSatelliteSyncState` RPC + store method so
31
+ satellite-backend can record per-satellite reconcile state for the admin
32
+ UI. Satellites pull blobs from core via the existing `getManifest` /
33
+ `downloadBlob` endpoints, never the registry.
34
+
35
+ - 270ef29: Satellite-side script-package reconciliation over the WS channel.
36
+
37
+ - `satellite-common`: WS request/reply messages for pulling the manifest +
38
+ blobs from core (`request_script_package_manifest` /
39
+ `request_script_package_blob` -> `script_package_manifest` /
40
+ `script_package_blob`).
41
+ - `satellite-backend`: the WS handler answers those requests from the
42
+ script-packages store (satellites pull from core, never the registry).
43
+ - `@checkstack/satellite`: the client gains request/reply plumbing + a
44
+ `SatelliteScriptPackages` manager that reuses the Phase 2 reconciler
45
+ (`reconcileToHash` + `createReconcileFsDeps`) over the WS transport. It
46
+ reconciles on a `refresh_script_packages` push and on the
47
+ assignment-carried hash (startup / reconnect backstop), pulls only missing
48
+ blobs (delta), materializes via `bun install --offline`, atomically flips
49
+ `current`, reports sync state back, and degrades cleanly (error state, no
50
+ stale tree, no registry access) when a blob can't be fetched. Reconciles
51
+ are serialized + coalesced + idempotent.
52
+
53
+ - 270ef29: Secrets platform Phase 3: just-in-time secret delivery to satellites + source-side masking, and central-execution injection for healthcheck collectors.
54
+
55
+ - New satellite WS messages `request_run_secrets` / `run_secrets`: just
56
+ before a satellite runs a collector that declares a `secretEnv`, it asks
57
+ core for that collector's resolved env; core resolves ONLY the secrets the
58
+ collector's OWN persisted assignment declares (least-privilege — the
59
+ satellite cannot choose) and replies with the env map (or a clear error).
60
+ The satellite injects it memory-only for the run and drops it on
61
+ completion. Secrets never ride the persisted assignment and never touch
62
+ disk.
63
+ - Source-side masking: the satellite runs `maskSecrets` over the collector's
64
+ stdout/stderr/result/error using the run's delivered values BEFORE the
65
+ result leaves the satellite (defense in depth).
66
+ - `CollectorStrategy.execute` gains an optional `secretEnv`. The
67
+ inline-script and shell collectors inject it into the runner
68
+ (`process.env` / `$VAR`) and mask the values out of their output.
69
+ - Healthcheck collectors running centrally (the queue executor) also resolve
70
+ - inject `secretEnv` via `secretResolverRef`, closing the gap where a
71
+ centrally-run secretEnv collector got no secrets. A missing required
72
+ secret fails the run clearly in all paths.
73
+
74
+ ### Patch Changes
75
+
76
+ - b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
77
+
78
+ Every plugin-backed domain (incident, catalog, dependency, maintenance, slo, satellite) reimplemented the same "no handle wired → run the plugin write directly; handle wired → route through `handle.mutate` / `handle.remove`" guard, varying only in the id-key name. `@checkstack/automation-backend` now exports `withEntityWrite` / `withEntityRemove` (from the entity barrel) and each domain's thin, well-named wrappers (`writeIncidentEntity`, `writeMaintenanceEntity`, satellite's `mirror`, …) delegate to it, so the branch lives in exactly one place. Behavior is unchanged.
79
+
80
+ `writeHealthEntity` (healthcheck-backend) is intentionally NOT migrated onto the helper — it is genuinely bespoke (closure-captured durable state, distinct rethrow-vs-fail-soft branches, a per-system serializer, and it returns the computed state). SLO keeps its fail-soft `onError` wrapper around the shared guard.
81
+
82
+ - Updated dependencies [270ef29]
83
+ - Updated dependencies [b995afb]
84
+ - Updated dependencies [b995afb]
85
+ - Updated dependencies [b995afb]
86
+ - Updated dependencies [270ef29]
87
+ - Updated dependencies [270ef29]
88
+ - Updated dependencies [270ef29]
89
+ - Updated dependencies [270ef29]
90
+ - Updated dependencies [270ef29]
91
+ - Updated dependencies [270ef29]
92
+ - Updated dependencies [270ef29]
93
+ - Updated dependencies [270ef29]
94
+ - Updated dependencies [270ef29]
95
+ - Updated dependencies [b995afb]
96
+ - Updated dependencies [b995afb]
97
+ - Updated dependencies [270ef29]
98
+ - Updated dependencies [b995afb]
99
+ - Updated dependencies [270ef29]
100
+ - Updated dependencies [b995afb]
101
+ - Updated dependencies [b995afb]
102
+ - Updated dependencies [270ef29]
103
+ - Updated dependencies [b995afb]
104
+ - Updated dependencies [b995afb]
105
+ - Updated dependencies [270ef29]
106
+ - Updated dependencies [b995afb]
107
+ - Updated dependencies [b995afb]
108
+ - Updated dependencies [b995afb]
109
+ - Updated dependencies [b995afb]
110
+ - Updated dependencies [b995afb]
111
+ - Updated dependencies [b995afb]
112
+ - Updated dependencies [b995afb]
113
+ - Updated dependencies [270ef29]
114
+ - Updated dependencies [270ef29]
115
+ - Updated dependencies [270ef29]
116
+ - Updated dependencies [270ef29]
117
+ - Updated dependencies [270ef29]
118
+ - Updated dependencies [270ef29]
119
+ - Updated dependencies [270ef29]
120
+ - Updated dependencies [b995afb]
121
+ - Updated dependencies [270ef29]
122
+ - Updated dependencies [b995afb]
123
+ - Updated dependencies [270ef29]
124
+ - Updated dependencies [270ef29]
125
+ - Updated dependencies [270ef29]
126
+ - Updated dependencies [b995afb]
127
+ - Updated dependencies [270ef29]
128
+ - Updated dependencies [b995afb]
129
+ - Updated dependencies [270ef29]
130
+ - Updated dependencies [270ef29]
131
+ - Updated dependencies [b995afb]
132
+ - Updated dependencies [270ef29]
133
+ - Updated dependencies [270ef29]
134
+ - Updated dependencies [270ef29]
135
+ - Updated dependencies [270ef29]
136
+ - Updated dependencies [270ef29]
137
+ - Updated dependencies [270ef29]
138
+ - Updated dependencies [270ef29]
139
+ - Updated dependencies [b995afb]
140
+ - Updated dependencies [b995afb]
141
+ - Updated dependencies [b995afb]
142
+ - @checkstack/backend-api@0.19.0
143
+ - @checkstack/automation-backend@0.3.0
144
+ - @checkstack/gitops-common@0.5.0
145
+ - @checkstack/gitops-backend@0.4.0
146
+ - @checkstack/automation-common@0.3.0
147
+ - @checkstack/healthcheck-backend@1.4.0
148
+ - @checkstack/healthcheck-common@1.4.0
149
+ - @checkstack/secrets-backend@0.1.0
150
+ - @checkstack/script-packages-backend@0.2.0
151
+ - @checkstack/script-packages-common@0.2.0
152
+ - @checkstack/satellite-common@0.7.0
153
+ - @checkstack/secrets-common@0.1.0
154
+ - @checkstack/queue-api@0.3.7
155
+
3
156
  ## 0.4.0
4
157
 
5
158
  ### Minor Changes
@@ -0,0 +1,3 @@
1
+ ALTER TABLE "satellites" ADD COLUMN "connection_status" text DEFAULT 'offline' NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "satellites" ADD COLUMN "last_seen_at" timestamp;--> statement-breakpoint
3
+ ALTER TABLE "satellites" ADD COLUMN "last_connection_event" text;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "satellites" DROP COLUMN "connection_status";--> statement-breakpoint
2
+ ALTER TABLE "satellites" DROP COLUMN "last_seen_at";
@@ -0,0 +1,102 @@
1
+ {
2
+ "id": "4ec7195f-656f-46cf-bdf7-966a470b8a0c",
3
+ "prevId": "8c89df94-7c41-44ec-9197-02edea2f23f2",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.satellites": {
8
+ "name": "satellites",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "name": {
19
+ "name": "name",
20
+ "type": "text",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "region": {
25
+ "name": "region",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "tags": {
31
+ "name": "tags",
32
+ "type": "jsonb",
33
+ "primaryKey": false,
34
+ "notNull": true,
35
+ "default": "'{}'::jsonb"
36
+ },
37
+ "token_hash": {
38
+ "name": "token_hash",
39
+ "type": "text",
40
+ "primaryKey": false,
41
+ "notNull": true
42
+ },
43
+ "last_heartbeat_at": {
44
+ "name": "last_heartbeat_at",
45
+ "type": "timestamp",
46
+ "primaryKey": false,
47
+ "notNull": false
48
+ },
49
+ "version": {
50
+ "name": "version",
51
+ "type": "text",
52
+ "primaryKey": false,
53
+ "notNull": false
54
+ },
55
+ "connection_status": {
56
+ "name": "connection_status",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "default": "'offline'"
61
+ },
62
+ "last_seen_at": {
63
+ "name": "last_seen_at",
64
+ "type": "timestamp",
65
+ "primaryKey": false,
66
+ "notNull": false
67
+ },
68
+ "last_connection_event": {
69
+ "name": "last_connection_event",
70
+ "type": "text",
71
+ "primaryKey": false,
72
+ "notNull": false
73
+ },
74
+ "created_at": {
75
+ "name": "created_at",
76
+ "type": "timestamp",
77
+ "primaryKey": false,
78
+ "notNull": true,
79
+ "default": "now()"
80
+ }
81
+ },
82
+ "indexes": {},
83
+ "foreignKeys": {},
84
+ "compositePrimaryKeys": {},
85
+ "uniqueConstraints": {},
86
+ "policies": {},
87
+ "checkConstraints": {},
88
+ "isRLSEnabled": false
89
+ }
90
+ },
91
+ "enums": {},
92
+ "schemas": {},
93
+ "sequences": {},
94
+ "roles": {},
95
+ "policies": {},
96
+ "views": {},
97
+ "_meta": {
98
+ "columns": {},
99
+ "schemas": {},
100
+ "tables": {}
101
+ }
102
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "id": "937ff36d-f1f0-4f70-bf75-2a6eb15e4dd7",
3
+ "prevId": "4ec7195f-656f-46cf-bdf7-966a470b8a0c",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.satellites": {
8
+ "name": "satellites",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "gen_random_uuid()"
17
+ },
18
+ "name": {
19
+ "name": "name",
20
+ "type": "text",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "region": {
25
+ "name": "region",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "tags": {
31
+ "name": "tags",
32
+ "type": "jsonb",
33
+ "primaryKey": false,
34
+ "notNull": true,
35
+ "default": "'{}'::jsonb"
36
+ },
37
+ "token_hash": {
38
+ "name": "token_hash",
39
+ "type": "text",
40
+ "primaryKey": false,
41
+ "notNull": true
42
+ },
43
+ "last_heartbeat_at": {
44
+ "name": "last_heartbeat_at",
45
+ "type": "timestamp",
46
+ "primaryKey": false,
47
+ "notNull": false
48
+ },
49
+ "version": {
50
+ "name": "version",
51
+ "type": "text",
52
+ "primaryKey": false,
53
+ "notNull": false
54
+ },
55
+ "last_connection_event": {
56
+ "name": "last_connection_event",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": false
60
+ },
61
+ "created_at": {
62
+ "name": "created_at",
63
+ "type": "timestamp",
64
+ "primaryKey": false,
65
+ "notNull": true,
66
+ "default": "now()"
67
+ }
68
+ },
69
+ "indexes": {},
70
+ "foreignKeys": {},
71
+ "compositePrimaryKeys": {},
72
+ "uniqueConstraints": {},
73
+ "policies": {},
74
+ "checkConstraints": {},
75
+ "isRLSEnabled": false
76
+ }
77
+ },
78
+ "enums": {},
79
+ "schemas": {},
80
+ "sequences": {},
81
+ "roles": {},
82
+ "policies": {},
83
+ "views": {},
84
+ "_meta": {
85
+ "columns": {},
86
+ "schemas": {},
87
+ "tables": {}
88
+ }
89
+ }
@@ -8,6 +8,20 @@
8
8
  "when": 1776590977685,
9
9
  "tag": "0000_melted_gargoyle",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1780215564477,
16
+ "tag": "0001_tiresome_terror",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1780221476961,
23
+ "tag": "0002_graceful_mac_gargan",
24
+ "breakpoints": true
11
25
  }
12
26
  ]
13
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/satellite-backend",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,27 +15,34 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/backend-api": "0.17.1",
19
- "@checkstack/automation-backend": "0.1.0",
20
- "@checkstack/satellite-common": "0.5.3",
21
- "@checkstack/healthcheck-common": "1.2.0",
22
- "@checkstack/signal-common": "0.2.4",
23
- "@checkstack/healthcheck-backend": "1.2.0",
24
- "@checkstack/gitops-backend": "0.3.6",
25
- "@checkstack/gitops-common": "0.4.1",
26
- "@checkstack/common": "0.11.0",
27
- "@checkstack/queue-api": "0.3.5",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/automation-backend": "0.2.0",
20
+ "@checkstack/automation-common": "0.2.0",
21
+ "@checkstack/satellite-common": "0.6.0",
22
+ "@checkstack/healthcheck-common": "1.3.0",
23
+ "@checkstack/signal-common": "0.2.5",
24
+ "@checkstack/healthcheck-backend": "1.3.0",
25
+ "@checkstack/script-packages-common": "0.1.0",
26
+ "@checkstack/script-packages-backend": "0.1.0",
27
+ "@checkstack/secrets-common": "0.0.1",
28
+ "@checkstack/secrets-backend": "0.0.1",
29
+ "@checkstack/gitops-backend": "0.3.7",
30
+ "@checkstack/gitops-common": "0.4.2",
31
+ "@checkstack/common": "0.12.0",
32
+ "@checkstack/queue-api": "0.3.6",
28
33
  "drizzle-orm": "^0.45.0",
29
34
  "zod": "^4.2.1",
30
35
  "@orpc/server": "^1.13.2"
31
36
  },
32
37
  "devDependencies": {
33
38
  "@checkstack/drizzle-helper": "0.0.5",
34
- "@checkstack/scripts": "0.3.3",
35
- "@checkstack/test-utils-backend": "0.1.30",
39
+ "@checkstack/scripts": "0.3.4",
40
+ "@checkstack/test-utils-backend": "0.1.31",
36
41
  "@checkstack/tsconfig": "0.0.7",
37
42
  "@types/bun": "^1.0.0",
43
+ "@types/pg": "^8.20.0",
38
44
  "drizzle-kit": "^0.31.10",
45
+ "pg": "^8.21.0",
39
46
  "typescript": "^5.0.0"
40
47
  }
41
48
  }
@@ -1,64 +1,105 @@
1
1
  /**
2
- * Satellite triggers registered with the Automation Platform.
2
+ * Satellite connection triggers registered with the Automation Platform.
3
3
  *
4
- * The plan calls for `satellite.connected`, `satellite.disconnected`,
5
- * and `satellite.heartbeat_lost` all three were added as new hooks
6
- * in `./hooks.ts` and emitted from the WS handler (connect/disconnect)
7
- * and heartbeat monitor (online offline). No mutation actions for
8
- * satellite in this phase.
4
+ * These three triggers are ENTITY-DRIVEN (reactive automation engine §10.6):
5
+ * the `satellite-connection` entity's change deriver fires
6
+ * `satellite.connected` / `.disconnected` / `.heartbeat_lost` via Stage-1
7
+ * routing, so they no longer subscribe to a hook. A no-op `setup`
8
+ * (`makeEntityDrivenTriggerSetup`) keeps them in the editor's trigger catalog
9
+ * (and payload-introspectable) without re-introducing a hook — mirroring how
10
+ * the incident / catalog / dependency / healthcheck domains kept their
11
+ * registrations after migrating. The runtime `trigger.payload` matches these
12
+ * schemas via the `satelliteChangeToPayload` mapper registered alongside the
13
+ * deriver.
9
14
  */
10
15
  import { z } from "zod";
11
16
  import type { TriggerDefinition } from "@checkstack/automation-backend";
17
+ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
12
18
 
13
- import { satelliteHooks } from "./hooks";
19
+ // ─── Payload schemas ───────────────────────────────────────────────────
20
+ //
21
+ // The reactive `satellite-connection` entity state is `{ status, name, region,
22
+ // lastSeenAt, lastEvent }`. The entity-driven `trigger.payload` (see
23
+ // `satelliteChangeToPayload`) carries `satelliteId` (the entity id) + `name` /
24
+ // `region` / `status` / `lastSeenAt`. `lastSeenAt` is nullable (`null` after a
25
+ // clean disconnect / before the satellite was ever seen).
26
+ const satelliteConnectedPayloadSchema = z.object({
27
+ satelliteId: z.string(),
28
+ name: z.string(),
29
+ region: z.string(),
30
+ status: z.enum(["online", "offline"]),
31
+ lastSeenAt: z.string().nullable(),
32
+ });
33
+
34
+ const satelliteDisconnectedPayloadSchema = z.object({
35
+ satelliteId: z.string(),
36
+ name: z.string(),
37
+ region: z.string(),
38
+ status: z.enum(["online", "offline"]),
39
+ lastSeenAt: z.string().nullable(),
40
+ });
14
41
 
15
- const satelliteEventPayloadSchema = z.object({
42
+ const satelliteHeartbeatLostPayloadSchema = z.object({
16
43
  satelliteId: z.string(),
17
44
  name: z.string(),
18
45
  region: z.string(),
19
- timestamp: z.string(),
46
+ status: z.enum(["online", "offline"]),
47
+ lastSeenAt: z.string().nullable(),
20
48
  });
21
49
 
50
+ // ─── Triggers ──────────────────────────────────────────────────────────
51
+
22
52
  export const satelliteConnectedTrigger: TriggerDefinition<
23
- z.infer<typeof satelliteEventPayloadSchema>
53
+ z.infer<typeof satelliteConnectedPayloadSchema>
24
54
  > = {
25
55
  id: "connected",
26
56
  displayName: "Satellite Connected",
27
- description: "Fires when a satellite WebSocket completes authentication",
57
+ description: "Fires when a satellite establishes a connection",
28
58
  category: "Satellites",
29
- icon: "Satellite",
30
- payloadSchema: satelliteEventPayloadSchema,
31
- hook: satelliteHooks.connected,
59
+ icon: "SatelliteDish",
60
+ payloadSchema: satelliteConnectedPayloadSchema,
61
+ setup: makeEntityDrivenTriggerSetup<
62
+ z.infer<typeof satelliteConnectedPayloadSchema>
63
+ >(),
32
64
  contextKey: (p) => p.satelliteId,
33
65
  };
34
66
 
35
67
  export const satelliteDisconnectedTrigger: TriggerDefinition<
36
- z.infer<typeof satelliteEventPayloadSchema>
68
+ z.infer<typeof satelliteDisconnectedPayloadSchema>
37
69
  > = {
38
70
  id: "disconnected",
39
71
  displayName: "Satellite Disconnected",
40
- description: "Fires when a satellite's WebSocket closes",
72
+ description: "Fires when a satellite's connection is closed",
41
73
  category: "Satellites",
42
- icon: "Satellite",
43
- payloadSchema: satelliteEventPayloadSchema,
44
- hook: satelliteHooks.disconnected,
74
+ icon: "SatelliteDish",
75
+ payloadSchema: satelliteDisconnectedPayloadSchema,
76
+ setup: makeEntityDrivenTriggerSetup<
77
+ z.infer<typeof satelliteDisconnectedPayloadSchema>
78
+ >(),
45
79
  contextKey: (p) => p.satelliteId,
46
80
  };
47
81
 
48
82
  export const satelliteHeartbeatLostTrigger: TriggerDefinition<
49
- z.infer<typeof satelliteEventPayloadSchema>
83
+ z.infer<typeof satelliteHeartbeatLostPayloadSchema>
50
84
  > = {
51
85
  id: "heartbeat_lost",
52
86
  displayName: "Satellite Heartbeat Lost",
53
87
  description:
54
- "Fires when a satellite transitions online offline (no heartbeat within threshold)",
88
+ "Fires when a connected satellite stops sending heartbeats and ages out to offline",
55
89
  category: "Satellites",
56
- icon: "Satellite",
57
- payloadSchema: satelliteEventPayloadSchema,
58
- hook: satelliteHooks.heartbeatLost,
90
+ icon: "SatelliteDish",
91
+ payloadSchema: satelliteHeartbeatLostPayloadSchema,
92
+ setup: makeEntityDrivenTriggerSetup<
93
+ z.infer<typeof satelliteHeartbeatLostPayloadSchema>
94
+ >(),
59
95
  contextKey: (p) => p.satelliteId,
60
96
  };
61
97
 
98
+ /**
99
+ * All satellite connection triggers as a heterogeneous list. Typed as
100
+ * `TriggerDefinition<unknown>[]` so the array can be iterated in the plugin
101
+ * entry without TypeScript collapsing the union to a single payload shape.
102
+ */
62
103
  export const satelliteTriggers: TriggerDefinition<unknown>[] = [
63
104
  satelliteConnectedTrigger as TriggerDefinition<unknown>,
64
105
  satelliteDisconnectedTrigger as TriggerDefinition<unknown>,