@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/CHANGELOG.md +157 -0
- package/package.json +17 -14
- package/src/automations.test.ts +442 -0
- package/src/automations.ts +254 -0
- package/src/catalog-entity.test.ts +308 -0
- package/src/catalog-entity.ts +274 -0
- package/src/hooks.ts +12 -42
- package/src/index.ts +141 -3
- package/src/router.ts +167 -49
- package/src/services/entity-service.ts +112 -5
- package/tsconfig.json +6 -0
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
|
|
222
|
-
|
|
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)
|
|
255
|
-
|
|
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
|
-
|
|
286
|
+
}
|
|
287
|
+
if (input.data.metadata !== undefined) {
|
|
258
288
|
cleanData.metadata = input.data.metadata ?? undefined;
|
|
289
|
+
}
|
|
259
290
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
338
|
+
const deleteSystem = os.deleteSystem.handler(async ({ input }) => {
|
|
278
339
|
await enforceNotGitOpsLocked("System", input);
|
|
279
|
-
|
|
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
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
461
|
+
const deleteGroup = os.deleteGroup.handler(async ({ input }) => {
|
|
351
462
|
await enforceNotGitOpsLocked("Group", input);
|
|
352
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|