@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
|
@@ -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
|
|
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 ({
|
|
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
|
|
291
|
-
await
|
|
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" },
|