@checkstack/maintenance-backend 1.3.1 → 1.4.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,69 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ MaintenanceApi,
6
+ maintenanceAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/maintenance-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `maintenance.removeLink`: the link id to remove. */
13
+ export const MaintenanceRemoveLinkInputSchema = z.object({
14
+ id: z.string(),
15
+ });
16
+ export type MaintenanceRemoveLinkInput = z.infer<
17
+ typeof MaintenanceRemoveLinkInputSchema
18
+ >;
19
+
20
+ /** Output returned once a human applies the removal. */
21
+ export interface MaintenanceRemoveLinkApplyResult {
22
+ id: string;
23
+ removed: true;
24
+ }
25
+
26
+ /**
27
+ * `maintenance.removeLink` - remove a hotlink from a maintenance window by link
28
+ * id.
29
+ *
30
+ * `effect: "destructive"` - removal is irreversible, so it ALWAYS routes through
31
+ * the propose/apply confirm card in BOTH permission modes (it can never
32
+ * auto-apply). `execute` (reached only via `apply`) performs the removal through
33
+ * the USER-SCOPED client, so handler-side authorization is enforced exactly as a
34
+ * direct UI/RPC call.
35
+ */
36
+ export function createMaintenanceRemoveLinkTool(): RegisteredAiTool<
37
+ MaintenanceRemoveLinkInput,
38
+ MaintenanceRemoveLinkApplyResult
39
+ > {
40
+ const dryRun = async ({
41
+ input,
42
+ }: {
43
+ input: MaintenanceRemoveLinkInput;
44
+ principal: AuthUser;
45
+ rpcClient: RpcClient;
46
+ }): Promise<AiProposalPreview<MaintenanceRemoveLinkInput>> => {
47
+ return {
48
+ summary: `Remove link "${input.id}" from its maintenance window. This is permanent.`,
49
+ payload: { id: input.id },
50
+ };
51
+ };
52
+
53
+ return {
54
+ name: "maintenance.removeLink",
55
+ description:
56
+ "Remove a hotlink from a maintenance window by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id with the maintenance read tools first.",
57
+ effect: "destructive",
58
+ input: MaintenanceRemoveLinkInputSchema,
59
+ requiredAccessRules: [
60
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
61
+ ],
62
+ dryRun,
63
+ async execute({ input, rpcClient }) {
64
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
65
+ await maintenanceClient.removeLink({ id: input.id });
66
+ return { id: input.id, removed: true };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createMaintenanceUpdateTool } from "./maintenance-update";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["maintenance.maintenance.manage"],
9
+ };
10
+
11
+ const existing = {
12
+ id: "m1",
13
+ title: "DB upgrade",
14
+ description: "Planned upgrade",
15
+ suppressNotifications: false,
16
+ status: "scheduled" as const,
17
+ startAt: new Date("2026-07-01T10:00:00.000Z"),
18
+ endAt: new Date("2026-07-01T12:00:00.000Z"),
19
+ createdAt: new Date(),
20
+ updatedAt: new Date(),
21
+ systemIds: ["s1"],
22
+ updates: [],
23
+ links: [],
24
+ };
25
+
26
+ function fakeRpcClient({
27
+ getMaintenance,
28
+ updateMaintenance,
29
+ }: {
30
+ getMaintenance: ReturnType<typeof mock>;
31
+ updateMaintenance: ReturnType<typeof mock>;
32
+ }): RpcClient {
33
+ return {
34
+ forPlugin: () => ({ getMaintenance, updateMaintenance }),
35
+ } as unknown as RpcClient;
36
+ }
37
+
38
+ describe("maintenance.update tool", () => {
39
+ test("declares mutate effect + the manage rule", () => {
40
+ const tool = createMaintenanceUpdateTool();
41
+ expect(tool.name).toBe("maintenance.update");
42
+ expect(tool.effect).toBe("mutate");
43
+ expect(tool.requiredAccessRules).toEqual([
44
+ "maintenance.maintenance.manage",
45
+ ]);
46
+ expect(typeof tool.dryRun).toBe("function");
47
+ });
48
+
49
+ test("dryRun fetches the maintenance and returns a diff", async () => {
50
+ const getMaintenance = mock(() => Promise.resolve(existing));
51
+ const updateMaintenance = mock(() => Promise.resolve());
52
+ const rpcClient = fakeRpcClient({ getMaintenance, updateMaintenance });
53
+ const tool = createMaintenanceUpdateTool();
54
+ const preview = await tool.dryRun!({
55
+ input: { id: "m1", title: "DB upgrade v2" },
56
+ principal,
57
+ rpcClient,
58
+ });
59
+ expect(getMaintenance).toHaveBeenCalledWith({ id: "m1" });
60
+ expect(updateMaintenance).not.toHaveBeenCalled();
61
+ expect(preview.diff).toBeDefined();
62
+ expect(preview.summary).toContain("DB upgrade");
63
+ });
64
+
65
+ test("dryRun throws a clear error when the id is unknown", async () => {
66
+ const rpcClient = fakeRpcClient({
67
+ getMaintenance: mock(() => Promise.resolve(null)),
68
+ updateMaintenance: mock(),
69
+ });
70
+ const tool = createMaintenanceUpdateTool();
71
+ await expect(
72
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
73
+ ).rejects.toThrow(/No maintenance found/);
74
+ });
75
+
76
+ test("coerces ISO string dates to Date instances before the RPC", async () => {
77
+ let received: { startAt: unknown; endAt: unknown } | undefined;
78
+ const getMaintenance = mock(() => Promise.resolve(existing));
79
+ const updateMaintenance = mock(
80
+ (arg: { startAt: unknown; endAt: unknown }) => {
81
+ received = arg;
82
+ return Promise.resolve({ ...existing });
83
+ },
84
+ );
85
+ const rpcClient = fakeRpcClient({ getMaintenance, updateMaintenance });
86
+ const tool = createMaintenanceUpdateTool();
87
+ const parsed = tool.input.parse({
88
+ id: "m1",
89
+ startAt: "2026-07-02T10:00:00.000Z",
90
+ endAt: "2026-07-02T12:00:00.000Z",
91
+ });
92
+ await tool.execute({ input: parsed, principal, rpcClient });
93
+ expect(received?.startAt instanceof Date).toBe(true);
94
+ expect(received?.endAt instanceof Date).toBe(true);
95
+ });
96
+ });
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ MaintenanceApi,
6
+ UpdateMaintenanceInputSchema,
7
+ maintenanceAccess,
8
+ pluginMetadata,
9
+ type MaintenanceWithSystems,
10
+ } from "@checkstack/maintenance-common";
11
+ import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /**
15
+ * Tool-local update schema. The exported `UpdateMaintenanceInputSchema` types
16
+ * `startAt` / `endAt` as `z.date()`, but the model sends ISO strings, so we
17
+ * `.extend` it to coerce just those two fields to `Date` while leaving every
18
+ * other (already-correct) field as published. The exported schema is NOT mutated.
19
+ */
20
+ export const MaintenanceUpdateInputSchema = UpdateMaintenanceInputSchema.extend({
21
+ startAt: z.coerce.date().optional(),
22
+ endAt: z.coerce.date().optional(),
23
+ });
24
+ export type MaintenanceUpdateInput = z.infer<typeof MaintenanceUpdateInputSchema>;
25
+
26
+ /** Output returned once a human applies the update (the updated maintenance). */
27
+ export interface MaintenanceUpdateApplyResult {
28
+ maintenance: MaintenanceWithSystems;
29
+ }
30
+
31
+ /** Fields a `maintenance.update` can change - used to build the before/after diff. */
32
+ const UPDATABLE_FIELDS = [
33
+ "title",
34
+ "description",
35
+ "suppressNotifications",
36
+ "startAt",
37
+ "endAt",
38
+ "systemIds",
39
+ ] as const;
40
+
41
+ /**
42
+ * `maintenance.update` - change an existing maintenance window by id with a
43
+ * partial body (only provided fields change).
44
+ *
45
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
46
+ * and is confirm-gated in APPROVE mode. `dryRun` fetches the live maintenance,
47
+ * throws a clear error if it does not exist, and renders a before/after field
48
+ * diff over the updatable subset so the confirm card shows exactly what changes.
49
+ */
50
+ export function createMaintenanceUpdateTool(): RegisteredAiTool<
51
+ MaintenanceUpdateInput,
52
+ MaintenanceUpdateApplyResult
53
+ > {
54
+ const dryRun = async ({
55
+ input,
56
+ rpcClient,
57
+ }: {
58
+ input: MaintenanceUpdateInput;
59
+ principal: AuthUser;
60
+ rpcClient: RpcClient;
61
+ }): Promise<AiProposalPreview<MaintenanceUpdateInput>> => {
62
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
63
+ const existing = await maintenanceClient.getMaintenance({ id: input.id });
64
+ if (!existing) {
65
+ throw new Error(
66
+ `No maintenance found with id "${input.id}". List maintenances first to get a valid id.`,
67
+ );
68
+ }
69
+
70
+ // before -> after diff over the updatable subset only (the fetched detail
71
+ // also carries id/status/timestamps/updates/links that are not updatable).
72
+ const before: Record<string, unknown> = {};
73
+ const after: Record<string, unknown> = {};
74
+ for (const field of UPDATABLE_FIELDS) {
75
+ before[field] = existing[field];
76
+ after[field] = field in input ? input[field] : existing[field];
77
+ }
78
+ const diff = computeFieldDiff({ before, after });
79
+
80
+ return {
81
+ summary: `Update maintenance "${existing.title}".`,
82
+ payload: input,
83
+ diff,
84
+ };
85
+ };
86
+
87
+ return {
88
+ name: "maintenance.update",
89
+ description:
90
+ "Update an existing maintenance window by id with a partial body (only provided fields change). Provide startAt/endAt as ISO 8601 timestamps if changing them. Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the maintenance read tools first.",
91
+ effect: "mutate",
92
+ input: MaintenanceUpdateInputSchema,
93
+ requiredAccessRules: [
94
+ qualifyAccessRuleId(pluginMetadata, maintenanceAccess.maintenance.manage),
95
+ ],
96
+ dryRun,
97
+ async execute({ input, rpcClient }) {
98
+ const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
99
+ const maintenance = await maintenanceClient.updateMaintenance(input);
100
+ return { maintenance };
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildProjectedTool,
4
+ deferredProjectionExecute,
5
+ } from "@checkstack/ai-backend";
6
+ import { qualifyAccessRuleId } from "@checkstack/common";
7
+ import {
8
+ maintenanceContract,
9
+ maintenanceAccess,
10
+ pluginMetadata,
11
+ } from "@checkstack/maintenance-common";
12
+
13
+ // Build the projected tools with the SAME inputs the plugin exposes via
14
+ // aiToolProjectionExtensionPoint in `index.ts`, and assert each carries the
15
+ // source procedure's OWN contract access rules - NOT the chat transport's
16
+ // `ai.chat.read` gate.
17
+ describe("maintenance AI projections", () => {
18
+ const expectedRule = qualifyAccessRuleId(
19
+ pluginMetadata,
20
+ maintenanceAccess.maintenance.read,
21
+ );
22
+
23
+ test("maintenance.list is a read-only tool with the read rule", () => {
24
+ const tool = buildProjectedTool({
25
+ procedure: maintenanceContract.listMaintenances,
26
+ sourcePluginMetadata: pluginMetadata,
27
+ procedureKey: "listMaintenances",
28
+ name: "maintenance.list",
29
+ description:
30
+ "List planned maintenance windows with optional status/system filters. Read-only.",
31
+ effect: "read",
32
+ execute: deferredProjectionExecute,
33
+ });
34
+ expect(tool.name).toBe("maintenance.list");
35
+ expect(tool.effect).toBe("read");
36
+ expect(tool.requiredAccessRules).toEqual([expectedRule]);
37
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
38
+ });
39
+
40
+ test("maintenance.get is a read-only tool with the read rule", () => {
41
+ const tool = buildProjectedTool({
42
+ procedure: maintenanceContract.getMaintenance,
43
+ sourcePluginMetadata: pluginMetadata,
44
+ procedureKey: "getMaintenance",
45
+ name: "maintenance.get",
46
+ description:
47
+ "Get one maintenance window with its updates and links. Read-only.",
48
+ effect: "read",
49
+ execute: deferredProjectionExecute,
50
+ });
51
+ expect(tool.name).toBe("maintenance.get");
52
+ expect(tool.effect).toBe("read");
53
+ expect(tool.requiredAccessRules).toEqual([expectedRule]);
54
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
55
+ });
56
+ });
@@ -0,0 +1,33 @@
1
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
2
+ import { createMaintenanceCreateTool } from "./maintenance-create";
3
+ import { createMaintenanceUpdateTool } from "./maintenance-update";
4
+ import { createMaintenanceDeleteTool } from "./maintenance-delete";
5
+ import { createMaintenanceAddUpdateTool } from "./maintenance-add-update";
6
+ import { createMaintenanceCloseTool } from "./maintenance-close";
7
+ import { createMaintenanceAddLinkTool } from "./maintenance-add-link";
8
+ import { createMaintenanceRemoveLinkTool } from "./maintenance-remove-link";
9
+
10
+ /**
11
+ * The maintenance plugin's AI tools, registered into the AI registry via
12
+ * `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
13
+ * ai-backend. This is the canonical pattern any plugin (first- or third-party)
14
+ * uses to contribute AI tools without ai-backend depending on it.
15
+ *
16
+ * create/update/addUpdate/close/addLink are `mutate` (auto-applies in AUTO mode,
17
+ * confirm-gated in APPROVE mode); delete/removeLink are `destructive` (always
18
+ * confirm-gated). They all go through the USER-SCOPED client passed at call time,
19
+ * so handler-side authorization (access rules AND per-resource/team scoping) is
20
+ * enforced exactly as a direct UI/RPC call; the resolver gate + the
21
+ * propose/apply re-check are the additional authorization authority.
22
+ */
23
+ export function buildMaintenanceAiTools(): RegisteredAiTool[] {
24
+ return [
25
+ createMaintenanceCreateTool(),
26
+ createMaintenanceUpdateTool(),
27
+ createMaintenanceDeleteTool(),
28
+ createMaintenanceAddUpdateTool(),
29
+ createMaintenanceCloseTool(),
30
+ createMaintenanceAddLinkTool(),
31
+ createMaintenanceRemoveLinkTool(),
32
+ ];
33
+ }
@@ -8,7 +8,7 @@
8
8
  * maintenance id and `apply` returns the §10.2 entity shape.
9
9
  */
10
10
  import { describe, expect, it, mock } from "bun:test";
11
- import type { Logger } from "@checkstack/backend-api";
11
+ import type { Logger, RpcClient } from "@checkstack/backend-api";
12
12
  import type {
13
13
  EntityHandle,
14
14
  EntityMutationOpts,
@@ -32,6 +32,7 @@ const ctxBase = {
32
32
  getService: async <T,>(): Promise<T> => {
33
33
  throw new Error("not used");
34
34
  },
35
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
35
36
  };
36
37
 
37
38
  interface RecordedMutate {
package/src/index.ts CHANGED
@@ -12,6 +12,12 @@ import {
12
12
  } from "@checkstack/maintenance-common";
13
13
 
14
14
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
15
+ import {
16
+ aiToolExtensionPoint,
17
+ aiToolProjectionExtensionPoint,
18
+ deferredProjectionExecute,
19
+ } from "@checkstack/ai-backend";
20
+ import { buildMaintenanceAiTools } from "./ai/register-ai-tools";
15
21
  import {
16
22
  automationActionExtensionPoint,
17
23
  automationArtifactTypeExtensionPoint,
@@ -170,6 +176,44 @@ export default createBackendPlugin({
170
176
  );
171
177
  rpc.registerRouter(router, maintenanceContract);
172
178
 
179
+ // Register this plugin's AI tools (create/update/delete/addUpdate/
180
+ // close/addLink/removeLink) into the AI registry via the extension
181
+ // point - owned here, not in ai-backend.
182
+ const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
183
+ for (const tool of buildMaintenanceAiTools()) {
184
+ aiToolExt.registerTool(tool, pluginMetadata);
185
+ }
186
+
187
+ // Expose this plugin's OWN read-only AI projections of the existing
188
+ // list/get queries via aiToolProjectionExtensionPoint - owned here,
189
+ // not in ai-backend. The projected read tools are routed by the
190
+ // transport (MCP / chat) AS the principal, so each procedure's own
191
+ // contract access rules gate it; `deferredProjectionExecute` is the
192
+ // fail-closed net if a transport ever forgot to route.
193
+ const aiProjectionExt = env.getExtensionPoint(
194
+ aiToolProjectionExtensionPoint,
195
+ );
196
+ aiProjectionExt.expose({
197
+ procedure: maintenanceContract.listMaintenances,
198
+ sourcePluginMetadata: pluginMetadata,
199
+ procedureKey: "listMaintenances",
200
+ name: "maintenance.list",
201
+ description:
202
+ "List planned maintenance windows with optional status/system filters. Read-only.",
203
+ effect: "read",
204
+ execute: deferredProjectionExecute,
205
+ });
206
+ aiProjectionExt.expose({
207
+ procedure: maintenanceContract.getMaintenance,
208
+ sourcePluginMetadata: pluginMetadata,
209
+ procedureKey: "getMaintenance",
210
+ name: "maintenance.get",
211
+ description:
212
+ "Get one maintenance window with its updates and links. Read-only.",
213
+ effect: "read",
214
+ execute: deferredProjectionExecute,
215
+ });
216
+
173
217
  // Register "Create Maintenance" command in the command palette
174
218
  registerSearchProvider({
175
219
  pluginMetadata,
package/src/schema.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  timestamp,
6
6
  primaryKey,
7
7
  boolean,
8
+ uniqueIndex,
8
9
  } from "drizzle-orm/pg-core";
9
10
 
10
11
  /**
@@ -68,12 +69,21 @@ export const maintenanceUpdates = pgTable("maintenance_updates", {
68
69
  * Hotlinks attached to a maintenance — e.g. a change ticket, runbook, or
69
70
  * chat thread. Free-form URL + optional human label.
70
71
  */
71
- export const maintenanceLinks = pgTable("maintenance_links", {
72
- id: text("id").primaryKey(),
73
- maintenanceId: text("maintenance_id")
74
- .notNull()
75
- .references(() => maintenances.id, { onDelete: "cascade" }),
76
- label: text("label"),
77
- url: text("url").notNull(),
78
- createdAt: timestamp("created_at").defaultNow().notNull(),
79
- });
72
+ export const maintenanceLinks = pgTable(
73
+ "maintenance_links",
74
+ {
75
+ id: text("id").primaryKey(),
76
+ maintenanceId: text("maintenance_id")
77
+ .notNull()
78
+ .references(() => maintenances.id, { onDelete: "cascade" }),
79
+ label: text("label"),
80
+ url: text("url").notNull(),
81
+ createdAt: timestamp("created_at").defaultNow().notNull(),
82
+ },
83
+ (t) => ({
84
+ // The same URL may be attached to a maintenance only once.
85
+ maintenanceUrlUnique: uniqueIndex(
86
+ "maintenance_links_maintenance_url_unique",
87
+ ).on(t.maintenanceId, t.url),
88
+ }),
89
+ );
package/src/service.ts CHANGED
@@ -257,23 +257,28 @@ export class MaintenanceService {
257
257
  input: CreateMaintenanceInput,
258
258
  id: string = generateId(),
259
259
  ): Promise<MaintenanceWithSystems> {
260
- await this.db.insert(maintenances).values({
261
- id,
262
- title: input.title,
263
- description: input.description,
264
- suppressNotifications: input.suppressNotifications ?? false,
265
- status: "scheduled",
266
- startAt: input.startAt,
267
- endAt: input.endAt,
268
- });
269
-
270
- // Insert system associations
271
- for (const systemId of input.systemIds) {
272
- await this.db.insert(maintenanceSystems).values({
273
- maintenanceId: id,
274
- systemId,
260
+ // Atomic: the maintenance row and its system associations must commit
261
+ // together. Without the transaction a failure mid-loop left a committed
262
+ // maintenance with only some (or none) of its system links.
263
+ await this.db.transaction(async (tx) => {
264
+ await tx.insert(maintenances).values({
265
+ id,
266
+ title: input.title,
267
+ description: input.description,
268
+ suppressNotifications: input.suppressNotifications ?? false,
269
+ status: "scheduled",
270
+ startAt: input.startAt,
271
+ endAt: input.endAt,
275
272
  });
276
- }
273
+
274
+ // Insert system associations
275
+ for (const systemId of input.systemIds) {
276
+ await tx.insert(maintenanceSystems).values({
277
+ maintenanceId: id,
278
+ systemId,
279
+ });
280
+ }
281
+ });
277
282
 
278
283
  return (await this.getMaintenance(id))!;
279
284
  }
@@ -303,24 +308,29 @@ export class MaintenanceService {
303
308
  if (input.startAt !== undefined) updateData.startAt = input.startAt;
304
309
  if (input.endAt !== undefined) updateData.endAt = input.endAt;
305
310
 
306
- await this.db
307
- .update(maintenances)
308
- .set(updateData)
309
- .where(eq(maintenances.id, input.id));
310
-
311
- // Update system associations if provided
312
- if (input.systemIds !== undefined) {
313
- await this.db
314
- .delete(maintenanceSystems)
315
- .where(eq(maintenanceSystems.maintenanceId, input.id));
316
-
317
- for (const systemId of input.systemIds) {
318
- await this.db.insert(maintenanceSystems).values({
319
- maintenanceId: input.id,
320
- systemId,
321
- });
311
+ // Atomic: the field update and the delete-then-reinsert of system links must
312
+ // commit together. Without the transaction a failure after the delete left
313
+ // the maintenance with ALL system associations wiped.
314
+ await this.db.transaction(async (tx) => {
315
+ await tx
316
+ .update(maintenances)
317
+ .set(updateData)
318
+ .where(eq(maintenances.id, input.id));
319
+
320
+ // Update system associations if provided
321
+ if (input.systemIds !== undefined) {
322
+ await tx
323
+ .delete(maintenanceSystems)
324
+ .where(eq(maintenanceSystems.maintenanceId, input.id));
325
+
326
+ for (const systemId of input.systemIds) {
327
+ await tx.insert(maintenanceSystems).values({
328
+ maintenanceId: input.id,
329
+ systemId,
330
+ });
331
+ }
322
332
  }
323
- }
333
+ });
324
334
 
325
335
  return (await this.getMaintenance(input.id))!;
326
336
  }
@@ -334,20 +344,24 @@ export class MaintenanceService {
334
344
  ): Promise<MaintenanceUpdate> {
335
345
  const id = generateId();
336
346
 
337
- // If status change is provided, update the maintenance status
338
- if (input.statusChange) {
339
- await this.db
340
- .update(maintenances)
341
- .set({ status: input.statusChange, updatedAt: new Date() })
342
- .where(eq(maintenances.id, input.maintenanceId));
343
- }
347
+ // Atomic: the status flip and the timeline entry that records it must commit
348
+ // together (status/timeline divergence otherwise).
349
+ await this.db.transaction(async (tx) => {
350
+ // If status change is provided, update the maintenance status
351
+ if (input.statusChange) {
352
+ await tx
353
+ .update(maintenances)
354
+ .set({ status: input.statusChange, updatedAt: new Date() })
355
+ .where(eq(maintenances.id, input.maintenanceId));
356
+ }
344
357
 
345
- await this.db.insert(maintenanceUpdates).values({
346
- id,
347
- maintenanceId: input.maintenanceId,
348
- message: input.message,
349
- statusChange: input.statusChange,
350
- createdBy: userId,
358
+ await tx.insert(maintenanceUpdates).values({
359
+ id,
360
+ maintenanceId: input.maintenanceId,
361
+ message: input.message,
362
+ statusChange: input.statusChange,
363
+ createdBy: userId,
364
+ });
351
365
  });
352
366
 
353
367
  const [update] = await this.db
@@ -377,18 +391,21 @@ export class MaintenanceService {
377
391
 
378
392
  if (!existing) return undefined;
379
393
 
380
- await this.db
381
- .update(maintenances)
382
- .set({ status: "completed", updatedAt: new Date() })
383
- .where(eq(maintenances.id, id));
394
+ // Atomic: mark completed + write the closing timeline entry together.
395
+ await this.db.transaction(async (tx) => {
396
+ await tx
397
+ .update(maintenances)
398
+ .set({ status: "completed", updatedAt: new Date() })
399
+ .where(eq(maintenances.id, id));
384
400
 
385
- // Add update entry
386
- await this.db.insert(maintenanceUpdates).values({
387
- id: generateId(),
388
- maintenanceId: id,
389
- message: message ?? "Maintenance completed early",
390
- statusChange: "completed",
391
- createdBy: userId,
401
+ // Add update entry
402
+ await tx.insert(maintenanceUpdates).values({
403
+ id: generateId(),
404
+ maintenanceId: id,
405
+ message: message ?? "Maintenance completed early",
406
+ statusChange: "completed",
407
+ createdBy: userId,
408
+ });
392
409
  });
393
410
 
394
411
  return (await this.getMaintenance(id))!;
package/tsconfig.json CHANGED
@@ -4,6 +4,12 @@
4
4
  "src"
5
5
  ],
6
6
  "references": [
7
+ {
8
+ "path": "../ai-backend"
9
+ },
10
+ {
11
+ "path": "../ai-common"
12
+ },
7
13
  {
8
14
  "path": "../auth-common"
9
15
  },