@checkstack/gitops-backend 0.1.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,262 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { z } from "zod";
3
+ import { createEntityKindRegistry } from "./kind-registry";
4
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
5
+
6
+ describe("EntityKindRegistry", () => {
7
+ it("registers a kind and retrieves it", () => {
8
+ const registry = createEntityKindRegistry();
9
+ const specSchema = z.object({ description: z.string().optional() });
10
+
11
+ registry.registerKind({
12
+ apiVersion: CHECKSTACK_API_VERSION,
13
+ kind: "System",
14
+ specSchema,
15
+ reconcile: async () => ({ entityId: "test-id" }),
16
+ });
17
+
18
+ const kind = registry.getKind({
19
+ apiVersion: CHECKSTACK_API_VERSION,
20
+ kind: "System",
21
+ });
22
+ expect(kind).toBeDefined();
23
+ expect(kind?.kind).toBe("System");
24
+ });
25
+
26
+ it("throws on duplicate kind registration", () => {
27
+ const registry = createEntityKindRegistry();
28
+ const def = {
29
+ apiVersion: CHECKSTACK_API_VERSION,
30
+ kind: "System",
31
+ specSchema: z.object({}),
32
+ reconcile: async () => ({ entityId: "test-id" }),
33
+ };
34
+
35
+ registry.registerKind(def);
36
+ expect(() => registry.registerKind(def)).toThrow(/already registered/);
37
+ });
38
+
39
+ it("registers a kind extension", () => {
40
+ const registry = createEntityKindRegistry();
41
+
42
+ registry.registerKind({
43
+ apiVersion: CHECKSTACK_API_VERSION,
44
+ kind: "System",
45
+ specSchema: z.object({ description: z.string().optional() }),
46
+ reconcile: async () => ({ entityId: "test-id" }),
47
+ });
48
+
49
+ registry.registerKindExtension({
50
+ apiVersion: CHECKSTACK_API_VERSION,
51
+ kind: "System",
52
+ namespace: "healthcheck",
53
+ specSchema: z.array(z.object({ ref: z.string() })).optional(),
54
+ reconcile: async () => {},
55
+ });
56
+
57
+ const extensions = registry.getExtensions({
58
+ apiVersion: CHECKSTACK_API_VERSION,
59
+ kind: "System",
60
+ });
61
+ expect(extensions).toHaveLength(1);
62
+ expect(extensions[0].namespace).toBe("healthcheck");
63
+ });
64
+
65
+ it("throws on duplicate extension namespace", () => {
66
+ const registry = createEntityKindRegistry();
67
+
68
+ registry.registerKind({
69
+ apiVersion: CHECKSTACK_API_VERSION,
70
+ kind: "System",
71
+ specSchema: z.object({}),
72
+ reconcile: async () => ({ entityId: "test-id" }),
73
+ });
74
+
75
+ const extDef = {
76
+ apiVersion: CHECKSTACK_API_VERSION,
77
+ kind: "System",
78
+ namespace: "healthcheck",
79
+ specSchema: z.object({}).optional(),
80
+ reconcile: async () => {},
81
+ };
82
+
83
+ registry.registerKindExtension(extDef);
84
+ expect(() => registry.registerKindExtension(extDef)).toThrow(
85
+ /already registered/,
86
+ );
87
+ });
88
+
89
+ it("allows registering extensions before the kind itself", () => {
90
+ const registry = createEntityKindRegistry();
91
+
92
+ // Extension first
93
+ registry.registerKindExtension({
94
+ apiVersion: CHECKSTACK_API_VERSION,
95
+ kind: "System",
96
+ namespace: "healthcheck",
97
+ specSchema: z.array(z.object({ ref: z.string() })).optional(),
98
+ reconcile: async () => {},
99
+ });
100
+
101
+ // Kind after
102
+ registry.registerKind({
103
+ apiVersion: CHECKSTACK_API_VERSION,
104
+ kind: "System",
105
+ specSchema: z.object({ description: z.string().optional() }),
106
+ reconcile: async () => ({ entityId: "test-id" }),
107
+ });
108
+
109
+ const kind = registry.getKind({
110
+ apiVersion: CHECKSTACK_API_VERSION,
111
+ kind: "System",
112
+ });
113
+ expect(kind).toBeDefined();
114
+
115
+ const extensions = registry.getExtensions({
116
+ apiVersion: CHECKSTACK_API_VERSION,
117
+ kind: "System",
118
+ });
119
+ expect(extensions).toHaveLength(1);
120
+ });
121
+
122
+ it("builds a merged spec schema with extensions", () => {
123
+ const registry = createEntityKindRegistry();
124
+
125
+ registry.registerKind({
126
+ apiVersion: CHECKSTACK_API_VERSION,
127
+ kind: "System",
128
+ specSchema: z.object({ description: z.string().optional() }),
129
+ reconcile: async () => ({ entityId: "test-id" }),
130
+ });
131
+
132
+ registry.registerKindExtension({
133
+ apiVersion: CHECKSTACK_API_VERSION,
134
+ kind: "System",
135
+ namespace: "healthcheck",
136
+ specSchema: z.array(z.object({ ref: z.string() })).optional(),
137
+ reconcile: async () => {},
138
+ });
139
+
140
+ const merged = registry.getMergedSpecSchema({
141
+ apiVersion: CHECKSTACK_API_VERSION,
142
+ kind: "System",
143
+ });
144
+
145
+ // Should validate base spec fields
146
+ const result1 = merged.safeParse({ description: "test" });
147
+ expect(result1.success).toBe(true);
148
+
149
+ // Should validate extension namespace fields
150
+ const result2 = merged.safeParse({
151
+ description: "test",
152
+ healthcheck: [{ ref: "my-check" }],
153
+ });
154
+ expect(result2.success).toBe(true);
155
+
156
+ // Extension namespace should be optional
157
+ const result3 = merged.safeParse({});
158
+ expect(result3.success).toBe(true);
159
+ });
160
+
161
+ it("lists all registered kinds", () => {
162
+ const registry = createEntityKindRegistry();
163
+
164
+ registry.registerKind({
165
+ apiVersion: CHECKSTACK_API_VERSION,
166
+ kind: "System",
167
+ specSchema: z.object({}),
168
+ reconcile: async () => ({ entityId: "test-id" }),
169
+ });
170
+
171
+ registry.registerKind({
172
+ apiVersion: CHECKSTACK_API_VERSION,
173
+ kind: "Healthcheck",
174
+ specSchema: z.object({}),
175
+ reconcile: async () => ({ entityId: "test-id" }),
176
+ });
177
+
178
+ expect(registry.getKinds()).toHaveLength(2);
179
+ });
180
+
181
+ it("returns empty array for extensions of unregistered kind", () => {
182
+ const registry = createEntityKindRegistry();
183
+ const extensions = registry.getExtensions({
184
+ apiVersion: CHECKSTACK_API_VERSION,
185
+ kind: "Unknown",
186
+ });
187
+ expect(extensions).toHaveLength(0);
188
+ });
189
+
190
+ describe("describeKinds", () => {
191
+ it("returns JSON Schema representations of registered kinds", () => {
192
+ const registry = createEntityKindRegistry();
193
+
194
+ registry.registerKind({
195
+ apiVersion: CHECKSTACK_API_VERSION,
196
+ kind: "System",
197
+ specSchema: z.object({
198
+ description: z.string().optional(),
199
+ tier: z.enum(["critical", "standard"]),
200
+ }),
201
+ reconcile: async () => ({ entityId: "test-id" }),
202
+ });
203
+
204
+ const described = registry.describeKinds();
205
+ expect(described).toHaveLength(1);
206
+
207
+ const sys = described[0];
208
+ expect(sys.apiVersion).toBe(CHECKSTACK_API_VERSION);
209
+ expect(sys.kind).toBe("System");
210
+ expect(sys.specSchema).toBeDefined();
211
+ expect(sys.extensions).toHaveLength(0);
212
+
213
+ // Verify JSON Schema structure
214
+ const schema = sys.specSchema as Record<string, unknown>;
215
+ expect(schema.type).toBe("object");
216
+ const props = schema.properties as Record<string, Record<string, unknown>>;
217
+ expect(props.description).toBeDefined();
218
+ expect(props.tier).toBeDefined();
219
+ });
220
+
221
+ it("includes extensions in the description", () => {
222
+ const registry = createEntityKindRegistry();
223
+
224
+ registry.registerKind({
225
+ apiVersion: CHECKSTACK_API_VERSION,
226
+ kind: "System",
227
+ specSchema: z.object({ description: z.string().optional() }),
228
+ reconcile: async () => ({ entityId: "test-id" }),
229
+ });
230
+
231
+ registry.registerKindExtension({
232
+ apiVersion: CHECKSTACK_API_VERSION,
233
+ kind: "System",
234
+ namespace: "healthcheck",
235
+ specSchema: z.array(z.object({ ref: z.string() })).optional(),
236
+ reconcile: async () => {},
237
+ });
238
+
239
+ const described = registry.describeKinds();
240
+ expect(described).toHaveLength(1);
241
+ expect(described[0].extensions).toHaveLength(1);
242
+ expect(described[0].extensions[0].namespace).toBe("healthcheck");
243
+ expect(described[0].extensions[0].specSchema).toBeDefined();
244
+ });
245
+
246
+ it("skips kinds without a base definition", () => {
247
+ const registry = createEntityKindRegistry();
248
+
249
+ // Only register an extension — no base kind
250
+ registry.registerKindExtension({
251
+ apiVersion: CHECKSTACK_API_VERSION,
252
+ kind: "Orphaned",
253
+ namespace: "test",
254
+ specSchema: z.object({}).optional(),
255
+ reconcile: async () => {},
256
+ });
257
+
258
+ const described = registry.describeKinds();
259
+ expect(described).toHaveLength(0);
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,191 @@
1
+ import { z } from "zod";
2
+ import { toJsonSchema } from "@checkstack/backend-api";
3
+ import type {
4
+ EntityKindDefinition,
5
+ EntityKindExtensionDefinition,
6
+ EntityKindRegistry,
7
+ } from "@checkstack/gitops-common";
8
+
9
+ /** Internal storage for a registered kind with its extensions. */
10
+ interface RegisteredKind {
11
+ definition: EntityKindDefinition<unknown> | undefined;
12
+ extensions: Map<string, EntityKindExtensionDefinition<unknown>>;
13
+ }
14
+
15
+ /** Composite key for looking up kinds by apiVersion + kind. */
16
+ function kindKey(params: { apiVersion: string; kind: string }): string {
17
+ return `${params.apiVersion}::${params.kind}`;
18
+ }
19
+
20
+ /**
21
+ * Creates a new Entity Kind Registry.
22
+ * This is the backing implementation for the `entityKindExtensionPoint`.
23
+ */
24
+ export function createEntityKindRegistry() {
25
+ const kinds = new Map<string, RegisteredKind>();
26
+
27
+ const registry: EntityKindRegistry & {
28
+ /** Get a registered kind definition. */
29
+ getKind: (params: {
30
+ apiVersion: string;
31
+ kind: string;
32
+ }) => EntityKindDefinition<unknown> | undefined;
33
+ /** Get all extensions for a kind. */
34
+ getExtensions: (params: {
35
+ apiVersion: string;
36
+ kind: string;
37
+ }) => EntityKindExtensionDefinition<unknown>[];
38
+ /** Get the merged spec schema (base + all extensions). */
39
+ getMergedSpecSchema: (params: {
40
+ apiVersion: string;
41
+ kind: string;
42
+ }) => z.ZodType<unknown>;
43
+ /** List all registered kinds. */
44
+ getKinds: () => EntityKindDefinition<unknown>[];
45
+ /** Describe all registered kinds with JSON Schema representations. */
46
+ describeKinds: () => Array<{
47
+ apiVersion: string;
48
+ kind: string;
49
+ specSchema: Record<string, unknown>;
50
+ extensions: Array<{
51
+ namespace: string;
52
+ specSchema: Record<string, unknown>;
53
+ }>;
54
+ }>;
55
+ } = {
56
+ registerKind<TSpec>(definition: EntityKindDefinition<TSpec>) {
57
+ const key = kindKey(definition);
58
+ const existing = kinds.get(key);
59
+
60
+ if (existing?.definition) {
61
+ throw new Error(
62
+ `Entity kind "${definition.kind}" (${definition.apiVersion}) is already registered`,
63
+ );
64
+ }
65
+
66
+ if (existing) {
67
+ // Extensions were registered before the kind — attach the definition
68
+ existing.definition = definition as EntityKindDefinition<unknown>;
69
+ } else {
70
+ kinds.set(key, {
71
+ definition: definition as EntityKindDefinition<unknown>,
72
+ extensions: new Map(),
73
+ });
74
+ }
75
+ },
76
+
77
+ registerKindExtension<TExtensionSpec>(
78
+ definition: EntityKindExtensionDefinition<TExtensionSpec>,
79
+ ) {
80
+ const key = kindKey(definition);
81
+ const registered = kinds.get(key);
82
+
83
+ if (!registered) {
84
+ // Allow registering extensions before the kind itself.
85
+ // The kind might be registered by a plugin that loads later.
86
+ kinds.set(key, {
87
+ definition: undefined,
88
+ extensions: new Map([
89
+ [
90
+ definition.namespace,
91
+ definition as EntityKindExtensionDefinition<unknown>,
92
+ ],
93
+ ]),
94
+ });
95
+ return;
96
+ }
97
+
98
+ if (registered.extensions.has(definition.namespace)) {
99
+ throw new Error(
100
+ `Extension namespace "${definition.namespace}" for kind "${definition.kind}" (${definition.apiVersion}) is already registered`,
101
+ );
102
+ }
103
+
104
+ registered.extensions.set(
105
+ definition.namespace,
106
+ definition as EntityKindExtensionDefinition<unknown>,
107
+ );
108
+ },
109
+
110
+ getKind(params) {
111
+ return kinds.get(kindKey(params))?.definition;
112
+ },
113
+
114
+ getExtensions(params) {
115
+ const registered = kinds.get(kindKey(params));
116
+ if (!registered) return [];
117
+ return [...registered.extensions.values()];
118
+ },
119
+
120
+ getMergedSpecSchema(params) {
121
+ const registered = kinds.get(kindKey(params));
122
+ if (!registered?.definition) {
123
+ throw new Error(
124
+ `Cannot build merged schema: kind "${params.kind}" (${params.apiVersion}) has no base definition`,
125
+ );
126
+ }
127
+
128
+ // Start with the base spec schema
129
+ let merged = registered.definition
130
+ .specSchema as z.ZodObject<z.ZodRawShape>;
131
+
132
+ // Merge each extension's schema under its namespace key
133
+ for (const [namespace, ext] of registered.extensions) {
134
+ merged = merged.extend({
135
+ [namespace]: ext.specSchema,
136
+ }) as z.ZodObject<z.ZodRawShape>;
137
+ }
138
+
139
+ return merged;
140
+ },
141
+
142
+ getKinds() {
143
+ return [...kinds.values()]
144
+ .map((r) => r.definition)
145
+ .filter((d): d is EntityKindDefinition<unknown> => d !== undefined);
146
+ },
147
+
148
+ describeKinds() {
149
+ const result: Array<{
150
+ apiVersion: string;
151
+ kind: string;
152
+ specSchema: Record<string, unknown>;
153
+ extensions: Array<{
154
+ namespace: string;
155
+ specSchema: Record<string, unknown>;
156
+ }>;
157
+ }> = [];
158
+
159
+ for (const registered of kinds.values()) {
160
+ if (!registered.definition) continue;
161
+
162
+ const def = registered.definition;
163
+ const baseSchema = toJsonSchema(
164
+ def.specSchema as z.ZodTypeAny,
165
+ );
166
+
167
+ const extensions = [...registered.extensions.entries()].map(
168
+ ([namespace, ext]) => ({
169
+ namespace,
170
+ specSchema: toJsonSchema(ext.specSchema as z.ZodTypeAny),
171
+ }),
172
+ );
173
+
174
+ result.push({
175
+ apiVersion: def.apiVersion,
176
+ kind: def.kind,
177
+ specSchema: baseSchema,
178
+ extensions,
179
+ });
180
+ }
181
+
182
+ return result;
183
+ },
184
+ };
185
+
186
+ return registry;
187
+ }
188
+
189
+ export type InternalEntityKindRegistry = ReturnType<
190
+ typeof createEntityKindRegistry
191
+ >;