@ontrails/schema 1.0.0-beta.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,252 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import { trail, hike, event, topo, Result } from '@ontrails/core';
4
+ import type { Topo } from '@ontrails/core';
5
+ import { z } from 'zod';
6
+
7
+ import { generateSurfaceMap } from '../generate.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const topoFrom = (...modules: Record<string, unknown>[]): Topo =>
14
+ topo('test-app', ...modules);
15
+
16
+ const noop = () => Result.ok(null as unknown);
17
+
18
+ const getFirstEntry = (map: ReturnType<typeof generateSurfaceMap>) => {
19
+ const [entry] = map.entries;
20
+ expect(entry).toBeDefined();
21
+ if (!entry) {
22
+ throw new Error('Expected surface map entry');
23
+ }
24
+ return entry;
25
+ };
26
+
27
+ const expectSchemaProperties = (
28
+ schema: unknown,
29
+ properties: Record<string, unknown>
30
+ ) => {
31
+ expect(schema).toEqual(
32
+ expect.objectContaining({
33
+ properties: expect.objectContaining(properties),
34
+ type: 'object',
35
+ })
36
+ );
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tests
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('generateSurfaceMap', () => {
44
+ describe('entries', () => {
45
+ test('produces entries for all trails in the topo', () => {
46
+ const a = trail('a.create', {
47
+ implementation: noop,
48
+ input: z.object({ name: z.string() }),
49
+ });
50
+ const b = trail('b.list', {
51
+ implementation: noop,
52
+ input: z.object({}),
53
+ });
54
+ const tp = topoFrom({ a, b });
55
+ const map = generateSurfaceMap(tp);
56
+
57
+ expect(map.entries).toHaveLength(2);
58
+ expect(map.entries.map((e) => e.id)).toEqual(['a.create', 'b.list']);
59
+ });
60
+
61
+ test('entries are sorted alphabetically by id', () => {
62
+ const z2 = trail('z.trail', {
63
+ implementation: noop,
64
+ input: z.object({}),
65
+ });
66
+ const a2 = trail('a.trail', {
67
+ implementation: noop,
68
+ input: z.object({}),
69
+ });
70
+ const m2 = trail('m.trail', {
71
+ implementation: noop,
72
+ input: z.object({}),
73
+ });
74
+ const tp = topoFrom({ a2, m2, z2 });
75
+ const map = generateSurfaceMap(tp);
76
+
77
+ expect(map.entries.map((e) => e.id)).toEqual([
78
+ 'a.trail',
79
+ 'm.trail',
80
+ 'z.trail',
81
+ ]);
82
+ });
83
+
84
+ test('trail with input/output schemas produces valid JSON Schema entries', () => {
85
+ const t = trail('entity.create', {
86
+ implementation: noop,
87
+ input: z.object({ age: z.number(), name: z.string() }),
88
+ output: z.object({ id: z.string(), name: z.string() }),
89
+ });
90
+ const map = generateSurfaceMap(topoFrom({ t }));
91
+ const entry = getFirstEntry(map);
92
+
93
+ expect(entry.input).toBeDefined();
94
+ expect(entry.output).toBeDefined();
95
+ expectSchemaProperties(entry.input, {
96
+ age: { type: 'number' },
97
+ name: { type: 'string' },
98
+ });
99
+ expectSchemaProperties(entry.output, {
100
+ id: { type: 'string' },
101
+ name: { type: 'string' },
102
+ });
103
+ });
104
+
105
+ test('trail without output schema has output undefined', () => {
106
+ const t = trail('fire.forget', {
107
+ implementation: noop,
108
+ input: z.object({ msg: z.string() }),
109
+ });
110
+ const tp = topoFrom({ t });
111
+ const map = generateSurfaceMap(tp);
112
+
113
+ expect(map.entries[0]?.output).toBeUndefined();
114
+ });
115
+
116
+ test('hike entries include follows array', () => {
117
+ const base = trail('user.get', {
118
+ implementation: noop,
119
+ input: z.object({ id: z.string() }),
120
+ });
121
+ const r = hike('user.update', {
122
+ follows: ['user.get'],
123
+ implementation: noop,
124
+ input: z.object({ id: z.string(), name: z.string() }),
125
+ });
126
+ const tp = topoFrom({ base, r });
127
+ const map = generateSurfaceMap(tp);
128
+ const hikeEntry = map.entries.find((e) => e.id === 'user.update');
129
+ expect(hikeEntry).toBeDefined();
130
+
131
+ expect(hikeEntry?.kind).toBe('hike');
132
+ expect(hikeEntry?.follows).toEqual(['user.get']);
133
+ });
134
+
135
+ test("event entries are included with kind 'event'", () => {
136
+ const e = event('user.created', {
137
+ description: 'A user was created',
138
+ payload: z.object({ userId: z.string() }),
139
+ });
140
+ const entry = getFirstEntry(generateSurfaceMap(topoFrom({ e })));
141
+
142
+ expect(entry.kind).toBe('event');
143
+ expect(entry.id).toBe('user.created');
144
+ expect(entry.description).toBe('A user was created');
145
+ expect(entry.input).toBeDefined();
146
+ });
147
+ });
148
+
149
+ describe('metadata', () => {
150
+ test('safety markers are included when set', () => {
151
+ const t = trail('safe.trail', {
152
+ destructive: false,
153
+ idempotent: true,
154
+ implementation: noop,
155
+ input: z.object({}),
156
+ readOnly: true,
157
+ });
158
+ const entry = getFirstEntry(generateSurfaceMap(topoFrom({ t })));
159
+
160
+ expect(entry.readOnly).toBe(true);
161
+ expect(entry.destructive).toBeUndefined();
162
+ expect(entry.idempotent).toBe(true);
163
+ });
164
+
165
+ test('exampleCount reflects the number of examples', () => {
166
+ const t = trail('with.examples', {
167
+ examples: [
168
+ { expected: { y: 2 }, input: { x: 1 }, name: 'basic' },
169
+ { expected: { y: 0 }, input: { x: 0 }, name: 'zero' },
170
+ { expected: { y: -2 }, input: { x: -1 }, name: 'negative' },
171
+ ],
172
+ implementation: noop,
173
+ input: z.object({ x: z.number() }),
174
+ output: z.object({ y: z.number() }),
175
+ });
176
+ const tp = topoFrom({ t });
177
+ const map = generateSurfaceMap(tp);
178
+
179
+ expect(map.entries[0]?.exampleCount).toBe(3);
180
+ });
181
+
182
+ test('detours are included and sorted', () => {
183
+ const t = trail('with.detours', {
184
+ detours: {
185
+ onError: ['notify.admin', 'log.error'],
186
+ onSuccess: ['cache.invalidate'],
187
+ },
188
+ implementation: noop,
189
+ input: z.object({}),
190
+ });
191
+ const entry = getFirstEntry(generateSurfaceMap(topoFrom({ t })));
192
+
193
+ expect(entry.detours).toEqual({
194
+ onError: ['log.error', 'notify.admin'],
195
+ onSuccess: ['cache.invalidate'],
196
+ });
197
+ });
198
+
199
+ test('description is included when present', () => {
200
+ const t = trail('described', {
201
+ description: 'A described trail',
202
+ implementation: noop,
203
+ input: z.object({}),
204
+ });
205
+ const tp = topoFrom({ t });
206
+ const map = generateSurfaceMap(tp);
207
+
208
+ expect(map.entries[0]?.description).toBe('A described trail');
209
+ });
210
+ });
211
+
212
+ describe('stability', () => {
213
+ test('determinism: same topo produces identical output', () => {
214
+ const t = trail('stable', {
215
+ description: 'Stable trail',
216
+ implementation: noop,
217
+ input: z.object({ a: z.string(), b: z.number() }),
218
+ output: z.object({ c: z.boolean() }),
219
+ readOnly: true,
220
+ });
221
+ const tp = topoFrom({ t });
222
+
223
+ const map1 = generateSurfaceMap(tp);
224
+ const map2 = generateSurfaceMap(tp);
225
+
226
+ expect(map1.entries).toEqual(map2.entries);
227
+ expect(map1.version).toBe(map2.version);
228
+ });
229
+
230
+ test('version is set to 1.0', () => {
231
+ const t = trail('v.check', {
232
+ implementation: noop,
233
+ input: z.object({}),
234
+ });
235
+ const tp = topoFrom({ t });
236
+ const map = generateSurfaceMap(tp);
237
+
238
+ expect(map.version).toBe('1.0');
239
+ });
240
+
241
+ test('generatedAt is an ISO timestamp', () => {
242
+ const t = trail('ts.check', {
243
+ implementation: noop,
244
+ input: z.object({}),
245
+ });
246
+ const tp = topoFrom({ t });
247
+ const map = generateSurfaceMap(tp);
248
+
249
+ expect(new Date(map.generatedAt).toISOString()).toBe(map.generatedAt);
250
+ });
251
+ });
252
+ });
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+
3
+ import { hashSurfaceMap } from '../hash.js';
4
+ import type { SurfaceMap } from '../types.js';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const makeSurfaceMap = (overrides?: Partial<SurfaceMap>): SurfaceMap => ({
11
+ entries: [
12
+ {
13
+ exampleCount: 2,
14
+ id: 'user.create',
15
+ input: {
16
+ properties: { name: { type: 'string' } },
17
+ required: ['name'],
18
+ type: 'object',
19
+ },
20
+ kind: 'trail',
21
+ output: {
22
+ properties: { id: { type: 'string' } },
23
+ required: ['id'],
24
+ type: 'object',
25
+ },
26
+ surfaces: ['cli', 'mcp'],
27
+ },
28
+ ],
29
+ generatedAt: '2025-01-01T00:00:00.000Z',
30
+ version: '1.0',
31
+ ...overrides,
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Tests
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('hashSurfaceMap', () => {
39
+ test('produces a valid SHA-256 hex string (64 characters)', () => {
40
+ const hash = hashSurfaceMap(makeSurfaceMap());
41
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
42
+ });
43
+
44
+ test('same surface map produces the same hash (deterministic)', () => {
45
+ const map = makeSurfaceMap();
46
+ const hash1 = hashSurfaceMap(map);
47
+ const hash2 = hashSurfaceMap(map);
48
+ expect(hash1).toBe(hash2);
49
+ });
50
+
51
+ test('different surface maps produce different hashes', () => {
52
+ const map1 = makeSurfaceMap();
53
+ const map2 = makeSurfaceMap({
54
+ entries: [
55
+ {
56
+ exampleCount: 0,
57
+ id: 'user.delete',
58
+ input: { type: 'object' },
59
+ kind: 'trail',
60
+ surfaces: [],
61
+ },
62
+ ],
63
+ });
64
+
65
+ expect(hashSurfaceMap(map1)).not.toBe(hashSurfaceMap(map2));
66
+ });
67
+
68
+ test('generatedAt does not affect the hash', () => {
69
+ const map1 = makeSurfaceMap({ generatedAt: '2025-01-01T00:00:00.000Z' });
70
+ const map2 = makeSurfaceMap({ generatedAt: '2099-12-31T23:59:59.999Z' });
71
+
72
+ expect(hashSurfaceMap(map1)).toBe(hashSurfaceMap(map2));
73
+ });
74
+
75
+ test('hash is stable across invocations', () => {
76
+ const map = makeSurfaceMap();
77
+ const hashes = Array.from({ length: 10 }, () => hashSurfaceMap(map));
78
+ const unique = new Set(hashes);
79
+ expect(unique.size).toBe(1);
80
+ });
81
+
82
+ test('key order in entry does not affect hash', () => {
83
+ // Build two maps with same data but different insertion order
84
+ const map1 = makeSurfaceMap({
85
+ entries: [
86
+ {
87
+ exampleCount: 0,
88
+ id: 'test',
89
+ input: {
90
+ properties: { a: { type: 'string' }, b: { type: 'number' } },
91
+ type: 'object',
92
+ },
93
+ kind: 'trail',
94
+ surfaces: [],
95
+ },
96
+ ],
97
+ });
98
+ const map2 = makeSurfaceMap({
99
+ entries: [
100
+ {
101
+ exampleCount: 0,
102
+ id: 'test',
103
+ input: {
104
+ properties: { a: { type: 'string' }, b: { type: 'number' } },
105
+ type: 'object',
106
+ },
107
+ kind: 'trail',
108
+ surfaces: [],
109
+ },
110
+ ],
111
+ });
112
+
113
+ expect(hashSurfaceMap(map1)).toBe(hashSurfaceMap(map2));
114
+ });
115
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdtemp, rm, readFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import {
7
+ writeSurfaceMap,
8
+ readSurfaceMap,
9
+ writeSurfaceLock,
10
+ readSurfaceLock,
11
+ } from '../io.js';
12
+ import type { SurfaceMap } from '../types.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Fixtures
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const makeSurfaceMap = (): SurfaceMap => ({
19
+ entries: [
20
+ {
21
+ description: 'Create a user',
22
+ exampleCount: 1,
23
+ id: 'user.create',
24
+ input: { properties: { name: { type: 'string' } }, type: 'object' },
25
+ kind: 'trail',
26
+ surfaces: ['cli'],
27
+ },
28
+ ],
29
+ generatedAt: '2025-01-01T00:00:00.000Z',
30
+ version: '1.0',
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Setup / Teardown
35
+ // ---------------------------------------------------------------------------
36
+
37
+ let tempDir: string;
38
+
39
+ beforeEach(async () => {
40
+ tempDir = await mkdtemp(join(tmpdir(), 'trails-schema-test-'));
41
+ });
42
+
43
+ afterEach(async () => {
44
+ await rm(tempDir, { force: true, recursive: true });
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Surface Map tests
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('writeSurfaceMap / readSurfaceMap', () => {
52
+ test('writes valid JSON to _surface.json', async () => {
53
+ const map = makeSurfaceMap();
54
+ const filePath = await writeSurfaceMap(map, { dir: tempDir });
55
+
56
+ expect(filePath).toBe(join(tempDir, '_surface.json'));
57
+
58
+ const content = await readFile(filePath, 'utf8');
59
+ const parsed = JSON.parse(content);
60
+ expect(parsed.version).toBe('1.0');
61
+ expect(parsed.entries).toHaveLength(1);
62
+ });
63
+
64
+ test('reads it back and produces identical data', async () => {
65
+ const map = makeSurfaceMap();
66
+ await writeSurfaceMap(map, { dir: tempDir });
67
+ const result = await readSurfaceMap({ dir: tempDir });
68
+
69
+ expect(result).toEqual(map);
70
+ });
71
+
72
+ test('returns null for missing file', async () => {
73
+ const result = await readSurfaceMap({ dir: join(tempDir, 'nonexistent') });
74
+ expect(result).toBeNull();
75
+ });
76
+ });
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Surface Lock tests
80
+ // ---------------------------------------------------------------------------
81
+
82
+ describe('writeSurfaceLock / readSurfaceLock', () => {
83
+ test('writes a single line with the hash', async () => {
84
+ const hash = 'abc123def456'.repeat(4);
85
+ const filePath = await writeSurfaceLock(hash, { dir: tempDir });
86
+
87
+ expect(filePath).toBe(join(tempDir, 'surface.lock'));
88
+
89
+ const content = await readFile(filePath, 'utf8');
90
+ expect(content.trim()).toBe(hash);
91
+ // Single line (content is hash + newline)
92
+ expect(content).toBe(`${hash}\n`);
93
+ });
94
+
95
+ test('reads the hash back', async () => {
96
+ const hash = 'deadbeef'.repeat(8);
97
+ await writeSurfaceLock(hash, { dir: tempDir });
98
+ const result = await readSurfaceLock({ dir: tempDir });
99
+
100
+ expect(result).toBe(hash);
101
+ });
102
+
103
+ test('returns null for missing file', async () => {
104
+ const result = await readSurfaceLock({ dir: join(tempDir, 'nonexistent') });
105
+ expect(result).toBeNull();
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Default directory
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe('default directory', () => {
114
+ test('defaults to .trails/', async () => {
115
+ // We can't easily test the actual default without polluting the repo,
116
+ // so we verify the custom directory option works and trust the default
117
+ const map = makeSurfaceMap();
118
+ const customDir = join(tempDir, 'custom-trails');
119
+ const filePath = await writeSurfaceMap(map, { dir: customDir });
120
+
121
+ expect(filePath).toBe(join(customDir, '_surface.json'));
122
+
123
+ const result = await readSurfaceMap({ dir: customDir });
124
+ expect(result).toEqual(map);
125
+ });
126
+
127
+ test('custom directory option works for lock files', async () => {
128
+ const customDir = join(tempDir, 'custom-lock-dir');
129
+ const hash = 'a1b2c3d4e5f6'.repeat(5);
130
+ const filePath = await writeSurfaceLock(hash, { dir: customDir });
131
+
132
+ expect(filePath).toBe(join(customDir, 'surface.lock'));
133
+
134
+ const result = await readSurfaceLock({ dir: customDir });
135
+ expect(result).toBe(hash);
136
+ });
137
+ });