@checkstack/catalog-backend 1.1.6 → 1.3.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/src/router.ts CHANGED
@@ -12,10 +12,19 @@ import * as schema from "./schema";
12
12
  import { NotificationApi } from "@checkstack/notification-common";
13
13
  import { AuthApi } from "@checkstack/auth-common";
14
14
  import type { InferClient } from "@checkstack/common";
15
- import { catalogHooks } from "./hooks";
16
15
  import { eq } from "drizzle-orm";
17
16
  import { GitOpsApi } from "@checkstack/gitops-common";
18
17
  import type { CatalogCache } from "./cache";
18
+ import type { EntityHandle } from "@checkstack/automation-backend";
19
+ import {
20
+ removeCatalogEntity,
21
+ toCatalogGroupState,
22
+ toCatalogSystemState,
23
+ writeCatalogGroupEntity,
24
+ writeCatalogSystemEntity,
25
+ type CatalogGroupState,
26
+ type CatalogSystemState,
27
+ } from "./catalog-entity";
19
28
 
20
29
  /**
21
30
  * Creates the catalog router using contract-based implementation.
@@ -35,6 +44,9 @@ export interface CatalogRouterDeps {
35
44
  gitOpsClient: InferClient<typeof GitOpsApi>;
36
45
  pluginId: string;
37
46
  cache: CatalogCache;
47
+ /** Resolvers for the reactive catalog entities (§10.4). Undefined in tests. */
48
+ getSystemEntity?: () => EntityHandle<CatalogSystemState> | undefined;
49
+ getGroupEntity?: () => EntityHandle<CatalogGroupState> | undefined;
38
50
  }
39
51
 
