@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/CHANGELOG.md +102 -0
- package/package.json +15 -15
- package/src/automations.test.ts +92 -19
- package/src/automations.ts +61 -18
- package/src/catalog-entity.test.ts +308 -0
- package/src/catalog-entity.ts +274 -0
- package/src/hooks.ts +12 -56
- package/src/index.ts +94 -5
- package/src/router.ts +160 -61
- package/src/services/entity-service.ts +112 -5
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
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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
|
-
|
|
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
|
|
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,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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
338
|
+
const deleteSystem = os.deleteSystem.handler(async ({ input }) => {
|
|
297
339
|
await enforceNotGitOpsLocked("System", input);
|
|
298
|
-
|
|
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
|
|
303
|
-
//
|
|
304
|
-
//
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
461
|
+
const deleteGroup = os.deleteGroup.handler(async ({ input }) => {
|
|
370
462
|
await enforceNotGitOpsLocked("Group", input);
|
|
371
|
-
|
|
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
|
-
|
|
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()
|