@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.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The reactive `catalog-system` + `catalog-group` entities (reactive
3
+ * automation engine §10.4).
4
+ *
5
+ * Model B PLUGIN-BACKED entities: the catalog `systems` / `groups` tables
6
+ * are authoritative AND ARE the entities' current-state storage — there is
7
+ * NO framework `entity_state` row for a catalog system/group.
8
+ * `defineEntity({ read })` makes that plugin state reactive: every
9
+ * reactive-state write goes through `handle.mutate`, whose `apply()` performs
10
+ * the REAL `systems`/`groups` write via the catalog `EntityService` (the
11
+ * plugin's own db/tx) and returns the resulting reactive subset. The
12
+ * framework snapshots `prev` via `read`, appends the transition log (its own
13
+ * db), and emits `ENTITY_CHANGED`. The change → trigger-event derivers
14
+ * reproduce `catalog.created/.updated/.deleted` +
15
+ * `catalog.group.created/.deleted` so automations keep firing.
16
+ */
17
+ import { z } from "zod";
18
+ import type {
19
+ EntityChangeDeriver,
20
+ EntityChangePayloadMapper,
21
+ EntityHandle,
22
+ EntityMutationOpts,
23
+ EntityRead,
24
+ } from "@checkstack/automation-backend";
25
+ import {
26
+ withEntityRemove,
27
+ withEntityWrite,
28
+ } from "@checkstack/automation-backend";
29
+
30
+ import type { EntityService } from "./services/entity-service";
31
+
32
+ export const CATALOG_SYSTEM_ENTITY_KIND = "catalog-system";
33
+ export const CATALOG_GROUP_ENTITY_KIND = "catalog-group";
34
+
35
+ /** Reactive state for a catalog system. */
36
+ export const CatalogSystemStateSchema = z.object({
37
+ name: z.string(),
38
+ description: z.string().nullable(),
39
+ metadata: z.record(z.string(), z.unknown()),
40
+ });
41
+ export type CatalogSystemState = z.infer<typeof CatalogSystemStateSchema>;
42
+
43
+ /** Reactive state for a catalog group. */
44
+ export const CatalogGroupStateSchema = z.object({
45
+ name: z.string(),
46
+ metadata: z.record(z.string(), z.unknown()),
47
+ });
48
+ export type CatalogGroupState = z.infer<typeof CatalogGroupStateSchema>;
49
+
50
+ /**
51
+ * Qualified TRIGGER event ids (`${pluginId}.${trigger.id}`) that automations
52
+ * store in `trigger.event` and Stage-1 routing matches on — NOT the dotted
53
+ * hook ids. The catalog system triggers use ids `created`/`updated`/`deleted`
54
+ * (pluginId `catalog`), so the deriver emits `catalog.created` etc., not the
55
+ * hook id `catalog.system.created`. (Verified against `automations.ts`.)
56
+ *
57
+ * There are NO registered catalog GROUP triggers today, so the group deriver
58
+ * fires nothing that any automation matches — kept for forward-compat + so
59
+ * group changes still drive scope/wake resolution as a known reactive kind.
60
+ */
61
+ export const CATALOG_SYSTEM_TRIGGER_EVENTS = {
62
+ created: "catalog.created",
63
+ updated: "catalog.updated",
64
+ deleted: "catalog.deleted",
65
+ } as const;
66
+
67
+ export const CATALOG_GROUP_TRIGGER_EVENTS = {
68
+ created: "catalog.group.created",
69
+ deleted: "catalog.group.deleted",
70
+ } as const;
71
+
72
+ /**
73
+ * `catalog-system` change → trigger events. Create (`prev === null`),
74
+ * tombstone (`next === null`), or a field update map to the matching
75
+ * lifecycle event. A no-op diff never reaches a deriver (the handle
76
+ * suppresses it), so an update always carries a real change.
77
+ */
78
+ export const deriveCatalogSystemTriggerEvents: EntityChangeDeriver = (
79
+ changed,
80
+ ) => {
81
+ if (changed.prev === null && changed.next !== null) {
82
+ return [CATALOG_SYSTEM_TRIGGER_EVENTS.created];
83
+ }
84
+ if (changed.next === null) {
85
+ return [CATALOG_SYSTEM_TRIGGER_EVENTS.deleted];
86
+ }
87
+ return [CATALOG_SYSTEM_TRIGGER_EVENTS.updated];
88
+ };
89
+
90
+ /**
91
+ * `catalog-group` change → trigger events. Only create + delete have
92
+ * matching hooks today (there is no `catalog.group.updated`), so a pure
93
+ * update diff fires nothing.
94
+ */
95
+ export const deriveCatalogGroupTriggerEvents: EntityChangeDeriver = (
96
+ changed,
97
+ ) => {
98
+ if (changed.prev === null && changed.next !== null) {
99
+ return [CATALOG_GROUP_TRIGGER_EVENTS.created];
100
+ }
101
+ if (changed.next === null) {
102
+ return [CATALOG_GROUP_TRIGGER_EVENTS.deleted];
103
+ }
104
+ return [];
105
+ };
106
+
107
+ /** The catalog `system.updated` trigger's `changedFields` enum members. */
108
+ const CATALOG_SYSTEM_CHANGED_FIELDS = ["name", "description", "metadata"] as const;
109
+
110
+ function readName(state: Record<string, unknown> | null): string | undefined {
111
+ if (state === null) return undefined;
112
+ const name = state["name"];
113
+ return typeof name === "string" ? name : undefined;
114
+ }
115
+
116
+ /**
117
+ * Map a `catalog-system` change to the domain-named `trigger.payload` the
118
+ * catalog system triggers declare via `payloadSchema` (`systemId`,
119
+ * `systemName`, `changedFields`). Restores the keys operators read
120
+ * (`trigger.payload.systemId`, `.systemName`, `.changedFields`) that the
121
+ * generic change shape omits.
122
+ *
123
+ * `systemId` is the entity id; `systemName` is `next.name` (absent on a
124
+ * tombstone, where the `deleted` schema marks it optional); `changedFields` is
125
+ * the change's `changedFields` intersected with the system trigger enum
126
+ * (`name` / `description` / `metadata`).
127
+ */
128
+ export const catalogSystemChangeToPayload: EntityChangePayloadMapper = (
129
+ changed,
130
+ ) => {
131
+ const changedFields = changed.changedFields.filter(
132
+ (f): f is (typeof CATALOG_SYSTEM_CHANGED_FIELDS)[number] =>
133
+ (CATALOG_SYSTEM_CHANGED_FIELDS as readonly string[]).includes(f),
134
+ );
135
+ const systemName = readName(changed.next);
136
+ return {
137
+ systemId: changed.id,
138
+ ...(systemName === undefined ? {} : { systemName }),
139
+ changedFields,
140
+ };
141
+ };
142
+
143
+ /**
144
+ * Map a `catalog-group` change to a domain-named `trigger.payload`
145
+ * (`groupId`, `groupName`). There are NO registered catalog GROUP triggers
146
+ * today, so this fires for no automation yet — it is supplied for forward
147
+ * compatibility + parity so a group change carries the same domain shape the
148
+ * other kinds do when group triggers are added.
149
+ */
150
+ export const catalogGroupChangeToPayload: EntityChangePayloadMapper = (
151
+ changed,
152
+ ) => {
153
+ const groupName = readName(changed.next);
154
+ return {
155
+ groupId: changed.id,
156
+ ...(groupName === undefined ? {} : { groupName }),
157
+ };
158
+ };
159
+
160
+ /**
161
+ * Build the PLUGIN-BACKED `read` accessor for the `catalog-system` entity.
162
+ * Routes straight to the service's batched authoritative read over the
163
+ * `systems` table — no framework storage.
164
+ */
165
+ export function createCatalogSystemEntityRead(
166
+ service: EntityService,
167
+ ): EntityRead<CatalogSystemState> {
168
+ return (ids) => service.getManySystemEntityStates(ids);
169
+ }
170
+
171
+ /**
172
+ * Build the PLUGIN-BACKED `read` accessor for the `catalog-group` entity.
173
+ * Routes straight to the service's batched authoritative read over the
174
+ * `groups` table — no framework storage.
175
+ */
176
+ export function createCatalogGroupEntityRead(
177
+ service: EntityService,
178
+ ): EntityRead<CatalogGroupState> {
179
+ return (ids) => service.getManyGroupEntityStates(ids);
180
+ }
181
+
182
+ /**
183
+ * Project a catalog system row onto the reactive `{ name, description,
184
+ * metadata }` subset. The router's service writes return the full row; this
185
+ * is the `apply()` return for `handle.mutate`.
186
+ */
187
+ export function toCatalogSystemState(system: {
188
+ name: string;
189
+ description: string | null | undefined;
190
+ metadata: Record<string, unknown> | null | undefined;
191
+ }): CatalogSystemState {
192
+ return {
193
+ name: system.name,
194
+ description: system.description ?? null,
195
+ metadata: system.metadata ?? {},
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Project a catalog group row onto the reactive `{ name, metadata }` subset.
201
+ */
202
+ export function toCatalogGroupState(group: {
203
+ name: string;
204
+ metadata: Record<string, unknown> | null | undefined;
205
+ }): CatalogGroupState {
206
+ return {
207
+ name: group.name,
208
+ metadata: group.metadata ?? {},
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Drive a reactive-state `catalog-system` write through `handle.mutate`
214
+ * (§10.4). `apply` performs the REAL `systems` write via the service (the
215
+ * plugin's own db/tx) and returns the new reactive state. The framework
216
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
217
+ * (the deriver turns that into `catalog.created/.updated`).
218
+ *
219
+ * When no handle is available (tests construct the router without one), the
220
+ * write still runs — the entity reactivity is layered on top, never required
221
+ * for the underlying write to succeed.
222
+ */
223
+ export async function writeCatalogSystemEntity(args: {
224
+ handle: EntityHandle<CatalogSystemState> | undefined;
225
+ systemId: string;
226
+ /**
227
+ * Mutation context (actor / runId). When the write originates inside a
228
+ * dispatch run (e.g. `system.update_metadata`), pass `opts: { runId }` so a
229
+ * run-resolved secret that lands in `metadata` is masked in the
230
+ * `entity_transitions` rows + the cluster-wide `ENTITY_CHANGED` — `metadata`
231
+ * is `z.record(z.string(), z.unknown())`, the only reactive catalog field
232
+ * that can carry an arbitrary secret string.
233
+ */
234
+ opts?: EntityMutationOpts;
235
+ apply: () => Promise<CatalogSystemState>;
236
+ }): Promise<void> {
237
+ const { handle, systemId, opts, apply } = args;
238
+ await withEntityWrite({ handle, id: systemId, opts, apply });
239
+ }
240
+
241
+ /**
242
+ * Drive a reactive-state `catalog-group` write through `handle.mutate`
243
+ * (§10.4). Mirrors {@link writeCatalogSystemEntity} for the group kind.
244
+ */
245
+ export async function writeCatalogGroupEntity(args: {
246
+ handle: EntityHandle<CatalogGroupState> | undefined;
247
+ groupId: string;
248
+ /** Mutation context (actor / runId) — see {@link writeCatalogSystemEntity}. */
249
+ opts?: EntityMutationOpts;
250
+ apply: () => Promise<CatalogGroupState>;
251
+ }): Promise<void> {
252
+ const { handle, groupId, opts, apply } = args;
253
+ await withEntityWrite({ handle, id: groupId, opts, apply });
254
+ }
255
+
256
+ /**
257
+ * Drive a catalog entity tombstone through `handle.remove` (§10.4). `apply`
258
+ * performs the REAL delete via the service; the framework records the
259
+ * tombstone transition and emits a tombstone change (the deriver fires
260
+ * `catalog.deleted` / `catalog.group.deleted`). Without a handle, the delete
261
+ * still runs.
262
+ */
263
+ export async function removeCatalogEntity<
264
+ TState extends Record<string, unknown>,
265
+ >(args: {
266
+ handle: EntityHandle<TState> | undefined;
267
+ id: string;
268
+ /** Mutation context (actor / runId) — see {@link writeCatalogSystemEntity}. */
269
+ opts?: EntityMutationOpts;
270
+ apply: () => Promise<void>;
271
+ }): Promise<void> {
272
+ const { handle, id, opts, apply } = args;
273
+ await withEntityRemove({ handle, id, opts, apply });
274
+ }
package/src/hooks.ts CHANGED
@@ -1,44 +1,14 @@
1
- import { createHook } from "@checkstack/backend-api";
2
-
3
1
  /**
4
- * Catalog hooks for cross-plugin communication
2
+ * Catalog cross-plugin hooks.
3
+ *
4
+ * The `catalog.system.created` / `.updated` / `.deleted` +
5
+ * `catalog.group.created` / `.deleted` hooks were removed in Phase 4
6
+ * (§10.4): catalog systems + groups are now the reactive `catalog-system`
7
+ * / `catalog-group` entities, whose change derivers fire the matching
8
+ * `catalog.created` / `.updated` / `.deleted` trigger events through
9
+ * Stage-1 routing, and cross-plugin cleanup reactors subscribe to the
10
+ * `catalog-system` tombstone via `onEntityChanged`. No cross-plugin hook
11
+ * remains, so this object is intentionally empty (kept for the stable
12
+ * `export { catalogHooks }` surface).
5
13
  */
6
- export const catalogHooks = {
7
- /**
8
- * Emitted when a system is created.
9
- * Plugins can subscribe (work-queue mode) to bootstrap related state
10
- * (e.g. per-system notification groups).
11
- */
12
- systemCreated: createHook<{
13
- systemId: string;
14
- systemName: string;
15
- }>("catalog.system.created"),
16
-
17
- /**
18
- * Emitted when a system is deleted.
19
- * Plugins can subscribe (work-queue mode) to clean up related data.
20
- */
21
- systemDeleted: createHook<{
22
- systemId: string;
23
- systemName?: string;
24
- }>("catalog.system.deleted"),
25
-
26
- /**
27
- * Emitted when a catalog group is created.
28
- * Plugins can subscribe to bootstrap related state (e.g. anomaly creates
29
- * its own per-group notification group on this signal).
30
- */
31
- groupCreated: createHook<{
32
- groupId: string;
33
- groupName: string;
34
- }>("catalog.group.created"),
35
-
36
- /**
37
- * Emitted when a group is deleted.
38
- * Plugins can subscribe (work-queue mode) to clean up related data.
39
- */
40
- groupDeleted: createHook<{
41
- groupId: string;
42
- groupName?: string;
43
- }>("catalog.group.deleted"),
44
- } as const;
14
+ export const catalogHooks = {} as const;
package/src/index.ts CHANGED
@@ -3,6 +3,27 @@ import {
3
3
  type SafeDatabase,
4
4
  } from "@checkstack/backend-api";
5
5
  import { coreServices } from "@checkstack/backend-api";
6
+ import {
7
+ automationActionExtensionPoint,
8
+ automationArtifactTypeExtensionPoint,
9
+ automationTriggerExtensionPoint,
10
+ entityExtensionPoint,
11
+ type EntityHandle,
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";
6
27
  import {
7
28
  catalogAccessRules,
8
29
  catalogAccess,
@@ -27,19 +48,107 @@ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
27
48
  import { CHECKSTACK_API_VERSION, entityRefSchema, GitOpsApi } from "@checkstack/gitops-common";
28
49
  import { z } from "zod";
29
50
 
51
+ import {
52
+ catalogTriggers,
53
+ createCatalogActions,
54
+ systemRecordArtifactType,
55
+ } from "./automations";
56
+
30
57
  // Database schema is still needed for types in creating the router
31
58
  import * as schema from "./schema";
32
59
 
33
60
  export let db: SafeDatabase<typeof schema> | undefined;
34
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
+
35
90
  // Export hooks for other plugins to subscribe to
36
91
  export { catalogHooks } from "./hooks";
37
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
+
38
102
  export default createBackendPlugin({
39
103
  metadata: pluginMetadata,
40
104
  register(env) {
41
105
  env.registerAccessRules(catalogAccessRules);
42
106
 
107
+ // ─── Automation Platform: triggers + artifact type ─────────────────
108
+ // Buffered behind the extension point until automation-backend's
109
+ // register() runs. The action factory is wired in afterPluginsReady
110
+ // below — that's where `emitHook` becomes available, which the
111
+ // `update_metadata` action needs in order to fire `systemUpdated`.
112
+ const automationTriggers = env.getExtensionPoint(
113
+ automationTriggerExtensionPoint,
114
+ );
115
+ for (const trigger of catalogTriggers) {
116
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
117
+ }
118
+ env
119
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
120
+ .registerArtifactType(systemRecordArtifactType, pluginMetadata);
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
+
43
152
  // ─── GitOps Entity Kind Registration ───────────────────────────────
44
153
  // Mutable DB reference — populated during init(), consumed by reconcile closures.
45
154
  // Safe because reconcile is only called during sync (afterPluginsReady), by which
@@ -197,6 +306,11 @@ export default createBackendPlugin({
197
306
 
198
307
  const typedDb = database as SafeDatabase<typeof schema>;
199
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
+
200
314
  // Get notification client for group management and sending notifications
201
315
  const notificationClient = rpcClient.forPlugin(NotificationApi);
202
316
  const authClient = rpcClient.forPlugin(AuthApi);
@@ -212,6 +326,8 @@ export default createBackendPlugin({
212
326
  gitOpsClient,
213
327
  pluginId: pluginMetadata.pluginId,
214
328
  cache,
329
+ getSystemEntity: () => systemEntity,
330
+ getGroupEntity: () => groupEntity,
215
331
  });
216
332
  rpc.registerRouter(catalogRouter, catalogContract);
217
333
 
@@ -271,7 +387,13 @@ export default createBackendPlugin({
271
387
  logger.debug("✅ Catalog Backend initialized.");
272
388
  },
273
389
  // Phase 3: Safe to make RPC calls after all plugins are ready
274
- afterPluginsReady: async ({ database, rpcClient, logger, onHook }) => {
390
+ afterPluginsReady: async ({
391
+ database,
392
+ rpcClient,
393
+ logger,
394
+ onHook,
395
+ cacheManager,
396
+ }) => {
275
397
  const typedDb = database as SafeDatabase<typeof schema>;
276
398
  const notificationClient = rpcClient.forPlugin(NotificationApi);
277
399
 
@@ -282,13 +404,29 @@ export default createBackendPlugin({
282
404
  // provisioning happens server-side from this signal.
283
405
  await bootstrapNotificationTargets(typedDb, notificationClient, logger);
284
406
 
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).
410
+ const automationActions = env.getExtensionPoint(
411
+ automationActionExtensionPoint,
412
+ );
413
+ const entityService = new EntityService(typedDb);
414
+ const cache = createCatalogCache({ cacheManager, logger });
415
+ for (const action of createCatalogActions({
416
+ entityService,
417
+ cache,
418
+ getSystemEntity: () => systemEntity,
419
+ })) {
420
+ automationActions.registerAction(action, pluginMetadata);
421
+ }
422
+
285
423
  // Subscribe to user deletion to clean up user contacts
286
424
  onHook(
287
425
  authHooks.userDeleted,
288
426
  async ({ userId }) => {
289
427
  logger.debug(`Cleaning up contacts for deleted user: ${userId}`);
290
- const entityService = new EntityService(typedDb);
291
- await entityService.deleteContactsByUserId(userId);
428
+ const userCleanupService = new EntityService(typedDb);
429
+ await userCleanupService.deleteContactsByUserId(userId);
292
430
  logger.debug(`Cleaned up contacts for user: ${userId}`);
293
431
  },
294
432
  { mode: "work-queue", workerGroup: "user-cleanup" },