40
52
  export const createCatalogRouter = ({
@@ -44,6 +56,8 @@ export const createCatalogRouter = ({
44
56
  gitOpsClient,
45
57
  pluginId: _pluginId,
46
58
  cache,
59
+ getSystemEntity,
60
+ getGroupEntity,
47
61
  }: CatalogRouterDeps) => {
48
62
  const entityService = new EntityService(database);
49
63
 
@@ -218,8 +232,27 @@ export const createCatalogRouter = ({
218
232
  },
219
233
  );
220
234
 
221
- const createSystem = os.createSystem.handler(async ({ input, context }) => {
222
- const result = await entityService.createSystem(input);
235
+ const createSystem = os.createSystem.handler(async ({ input }) => {
236
+ // Drive the create through the reactive `catalog-system` entity (§10.4):
237
+ // `apply` performs the REAL `systems` write (the plugin's own db/tx) and
238
+ // returns the new reactive state; the deriver fires `catalog.created`
239
+ // from the resulting change. The id is generated up front so the handle
240
+ // is keyed on it and the create's `prev` snapshot correctly reads the
241
+ // not-yet-existing row as absent.
242
+ const systemId = crypto.randomUUID();
243
+ let result!: Awaited<ReturnType<typeof entityService.createSystem>>;
244
+ await writeCatalogSystemEntity({
245
+ handle: getSystemEntity?.(),
246
+ systemId,
247
+ apply: async () => {
248
+ result = await entityService.createSystem(input, systemId);
249
+ return toCatalogSystemState({
250
+ name: result.name,
251
+ description: result.description,
252
+ metadata: result.metadata as Record<string, unknown> | null,
253
+ });
254
+ },
255
+ });
223
256
 
224
257
  // Push the new system into notification-backend's resource registry.
225
258
  // notification-backend handles all per-spec group provisioning from
@@ -230,14 +263,6 @@ export const createCatalogRouter = ({
230
263
 
231
264
  await cache.invalidateTopology();
232
265
 
233
- // Hooks remain for non-notification cleanup concerns (e.g. incident
234
- // associations) — emitting plugins no longer use them for
235
- // subscription provisioning.
236
- await context.emitHook(catalogHooks.systemCreated, {
237
- systemId: result.id,
238
- systemName: result.name,
239
- });
240
-
241
266
  return result as typeof result & {
242
267
  metadata: Record<string, unknown> | null;
243
268
  };
@@ -245,70 +270,127 @@ export const createCatalogRouter = ({
245
270
 
246
271
  const updateSystem = os.updateSystem.handler(async ({ input }) => {
247
272
  await enforceNotGitOpsLocked("System", input.id);
248
- // Convert null to undefined and filter out fields
273
+ // Convert null to undefined and filter out fields. The entity mirror
274
+ // diffs internally now, so we no longer track `changedFields` for a
275
+ // hook payload (§10.4).
249
276
  const cleanData: Partial<{
250
277
  name: string;
251
278
  description?: string;
252
279
  metadata?: Record<string, unknown>;
253
280
  }> = {};
254
- if (input.data.name !== undefined) cleanData.name = input.data.name;
255
- if (input.data.description !== undefined)
281
+ if (input.data.name !== undefined) {
282
+ cleanData.name = input.data.name;
283
+ }
284
+ if (input.data.description !== undefined) {
256
285
  cleanData.description = input.data.description ?? undefined;
257
- if (input.data.metadata !== undefined)
286
+ }
287
+ if (input.data.metadata !== undefined) {
258
288
  cleanData.metadata = input.data.metadata ?? undefined;
289
+ }
259
290
 
260
- const result = await entityService.updateSystem(input.id, cleanData);
261
- if (!result) {
291
+ // Probe existence first so a missing system still surfaces as NOT_FOUND
292
+ // without driving an entity write.
293
+ const exists = await entityService.getSystem(input.id);
294
+ if (!exists) {
262
295
  throw new ORPCError("NOT_FOUND", {
263
296
  message: "System not found",
264
297
  });
265
298
  }
299
+
300
+ // Drive the update through the reactive `catalog-system` entity (§10.4).
301
+ // The REAL update runs INSIDE `apply`, so `prev` is snapshotted before
302
+ // the write and the deriver fires `catalog.updated` from the resulting
303
+ // change. The handle diffs internally, so a save-with-no-diff stays a
304
+ // no-op — preserving the old "don't fire automations on no-op updates"
305
+ // behavior without the explicit `changedFields` guard.
306
+ let result!: NonNullable<
307
+ Awaited<ReturnType<typeof entityService.updateSystem>>
308
+ >;
309
+ await writeCatalogSystemEntity({
310
+ handle: getSystemEntity?.(),
311
+ systemId: input.id,
312
+ apply: async () => {
313
+ const updated = await entityService.updateSystem(input.id, cleanData);
314
+ if (!updated) {
315
+ throw new ORPCError("NOT_FOUND", { message: "System not found" });
316
+ }
317
+ result = updated;
318
+ return toCatalogSystemState({
319
+ name: result.name,
320
+ description: result.description,
321
+ metadata: result.metadata as Record<string, unknown> | null,
322
+ });
323
+ },
324
+ });
325
+
266
326
  await cache.invalidateTopology();
267
327
  // Refresh display label in notification-backend on rename so the
268
328
  // settings/audit UI shows the current name.
269
329
  if (input.data.name !== undefined) {
270
330
  await upsertSystemResource({ id: result.id, name: result.name });
271
331
  }
332
+
272
333
  return result as typeof result & {
273
334
  metadata: Record<string, unknown> | null;
274
335
  };
275
336
  });
276
337
 
277
- const deleteSystem = os.deleteSystem.handler(async ({ input, context }) => {
338
+ const deleteSystem = os.deleteSystem.handler(async ({ input }) => {
278
339
  await enforceNotGitOpsLocked("System", input);
279
- await entityService.deleteSystem(input);
340
+
341
+ // Drive the delete through the reactive `catalog-system` entity tombstone
342
+ // (§10.4). The REAL delete runs INSIDE `apply`, so `prev` is snapshotted
343
+ // before it and the deriver fires `catalog.deleted` from the tombstone.
344
+ // Cross-plugin cleanup reactors (incident/dependency/slo/healthcheck)
345
+ // subscribe to that `catalog-system` tombstone via onEntityChanged.
346
+ await removeCatalogEntity({
347
+ handle: getSystemEntity?.(),
348
+ id: input,
349
+ apply: async () => {
350
+ await entityService.deleteSystem(input);
351
+ },
352
+ });
280
353
 
281
354
  await removeSystemResource(input);
282
355
 
283
- // Drop catalog topology + this system's contacts BEFORE the hook fires,
284
- // so downstream plugins (e.g. healthcheck) and any frontend that
285
- // refetches in response see fresh data.
356
+ // Drop catalog topology + this system's contacts so downstream plugins
357
+ // (e.g. healthcheck) and any frontend that refetches in response see
358
+ // fresh data.
286
359
  await Promise.all([
287
360
  cache.invalidateTopology(),
288
361
  cache.invalidateContacts(input),
289
362
  ]);
290
363
 
291
- // Emit hook for other plugins to clean up related data
292
- await context.emitHook(catalogHooks.systemDeleted, { systemId: input });
293
-
294
364
  return { success: true };
295
365
  });
296
366
 
297
- const createGroup = os.createGroup.handler(async ({ input, context }) => {
298
- const result = await entityService.createGroup({
299
- name: input.name,
300
- metadata: input.metadata,
367
+ const createGroup = os.createGroup.handler(async ({ input }) => {
368
+ // Drive the create through the reactive `catalog-group` entity (§10.4):
369
+ // `apply` performs the REAL `groups` write and returns the new reactive
370
+ // state; the deriver fires `catalog.group.created`. The id is generated
371
+ // up front so the handle is keyed on it and the create's `prev` snapshot
372
+ // reads the not-yet-existing row as absent.
373
+ const groupId = crypto.randomUUID();
374
+ let result!: Awaited<ReturnType<typeof entityService.createGroup>>;
375
+ await writeCatalogGroupEntity({
376
+ handle: getGroupEntity?.(),
377
+ groupId,
378
+ apply: async () => {
379
+ result = await entityService.createGroup(
380
+ { name: input.name, metadata: input.metadata },
381
+ groupId,
382
+ );
383
+ return toCatalogGroupState({
384
+ name: result.name,
385
+ metadata: result.metadata as Record<string, unknown> | null,
386
+ });
387
+ },
301
388
  });
302
389
 
303
390
  await upsertGroupResource({ id: result.id, name: result.name });
304
391
 
305
392
  await cache.invalidateTopology();
306
393
 
307
- await context.emitHook(catalogHooks.groupCreated, {
308
- groupId: result.id,
309
- groupName: result.name,
310
- });
311
-
312
394
  // New groups have no systems yet
313
395
  return {
314
396
  ...result,
@@ -324,40 +406,76 @@ export const createCatalogRouter = ({
324
406
  ...input.data,
325
407
  metadata: input.data.metadata ?? undefined,
326
408
  };
327
- const result = await entityService.updateGroup(input.id, cleanData);
328
- if (!result) {
409
+ // Probe existence first so a missing group still surfaces as NOT_FOUND
410
+ // without driving an entity write.
411
+ const existing = await entityService.getGroups();
412
+ const existingGroup = existing.find((g) => g.id === input.id);
413
+ if (!existingGroup) {
329
414
  throw new ORPCError("NOT_FOUND", {
330
415
  message: "Group not found",
331
416
  });
332
417
  }
333
- // Get the full group with systemIds after update
334
- const groups = await entityService.getGroups();
335
- const fullGroup = groups.find((g) => g.id === result.id);
336
- if (!fullGroup) {
337
- throw new ORPCError("INTERNAL_SERVER_ERROR", {
338
- message: "Group not found after update",
339
- });
340
- }
418
+
419
+ // Drive the update through the reactive `catalog-group` entity (§10.4).
420
+ // The REAL update runs INSIDE `apply`, so `prev` is snapshotted before
421
+ // the write. There is no `catalog.group.updated` hook, so the deriver
422
+ // fires nothing on a pure update — but the entity state stays current
423
+ // for scope/conditions.
424
+ let fullGroup!: NonNullable<
425
+ Awaited<ReturnType<typeof entityService.getGroups>>[number]
426
+ >;
427
+ await writeCatalogGroupEntity({
428
+ handle: getGroupEntity?.(),
429
+ groupId: input.id,
430
+ apply: async () => {
431
+ const result = await entityService.updateGroup(input.id, cleanData);
432
+ if (!result) {
433
+ throw new ORPCError("NOT_FOUND", { message: "Group not found" });
434
+ }
435
+ // Get the full group with systemIds after update
436
+ const groups = await entityService.getGroups();
437
+ const updated = groups.find((g) => g.id === result.id);
438
+ if (!updated) {
439
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
440
+ message: "Group not found after update",
441
+ });
442
+ }
443
+ fullGroup = updated;
444
+ return toCatalogGroupState({
445
+ name: fullGroup.name,
446
+ metadata: fullGroup.metadata as Record<string, unknown> | null,
447
+ });
448
+ },
449
+ });
450
+
341
451
  await cache.invalidateTopology();
342
452
  if (input.data.name !== undefined) {
343
453
  await upsertGroupResource({ id: fullGroup.id, name: fullGroup.name });
344
454
  }
455
+
345
456
  return fullGroup as unknown as typeof fullGroup & {
346
457
  metadata: Record<string, unknown> | null;
347
458
  };
348
459
  });
349
460
 
350
- const deleteGroup = os.deleteGroup.handler(async ({ input, context }) => {
461
+ const deleteGroup = os.deleteGroup.handler(async ({ input }) => {
351
462
  await enforceNotGitOpsLocked("Group", input);
352
- await entityService.deleteGroup(input);
463
+
464
+ // Drive the delete through the reactive `catalog-group` entity tombstone
465
+ // (§10.4). The REAL delete runs INSIDE `apply`; the deriver fires
466
+ // `catalog.group.deleted` from the tombstone.
467
+ await removeCatalogEntity({
468
+ handle: getGroupEntity?.(),
469
+ id: input,
470
+ apply: async () => {
471
+ await entityService.deleteGroup(input);
472
+ },
473
+ });
353
474
 
354
475
  await removeGroupResource(input);
355
476
 
356
477
  await cache.invalidateTopology();
357
478
 
358
- // Emit hook for other plugins to clean up related data
359
- await context.emitHook(catalogHooks.groupDeleted, { groupId: input });
360
-
361
479
  return { success: true };
362
480
  });
363
481
 
@@ -1,8 +1,34 @@
1
- import { eq, and } from "drizzle-orm";
1
+ import { eq, and, inArray } from "drizzle-orm";
2
2
  import * as schema from "../schema";
3
3
  import { SafeDatabase } from "@checkstack/backend-api";
4
4
  import { v4 as uuidv4 } from "uuid";
5
5
 
6
+ /** Reactive subset of a catalog system (the `catalog-system` entity state). */
7
+ type CatalogSystemEntityState = {
8
+ name: string;
9
+ description: string | null;
10
+ metadata: Record<string, unknown>;
11
+ };
12
+
13
+ /** Reactive subset of a catalog group (the `catalog-group` entity state). */
14
+ type CatalogGroupEntityState = {
15
+ name: string;
16
+ metadata: Record<string, unknown>;
17
+ };
18
+
19
+ /**
20
+ * Narrow the drizzle `json()` column (typed `unknown`) to the reactive
21
+ * `Record<string, unknown>` metadata shape, defaulting non-object values
22
+ * (null / scalars / arrays) to an empty object so the entity state is always
23
+ * a well-formed record.
24
+ */
25
+ function normalizeMetadata(value: unknown): Record<string, unknown> {
26
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
27
+ return value as Record<string, unknown>;
28
+ }
29
+ return {};
30
+ }
31
+
6
32
  // Type aliases for entity creation
7
33
  type NewSystem = {
8
34
  name: string;
@@ -49,10 +75,19 @@ export class EntityService {
49
75
  return result[0];
50
76
  }
51
77
 
52
- async createSystem(data: NewSystem) {
78
+ /**
79
+ * Create a system.
80
+ *
81
+ * `id` may be supplied by the caller so the reactive `catalog-system`
82
+ * entity can be keyed on a known id BEFORE the insert runs (the create's
83
+ * `prev` snapshot must read the not-yet-existing row as absent — see
84
+ * §10.4). When omitted, a fresh id is generated. The id is server-owned
85
+ * either way.
86
+ */
87
+ async createSystem(data: NewSystem, id: string = uuidv4()) {
53
88
  const result = await this.database
54
89
  .insert(schema.systems)
55
- .values({ id: uuidv4(), ...data })
90
+ .values({ id, ...data })
56
91
  .returning();
57
92
  return result[0];
58
93
  }
@@ -70,6 +105,40 @@ export class EntityService {
70
105
  await this.database.delete(schema.systems).where(eq(schema.systems.id, id));
71
106
  }
72
107
 
108
+ /**
109
+ * Batched reactive-state read for the `catalog-system` entity (Model B
110
+ * plugin-backed `read` accessor). Given system ids, return the reactive
111
+ * subset `{ name, description, metadata }` for each that exists (missing
112
+ * ids omitted). Reads the AUTHORITATIVE `systems` table — no framework
113
+ * `entity_state` storage. This is the single source of truth
114
+ * `handle.mutate` snapshots `prev` from and `get`/`getMany`/scope
115
+ * enrichment route through.
116
+ */
117
+ async getManySystemEntityStates(
118
+ ids: ReadonlyArray<string>,
119
+ ): Promise<Record<string, CatalogSystemEntityState>> {
120
+ if (ids.length === 0) return {};
121
+ const rows = await this.database
122
+ .select({
123
+ id: schema.systems.id,
124
+ name: schema.systems.name,
125
+ description: schema.systems.description,
126
+ metadata: schema.systems.metadata,
127
+ })
128
+ .from(schema.systems)
129
+ .where(inArray(schema.systems.id, [...ids]));
130
+
131
+ const out: Record<string, CatalogSystemEntityState> = {};
132
+ for (const row of rows) {
133
+ out[row.id] = {
134
+ name: row.name,
135
+ description: row.description ?? null,
136
+ metadata: normalizeMetadata(row.metadata),
137
+ };
138
+ }
139
+ return out;
140
+ }
141
+
73
142
  // System Contacts
74
143
  async getContactsForSystem(systemId: string) {
75
144
  return this.database
@@ -147,10 +216,18 @@ export class EntityService {
147
216
  }));
148
217
  }
149
218
 
150
- async createGroup(data: NewGroup) {
219
+ /**
220
+ * Create a group.
221
+ *
222
+ * `id` may be supplied so the reactive `catalog-group` entity can be keyed
223
+ * on a known id BEFORE the insert runs (the create's `prev` snapshot must
224
+ * read the not-yet-existing row as absent — see §10.4). When omitted, a
225
+ * fresh id is generated. The id is server-owned either way.
226
+ */
227
+ async createGroup(data: NewGroup, id: string = uuidv4()) {
151
228
  const result = await this.database
152
229
  .insert(schema.groups)
153
- .values({ id: uuidv4(), ...data })
230
+ .values({ id, ...data })
154
231
  .returning();
155
232
  return result[0];
156
233
  }
@@ -168,6 +245,36 @@ export class EntityService {
168
245
  await this.database.delete(schema.groups).where(eq(schema.groups.id, id));
169
246
  }
170
247
 
248
+ /**
249
+ * Batched reactive-state read for the `catalog-group` entity (Model B
250
+ * plugin-backed `read` accessor). Given group ids, return the reactive
251
+ * subset `{ name, metadata }` for each that exists (missing ids omitted).
252
+ * Reads the AUTHORITATIVE `groups` table — no framework `entity_state`
253
+ * storage.
254
+ */
255
+ async getManyGroupEntityStates(
256
+ ids: ReadonlyArray<string>,
257
+ ): Promise<Record<string, CatalogGroupEntityState>> {
258
+ if (ids.length === 0) return {};
259
+ const rows = await this.database
260
+ .select({
261
+ id: schema.groups.id,
262
+ name: schema.groups.name,
263
+ metadata: schema.groups.metadata,
264
+ })
265
+ .from(schema.groups)
266
+ .where(inArray(schema.groups.id, [...ids]));
267
+
268
+ const out: Record<string, CatalogGroupEntityState> = {};
269
+ for (const row of rows) {
270
+ out[row.id] = {
271
+ name: row.name,
272
+ metadata: normalizeMetadata(row.metadata),
273
+ };
274
+ }
275
+ return out;
276
+ }
277
+
171
278
  async getGroupsForSystem(systemId: string) {
172
279
  const associations = await this.database
173
280
  .select()
package/tsconfig.json CHANGED
@@ -10,6 +10,9 @@
10
10
  {
11
11
  "path": "../auth-common"
12
12
  },
13
+ {
14
+ "path": "../automation-backend"
15
+ },
13
16
  {
14
17
  "path": "../backend-api"
15
18
  },
@@ -39,6 +42,9 @@
39
42
  },
40
43
  {
41
44
  "path": "../notification-common"
45
+ },
46
+ {
47
+ "path": "../test-utils-backend"
42
48
  }
43
49
  ]
44
50
  }