@checkstack/catalog-backend 1.2.0 → 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/index.ts CHANGED
@@ -7,7 +7,23 @@ import {
7
7
  automationActionExtensionPoint,
8
8
  automationArtifactTypeExtensionPoint,
9
9
  automationTriggerExtensionPoint,
10
+ entityExtensionPoint,
11
+ type EntityHandle,
10
12
  } from "@checkstack/automation-backend";
13
+ import {
14
+ CATALOG_GROUP_ENTITY_KIND,
15
+ CATALOG_SYSTEM_ENTITY_KIND,
16
+ CatalogGroupStateSchema,
17
+ CatalogSystemStateSchema,
18
+ catalogGroupChangeToPayload,
19
+ catalogSystemChangeToPayload,
20
+ createCatalogGroupEntityRead,
21
+ createCatalogSystemEntityRead,
22
+ deriveCatalogGroupTriggerEvents,
23
+ deriveCatalogSystemTriggerEvents,
24
+ type CatalogGroupState,
25
+ type CatalogSystemState,
26
+ } from "./catalog-entity";
11
27
  import {
12
28
  catalogAccessRules,
13
29
  catalogAccess,
@@ -43,9 +59,46 @@ import * as schema from "./schema";
43
59
 
44
60
  export let db: SafeDatabase<typeof schema> | undefined;
45
61
 
62
+ // Reactive catalog entity handles (§10.4). Defined in register() via the
63
+ // entity extension point; mutate from init()/afterPluginsReady onward.
64
+ let systemEntity: EntityHandle<CatalogSystemState> | undefined;
65
+ let groupEntity: EntityHandle<CatalogGroupState> | undefined;
66
+
67
+ // The catalog EntityService is created in init() (it needs the resolved
68
+ // database), but the PLUGIN-BACKED entity `read` accessors must be supplied
69
+ // at `defineEntity` time in register(). This holder bridges the two: the
70
+ // `read` closures resolve the service lazily, and init() sets it before any
71
+ // mutation runs (the registry only mutates from init() onward).
72
+ let catalogEntityServiceRef: EntityService | undefined;
73
+
74
+ /**
75
+ * Resolve the catalog EntityService for the PLUGIN-BACKED entity `read`
76
+ * accessors. Throws if invoked before init() has published the service —
77
+ * which never happens in practice, since the registry only reads/mutates
78
+ * from init() onward.
79
+ */
80
+ function resolveCatalogEntityService(): EntityService {
81
+ const svc = catalogEntityServiceRef;
82
+ if (!svc) {
83
+ throw new Error(
84
+ "catalog entity read before init: service not yet resolved",
85
+ );
86
+ }
87
+ return svc;
88
+ }
89
+
46
90
  // Export hooks for other plugins to subscribe to
47
91
  export { catalogHooks } from "./hooks";
48
92
 
93
+ // Re-export the reactive catalog entity kind ids so cross-plugin consumers
94
+ // (incident, dependency, slo) can subscribe via onEntityChanged (§10.4).
95
+ export {
96
+ CATALOG_SYSTEM_ENTITY_KIND,
97
+ CATALOG_GROUP_ENTITY_KIND,
98
+ type CatalogSystemState,
99
+ type CatalogGroupState,
100
+ } from "./catalog-entity";
101
+
49
102
  export default createBackendPlugin({
50
103
  metadata: pluginMetadata,
51
104
  register(env) {
@@ -66,6 +119,36 @@ export default createBackendPlugin({
66
119
  .getExtensionPoint(automationArtifactTypeExtensionPoint)
67
120
  .registerArtifactType(systemRecordArtifactType, pluginMetadata);
68
121
 
122
+ // ─── Reactive catalog entities (§10.4) ─────────────────────────────
123
+ // PLUGIN-BACKED (Model B): the `systems` / `groups` tables ARE the
124
+ // current-state storage. `read` routes straight to the EntityService's
125
+ // batched authoritative read — no framework `entity_state` row, so no
126
+ // `indexes` (those only apply to store-backed kinds). The `read` closures
127
+ // resolve the service set by init() (mutations only happen from init on).
128
+ const entityPoint = env.getExtensionPoint(entityExtensionPoint);
129
+ systemEntity = entityPoint.defineEntity<CatalogSystemState>({
130
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
131
+ state: CatalogSystemStateSchema,
132
+ read: (ids) =>
133
+ createCatalogSystemEntityRead(resolveCatalogEntityService())(ids),
134
+ });
135
+ groupEntity = entityPoint.defineEntity<CatalogGroupState>({
136
+ kind: CATALOG_GROUP_ENTITY_KIND,
137
+ state: CatalogGroupStateSchema,
138
+ read: (ids) =>
139
+ createCatalogGroupEntityRead(resolveCatalogEntityService())(ids),
140
+ });
141
+ entityPoint.registerChangeDeriver({
142
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
143
+ derive: deriveCatalogSystemTriggerEvents,
144
+ toPayload: catalogSystemChangeToPayload,
145
+ });
146
+ entityPoint.registerChangeDeriver({
147
+ kind: CATALOG_GROUP_ENTITY_KIND,
148
+ derive: deriveCatalogGroupTriggerEvents,
149
+ toPayload: catalogGroupChangeToPayload,
150
+ });
151
+
69
152
  // ─── GitOps Entity Kind Registration ───────────────────────────────
70
153
  // Mutable DB reference — populated during init(), consumed by reconcile closures.
71
154
  // Safe because reconcile is only called during sync (afterPluginsReady), by which
@@ -223,6 +306,11 @@ export default createBackendPlugin({
223
306
 
224
307
  const typedDb = database as SafeDatabase<typeof schema>;
225
308
 
309
+ // Publish the EntityService for the PLUGIN-BACKED catalog entity
310
+ // `read` accessors (defined in register()). Mutations only run from
311
+ // here onward, so the lazy `read` closures always find it resolved.
312
+ catalogEntityServiceRef = new EntityService(typedDb);
313
+
226
314
  // Get notification client for group management and sending notifications
227
315
  const notificationClient = rpcClient.forPlugin(NotificationApi);
228
316
  const authClient = rpcClient.forPlugin(AuthApi);
@@ -238,6 +326,8 @@ export default createBackendPlugin({
238
326
  gitOpsClient,
239
327
  pluginId: pluginMetadata.pluginId,
240
328
  cache,
329
+ getSystemEntity: () => systemEntity,
330
+ getGroupEntity: () => groupEntity,
241
331
  });
242
332
  rpc.registerRouter(catalogRouter, catalogContract);
243
333
 
@@ -302,7 +392,6 @@ export default createBackendPlugin({
302
392
  rpcClient,
303
393
  logger,
304
394
  onHook,
305
- emitHook,
306
395
  cacheManager,
307
396
  }) => {
308
397
  const typedDb = database as SafeDatabase<typeof schema>;
@@ -315,9 +404,9 @@ export default createBackendPlugin({
315
404
  // provisioning happens server-side from this signal.
316
405
  await bootstrapNotificationTargets(typedDb, notificationClient, logger);
317
406
 
318
- // Register automation actions now that `emitHook` is available
319
- // the `update_metadata` action needs to fire `systemUpdated`
320
- // downstream so other automations + caches react to the change.
407
+ // Register automation actions. The `update_metadata` action mirrors
408
+ // its edit into the `catalog-system` entity, whose deriver fires the
409
+ // `catalog.updated` trigger event downstream (§10.4).
321
410
  const automationActions = env.getExtensionPoint(
322
411
  automationActionExtensionPoint,
323
412
  );
@@ -326,7 +415,7 @@ export default createBackendPlugin({
326
415
  for (const action of createCatalogActions({
327
416
  entityService,
328
417
  cache,
329
- emitHook,
418
+ getSystemEntity: () => systemEntity,
330
419
  })) {
331
420
  automationActions.registerAction(action, pluginMetadata);
332
421
  }
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,47 +263,66 @@ 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
  };
244
269
  });
245
270
 
246
- const updateSystem = os.updateSystem.handler(async ({ input, context }) => {
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
- const changedFields: Array<"name" | "description" | "metadata"> = [];
255
281
  if (input.data.name !== undefined) {
256
282
  cleanData.name = input.data.name;
257
- changedFields.push("name");
258
283
  }
259
284
  if (input.data.description !== undefined) {
260
285
  cleanData.description = input.data.description ?? undefined;
261
- changedFields.push("description");
262
286
  }
263
287
  if (input.data.metadata !== undefined) {
264
288
  cleanData.metadata = input.data.metadata ?? undefined;
265
- changedFields.push("metadata");
266
289
  }
267
290
 
268
- const result = await entityService.updateSystem(input.id, cleanData);
269
- 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) {
270
295
  throw new ORPCError("NOT_FOUND", {
271
296
  message: "System not found",
272
297
  });
273
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
+
274
326
  await cache.invalidateTopology();
275
327
  // Refresh display label in notification-backend on rename so the
276
328
  // settings/audit UI shows the current name.
@@ -278,56 +330,67 @@ export const createCatalogRouter = ({
278
330
  await upsertSystemResource({ id: result.id, name: result.name });
279
331
  }
280
332
 
281
- // Emit only when a tracked field actually changed (skip no-op
282
- // updates so automations don't fire on every save-with-no-diff).
283
- if (changedFields.length > 0) {
284
- await context.emitHook(catalogHooks.systemUpdated, {
285
- systemId: result.id,
286
- systemName: result.name,
287
- changedFields,
288
- });
289
- }
290
-
291
333
  return result as typeof result & {
292
334
  metadata: Record<string, unknown> | null;
293
335
  };
294
336
  });
295
337
 
296
- const deleteSystem = os.deleteSystem.handler(async ({ input, context }) => {
338
+ const deleteSystem = os.deleteSystem.handler(async ({ input }) => {
297
339
  await enforceNotGitOpsLocked("System", input);
298
- 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
+ });
299
353
 
300
354
  await removeSystemResource(input);
301
355
 
302
- // Drop catalog topology + this system's contacts BEFORE the hook fires,
303
- // so downstream plugins (e.g. healthcheck) and any frontend that
304
- // 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.
305
359
  await Promise.all([
306
360
  cache.invalidateTopology(),
307
361
  cache.invalidateContacts(input),
308
362
  ]);
309
363
 
310
- // Emit hook for other plugins to clean up related data
311
- await context.emitHook(catalogHooks.systemDeleted, { systemId: input });
312
-
313
364
  return { success: true };
314
365
  });
315
366
 
316
- const createGroup = os.createGroup.handler(async ({ input, context }) => {
317
- const result = await entityService.createGroup({
318
- name: input.name,
319
- 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
+ },
320
388
  });
321
389
 
322
390
  await upsertGroupResource({ id: result.id, name: result.name });
323
391
 
324
392
  await cache.invalidateTopology();
325
393
 
326
- await context.emitHook(catalogHooks.groupCreated, {
327
- groupId: result.id,
328
- groupName: result.name,
329
- });
330
-
331
394
  // New groups have no systems yet
332
395
  return {
333
396
  ...result,
@@ -343,40 +406,76 @@ export const createCatalogRouter = ({
343
406
  ...input.data,
344
407
  metadata: input.data.metadata ?? undefined,
345
408
  };
346
- const result = await entityService.updateGroup(input.id, cleanData);
347
- 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) {
348
414
  throw new ORPCError("NOT_FOUND", {
349
415
  message: "Group not found",
350
416
  });
351
417
  }
352
- // Get the full group with systemIds after update
353
- const groups = await entityService.getGroups();
354
- const fullGroup = groups.find((g) => g.id === result.id);
355
- if (!fullGroup) {
356
- throw new ORPCError("INTERNAL_SERVER_ERROR", {
357
- message: "Group not found after update",
358
- });
359
- }
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
+
360
451
  await cache.invalidateTopology();
361
452
  if (input.data.name !== undefined) {
362
453
  await upsertGroupResource({ id: fullGroup.id, name: fullGroup.name });
363
454
  }
455
+
364
456
  return fullGroup as unknown as typeof fullGroup & {
365
457
  metadata: Record<string, unknown> | null;
366
458
  };
367
459
  });
368
460
 
369
- const deleteGroup = os.deleteGroup.handler(async ({ input, context }) => {
461
+ const deleteGroup = os.deleteGroup.handler(async ({ input }) => {
370
462
  await enforceNotGitOpsLocked("Group", input);
371
- 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
+ });
372
474
 
373
475
  await removeGroupResource(input);
374
476
 
375
477
  await cache.invalidateTopology();
376
478
 
377
- // Emit hook for other plugins to clean up related data
378
- await context.emitHook(catalogHooks.groupDeleted, { groupId: input });
379
-
380
479
  return { success: true };
381
480
  });
382
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()