@checkstack/maintenance-backend 1.2.0 → 1.3.1

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,280 @@
1
+ /**
2
+ * Unit tests for the maintenance reactive-entity mapping (reactive
3
+ * automation engine §10.2). Proves the deriver returns the EXACT qualified
4
+ * trigger event ids existing automations match, the state builder produces
5
+ * the §10.2 entity shape, and the PLUGIN-BACKED `read`/`mutate`/`remove`
6
+ * helpers route to the service (no framework `entity_state`).
7
+ */
8
+ import { describe, expect, it } from "bun:test";
9
+ import { SYSTEM_ACTOR } from "@checkstack/common";
10
+ import type {
11
+ EntityChanged,
12
+ EntityHandle,
13
+ } from "@checkstack/automation-backend";
14
+
15
+ import {
16
+ MAINTENANCE_CREATED_EVENT,
17
+ MAINTENANCE_ENTITY_KIND,
18
+ MAINTENANCE_UPDATED_EVENT,
19
+ createMaintenanceEntityRead,
20
+ deriveMaintenanceEvents,
21
+ maintenanceChangeToPayload,
22
+ maintenanceEntityStateSchema,
23
+ removeMaintenanceEntity,
24
+ toMaintenanceEntityState,
25
+ writeMaintenanceEntity,
26
+ type MaintenanceEntityState,
27
+ } from "./entity";
28
+ import {
29
+ maintenanceCreatedTrigger,
30
+ maintenanceUpdatedTrigger,
31
+ } from "./automations";
32
+ import type { MaintenanceService } from "./service";
33
+
34
+ function makeChange(over: Partial<EntityChanged>): EntityChanged {
35
+ return {
36
+ kind: "maintenance",
37
+ id: "m-1",
38
+ prev: null,
39
+ next: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
40
+ delta: {},
41
+ changedFields: [],
42
+ actor: SYSTEM_ACTOR,
43
+ occurredAt: "2026-05-31T00:00:00.000Z",
44
+ ...over,
45
+ };
46
+ }
47
+
48
+ describe("maintenanceChangeToPayload — payloadSchema parity", () => {
49
+ it("a create payload validates against the created trigger's payloadSchema", () => {
50
+ const parsed = maintenanceCreatedTrigger.payloadSchema.parse(
51
+ maintenanceChangeToPayload(
52
+ makeChange({
53
+ prev: null,
54
+ next: {
55
+ status: "scheduled",
56
+ systemIds: ["sys-1"],
57
+ startAt: "2026-05-31T00:00:00.000Z",
58
+ endAt: "2026-05-31T01:00:00.000Z",
59
+ },
60
+ }),
61
+ ),
62
+ );
63
+ expect(parsed.maintenanceId).toBe("m-1");
64
+ expect(parsed.status).toBe("scheduled");
65
+ expect(parsed.systemIds).toEqual(["sys-1"]);
66
+ expect(parsed.startAt).toBe("2026-05-31T00:00:00.000Z");
67
+ expect(parsed.endAt).toBe("2026-05-31T01:00:00.000Z");
68
+ });
69
+
70
+ it("an update payload validates against the updated trigger's payloadSchema", () => {
71
+ const parsed = maintenanceUpdatedTrigger.payloadSchema.parse(
72
+ maintenanceChangeToPayload(
73
+ makeChange({
74
+ prev: {
75
+ status: "scheduled",
76
+ systemIds: ["sys-1"],
77
+ startAt: "2026-05-31T00:00:00.000Z",
78
+ endAt: "2026-05-31T01:00:00.000Z",
79
+ },
80
+ next: {
81
+ status: "in_progress",
82
+ systemIds: ["sys-1"],
83
+ startAt: "2026-05-31T00:00:00.000Z",
84
+ endAt: "2026-05-31T01:00:00.000Z",
85
+ },
86
+ }),
87
+ ),
88
+ );
89
+ expect(parsed.maintenanceId).toBe("m-1");
90
+ expect(parsed.status).toBe("in_progress");
91
+ });
92
+ });
93
+
94
+ describe("deriveMaintenanceEvents", () => {
95
+ it("maps a create (prev === null) to maintenance.created", () => {
96
+ const events = deriveMaintenanceEvents(makeChange({ prev: null }));
97
+ expect(events).toEqual([MAINTENANCE_CREATED_EVENT]);
98
+ });
99
+
100
+ it("maps an update (prev present, next present) to maintenance.updated", () => {
101
+ const events = deriveMaintenanceEvents(
102
+ makeChange({
103
+ prev: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
104
+ next: { status: "in_progress", systemIds: [], startAt: "a", endAt: "b" },
105
+ }),
106
+ );
107
+ expect(events).toEqual([MAINTENANCE_UPDATED_EVENT]);
108
+ });
109
+
110
+ it("maps a tombstone (next === null) to no event", () => {
111
+ const events = deriveMaintenanceEvents(
112
+ makeChange({
113
+ prev: { status: "scheduled", systemIds: [], startAt: "a", endAt: "b" },
114
+ next: null,
115
+ }),
116
+ );
117
+ expect(events).toEqual([]);
118
+ });
119
+
120
+ it("returns event ids that exactly equal the qualified trigger ids", () => {
121
+ // The created/updated trigger ids are namespaced to `${pluginId}.${id}`
122
+ // → `maintenance.created` / `maintenance.updated`. Lock the constants.
123
+ expect(MAINTENANCE_CREATED_EVENT).toBe("maintenance.created");
124
+ expect(MAINTENANCE_UPDATED_EVENT).toBe("maintenance.updated");
125
+ });
126
+ });
127
+
128
+ describe("toMaintenanceEntityState", () => {
129
+ it("serializes Date columns to ISO strings and validates against the schema", () => {
130
+ const state = toMaintenanceEntityState({
131
+ status: "scheduled",
132
+ systemIds: ["sys-1", "sys-2"],
133
+ startAt: new Date("2026-05-29T11:00:00Z"),
134
+ endAt: new Date("2026-05-29T12:00:00Z"),
135
+ });
136
+ expect(state).toEqual({
137
+ status: "scheduled",
138
+ systemIds: ["sys-1", "sys-2"],
139
+ startAt: "2026-05-29T11:00:00.000Z",
140
+ endAt: "2026-05-29T12:00:00.000Z",
141
+ });
142
+ expect(() => maintenanceEntityStateSchema.parse(state)).not.toThrow();
143
+ });
144
+
145
+ it("passes through already-ISO strings unchanged", () => {
146
+ const state = toMaintenanceEntityState({
147
+ status: "completed",
148
+ systemIds: [],
149
+ startAt: "2026-05-29T11:00:00.000Z",
150
+ endAt: "2026-05-29T12:00:00.000Z",
151
+ });
152
+ expect(state.startAt).toBe("2026-05-29T11:00:00.000Z");
153
+ expect(state.endAt).toBe("2026-05-29T12:00:00.000Z");
154
+ });
155
+ });
156
+
157
+ describe("createMaintenanceEntityRead", () => {
158
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
159
+ const seen: ReadonlyArray<string>[] = [];
160
+ const service = {
161
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
162
+ seen.push(ids);
163
+ return {
164
+ "m-1": {
165
+ status: "scheduled" as const,
166
+ systemIds: ["sys-1"],
167
+ startAt: "2026-05-29T11:00:00.000Z",
168
+ endAt: "2026-05-29T12:00:00.000Z",
169
+ },
170
+ };
171
+ },
172
+ } as unknown as MaintenanceService;
173
+ const read = createMaintenanceEntityRead(service);
174
+ const out = await read(["m-1", "m-2"]);
175
+ expect(seen).toEqual([["m-1", "m-2"]]);
176
+ expect(out["m-1"]).toEqual({
177
+ status: "scheduled",
178
+ systemIds: ["sys-1"],
179
+ startAt: "2026-05-29T11:00:00.000Z",
180
+ endAt: "2026-05-29T12:00:00.000Z",
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("writeMaintenanceEntity", () => {
186
+ it("drives the write through handle.mutate keyed by maintenance id", async () => {
187
+ const calls: Array<{ id: string; next: MaintenanceEntityState }> = [];
188
+ const handle = {
189
+ kind: MAINTENANCE_ENTITY_KIND,
190
+ async mutate(input: {
191
+ id: string;
192
+ apply: () => Promise<MaintenanceEntityState>;
193
+ }) {
194
+ const next = await input.apply();
195
+ calls.push({ id: input.id, next });
196
+ return next;
197
+ },
198
+ } as unknown as EntityHandle<MaintenanceEntityState>;
199
+
200
+ let applied = false;
201
+ await writeMaintenanceEntity({
202
+ handle,
203
+ maintenanceId: "m-9",
204
+ apply: async () => {
205
+ applied = true;
206
+ return {
207
+ status: "in_progress",
208
+ systemIds: ["sys-1"],
209
+ startAt: "2026-05-29T11:00:00.000Z",
210
+ endAt: "2026-05-29T12:00:00.000Z",
211
+ };
212
+ },
213
+ });
214
+ expect(applied).toBe(true);
215
+ expect(calls).toEqual([
216
+ {
217
+ id: "m-9",
218
+ next: {
219
+ status: "in_progress",
220
+ systemIds: ["sys-1"],
221
+ startAt: "2026-05-29T11:00:00.000Z",
222
+ endAt: "2026-05-29T12:00:00.000Z",
223
+ },
224
+ },
225
+ ]);
226
+ });
227
+
228
+ it("still runs the plugin write when no handle is wired", async () => {
229
+ let applied = false;
230
+ await writeMaintenanceEntity({
231
+ handle: undefined,
232
+ maintenanceId: "m-9",
233
+ apply: async () => {
234
+ applied = true;
235
+ return {
236
+ status: "scheduled",
237
+ systemIds: [],
238
+ startAt: "a",
239
+ endAt: "b",
240
+ };
241
+ },
242
+ });
243
+ expect(applied).toBe(true);
244
+ });
245
+ });
246
+
247
+ describe("removeMaintenanceEntity", () => {
248
+ it("tombstones via handle.remove({ apply })", async () => {
249
+ const removed: string[] = [];
250
+ let deleted = false;
251
+ const handle = {
252
+ kind: MAINTENANCE_ENTITY_KIND,
253
+ async remove(input: { id: string; apply: () => Promise<void> }) {
254
+ await input.apply();
255
+ removed.push(input.id);
256
+ },
257
+ } as unknown as EntityHandle<MaintenanceEntityState>;
258
+ await removeMaintenanceEntity({
259
+ handle,
260
+ maintenanceId: "m-9",
261
+ apply: async () => {
262
+ deleted = true;
263
+ },
264
+ });
265
+ expect(deleted).toBe(true);
266
+ expect(removed).toEqual(["m-9"]);
267
+ });
268
+
269
+ it("still runs the delete when no handle is wired", async () => {
270
+ let deleted = false;
271
+ await removeMaintenanceEntity({
272
+ handle: undefined,
273
+ maintenanceId: "m-9",
274
+ apply: async () => {
275
+ deleted = true;
276
+ },
277
+ });
278
+ expect(deleted).toBe(true);
279
+ });
280
+ });
package/src/entity.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The reactive `maintenance` entity (reactive automation engine §10.2).
3
+ *
4
+ * Model B PLUGIN-BACKED entity: the `maintenances` + `maintenance_systems`
5
+ * tables are authoritative AND ARE the entity's current-state storage — there
6
+ * is NO framework `entity_state` row for a maintenance window.
7
+ * `defineEntity({ read })` makes that plugin state reactive: every
8
+ * reactive-state write goes through `handle.mutate`, whose `apply()` performs
9
+ * the REAL `maintenances`/junction write via the maintenance service (the
10
+ * plugin's own db/tx) and returns the resulting `{ status, systemIds, startAt,
11
+ * endAt }`. The framework snapshots `prev` via `read`, appends the transition
12
+ * log (its own db), and emits `ENTITY_CHANGED`. The change → trigger-event
13
+ * deriver reproduces `maintenance.created` / `.updated` so automations keep
14
+ * firing.
15
+ */
16
+ import { z } from "zod";
17
+ import {
18
+ MaintenanceStatusEnum,
19
+ type MaintenanceStatus,
20
+ } from "@checkstack/maintenance-common";
21
+ import type {
22
+ EntityChanged,
23
+ EntityChangePayloadMapper,
24
+ EntityHandle,
25
+ EntityMutationOpts,
26
+ EntityRead,
27
+ } from "@checkstack/automation-backend";
28
+ import {
29
+ withEntityRemove,
30
+ withEntityWrite,
31
+ } from "@checkstack/automation-backend";
32
+
33
+ import type { MaintenanceService } from "./service";
34
+
35
+ /** Globally-unique entity kind for a maintenance window. */
36
+ export const MAINTENANCE_ENTITY_KIND = "maintenance";
37
+
38
+ /**
39
+ * Qualified trigger event ids the deriver maps to — `${pluginId}.${triggerId}`
40
+ * for the (now-removed) `created` / `updated` triggers. Automations reference
41
+ * these strings in `definition.triggers[].event`, so the deriver MUST return
42
+ * them verbatim for Stage-1 routing to match.
43
+ */
44
+ export const MAINTENANCE_CREATED_EVENT = "maintenance.created";
45
+ export const MAINTENANCE_UPDATED_EVENT = "maintenance.updated";
46
+
47
+ /**
48
+ * Reactive state of a maintenance window (reactive automation engine §10.2).
49
+ * `startAt` / `endAt` are ISO strings (the `read` accessor serializes the
50
+ * service's `Date` columns; the `apply()` return uses {@link
51
+ * toMaintenanceEntityState} to do the same).
52
+ */
53
+ export const maintenanceEntityStateSchema = z.object({
54
+ status: MaintenanceStatusEnum,
55
+ systemIds: z.array(z.string()),
56
+ startAt: z.string(),
57
+ endAt: z.string(),
58
+ });
59
+
60
+ export type MaintenanceEntityState = z.infer<
61
+ typeof maintenanceEntityStateSchema
62
+ >;
63
+
64
+ /**
65
+ * Project a freshly-written maintenance row onto the reactive `{ status,
66
+ * systemIds, startAt, endAt }` subset. Accepts either `Date` (service return
67
+ * shape) or already-ISO strings. This is the `apply()` return for
68
+ * `handle.mutate`.
69
+ */
70
+ export function toMaintenanceEntityState(row: {
71
+ status: MaintenanceStatus;
72
+ systemIds: string[];
73
+ startAt: Date | string;
74
+ endAt: Date | string;
75
+ }): MaintenanceEntityState {
76
+ return {
77
+ status: row.status,
78
+ systemIds: row.systemIds,
79
+ startAt:
80
+ row.startAt instanceof Date ? row.startAt.toISOString() : row.startAt,
81
+ endAt: row.endAt instanceof Date ? row.endAt.toISOString() : row.endAt,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Map a `maintenance` entity change to the qualified trigger event id(s) it
87
+ * should fire (reactive automation engine §7, Stage-1 routing):
88
+ *
89
+ * - create (`prev === null`) → `maintenance.created`
90
+ * - update (a real diff, prev present) → `maintenance.updated`
91
+ * - remove (tombstone, `next === null`) → nothing (the old domain never
92
+ * emitted a hook on delete)
93
+ *
94
+ * The deriver never sees no-op writes — the handle only emits a change event
95
+ * on a real diff — so a plain non-tombstone change is always a meaningful
96
+ * `created`/`updated`.
97
+ */
98
+ export function deriveMaintenanceEvents(
99
+ changed: EntityChanged,
100
+ ): ReadonlyArray<string> {
101
+ if (changed.next === null) {
102
+ // Tombstone — delete fired no maintenance hook historically.
103
+ return [];
104
+ }
105
+ if (changed.prev === null) {
106
+ return [MAINTENANCE_CREATED_EVENT];
107
+ }
108
+ return [MAINTENANCE_UPDATED_EVENT];
109
+ }
110
+
111
+ /**
112
+ * Map a `maintenance` entity change to the domain-named `trigger.payload` the
113
+ * maintenance triggers declare via `payloadSchema` (`maintenanceId`, `status`,
114
+ * `systemIds`, `startAt`, `endAt`). Restores the keys operators read
115
+ * (`trigger.payload.maintenanceId`, `.status`, …) that the generic change shape
116
+ * omits, so an entity-driven maintenance trigger sees the same documented
117
+ * payload the four migrated lifecycle domains do.
118
+ *
119
+ * `maintenanceId` is the entity id; the remaining fields read off `next` (a
120
+ * tombstone fires no event, so `next` is always present when a trigger fires).
121
+ * The reactive subset is `{ status, systemIds, startAt, endAt }`, so the
122
+ * descriptive fields the old hook carried (`title`, `description`) are NOT
123
+ * derivable from a change and are declared OPTIONAL on the payload schemas.
124
+ */
125
+ export const maintenanceChangeToPayload: EntityChangePayloadMapper = (
126
+ changed,
127
+ ) => {
128
+ const next = changed.next;
129
+ const systemIds = next === null ? undefined : next["systemIds"];
130
+ const readString = (field: string): unknown =>
131
+ next === null ? undefined : next[field];
132
+ return {
133
+ maintenanceId: changed.id,
134
+ status: readString("status"),
135
+ systemIds: Array.isArray(systemIds) ? systemIds : [],
136
+ startAt: readString("startAt"),
137
+ endAt: readString("endAt"),
138
+ };
139
+ };
140
+
141
+ /**
142
+ * Build the PLUGIN-BACKED `read` accessor for the `maintenance` entity. Routes
143
+ * straight to the service's batched authoritative read — no framework storage.
144
+ */
145
+ export function createMaintenanceEntityRead(
146
+ service: MaintenanceService,
147
+ ): EntityRead<MaintenanceEntityState> {
148
+ return (ids) => service.getManyEntityStates(ids);
149
+ }
150
+
151
+ /**
152
+ * Drive a reactive-state maintenance write through `handle.mutate` (§10.2).
153
+ * `apply` performs the REAL `maintenances`/junction write via the service (the
154
+ * plugin's own db/tx) and returns the new reactive state. The framework
155
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
156
+ * (the deriver turns that into `maintenance.created/.updated`).
157
+ *
158
+ * When no handle is available (tests construct the router without one), the
159
+ * write still runs — the entity reactivity is layered on top, never required
160
+ * for the underlying write to succeed.
161
+ */
162
+ export async function writeMaintenanceEntity(args: {
163
+ handle: EntityHandle<MaintenanceEntityState> | undefined;
164
+ maintenanceId: string;
165
+ opts?: EntityMutationOpts;
166
+ apply: () => Promise<MaintenanceEntityState>;
167
+ }): Promise<void> {
168
+ const { handle, maintenanceId, opts, apply } = args;
169
+ await withEntityWrite({ handle, id: maintenanceId, opts, apply });
170
+ }
171
+
172
+ /**
173
+ * Drive a maintenance tombstone through `handle.remove` (§10.2). `apply`
174
+ * performs the REAL delete via the service; the framework records the
175
+ * tombstone transition and emits a tombstone change (the deriver fires
176
+ * nothing — the old domain never emitted a hook on delete). Without a handle,
177
+ * the delete still runs.
178
+ */
179
+ export async function removeMaintenanceEntity(args: {
180
+ handle: EntityHandle<MaintenanceEntityState> | undefined;
181
+ maintenanceId: string;
182
+ opts?: EntityMutationOpts;
183
+ apply: () => Promise<void>;
184
+ }): Promise<void> {
185
+ const { handle, maintenanceId, opts, apply } = args;
186
+ await withEntityRemove({ handle, id: maintenanceId, opts, apply });
187
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { MaintenanceService } from "./service";
3
+
4
+ /**
5
+ * Mock db for hasActiveMaintenance. The method issues two select shapes
6
+ * in order:
7
+ * 1. .select({maintenanceId}).from(maintenanceSystems).where(...) -> ids
8
+ * 2. .select({id}).from(maintenances).where(...).limit(1) -> match
9
+ * We disambiguate by call order.
10
+ */
11
+ function createMockDb(opts: {
12
+ systemMaintenanceIds: string[];
13
+ activeMatch: boolean;
14
+ }) {
15
+ let firstSelect = true;
16
+ return {
17
+ select: mock(() => {
18
+ if (firstSelect) {
19
+ firstSelect = false;
20
+ return {
21
+ from: mock(() => ({
22
+ where: mock(() =>
23
+ Promise.resolve(
24
+ opts.systemMaintenanceIds.map((maintenanceId) => ({
25
+ maintenanceId,
26
+ })),
27
+ ),
28
+ ),
29
+ })),
30
+ };
31
+ }
32
+ return {
33
+ from: mock(() => ({
34
+ where: mock(() => ({
35
+ limit: mock(() =>
36
+ Promise.resolve(opts.activeMatch ? [{ id: "m-1" }] : []),
37
+ ),
38
+ })),
39
+ })),
40
+ };
41
+ }),
42
+ };
43
+ }
44
+
45
+ describe("MaintenanceService.hasActiveMaintenance", () => {
46
+ it("returns false when the system has no maintenance memberships", async () => {
47
+ const db = createMockDb({ systemMaintenanceIds: [], activeMatch: false });
48
+ const service = new MaintenanceService(db as never);
49
+ expect(await service.hasActiveMaintenance("system-1")).toBe(false);
50
+ });
51
+
52
+ it("returns true when an in_progress maintenance covers the system", async () => {
53
+ const db = createMockDb({
54
+ systemMaintenanceIds: ["m-1"],
55
+ activeMatch: true,
56
+ });
57
+ const service = new MaintenanceService(db as never);
58
+ expect(await service.hasActiveMaintenance("system-1")).toBe(true);
59
+ });
60
+
61
+ it("returns false when memberships exist but none are in_progress", async () => {
62
+ const db = createMockDb({
63
+ systemMaintenanceIds: ["m-1", "m-2"],
64
+ activeMatch: false,
65
+ });
66
+ const service = new MaintenanceService(db as never);
67
+ expect(await service.hasActiveMaintenance("system-1")).toBe(false);
68
+ });
69
+ });
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  automationActionExtensionPoint,
17
17
  automationArtifactTypeExtensionPoint,
18
18
  automationTriggerExtensionPoint,
19
+ entityExtensionPoint,
20
+ type EntityHandle,
19
21
  } from "@checkstack/automation-backend";
20
22
  import {
21
23
  NotificationApi,
@@ -33,6 +35,14 @@ import {
33
35
  maintenanceArtifactType,
34
36
  maintenanceTriggers,
35
37
  } from "./automations";
38
+ import {
39
+ MAINTENANCE_ENTITY_KIND,
40
+ createMaintenanceEntityRead,
41
+ deriveMaintenanceEvents,
42
+ maintenanceChangeToPayload,
43
+ maintenanceEntityStateSchema,
44
+ type MaintenanceEntityState,
45
+ } from "./entity";
36
46
 
37
47
  // Queue and job constants
38
48
  const STATUS_TRANSITION_QUEUE = "maintenance-status-transitions";
@@ -52,10 +62,53 @@ export default createBackendPlugin({
52
62
  maintenanceGroupSubscription,
53
63
  ]);
54
64
 
55
- // ─── Automation Platform: triggers + artifact type ─────────────────
65
+ // ─── Automation Platform: entity + artifact type ───────────────────
56
66
  // Buffered behind the extension point until automation-backend's
57
- // register() runs. Actions are wired in afterPluginsReady so
58
- // `emitHook` is available — see below.
67
+ // register() runs. Actions are wired in afterPluginsReady so the entity
68
+ // handle is available on the service — see below.
69
+ //
70
+ // Reactive entity (reactive automation engine §10.2): the
71
+ // `maintenance.created` / `maintenance.updated` trigger events are now
72
+ // DERIVED from `maintenance` entity changes. The triggers stay registered
73
+ // (ENTITY-DRIVEN, no hook) so they remain in the editor's trigger catalog +
74
+ // payload-introspectable; a `toPayload` mapper makes the runtime
75
+ // `trigger.payload` match their `payloadSchema` (mirroring incident /
76
+ // catalog / dependency / healthcheck).
77
+ //
78
+ // PLUGIN-BACKED (Model B): the `maintenances` + `maintenance_systems`
79
+ // tables ARE the current-state storage. `read` routes straight to the
80
+ // service's batched authoritative read — no framework `entity_state` row,
81
+ // so no `indexes` (those only apply to store-backed kinds). The `read`
82
+ // closure resolves the service set by init() (mutations only happen from
83
+ // init onward).
84
+ const entity = env.getExtensionPoint(entityExtensionPoint);
85
+
86
+ // The maintenance service is created in init() (it needs the resolved
87
+ // database), but the PLUGIN-BACKED entity `read` accessor must be supplied
88
+ // at `defineEntity` time in register(). This holder bridges the two: the
89
+ // `read` closure resolves the service lazily, and init() sets it before
90
+ // any mutation runs (the registry only mutates from init() onward).
91
+ let maintenanceServiceRef: MaintenanceService | undefined;
92
+
93
+ const maintenanceEntityHandle: EntityHandle<MaintenanceEntityState> =
94
+ entity.defineEntity<MaintenanceEntityState>({
95
+ kind: MAINTENANCE_ENTITY_KIND,
96
+ state: maintenanceEntityStateSchema,
97
+ read: (ids) => {
98
+ const svc = maintenanceServiceRef;
99
+ if (!svc) {
100
+ throw new Error(
101
+ "maintenance entity read before init: service not yet resolved",
102
+ );
103
+ }
104
+ return createMaintenanceEntityRead(svc)(ids);
105
+ },
106
+ });
107
+ entity.registerChangeDeriver({
108
+ kind: MAINTENANCE_ENTITY_KIND,
109
+ derive: deriveMaintenanceEvents,
110
+ toPayload: maintenanceChangeToPayload,
111
+ });
59
112
  const automationTriggers = env.getExtensionPoint(
60
113
  automationTriggerExtensionPoint,
61
114
  );
@@ -101,6 +154,9 @@ export default createBackendPlugin({
101
154
  maintenanceService = new MaintenanceService(
102
155
  database as SafeDatabase<typeof schema>,
103
156
  );
157
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
158
+ // (defined in register()). Mutations only run from here onward.
159
+ maintenanceServiceRef = maintenanceService;
104
160
  const cache = createMaintenanceCache({ cacheManager, logger });
105
161
  const router = createRouter(
106
162
  maintenanceService,
@@ -110,6 +166,7 @@ export default createBackendPlugin({
110
166
  authClient,
111
167
  logger,
112
168
  cache,
169
+ maintenanceEntityHandle,
113
170
  );
114
171
  rpc.registerRouter(router, maintenanceContract);
115
172
 
@@ -141,14 +198,16 @@ export default createBackendPlugin({
141
198
 
142
199
  logger.debug("✅ Maintenance Backend initialized.");
143
200
  },
144
- afterPluginsReady: async ({ queueManager, logger, emitHook }) => {
145
- // Register automation actions now that `emitHook` is available.
201
+ afterPluginsReady: async ({ queueManager, logger }) => {
202
+ // Register automation actions. Mutation actions mirror window state
203
+ // through the `maintenance` entity handle (created in init) rather
204
+ // than emitting the removed hooks.
146
205
  const automationActions = env.getExtensionPoint(
147
206
  automationActionExtensionPoint,
148
207
  );
149
208
  for (const action of createMaintenanceActions({
150
209
  service: maintenanceService,
151
- emitHook,
210
+ entityHandle: maintenanceEntityHandle,
152
211
  })) {
153
212
  automationActions.registerAction(action, pluginMetadata);
154
213
  }
@@ -244,6 +303,3 @@ export default createBackendPlugin({
244
303
  });
245
304
  },
246
305
  });
247
-
248
- // Re-export hooks for other plugins to use
249
- export { maintenanceHooks } from "./hooks";