@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
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
EntityChanged,
|
|
4
|
+
EntityHandle,
|
|
5
|
+
} from "@checkstack/automation-backend";
|
|
6
|
+
import { SYSTEM_ACTOR } from "@checkstack/common";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CATALOG_GROUP_ENTITY_KIND,
|
|
10
|
+
CATALOG_GROUP_TRIGGER_EVENTS,
|
|
11
|
+
CATALOG_SYSTEM_ENTITY_KIND,
|
|
12
|
+
CATALOG_SYSTEM_TRIGGER_EVENTS,
|
|
13
|
+
catalogSystemChangeToPayload,
|
|
14
|
+
createCatalogGroupEntityRead,
|
|
15
|
+
createCatalogSystemEntityRead,
|
|
16
|
+
deriveCatalogGroupTriggerEvents,
|
|
17
|
+
deriveCatalogSystemTriggerEvents,
|
|
18
|
+
removeCatalogEntity,
|
|
19
|
+
toCatalogGroupState,
|
|
20
|
+
toCatalogSystemState,
|
|
21
|
+
writeCatalogGroupEntity,
|
|
22
|
+
writeCatalogSystemEntity,
|
|
23
|
+
type CatalogGroupState,
|
|
24
|
+
type CatalogSystemState,
|
|
25
|
+
} from "./catalog-entity";
|
|
26
|
+
import {
|
|
27
|
+
systemCreatedTrigger,
|
|
28
|
+
systemDeletedTrigger,
|
|
29
|
+
systemUpdatedTrigger,
|
|
30
|
+
} from "./automations";
|
|
31
|
+
import type { EntityService } from "./services/entity-service";
|
|
32
|
+
|
|
33
|
+
function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
|
|
34
|
+
return {
|
|
35
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
36
|
+
id: "sys-1",
|
|
37
|
+
prev: { name: "old", description: null, metadata: {} },
|
|
38
|
+
next: { name: "new", description: null, metadata: {} },
|
|
39
|
+
delta: { name: "new" },
|
|
40
|
+
changedFields: ["name"],
|
|
41
|
+
actor: SYSTEM_ACTOR,
|
|
42
|
+
occurredAt: new Date().toISOString(),
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("CATALOG_SYSTEM_TRIGGER_EVENTS (must equal the trigger qualifiedIds)", () => {
|
|
48
|
+
it("emits the registered system trigger qualifiedIds, not the dotted hook ids", () => {
|
|
49
|
+
// The catalog system triggers have ids `created`/`updated`/`deleted`
|
|
50
|
+
// (pluginId `catalog`), so the deriver MUST emit `catalog.created` etc.,
|
|
51
|
+
// NOT the dotted hook ids `catalog.system.created`.
|
|
52
|
+
expect(CATALOG_SYSTEM_TRIGGER_EVENTS.created).toBe("catalog.created");
|
|
53
|
+
expect(CATALOG_SYSTEM_TRIGGER_EVENTS.updated).toBe("catalog.updated");
|
|
54
|
+
expect(CATALOG_SYSTEM_TRIGGER_EVENTS.deleted).toBe("catalog.deleted");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("deriveCatalogSystemTriggerEvents", () => {
|
|
59
|
+
it("maps a create (prev === null) to system.created", () => {
|
|
60
|
+
expect(
|
|
61
|
+
deriveCatalogSystemTriggerEvents(
|
|
62
|
+
change({ prev: null, next: { name: "n", description: null, metadata: {} } }),
|
|
63
|
+
),
|
|
64
|
+
).toEqual([CATALOG_SYSTEM_TRIGGER_EVENTS.created]);
|
|
65
|
+
});
|
|
66
|
+
it("maps a tombstone (next === null) to system.deleted", () => {
|
|
67
|
+
expect(
|
|
68
|
+
deriveCatalogSystemTriggerEvents(change({ next: null })),
|
|
69
|
+
).toEqual([CATALOG_SYSTEM_TRIGGER_EVENTS.deleted]);
|
|
70
|
+
});
|
|
71
|
+
it("maps a field update to system.updated", () => {
|
|
72
|
+
expect(deriveCatalogSystemTriggerEvents(change())).toEqual([
|
|
73
|
+
CATALOG_SYSTEM_TRIGGER_EVENTS.updated,
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("catalogSystemChangeToPayload — payloadSchema parity", () => {
|
|
79
|
+
it("a create payload validates against the created trigger's payloadSchema", () => {
|
|
80
|
+
const payload = catalogSystemChangeToPayload(
|
|
81
|
+
change({
|
|
82
|
+
prev: null,
|
|
83
|
+
next: { name: "new", description: null, metadata: {} },
|
|
84
|
+
delta: { name: "new" },
|
|
85
|
+
changedFields: ["name", "description", "metadata"],
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const parsed = systemCreatedTrigger.payloadSchema.parse(payload);
|
|
89
|
+
expect(parsed.systemId).toBe("sys-1");
|
|
90
|
+
expect(parsed.systemName).toBe("new");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("an update payload validates against the updated trigger's payloadSchema with changedFields", () => {
|
|
94
|
+
const payload = catalogSystemChangeToPayload(
|
|
95
|
+
change({ changedFields: ["name", "metadata"] }),
|
|
96
|
+
);
|
|
97
|
+
const parsed = systemUpdatedTrigger.payloadSchema.parse(payload);
|
|
98
|
+
expect(parsed.systemId).toBe("sys-1");
|
|
99
|
+
expect(parsed.systemName).toBe("new");
|
|
100
|
+
expect(parsed.changedFields).toEqual(["name", "metadata"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("a delete payload validates against the deleted trigger's payloadSchema (systemName omitted on tombstone)", () => {
|
|
104
|
+
const payload = catalogSystemChangeToPayload(change({ next: null }));
|
|
105
|
+
const parsed = systemDeletedTrigger.payloadSchema.parse(payload);
|
|
106
|
+
expect(parsed.systemId).toBe("sys-1");
|
|
107
|
+
expect(parsed.systemName).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("drops non-enum changedFields (only name/description/metadata survive)", () => {
|
|
111
|
+
const payload = catalogSystemChangeToPayload(
|
|
112
|
+
change({ changedFields: ["name", "ownerId", "metadata"] }),
|
|
113
|
+
);
|
|
114
|
+
const parsed = systemUpdatedTrigger.payloadSchema.parse(payload);
|
|
115
|
+
expect(parsed.changedFields).toEqual(["name", "metadata"]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("deriveCatalogGroupTriggerEvents", () => {
|
|
120
|
+
it("maps a create to group.created", () => {
|
|
121
|
+
expect(
|
|
122
|
+
deriveCatalogGroupTriggerEvents(
|
|
123
|
+
change({
|
|
124
|
+
kind: CATALOG_GROUP_ENTITY_KIND,
|
|
125
|
+
prev: null,
|
|
126
|
+
next: { name: "g", metadata: {} },
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
).toEqual([CATALOG_GROUP_TRIGGER_EVENTS.created]);
|
|
130
|
+
});
|
|
131
|
+
it("maps a tombstone to group.deleted", () => {
|
|
132
|
+
expect(
|
|
133
|
+
deriveCatalogGroupTriggerEvents(
|
|
134
|
+
change({ kind: CATALOG_GROUP_ENTITY_KIND, next: null }),
|
|
135
|
+
),
|
|
136
|
+
).toEqual([CATALOG_GROUP_TRIGGER_EVENTS.deleted]);
|
|
137
|
+
});
|
|
138
|
+
it("fires nothing on a group update (no group.updated hook)", () => {
|
|
139
|
+
expect(
|
|
140
|
+
deriveCatalogGroupTriggerEvents(
|
|
141
|
+
change({
|
|
142
|
+
kind: CATALOG_GROUP_ENTITY_KIND,
|
|
143
|
+
prev: { name: "a", metadata: {} },
|
|
144
|
+
next: { name: "b", metadata: {} },
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("toCatalogSystemState / toCatalogGroupState", () => {
|
|
152
|
+
it("projects a system, normalising null description + metadata", () => {
|
|
153
|
+
expect(
|
|
154
|
+
toCatalogSystemState({
|
|
155
|
+
name: "API",
|
|
156
|
+
description: undefined,
|
|
157
|
+
metadata: undefined,
|
|
158
|
+
}),
|
|
159
|
+
).toEqual({ name: "API", description: null, metadata: {} });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("projects a group, normalising null metadata", () => {
|
|
163
|
+
expect(
|
|
164
|
+
toCatalogGroupState({ name: "Team A", metadata: null }),
|
|
165
|
+
).toEqual({ name: "Team A", metadata: {} });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("createCatalogSystemEntityRead", () => {
|
|
170
|
+
it("routes the batched read straight to the service (plugin-backed)", async () => {
|
|
171
|
+
const seen: ReadonlyArray<string>[] = [];
|
|
172
|
+
const service = {
|
|
173
|
+
async getManySystemEntityStates(ids: ReadonlyArray<string>) {
|
|
174
|
+
seen.push(ids);
|
|
175
|
+
return {
|
|
176
|
+
"sys-1": { name: "API", description: null, metadata: { tier: "1" } },
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
} as unknown as EntityService;
|
|
180
|
+
const read = createCatalogSystemEntityRead(service);
|
|
181
|
+
const out = await read(["sys-1", "sys-2"]);
|
|
182
|
+
expect(seen).toEqual([["sys-1", "sys-2"]]);
|
|
183
|
+
expect(out["sys-1"]).toEqual({
|
|
184
|
+
name: "API",
|
|
185
|
+
description: null,
|
|
186
|
+
metadata: { tier: "1" },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("createCatalogGroupEntityRead", () => {
|
|
192
|
+
it("routes the batched read straight to the service (plugin-backed)", async () => {
|
|
193
|
+
const seen: ReadonlyArray<string>[] = [];
|
|
194
|
+
const service = {
|
|
195
|
+
async getManyGroupEntityStates(ids: ReadonlyArray<string>) {
|
|
196
|
+
seen.push(ids);
|
|
197
|
+
return { "g-1": { name: "Team A", metadata: {} } };
|
|
198
|
+
},
|
|
199
|
+
} as unknown as EntityService;
|
|
200
|
+
const read = createCatalogGroupEntityRead(service);
|
|
201
|
+
const out = await read(["g-1"]);
|
|
202
|
+
expect(seen).toEqual([["g-1"]]);
|
|
203
|
+
expect(out["g-1"]).toEqual({ name: "Team A", metadata: {} });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("writeCatalogSystemEntity", () => {
|
|
208
|
+
it("drives the write through handle.mutate keyed by system id", async () => {
|
|
209
|
+
const calls: Array<{ id: string; next: CatalogSystemState }> = [];
|
|
210
|
+
const handle = {
|
|
211
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
212
|
+
async mutate(input: {
|
|
213
|
+
id: string;
|
|
214
|
+
apply: () => Promise<CatalogSystemState>;
|
|
215
|
+
}) {
|
|
216
|
+
const next = await input.apply();
|
|
217
|
+
calls.push({ id: input.id, next });
|
|
218
|
+
return next;
|
|
219
|
+
},
|
|
220
|
+
} as unknown as EntityHandle<CatalogSystemState>;
|
|
221
|
+
let applied = false;
|
|
222
|
+
await writeCatalogSystemEntity({
|
|
223
|
+
handle,
|
|
224
|
+
systemId: "sys-9",
|
|
225
|
+
apply: async () => {
|
|
226
|
+
applied = true;
|
|
227
|
+
return { name: "API", description: null, metadata: {} };
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
expect(applied).toBe(true);
|
|
231
|
+
expect(calls).toEqual([
|
|
232
|
+
{ id: "sys-9", next: { name: "API", description: null, metadata: {} } },
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("still runs the plugin write when no handle is wired", async () => {
|
|
237
|
+
let applied = false;
|
|
238
|
+
await writeCatalogSystemEntity({
|
|
239
|
+
handle: undefined,
|
|
240
|
+
systemId: "sys-9",
|
|
241
|
+
apply: async () => {
|
|
242
|
+
applied = true;
|
|
243
|
+
return { name: "API", description: null, metadata: {} };
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
expect(applied).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("writeCatalogGroupEntity", () => {
|
|
251
|
+
it("drives the write through handle.mutate keyed by group id", async () => {
|
|
252
|
+
const calls: Array<{ id: string; next: CatalogGroupState }> = [];
|
|
253
|
+
const handle = {
|
|
254
|
+
kind: CATALOG_GROUP_ENTITY_KIND,
|
|
255
|
+
async mutate(input: {
|
|
256
|
+
id: string;
|
|
257
|
+
apply: () => Promise<CatalogGroupState>;
|
|
258
|
+
}) {
|
|
259
|
+
const next = await input.apply();
|
|
260
|
+
calls.push({ id: input.id, next });
|
|
261
|
+
return next;
|
|
262
|
+
},
|
|
263
|
+
} as unknown as EntityHandle<CatalogGroupState>;
|
|
264
|
+
await writeCatalogGroupEntity({
|
|
265
|
+
handle,
|
|
266
|
+
groupId: "g-1",
|
|
267
|
+
apply: async () => ({ name: "Team A", metadata: { tier: "1" } }),
|
|
268
|
+
});
|
|
269
|
+
expect(calls).toEqual([
|
|
270
|
+
{ id: "g-1", next: { name: "Team A", metadata: { tier: "1" } } },
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("removeCatalogEntity", () => {
|
|
276
|
+
it("tombstones via handle.remove({ apply })", async () => {
|
|
277
|
+
const removed: string[] = [];
|
|
278
|
+
let deleted = false;
|
|
279
|
+
const handle = {
|
|
280
|
+
kind: CATALOG_SYSTEM_ENTITY_KIND,
|
|
281
|
+
async remove(input: { id: string; apply: () => Promise<void> }) {
|
|
282
|
+
await input.apply();
|
|
283
|
+
removed.push(input.id);
|
|
284
|
+
},
|
|
285
|
+
} as unknown as EntityHandle<Record<string, unknown>>;
|
|
286
|
+
await removeCatalogEntity({
|
|
287
|
+
handle,
|
|
288
|
+
id: "sys-9",
|
|
289
|
+
apply: async () => {
|
|
290
|
+
deleted = true;
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
expect(deleted).toBe(true);
|
|
294
|
+
expect(removed).toEqual(["sys-9"]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("still runs the delete when no handle is wired", async () => {
|
|
298
|
+
let deleted = false;
|
|
299
|
+
await removeCatalogEntity({
|
|
300
|
+
handle: undefined,
|
|
301
|
+
id: "x",
|
|
302
|
+
apply: async () => {
|
|
303
|
+
deleted = true;
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
expect(deleted).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -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,58 +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's name, description, or metadata changes.
|
|
19
|
-
*
|
|
20
|
-
* `changedFields` lists the keys the caller actually set on the
|
|
21
|
-
* update (excluding `id`), so subscribers can route on field-level
|
|
22
|
-
* changes (e.g. "only react to metadata edits") without diffing the
|
|
23
|
-
* full record.
|
|
24
|
-
*/
|
|
25
|
-
systemUpdated: createHook<{
|
|
26
|
-
systemId: string;
|
|
27
|
-
systemName: string;
|
|
28
|
-
changedFields: Array<"name" | "description" | "metadata">;
|
|
29
|
-
}>("catalog.system.updated"),
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Emitted when a system is deleted.
|
|
33
|
-
* Plugins can subscribe (work-queue mode) to clean up related data.
|
|
34
|
-
*/
|
|
35
|
-
systemDeleted: createHook<{
|
|
36
|
-
systemId: string;
|
|
37
|
-
systemName?: string;
|
|
38
|
-
}>("catalog.system.deleted"),
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Emitted when a catalog group is created.
|
|
42
|
-
* Plugins can subscribe to bootstrap related state (e.g. anomaly creates
|
|
43
|
-
* its own per-group notification group on this signal).
|
|
44
|
-
*/
|
|
45
|
-
groupCreated: createHook<{
|
|
46
|
-
groupId: string;
|
|
47
|
-
groupName: string;
|
|
48
|
-
}>("catalog.group.created"),
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Emitted when a group is deleted.
|
|
52
|
-
* Plugins can subscribe (work-queue mode) to clean up related data.
|
|
53
|
-
*/
|
|
54
|
-
groupDeleted: createHook<{
|
|
55
|
-
groupId: string;
|
|
56
|
-
groupName?: string;
|
|
57
|
-
}>("catalog.group.deleted"),
|
|
58
|
-
} as const;
|
|
14
|
+
export const catalogHooks = {} as const;
|