@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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog triggers + actions registered with the Automation Platform.
|
|
3
|
+
*
|
|
4
|
+
* Triggers re-expose the existing `catalogHooks` as automation entry
|
|
5
|
+
* points (`system.created`, `system.updated`, `system.deleted`).
|
|
6
|
+
* Actions wrap the existing `EntityService` so operators can mutate
|
|
7
|
+
* systems from automation flows (e.g. "when an incident is resolved,
|
|
8
|
+
* clear the system's `incident_severity` metadata field").
|
|
9
|
+
*
|
|
10
|
+
* `system.set_maintenance` / `system.clear_maintenance` are NOT in
|
|
11
|
+
* this file — those wrap maintenance-backend RPCs and ship as part of
|
|
12
|
+
* the maintenance Phase 9 chunk. `system.health_changed` is owned by
|
|
13
|
+
* the healthcheck chunk where the aggregation data lives.
|
|
14
|
+
*
|
|
15
|
+
* Action handlers call `entityService.updateSystem` directly and then
|
|
16
|
+
* invalidate the catalog topology cache + emit the `systemUpdated`
|
|
17
|
+
* hook themselves. The router handler does the same thing for
|
|
18
|
+
* RPC-driven edits; centralising it in the service would be a
|
|
19
|
+
* separate refactor (covered elsewhere). For now, both paths emit the
|
|
20
|
+
* hook so downstream automations + cache subscribers see the change.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { Versioned } from "@checkstack/backend-api";
|
|
24
|
+
import type {
|
|
25
|
+
ActionDefinition,
|
|
26
|
+
TriggerDefinition,
|
|
27
|
+
} from "@checkstack/automation-backend";
|
|
28
|
+
import {
|
|
29
|
+
makeEntityDrivenTriggerSetup,
|
|
30
|
+
type EntityHandle,
|
|
31
|
+
} from "@checkstack/automation-backend";
|
|
32
|
+
import type { EntityService } from "./services/entity-service";
|
|
33
|
+
import type { createCatalogCache } from "./cache";
|
|
34
|
+
import {
|
|
35
|
+
toCatalogSystemState,
|
|
36
|
+
writeCatalogSystemEntity,
|
|
37
|
+
type CatalogSystemState,
|
|
38
|
+
} from "./catalog-entity";
|
|
39
|
+
|
|
40
|
+
// ─── Payload schemas — match the hook payloads exactly ─────────────────
|
|
41
|
+
|
|
42
|
+
const systemCreatedPayloadSchema = z.object({
|
|
43
|
+
systemId: z.string(),
|
|
44
|
+
systemName: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const systemUpdatedPayloadSchema = z.object({
|
|
48
|
+
systemId: z.string(),
|
|
49
|
+
systemName: z.string(),
|
|
50
|
+
changedFields: z.array(z.enum(["name", "description", "metadata"])),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const systemDeletedPayloadSchema = z.object({
|
|
54
|
+
systemId: z.string(),
|
|
55
|
+
systemName: z.string().optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ─── Triggers ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
// These triggers are ENTITY-DRIVEN (§10.4): the `catalog-system` entity's
|
|
61
|
+
// change deriver fires `catalog.created/.updated/.deleted` via Stage-1
|
|
62
|
+
// routing, so they no longer subscribe to a hook. A no-op `setup` keeps
|
|
63
|
+
// them in the editor's trigger catalog without re-introducing a hook.
|
|
64
|
+
export const systemCreatedTrigger: TriggerDefinition<
|
|
65
|
+
z.infer<typeof systemCreatedPayloadSchema>
|
|
66
|
+
> = {
|
|
67
|
+
id: "created",
|
|
68
|
+
displayName: "System Created",
|
|
69
|
+
description: "Fires when a new system is added to the catalog",
|
|
70
|
+
category: "Catalog",
|
|
71
|
+
icon: "Activity",
|
|
72
|
+
payloadSchema: systemCreatedPayloadSchema,
|
|
73
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
74
|
+
z.infer<typeof systemCreatedPayloadSchema>
|
|
75
|
+
>(),
|
|
76
|
+
contextKey: (p) => p.systemId,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const systemUpdatedTrigger: TriggerDefinition<
|
|
80
|
+
z.infer<typeof systemUpdatedPayloadSchema>
|
|
81
|
+
> = {
|
|
82
|
+
id: "updated",
|
|
83
|
+
displayName: "System Updated",
|
|
84
|
+
description: "Fires when a system's name, description, or metadata changes",
|
|
85
|
+
category: "Catalog",
|
|
86
|
+
icon: "Activity",
|
|
87
|
+
payloadSchema: systemUpdatedPayloadSchema,
|
|
88
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
89
|
+
z.infer<typeof systemUpdatedPayloadSchema>
|
|
90
|
+
>(),
|
|
91
|
+
contextKey: (p) => p.systemId,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const systemDeletedTrigger: TriggerDefinition<
|
|
95
|
+
z.infer<typeof systemDeletedPayloadSchema>
|
|
96
|
+
> = {
|
|
97
|
+
id: "deleted",
|
|
98
|
+
displayName: "System Deleted",
|
|
99
|
+
description: "Fires when a system is removed from the catalog",
|
|
100
|
+
category: "Catalog",
|
|
101
|
+
icon: "Activity",
|
|
102
|
+
payloadSchema: systemDeletedPayloadSchema,
|
|
103
|
+
setup: makeEntityDrivenTriggerSetup<
|
|
104
|
+
z.infer<typeof systemDeletedPayloadSchema>
|
|
105
|
+
>(),
|
|
106
|
+
contextKey: (p) => p.systemId,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const catalogTriggers: TriggerDefinition<unknown>[] = [
|
|
110
|
+
systemCreatedTrigger as TriggerDefinition<unknown>,
|
|
111
|
+
systemUpdatedTrigger as TriggerDefinition<unknown>,
|
|
112
|
+
systemDeletedTrigger as TriggerDefinition<unknown>,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// ─── Action: system.update_metadata ────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const systemUpdateMetadataConfigSchema = z.object({
|
|
118
|
+
systemId: z.string().min(1).describe("Target system id"),
|
|
119
|
+
/**
|
|
120
|
+
* Strategy for combining `metadata` with the system's existing
|
|
121
|
+
* metadata object:
|
|
122
|
+
* - `merge` (default): shallow-merge — preserves untouched keys.
|
|
123
|
+
* - `replace`: overwrite the entire metadata object.
|
|
124
|
+
*/
|
|
125
|
+
strategy: z.enum(["merge", "replace"]).default("merge"),
|
|
126
|
+
metadata: z
|
|
127
|
+
.record(z.string(), z.unknown())
|
|
128
|
+
.describe("New metadata key-value pairs"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export type SystemUpdateMetadataConfig = z.infer<
|
|
132
|
+
typeof systemUpdateMetadataConfigSchema
|
|
133
|
+
>;
|
|
134
|
+
|
|
135
|
+
const systemRecordArtifactSchema = z.object({
|
|
136
|
+
systemId: z.string(),
|
|
137
|
+
systemName: z.string(),
|
|
138
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export type SystemRecordArtifact = z.infer<typeof systemRecordArtifactSchema>;
|
|
142
|
+
|
|
143
|
+
export const systemRecordArtifactType = {
|
|
144
|
+
id: "system_record",
|
|
145
|
+
displayName: "System Record",
|
|
146
|
+
description: "Snapshot of a system after an automation-driven change",
|
|
147
|
+
schema: systemRecordArtifactSchema,
|
|
148
|
+
} as const;
|
|
149
|
+
|
|
150
|
+
export interface CatalogActionDeps {
|
|
151
|
+
entityService: EntityService;
|
|
152
|
+
cache: ReturnType<typeof createCatalogCache>;
|
|
153
|
+
/**
|
|
154
|
+
* Resolver for the reactive `catalog-system` entity (§10.4). The
|
|
155
|
+
* `update_metadata` action mirrors its edit here; the entity's change
|
|
156
|
+
* deriver fires the `catalog.updated` trigger event downstream (the old
|
|
157
|
+
* `systemUpdated` hook emission was removed).
|
|
158
|
+
*/
|
|
159
|
+
getSystemEntity?: () => EntityHandle<CatalogSystemState> | undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createCatalogActions(
|
|
163
|
+
deps: CatalogActionDeps,
|
|
164
|
+
): ActionDefinition<unknown, unknown>[] {
|
|
165
|
+
const updateMetadata: ActionDefinition<
|
|
166
|
+
SystemUpdateMetadataConfig,
|
|
167
|
+
SystemRecordArtifact
|
|
168
|
+
> = {
|
|
169
|
+
id: "update_metadata",
|
|
170
|
+
displayName: "Update System Metadata",
|
|
171
|
+
description:
|
|
172
|
+
"Set or merge metadata keys on a system. Useful for cross-plugin state (e.g. flagging a system from an automation).",
|
|
173
|
+
category: "Catalog",
|
|
174
|
+
icon: "FilePenLine",
|
|
175
|
+
config: new Versioned({
|
|
176
|
+
version: 1,
|
|
177
|
+
schema: systemUpdateMetadataConfigSchema,
|
|
178
|
+
}),
|
|
179
|
+
produces: "catalog.system_record",
|
|
180
|
+
execute: async ({ config, logger, runId }) => {
|
|
181
|
+
const existing = await deps.entityService.getSystem(config.systemId);
|
|
182
|
+
if (!existing) {
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
error: `System not found: ${config.systemId}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const existingMetadata =
|
|
190
|
+
(existing.metadata as Record<string, unknown> | null) ?? {};
|
|
191
|
+
const nextMetadata =
|
|
192
|
+
config.strategy === "replace"
|
|
193
|
+
? config.metadata
|
|
194
|
+
: { ...existingMetadata, ...config.metadata };
|
|
195
|
+
|
|
196
|
+
// Drive the update through the reactive `catalog-system` entity (§10.4);
|
|
197
|
+
// the REAL `updateSystem` runs INSIDE `apply`, so `prev` is snapshotted
|
|
198
|
+
// before the write and the deriver fires `catalog.updated`. If the row
|
|
199
|
+
// was race-deleted mid-update, `apply` falls back to the pre-write
|
|
200
|
+
// state so the diff is a no-op (no spurious `catalog.updated`), and the
|
|
201
|
+
// failure is surfaced from the captured `updated`.
|
|
202
|
+
// Capture the post-write row in a holder so TS control-flow tracks the
|
|
203
|
+
// assignment made inside the async `apply` closure (a plain `let`
|
|
204
|
+
// mutated in a closure is invisible to CFA and would narrow to `never`).
|
|
205
|
+
const captured: {
|
|
206
|
+
row: Awaited<ReturnType<typeof deps.entityService.updateSystem>> | null;
|
|
207
|
+
} = { row: null };
|
|
208
|
+
await writeCatalogSystemEntity({
|
|
209
|
+
handle: deps.getSystemEntity?.(),
|
|
210
|
+
systemId: config.systemId,
|
|
211
|
+
// Run-secret masking choke point: this action resolves config
|
|
212
|
+
// (including `metadata` values) against the run scope, which can
|
|
213
|
+
// contain run-resolved secrets. Passing `runId` masks any such secret
|
|
214
|
+
// in the `entity_transitions` rows + the cluster-wide `ENTITY_CHANGED`.
|
|
215
|
+
opts: { runId },
|
|
216
|
+
apply: async () => {
|
|
217
|
+
const row = await deps.entityService.updateSystem(config.systemId, {
|
|
218
|
+
metadata: nextMetadata,
|
|
219
|
+
});
|
|
220
|
+
captured.row = row ?? null;
|
|
221
|
+
return toCatalogSystemState({
|
|
222
|
+
name: row?.name ?? existing.name,
|
|
223
|
+
description: row?.description ?? existing.description,
|
|
224
|
+
metadata: row ? nextMetadata : existingMetadata,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
const updated = captured.row;
|
|
229
|
+
if (!updated) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
error: `System ${config.systemId} disappeared mid-update`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await deps.cache.invalidateTopology();
|
|
237
|
+
|
|
238
|
+
logger.info(
|
|
239
|
+
`Automation updated metadata on system ${updated.id} (${config.strategy})`,
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
success: true,
|
|
243
|
+
externalId: updated.id,
|
|
244
|
+
artifact: {
|
|
245
|
+
systemId: updated.id,
|
|
246
|
+
systemName: updated.name,
|
|
247
|
+
metadata: nextMetadata,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return [updateMetadata as ActionDefinition<unknown, unknown>];
|
|
254
|
+
}
|
|
@@ -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
|
+
});
|