@kindgi/schema 0.0.0-bootstrap.0 → 0.1.1

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,347 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { readFile, readdir } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+
7
+ import type { ValidateFunction } from 'ajv';
8
+ import * as addFormatsModule from 'ajv-formats';
9
+ // Ajv 8's `/dist/2020` entry exports the Ajv2020 class as a named export.
10
+ // The named-import form is stable across ESM/CJS/bundler interop; the default form is not.
11
+ import { Ajv2020 } from 'ajv/dist/2020.js';
12
+
13
+ import type { Result } from '@kindgi/types';
14
+
15
+ import type { SchemaError } from './errors.js';
16
+
17
+ // ajv-formats uses CJS `export = fn` style. In ESM with esModuleInterop, the
18
+ // module namespace holds the function either directly or under `.default`
19
+ // depending on the resolver. Runtime-check to unwrap regardless of shape.
20
+ type AddFormatsFn = (ajv: InstanceType<typeof Ajv2020>, opts?: unknown) => unknown;
21
+ const addFormatsRaw = addFormatsModule as unknown;
22
+ const addFormats: AddFormatsFn =
23
+ typeof addFormatsRaw === 'function'
24
+ ? (addFormatsRaw as AddFormatsFn)
25
+ : (addFormatsRaw as { default: AddFormatsFn }).default;
26
+
27
+ /**
28
+ * A registry of loaded and compiled JSON Schemas, keyed by `$id`.
29
+ *
30
+ * The registry pre-compiles a validator for every schema at construction time,
31
+ * so `validate()` is a hot-path operation with no per-call compilation cost.
32
+ * Cross-schema `$ref`s (e.g. pack manifest → tool schema) are resolved
33
+ * automatically because all schemas are registered before compilation.
34
+ */
35
+ export interface SpecRegistry {
36
+ /**
37
+ * Validate `data` against the schema with the given `$id`.
38
+ *
39
+ * The generic `T` is a caller-asserted type — the runtime does not verify
40
+ * it beyond schema conformance. Consumers pair the `$id` with a manually
41
+ * maintained TypeScript type (or, later, a codegen'd one).
42
+ *
43
+ * @example
44
+ * const r = registry.validate<Flow>('https://kindgi.com/schemas/v1/flow.schema.json', input);
45
+ * if (r.kind === 'ok') { runOnGraph(r.value); }
46
+ */
47
+ validate<T = unknown>(id: string, data: unknown): Result<T, SchemaError>;
48
+
49
+ /** Retrieve the raw parsed schema JSON registered at `$id`. */
50
+ getSchema(id: string): Result<unknown, SchemaError>;
51
+
52
+ /** Return every registered schema `$id` in insertion order. */
53
+ ids(): readonly string[];
54
+ }
55
+
56
+ interface RegistryState {
57
+ readonly schemas: ReadonlyMap<string, unknown>;
58
+ readonly validators: ReadonlyMap<string, ValidateFunction>;
59
+ }
60
+
61
+ function notFound(id: string): SchemaError {
62
+ return {
63
+ code: 'schema-not-found',
64
+ message: `No schema registered with $id: ${id}`,
65
+ id,
66
+ };
67
+ }
68
+
69
+ function makeRegistry(state: RegistryState): SpecRegistry {
70
+ return {
71
+ validate<T = unknown>(id: string, data: unknown): Result<T, SchemaError> {
72
+ const validator = state.validators.get(id);
73
+ if (validator === undefined) {
74
+ return { kind: 'err', error: notFound(id) };
75
+ }
76
+ if (validator(data)) {
77
+ return { kind: 'ok', value: data as T };
78
+ }
79
+ return {
80
+ kind: 'err',
81
+ error: {
82
+ code: 'validation-error',
83
+ message: `Data does not validate against ${id}`,
84
+ id,
85
+ errors: validator.errors ?? [],
86
+ },
87
+ };
88
+ },
89
+ getSchema(id): Result<unknown, SchemaError> {
90
+ const schema = state.schemas.get(id);
91
+ if (schema === undefined) {
92
+ return { kind: 'err', error: notFound(id) };
93
+ }
94
+ return { kind: 'ok', value: schema };
95
+ },
96
+ ids(): readonly string[] {
97
+ return Array.from(state.schemas.keys());
98
+ },
99
+ };
100
+ }
101
+
102
+ function extractId(schema: unknown): Result<string, SchemaError> {
103
+ if (typeof schema !== 'object' || schema === null || !('$id' in schema)) {
104
+ return {
105
+ kind: 'err',
106
+ error: {
107
+ code: 'schema-compile-error',
108
+ message: 'Schema is missing top-level $id',
109
+ id: '<unknown>',
110
+ cause: schema,
111
+ },
112
+ };
113
+ }
114
+ const id = (schema as { $id: unknown }).$id;
115
+ if (typeof id !== 'string' || id.length === 0) {
116
+ return {
117
+ kind: 'err',
118
+ error: {
119
+ code: 'schema-compile-error',
120
+ message: 'Schema $id is not a non-empty string',
121
+ id: String(id),
122
+ cause: schema,
123
+ },
124
+ };
125
+ }
126
+ return { kind: 'ok', value: id };
127
+ }
128
+
129
+ /**
130
+ * Build a SpecRegistry from an array of already-parsed schema documents.
131
+ *
132
+ * All schemas are registered with the Ajv instance FIRST (so cross-schema
133
+ * `$ref`s can resolve), then validators are compiled and cached.
134
+ *
135
+ * Returns `err` on the first failure — this is fail-fast on purpose. If a
136
+ * spec is broken, downstream validators are meaningless.
137
+ */
138
+ export function createSpecRegistry(schemas: readonly unknown[]): Result<SpecRegistry, SchemaError> {
139
+ const ajv = new Ajv2020({
140
+ strict: true,
141
+ allErrors: true,
142
+ allowUnionTypes: false,
143
+ });
144
+ addFormats(ajv);
145
+
146
+ const schemaMap = new Map<string, unknown>();
147
+
148
+ // Pass 1: register every schema so $refs can resolve during compilation.
149
+ for (const schema of schemas) {
150
+ const idResult = extractId(schema);
151
+ if (idResult.kind === 'err') {
152
+ return idResult;
153
+ }
154
+ const id = idResult.value;
155
+ try {
156
+ ajv.addSchema(schema as object, id);
157
+ schemaMap.set(id, schema);
158
+ } catch (cause) {
159
+ return {
160
+ kind: 'err',
161
+ error: {
162
+ code: 'schema-compile-error',
163
+ message: `Failed to register schema with $id ${id}`,
164
+ id,
165
+ cause,
166
+ },
167
+ };
168
+ }
169
+ }
170
+
171
+ // Pass 2: compile validators, now that cross-refs resolve.
172
+ const validators = new Map<string, ValidateFunction>();
173
+ for (const id of schemaMap.keys()) {
174
+ let validator: ValidateFunction | undefined;
175
+ try {
176
+ validator = ajv.getSchema(id);
177
+ } catch (cause) {
178
+ return {
179
+ kind: 'err',
180
+ error: {
181
+ code: 'schema-compile-error',
182
+ message: `Failed to compile validator for $id ${id}`,
183
+ id,
184
+ cause,
185
+ },
186
+ };
187
+ }
188
+ if (validator === undefined) {
189
+ return {
190
+ kind: 'err',
191
+ error: {
192
+ code: 'schema-compile-error',
193
+ message: `Ajv did not return a validator for $id ${id}`,
194
+ id,
195
+ cause: null,
196
+ },
197
+ };
198
+ }
199
+ validators.set(id, validator);
200
+ }
201
+
202
+ return { kind: 'ok', value: makeRegistry({ schemas: schemaMap, validators }) };
203
+ }
204
+
205
+ /**
206
+ * A compiled inline JSON Schema. Unlike `SpecRegistry`, which stores schemas
207
+ * addressed by `$id`, this represents a single anonymous schema (e.g. a loop
208
+ * node's `outputSchema`) whose validator has already been compiled.
209
+ *
210
+ * Callers compile once (at flow load time) and validate many times (each
211
+ * loop iteration).
212
+ */
213
+ export interface CompiledInlineSchema {
214
+ /**
215
+ * Validate `data`. Returns the original value on success (typed as `T`
216
+ * for caller convenience — the runtime does not verify the T assertion
217
+ * beyond schema conformance).
218
+ */
219
+ validate<T = unknown>(data: unknown): Result<T, ValidationErrorLike>;
220
+ /** The raw schema JSON, for round-tripping or debugging. */
221
+ readonly source: unknown;
222
+ }
223
+
224
+ /**
225
+ * Structural shape of a validation failure, decoupled from the `SchemaError`
226
+ * discriminated union (which requires an `id`). Consumers that want to
227
+ * surface an inline-schema validation failure typically wrap this in their
228
+ * own domain error.
229
+ */
230
+ export interface ValidationErrorLike {
231
+ readonly code: 'validation-error';
232
+ readonly message: string;
233
+ readonly errors: readonly unknown[];
234
+ }
235
+
236
+ /**
237
+ * Compile a single anonymous JSON Schema (draft 2020-12) into a reusable
238
+ * validator. Returns `err` if the schema itself is malformed (Ajv rejects
239
+ * it), which is how the flow loader surfaces `LoopOutputSchemaError` and
240
+ * similar load-time diagnostics.
241
+ *
242
+ * Design note: this is intentionally decoupled from `SpecRegistry` — inline
243
+ * schemas don't have a `$id` and don't need cross-schema `$ref` resolution.
244
+ */
245
+ export function compileInlineSchema(schema: unknown): Result<CompiledInlineSchema, SchemaError> {
246
+ const ajv = new Ajv2020({
247
+ strict: true,
248
+ allErrors: true,
249
+ allowUnionTypes: false,
250
+ });
251
+ addFormats(ajv);
252
+
253
+ let validator: ValidateFunction;
254
+ try {
255
+ validator = ajv.compile(schema as object);
256
+ } catch (cause) {
257
+ return {
258
+ kind: 'err',
259
+ error: {
260
+ code: 'schema-compile-error',
261
+ message: `Inline schema failed to compile: ${(cause as Error)?.message ?? String(cause)}`,
262
+ id: '<inline>',
263
+ cause,
264
+ },
265
+ };
266
+ }
267
+
268
+ return {
269
+ kind: 'ok',
270
+ value: {
271
+ source: schema,
272
+ validate<T = unknown>(data: unknown): Result<T, ValidationErrorLike> {
273
+ if (validator(data)) {
274
+ return { kind: 'ok', value: data as T };
275
+ }
276
+ return {
277
+ kind: 'err',
278
+ error: {
279
+ code: 'validation-error',
280
+ message: 'Data does not validate against inline schema',
281
+ errors: validator.errors ?? [],
282
+ },
283
+ };
284
+ },
285
+ },
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Load a SpecRegistry from a directory of `*.schema.json` files.
291
+ *
292
+ * Reads every `*.schema.json` in the directory (non-recursive), parses each,
293
+ * and delegates to `createSpecRegistry`. Files are loaded in sorted order for
294
+ * deterministic behavior across environments.
295
+ */
296
+ export async function loadSpecRegistry(dir: string): Promise<Result<SpecRegistry, SchemaError>> {
297
+ let entries: readonly string[];
298
+ try {
299
+ entries = await readdir(dir);
300
+ } catch (cause) {
301
+ return {
302
+ kind: 'err',
303
+ error: {
304
+ code: 'schema-load-error',
305
+ message: `Cannot read specs directory: ${dir}`,
306
+ path: dir,
307
+ cause,
308
+ },
309
+ };
310
+ }
311
+
312
+ const files = entries.filter((n) => n.endsWith('.schema.json')).sort();
313
+ const schemas: unknown[] = [];
314
+
315
+ for (const file of files) {
316
+ const fullPath = join(dir, file);
317
+ let raw: string;
318
+ try {
319
+ raw = await readFile(fullPath, 'utf-8');
320
+ } catch (cause) {
321
+ return {
322
+ kind: 'err',
323
+ error: {
324
+ code: 'schema-load-error',
325
+ message: `Cannot read schema file: ${fullPath}`,
326
+ path: fullPath,
327
+ cause,
328
+ },
329
+ };
330
+ }
331
+ try {
332
+ schemas.push(JSON.parse(raw));
333
+ } catch (cause) {
334
+ return {
335
+ kind: 'err',
336
+ error: {
337
+ code: 'schema-parse-error',
338
+ message: `Invalid JSON in schema file: ${fullPath}`,
339
+ path: fullPath,
340
+ cause,
341
+ },
342
+ };
343
+ }
344
+ }
345
+
346
+ return createSpecRegistry(schemas);
347
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { describe, expect, test } from 'vitest';
5
+
6
+ import { versionOf } from './version.js';
7
+
8
+ const validSchema = {
9
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
10
+ $id: 'https://kindgi.com/schemas/v1/flow.schema.json',
11
+ $comment: 'schema-version: 1.0.0',
12
+ title: 'Flow',
13
+ type: 'object',
14
+ };
15
+
16
+ describe('versionOf', () => {
17
+ test('extracts major from $id and semver from $comment for a valid schema', () => {
18
+ const result = versionOf(validSchema);
19
+ expect(result.kind).toBe('ok');
20
+ if (result.kind === 'ok') {
21
+ expect(result.value.major).toBe(1);
22
+ expect(result.value.semver).toBe('1.0.0');
23
+ }
24
+ });
25
+
26
+ test('supports semver prerelease strings in $comment', () => {
27
+ const schema = { ...validSchema, $comment: 'schema-version: 2.1.0-rc.3' };
28
+ const result = versionOf(schema);
29
+ expect(result.kind).toBe('ok');
30
+ if (result.kind === 'ok') {
31
+ expect(result.value.semver).toBe('2.1.0-rc.3');
32
+ }
33
+ });
34
+
35
+ test('supports higher major versions', () => {
36
+ const schema = {
37
+ ...validSchema,
38
+ $id: 'https://kindgi.com/schemas/v42/flow.schema.json',
39
+ $comment: 'schema-version: 42.0.0',
40
+ };
41
+ const result = versionOf(schema);
42
+ expect(result.kind).toBe('ok');
43
+ if (result.kind === 'ok') {
44
+ expect(result.value.major).toBe(42);
45
+ }
46
+ });
47
+
48
+ test('errors when schema is not an object', () => {
49
+ const result = versionOf('not-a-schema');
50
+ expect(result.kind).toBe('err');
51
+ if (result.kind === 'err') {
52
+ expect(result.error.code).toBe('schema-compile-error');
53
+ expect(result.error.message).toContain('not an object');
54
+ }
55
+ });
56
+
57
+ test('errors when schema is null', () => {
58
+ const result = versionOf(null);
59
+ expect(result.kind).toBe('err');
60
+ });
61
+
62
+ test('errors when $id is missing', () => {
63
+ const { $id: _unused, ...rest } = validSchema;
64
+ const result = versionOf(rest);
65
+ expect(result.kind).toBe('err');
66
+ if (result.kind === 'err') {
67
+ expect(result.error.code).toBe('schema-compile-error');
68
+ expect(result.error.message).toContain('$id');
69
+ }
70
+ });
71
+
72
+ test('errors when $id does not match the versioned URL convention', () => {
73
+ const schema = { ...validSchema, $id: 'https://example.com/flow.json' };
74
+ const result = versionOf(schema);
75
+ expect(result.kind).toBe('err');
76
+ if (result.kind === 'err') {
77
+ expect(result.error.code).toBe('schema-compile-error');
78
+ expect(result.error.message).toContain('does not match');
79
+ }
80
+ });
81
+
82
+ test('errors when $id has no version segment', () => {
83
+ const schema = {
84
+ ...validSchema,
85
+ $id: 'https://kindgi.com/schemas/flow.schema.json',
86
+ };
87
+ const result = versionOf(schema);
88
+ expect(result.kind).toBe('err');
89
+ });
90
+
91
+ test('errors when $comment is missing', () => {
92
+ const { $comment: _unused, ...rest } = validSchema;
93
+ const result = versionOf(rest);
94
+ expect(result.kind).toBe('err');
95
+ if (result.kind === 'err') {
96
+ expect(result.error.message).toContain('$comment');
97
+ }
98
+ });
99
+
100
+ test('errors when $comment does not match the schema-version format', () => {
101
+ const schema = { ...validSchema, $comment: 'just some comment' };
102
+ const result = versionOf(schema);
103
+ expect(result.kind).toBe('err');
104
+ if (result.kind === 'err') {
105
+ expect(result.error.message).toContain('schema-version');
106
+ }
107
+ });
108
+
109
+ test('errors when $comment has an invalid semver', () => {
110
+ const schema = { ...validSchema, $comment: 'schema-version: 1.0' };
111
+ const result = versionOf(schema);
112
+ expect(result.kind).toBe('err');
113
+ });
114
+ });
package/src/version.ts ADDED
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import type { Result, SchemaMajor, Semver } from '@kindgi/types';
5
+
6
+ import type { SchemaError } from './errors.js';
7
+
8
+ /** Parsed version information from a schema's `$id` and `$comment`. */
9
+ export interface SchemaVersion {
10
+ /** Major version, parsed from the `$id` URL path segment (e.g. `v1` → 1). */
11
+ readonly major: SchemaMajor;
12
+ /** Full semver, parsed from `$comment: "schema-version: X.Y.Z"`. */
13
+ readonly semver: Semver;
14
+ }
15
+
16
+ const ID_PATTERN = /^https:\/\/kindgi\.com\/schemas\/v(\d+)\/([a-z][a-z0-9-]*)\.schema\.json$/;
17
+ const COMMENT_PATTERN = /^schema-version: (\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/;
18
+
19
+ function compileError(id: string, message: string, cause: unknown = null): SchemaError {
20
+ return { code: 'schema-compile-error', message, id, cause };
21
+ }
22
+
23
+ /**
24
+ * Extract the schema version from a schema document.
25
+ *
26
+ * Requires BOTH:
27
+ * - A `$id` matching `https://kindgi.com/schemas/v<N>/<name>.schema.json`.
28
+ * - A `$comment: "schema-version: X.Y.Z"` line (semver, with optional prerelease).
29
+ *
30
+ * The major version comes from `$id`; the full semver comes from `$comment`.
31
+ * A mismatch between them (e.g. `$id` says `v2` but comment says `1.0.0`) is
32
+ * NOT detected here — that's a separate lint responsibility.
33
+ *
34
+ * @example
35
+ * // SPECS_SCHEMAS_DIR from '@kindgi/specs'
36
+ * const schema = JSON.parse(await readFile(join(SPECS_SCHEMAS_DIR, 'flow.schema.json'), 'utf-8'));
37
+ * const result = versionOf(schema);
38
+ * if (result.kind === 'ok') {
39
+ * console.log(result.value.major, result.value.semver);
40
+ * }
41
+ */
42
+ export function versionOf(schema: unknown): Result<SchemaVersion, SchemaError> {
43
+ if (typeof schema !== 'object' || schema === null) {
44
+ return { kind: 'err', error: compileError('<unknown>', 'Schema is not an object', schema) };
45
+ }
46
+ const s = schema as { $id?: unknown; $comment?: unknown };
47
+
48
+ if (typeof s.$id !== 'string' || s.$id.length === 0) {
49
+ return { kind: 'err', error: compileError('<unknown>', 'Schema $id is missing or empty') };
50
+ }
51
+ const idMatch = ID_PATTERN.exec(s.$id);
52
+ if (idMatch === null) {
53
+ return {
54
+ kind: 'err',
55
+ error: compileError(
56
+ s.$id,
57
+ `Schema $id does not match https://kindgi.com/schemas/v<N>/<name>.schema.json: ${s.$id}`,
58
+ ),
59
+ };
60
+ }
61
+ const majorStr = idMatch[1];
62
+ if (majorStr === undefined) {
63
+ // Regex guarantees the capture, but TS narrowing requires the check.
64
+ return { kind: 'err', error: compileError(s.$id, 'Unreachable: $id regex captured no major') };
65
+ }
66
+ const major = Number(majorStr) as SchemaMajor;
67
+
68
+ if (typeof s.$comment !== 'string') {
69
+ return {
70
+ kind: 'err',
71
+ error: compileError(s.$id, 'Schema $comment is missing (expected `schema-version: X.Y.Z`)'),
72
+ };
73
+ }
74
+ const commentMatch = COMMENT_PATTERN.exec(s.$comment);
75
+ if (commentMatch === null) {
76
+ return {
77
+ kind: 'err',
78
+ error: compileError(
79
+ s.$id,
80
+ `Schema $comment does not match \`schema-version: X.Y.Z\`: ${s.$comment}`,
81
+ ),
82
+ };
83
+ }
84
+ const semverStr = commentMatch[1];
85
+ if (semverStr === undefined) {
86
+ return {
87
+ kind: 'err',
88
+ error: compileError(s.$id, 'Unreachable: $comment regex captured no semver'),
89
+ };
90
+ }
91
+
92
+ return { kind: 'ok', value: { major, semver: semverStr as Semver } };
93
+ }