@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.14
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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +2 -1
- package/dist/derive.d.ts +6 -0
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +29 -5
- package/dist/derive.js.map +1 -1
- package/dist/draft.d.ts +28 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +156 -0
- package/dist/draft.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/topo-saves.d.ts +47 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +310 -0
- package/dist/internal/topo-saves.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +67 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +222 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +12 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +571 -0
- package/dist/internal/topo-store.js.map +1 -0
- package/dist/internal/trails-db.d.ts +16 -0
- package/dist/internal/trails-db.d.ts.map +1 -0
- package/dist/internal/trails-db.js +118 -0
- package/dist/internal/trails-db.js.map +1 -0
- package/dist/topo-store.d.ts +48 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +175 -0
- package/dist/topo-store.js.map +1 -0
- package/dist/validate-established-topo.d.ts +76 -0
- package/dist/validate-established-topo.d.ts.map +1 -0
- package/dist/validate-established-topo.js +43 -0
- package/dist/validate-established-topo.js.map +1 -0
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +5 -3
- package/dist/validate-topo.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/derive.test.ts +58 -1
- package/src/__tests__/topo-store-read.test.ts +251 -0
- package/src/__tests__/topo-store.test.ts +469 -0
- package/src/__tests__/trails-db.test.ts +191 -0
- package/src/__tests__/validate-topo.test.ts +167 -0
- package/src/derive.ts +39 -8
- package/src/draft.ts +334 -0
- package/src/index.ts +30 -1
- package/src/internal/topo-saves.ts +429 -0
- package/src/internal/topo-store-read.ts +473 -0
- package/src/internal/topo-store.ts +1087 -0
- package/src/internal/trails-db.ts +189 -0
- package/src/topo-store.ts +301 -0
- package/src/validate-established-topo.ts +63 -0
- package/src/validate-topo.ts +7 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
import { deriveCliPath } from '../derive.js';
|
|
4
|
+
import { Result } from '../result.js';
|
|
5
|
+
import type { AnyProvision } from '../provision.js';
|
|
6
|
+
import type { AnySignal } from '../signal.js';
|
|
7
|
+
import type { Topo } from '../topo.js';
|
|
8
|
+
import type { AnyTrail } from '../trail.js';
|
|
9
|
+
import { validateEstablishedTopo } from '../validate-established-topo.js';
|
|
10
|
+
import { zodToJsonSchema } from '../validation.js';
|
|
11
|
+
import type { CreateTopoSaveInput, TopoSaveRecord } from './topo-saves.js';
|
|
12
|
+
import { ensureTopoHistorySchema, insertTopoSaveRecord } from './topo-saves.js';
|
|
13
|
+
|
|
14
|
+
type TrailheadMapEntryRecord = Readonly<Record<string, unknown>> & {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly kind: 'provision' | 'signal' | 'trail';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type TrailheadMapRecord = Readonly<{
|
|
20
|
+
readonly entries: readonly TrailheadMapEntryRecord[];
|
|
21
|
+
readonly generatedAt: string;
|
|
22
|
+
readonly version: '1.0';
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
type JsonRecord = Readonly<Record<string, unknown>>;
|
|
26
|
+
type ZodSchemaInput = Parameters<typeof zodToJsonSchema>[0];
|
|
27
|
+
|
|
28
|
+
interface TopoTrailRow {
|
|
29
|
+
readonly description: string | null;
|
|
30
|
+
readonly exampleCount: number;
|
|
31
|
+
readonly hasExamples: number;
|
|
32
|
+
readonly hasOutput: number;
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly idempotent: number;
|
|
35
|
+
readonly intent: string;
|
|
36
|
+
readonly meta: string | null;
|
|
37
|
+
readonly saveId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TopoCrossingRow {
|
|
41
|
+
readonly saveId: string;
|
|
42
|
+
readonly sourceId: string;
|
|
43
|
+
readonly targetId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TopoTrailProvisionRow {
|
|
47
|
+
readonly provisionId: string;
|
|
48
|
+
readonly saveId: string;
|
|
49
|
+
readonly trailId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface TopoProvisionRow {
|
|
53
|
+
readonly hasHealth: number;
|
|
54
|
+
readonly hasMock: number;
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly saveId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface TopoSignalRow {
|
|
60
|
+
readonly description: string | null;
|
|
61
|
+
readonly id: string;
|
|
62
|
+
readonly saveId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TopoTrailSignalRow {
|
|
66
|
+
readonly saveId: string;
|
|
67
|
+
readonly signalId: string;
|
|
68
|
+
readonly trailId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TopoTrailheadRow {
|
|
72
|
+
readonly derivedName: string;
|
|
73
|
+
readonly method: string | null;
|
|
74
|
+
readonly saveId: string;
|
|
75
|
+
readonly trailId: string;
|
|
76
|
+
readonly trailhead: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface TopoExampleRow {
|
|
80
|
+
readonly description: string | null;
|
|
81
|
+
readonly error: string | null;
|
|
82
|
+
readonly expected: string | null;
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly input: string;
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly ordinal: number;
|
|
87
|
+
readonly saveId: string;
|
|
88
|
+
readonly trailId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface TopoSchemaRow {
|
|
92
|
+
readonly jsonSchema: string;
|
|
93
|
+
readonly ownerId: string;
|
|
94
|
+
readonly ownerKind: 'signal' | 'trail';
|
|
95
|
+
readonly saveId: string;
|
|
96
|
+
readonly schemaKind: 'input' | 'output' | 'payload';
|
|
97
|
+
readonly zodHash: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface StoredTopoExportRow {
|
|
101
|
+
readonly saveId: string;
|
|
102
|
+
readonly serializedLock: string;
|
|
103
|
+
readonly trailheadHash: string;
|
|
104
|
+
readonly trailheadMap: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface StoredTopoExportDbRow {
|
|
108
|
+
readonly serialized_lock: string;
|
|
109
|
+
readonly trailhead_hash: string;
|
|
110
|
+
readonly trailhead_map: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface StoredTopoExport {
|
|
114
|
+
readonly lockContent: string;
|
|
115
|
+
readonly trailheadHash: string;
|
|
116
|
+
readonly trailheadMapJson: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface MaterializedSchemas {
|
|
120
|
+
readonly rows: readonly TopoSchemaRow[];
|
|
121
|
+
readonly signalPayloads: ReadonlyMap<string, JsonRecord>;
|
|
122
|
+
readonly trailSchemas: ReadonlyMap<
|
|
123
|
+
string,
|
|
124
|
+
Readonly<{
|
|
125
|
+
readonly input: JsonRecord;
|
|
126
|
+
readonly output?: JsonRecord;
|
|
127
|
+
}>
|
|
128
|
+
>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface MaterializedTopoArtifacts {
|
|
132
|
+
readonly exportRow: StoredTopoExportRow;
|
|
133
|
+
readonly schemaRows: readonly TopoSchemaRow[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface NormalizedTopoProjection {
|
|
137
|
+
readonly crossings: readonly TopoCrossingRow[];
|
|
138
|
+
readonly examples: readonly TopoExampleRow[];
|
|
139
|
+
readonly provisions: readonly TopoProvisionRow[];
|
|
140
|
+
readonly signals: readonly TopoSignalRow[];
|
|
141
|
+
readonly trailheads: readonly TopoTrailheadRow[];
|
|
142
|
+
readonly trailProvisions: readonly TopoTrailProvisionRow[];
|
|
143
|
+
readonly trailSignals: readonly TopoTrailSignalRow[];
|
|
144
|
+
readonly trails: readonly TopoTrailRow[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const canonicalLeaf = (value: unknown): unknown => {
|
|
148
|
+
switch (typeof value) {
|
|
149
|
+
case 'bigint': {
|
|
150
|
+
return value.toString();
|
|
151
|
+
}
|
|
152
|
+
case 'function': {
|
|
153
|
+
return `[Function:${value.name || 'anonymous'}]`;
|
|
154
|
+
}
|
|
155
|
+
case 'symbol': {
|
|
156
|
+
return `[Symbol:${value.description ?? ''}]`;
|
|
157
|
+
}
|
|
158
|
+
case 'undefined': {
|
|
159
|
+
return '[Undefined]';
|
|
160
|
+
}
|
|
161
|
+
default: {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const canonicalObject = (
|
|
168
|
+
value: Record<string, unknown>,
|
|
169
|
+
visit: (value: unknown) => unknown
|
|
170
|
+
): Record<string, unknown> => {
|
|
171
|
+
const sorted: Record<string, unknown> = {};
|
|
172
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
173
|
+
const next = value[key];
|
|
174
|
+
sorted[key] = next === undefined ? '[Undefined]' : visit(next);
|
|
175
|
+
}
|
|
176
|
+
return sorted;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const canonicalize = (value: unknown): unknown => {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
return value.map(canonicalize);
|
|
182
|
+
}
|
|
183
|
+
if (value instanceof Date) {
|
|
184
|
+
return value.toISOString();
|
|
185
|
+
}
|
|
186
|
+
if (value instanceof RegExp) {
|
|
187
|
+
return value.toString();
|
|
188
|
+
}
|
|
189
|
+
if (value !== null && typeof value === 'object') {
|
|
190
|
+
return canonicalObject(value as Record<string, unknown>, canonicalize);
|
|
191
|
+
}
|
|
192
|
+
return canonicalLeaf(value);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Canonicalize a value for schema definition hashing.
|
|
197
|
+
*
|
|
198
|
+
* Matches the canonicalization logic used in `@ontrails/schema` so that
|
|
199
|
+
* schema hashes are consistent regardless of which code path computes them.
|
|
200
|
+
* Unlike the general `canonicalize`, this does not convert `Date`, `RegExp`,
|
|
201
|
+
* or `undefined` to sentinel strings — Zod `_zod.def` objects are plain
|
|
202
|
+
* JSON-serializable structures and both paths must agree on the same encoding.
|
|
203
|
+
*/
|
|
204
|
+
const schemaCanonical = (value: unknown): unknown => {
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
return value.map(schemaCanonical);
|
|
207
|
+
}
|
|
208
|
+
if (value !== null && typeof value === 'object') {
|
|
209
|
+
const sorted: Record<string, unknown> = {};
|
|
210
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
211
|
+
sorted[key] = schemaCanonical((value as Record<string, unknown>)[key]);
|
|
212
|
+
}
|
|
213
|
+
return sorted;
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const stableJson = (value: unknown): string =>
|
|
219
|
+
JSON.stringify(canonicalize(value));
|
|
220
|
+
|
|
221
|
+
const hashText = (text: string): string => {
|
|
222
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
223
|
+
hasher.update(text);
|
|
224
|
+
return hasher.digest('hex');
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const hashValue = (value: unknown): string => hashText(stableJson(value));
|
|
228
|
+
|
|
229
|
+
const parseJsonRecord = (value: string): JsonRecord =>
|
|
230
|
+
JSON.parse(value) as JsonRecord;
|
|
231
|
+
|
|
232
|
+
const schemaDefinitionHash = (schema: unknown): string => {
|
|
233
|
+
const def =
|
|
234
|
+
typeof schema === 'object' &&
|
|
235
|
+
schema !== null &&
|
|
236
|
+
'_zod' in schema &&
|
|
237
|
+
typeof (schema as { readonly _zod: unknown })._zod === 'object' &&
|
|
238
|
+
(schema as { readonly _zod: Record<string, unknown> })._zod !== null &&
|
|
239
|
+
'def' in (schema as { readonly _zod: Record<string, unknown> })._zod
|
|
240
|
+
? (schema as { readonly _zod: { readonly def: unknown } })._zod.def
|
|
241
|
+
: schema;
|
|
242
|
+
return hashText(JSON.stringify(schemaCanonical(def)));
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const sortedJsonSchema = (
|
|
246
|
+
schema: ZodSchemaInput
|
|
247
|
+
): { readonly json: string; readonly value: JsonRecord } => {
|
|
248
|
+
const json = stableJson(zodToJsonSchema(schema));
|
|
249
|
+
return {
|
|
250
|
+
json,
|
|
251
|
+
value: parseJsonRecord(json),
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const sortKeys = <T extends Record<string, unknown>>(value: T): T => {
|
|
256
|
+
const sorted: Record<string, unknown> = {};
|
|
257
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
258
|
+
sorted[key] = value[key];
|
|
259
|
+
}
|
|
260
|
+
return sorted as T;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const normalizeTrailRows = (
|
|
264
|
+
trails: readonly AnyTrail[],
|
|
265
|
+
saveId: string
|
|
266
|
+
): readonly TopoTrailRow[] =>
|
|
267
|
+
trails.map((trail) => ({
|
|
268
|
+
description: trail.description ?? null,
|
|
269
|
+
exampleCount: trail.examples?.length ?? 0,
|
|
270
|
+
hasExamples: (trail.examples?.length ?? 0) > 0 ? 1 : 0,
|
|
271
|
+
hasOutput: trail.output === undefined ? 0 : 1,
|
|
272
|
+
id: trail.id,
|
|
273
|
+
idempotent: trail.idempotent === true ? 1 : 0,
|
|
274
|
+
intent: trail.intent,
|
|
275
|
+
meta: trail.meta === undefined ? null : stableJson(trail.meta),
|
|
276
|
+
saveId,
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
const normalizeCrossingRows = (
|
|
280
|
+
trails: readonly AnyTrail[],
|
|
281
|
+
saveId: string
|
|
282
|
+
): readonly TopoCrossingRow[] =>
|
|
283
|
+
trails.flatMap((trail) =>
|
|
284
|
+
[...new Set(trail.crosses)].toSorted().map((targetId) => ({
|
|
285
|
+
saveId,
|
|
286
|
+
sourceId: trail.id,
|
|
287
|
+
targetId,
|
|
288
|
+
}))
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const normalizeTrailProvisionRows = (
|
|
292
|
+
trails: readonly AnyTrail[],
|
|
293
|
+
saveId: string
|
|
294
|
+
): readonly TopoTrailProvisionRow[] =>
|
|
295
|
+
trails.flatMap((trail) =>
|
|
296
|
+
[...new Set(trail.provisions.map((provision) => provision.id))]
|
|
297
|
+
.toSorted()
|
|
298
|
+
.map((provisionId) => ({
|
|
299
|
+
provisionId,
|
|
300
|
+
saveId,
|
|
301
|
+
trailId: trail.id,
|
|
302
|
+
}))
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const normalizeProvisionRows = (
|
|
306
|
+
provisions: readonly AnyProvision[],
|
|
307
|
+
saveId: string
|
|
308
|
+
): readonly TopoProvisionRow[] =>
|
|
309
|
+
provisions.map((provision) => ({
|
|
310
|
+
hasHealth: provision.health === undefined ? 0 : 1,
|
|
311
|
+
hasMock: provision.mock === undefined ? 0 : 1,
|
|
312
|
+
id: provision.id,
|
|
313
|
+
saveId,
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
const normalizeSignalRows = (
|
|
317
|
+
signals: readonly AnySignal[],
|
|
318
|
+
saveId: string
|
|
319
|
+
): readonly TopoSignalRow[] =>
|
|
320
|
+
signals.map((signal) => ({
|
|
321
|
+
description: signal.description ?? null,
|
|
322
|
+
id: signal.id,
|
|
323
|
+
saveId,
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
const normalizeTrailSignalRows = (
|
|
327
|
+
signals: readonly AnySignal[],
|
|
328
|
+
saveId: string
|
|
329
|
+
): readonly TopoTrailSignalRow[] =>
|
|
330
|
+
signals.flatMap((signal) =>
|
|
331
|
+
[...new Set(signal.from)].toSorted().map((trailId) => ({
|
|
332
|
+
saveId,
|
|
333
|
+
signalId: signal.id,
|
|
334
|
+
trailId,
|
|
335
|
+
}))
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Project trailhead rows for stored topo.
|
|
340
|
+
*
|
|
341
|
+
* Currently records only CLI-derived rows. MCP, HTTP, and other trailhead
|
|
342
|
+
* projections are intentionally deferred until the topo-store schema supports
|
|
343
|
+
* multi-trailhead representation. The JSON export (`trailhead_map` in
|
|
344
|
+
* `topo_exports`) is more faithful for now. See ADR-0015 for the target shape.
|
|
345
|
+
*/
|
|
346
|
+
const normalizeTrailheadRows = (
|
|
347
|
+
trails: readonly AnyTrail[],
|
|
348
|
+
saveId: string
|
|
349
|
+
): readonly TopoTrailheadRow[] =>
|
|
350
|
+
trails.map((trail) => ({
|
|
351
|
+
derivedName: deriveCliPath(trail.id).join(' '),
|
|
352
|
+
method: null,
|
|
353
|
+
saveId,
|
|
354
|
+
trailId: trail.id,
|
|
355
|
+
trailhead: 'cli',
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
const buildExampleId = (
|
|
359
|
+
saveId: string,
|
|
360
|
+
trailId: string,
|
|
361
|
+
ordinal: number
|
|
362
|
+
): string => `${saveId}:${trailId}:${ordinal.toString().padStart(4, '0')}`;
|
|
363
|
+
|
|
364
|
+
const normalizeExampleRows = (
|
|
365
|
+
trails: readonly AnyTrail[],
|
|
366
|
+
saveId: string
|
|
367
|
+
): readonly TopoExampleRow[] =>
|
|
368
|
+
trails.flatMap((trail) =>
|
|
369
|
+
(trail.examples ?? []).map((example, index) => ({
|
|
370
|
+
description: example.description ?? null,
|
|
371
|
+
error: example.error ?? null,
|
|
372
|
+
expected:
|
|
373
|
+
example.expected === undefined ? null : stableJson(example.expected),
|
|
374
|
+
id: buildExampleId(saveId, trail.id, index),
|
|
375
|
+
input: stableJson(example.input),
|
|
376
|
+
name: example.name,
|
|
377
|
+
ordinal: index,
|
|
378
|
+
saveId,
|
|
379
|
+
trailId: trail.id,
|
|
380
|
+
}))
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const normalizeTopoProjection = (
|
|
384
|
+
topo: Topo,
|
|
385
|
+
saveId: string
|
|
386
|
+
): NormalizedTopoProjection => {
|
|
387
|
+
const trails = topo.list().toSorted((a, b) => a.id.localeCompare(b.id));
|
|
388
|
+
const provisions = topo
|
|
389
|
+
.listProvisions()
|
|
390
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
391
|
+
const signals = topo
|
|
392
|
+
.listSignals()
|
|
393
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
crossings: normalizeCrossingRows(trails, saveId),
|
|
397
|
+
examples: normalizeExampleRows(trails, saveId),
|
|
398
|
+
provisions: normalizeProvisionRows(provisions, saveId),
|
|
399
|
+
signals: normalizeSignalRows(signals, saveId),
|
|
400
|
+
trailProvisions: normalizeTrailProvisionRows(trails, saveId),
|
|
401
|
+
trailSignals: normalizeTrailSignalRows(signals, saveId),
|
|
402
|
+
trailheads: normalizeTrailheadRows(trails, saveId),
|
|
403
|
+
trails: normalizeTrailRows(trails, saveId),
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Look up a cached JSON schema by content hash.
|
|
409
|
+
*
|
|
410
|
+
* The query matches on `zod_hash` without filtering by `save_id` because the
|
|
411
|
+
* cache is intentionally cross-save: if the Zod schema definition has not
|
|
412
|
+
* changed (same hash), the serialized JSON Schema is reused regardless of
|
|
413
|
+
* which save produced it. This is safe as long as `zodToJsonSchema` is
|
|
414
|
+
* deterministic for a given `_def` hash — the `schemaDefinitionHash` pipeline
|
|
415
|
+
* guarantees that structurally identical schemas produce the same hash.
|
|
416
|
+
*/
|
|
417
|
+
const readCachedJsonSchema = (
|
|
418
|
+
db: Database,
|
|
419
|
+
ownerId: string,
|
|
420
|
+
ownerKind: TopoSchemaRow['ownerKind'],
|
|
421
|
+
schemaKind: TopoSchemaRow['schemaKind'],
|
|
422
|
+
zodHash: string
|
|
423
|
+
): string | undefined => {
|
|
424
|
+
const row = db
|
|
425
|
+
.query<
|
|
426
|
+
{
|
|
427
|
+
readonly json_schema: string;
|
|
428
|
+
},
|
|
429
|
+
[string, string, string, string]
|
|
430
|
+
>(
|
|
431
|
+
`SELECT json_schema
|
|
432
|
+
FROM topo_schemas
|
|
433
|
+
WHERE owner_id = ?
|
|
434
|
+
AND owner_kind = ?
|
|
435
|
+
AND schema_kind = ?
|
|
436
|
+
AND zod_hash = ?
|
|
437
|
+
LIMIT 1`
|
|
438
|
+
)
|
|
439
|
+
.get(ownerId, ownerKind, schemaKind, zodHash);
|
|
440
|
+
|
|
441
|
+
return row?.json_schema;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const resolveSchemaRow = (
|
|
445
|
+
db: Database,
|
|
446
|
+
ownerId: string,
|
|
447
|
+
ownerKind: TopoSchemaRow['ownerKind'],
|
|
448
|
+
saveId: string,
|
|
449
|
+
schemaKind: TopoSchemaRow['schemaKind'],
|
|
450
|
+
schema: ZodSchemaInput
|
|
451
|
+
): {
|
|
452
|
+
readonly row: TopoSchemaRow;
|
|
453
|
+
readonly value: JsonRecord;
|
|
454
|
+
} => {
|
|
455
|
+
const zodHash = schemaDefinitionHash(schema);
|
|
456
|
+
const cachedJson = readCachedJsonSchema(
|
|
457
|
+
db,
|
|
458
|
+
ownerId,
|
|
459
|
+
ownerKind,
|
|
460
|
+
schemaKind,
|
|
461
|
+
zodHash
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
if (cachedJson !== undefined) {
|
|
465
|
+
return {
|
|
466
|
+
row: {
|
|
467
|
+
jsonSchema: cachedJson,
|
|
468
|
+
ownerId,
|
|
469
|
+
ownerKind,
|
|
470
|
+
saveId,
|
|
471
|
+
schemaKind,
|
|
472
|
+
zodHash,
|
|
473
|
+
},
|
|
474
|
+
value: parseJsonRecord(cachedJson),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const generated = sortedJsonSchema(schema);
|
|
479
|
+
return {
|
|
480
|
+
row: {
|
|
481
|
+
jsonSchema: generated.json,
|
|
482
|
+
ownerId,
|
|
483
|
+
ownerKind,
|
|
484
|
+
saveId,
|
|
485
|
+
schemaKind,
|
|
486
|
+
zodHash,
|
|
487
|
+
},
|
|
488
|
+
value: generated.value,
|
|
489
|
+
};
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const materializeTrailSchema = (
|
|
493
|
+
db: Database,
|
|
494
|
+
saveId: string,
|
|
495
|
+
trail: AnyTrail
|
|
496
|
+
): {
|
|
497
|
+
readonly rows: readonly TopoSchemaRow[];
|
|
498
|
+
readonly value: Readonly<{
|
|
499
|
+
readonly input: JsonRecord;
|
|
500
|
+
readonly output?: JsonRecord;
|
|
501
|
+
}>;
|
|
502
|
+
} => {
|
|
503
|
+
const inputSchema = resolveSchemaRow(
|
|
504
|
+
db,
|
|
505
|
+
trail.id,
|
|
506
|
+
'trail',
|
|
507
|
+
saveId,
|
|
508
|
+
'input',
|
|
509
|
+
trail.input as ZodSchemaInput
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (trail.output === undefined) {
|
|
513
|
+
return {
|
|
514
|
+
rows: [inputSchema.row],
|
|
515
|
+
value: {
|
|
516
|
+
input: inputSchema.value,
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const outputSchema = resolveSchemaRow(
|
|
522
|
+
db,
|
|
523
|
+
trail.id,
|
|
524
|
+
'trail',
|
|
525
|
+
saveId,
|
|
526
|
+
'output',
|
|
527
|
+
trail.output as ZodSchemaInput
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
rows: [inputSchema.row, outputSchema.row],
|
|
532
|
+
value: {
|
|
533
|
+
input: inputSchema.value,
|
|
534
|
+
output: outputSchema.value,
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const materializeTrailSchemas = (
|
|
540
|
+
db: Database,
|
|
541
|
+
saveId: string,
|
|
542
|
+
trails: readonly AnyTrail[]
|
|
543
|
+
): Pick<MaterializedSchemas, 'rows' | 'trailSchemas'> => {
|
|
544
|
+
const rows: TopoSchemaRow[] = [];
|
|
545
|
+
const trailSchemas = new Map<
|
|
546
|
+
string,
|
|
547
|
+
Readonly<{
|
|
548
|
+
readonly input: JsonRecord;
|
|
549
|
+
readonly output?: JsonRecord;
|
|
550
|
+
}>
|
|
551
|
+
>();
|
|
552
|
+
|
|
553
|
+
for (const trail of trails) {
|
|
554
|
+
const materialized = materializeTrailSchema(db, saveId, trail);
|
|
555
|
+
rows.push(...materialized.rows);
|
|
556
|
+
trailSchemas.set(trail.id, materialized.value);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
rows,
|
|
561
|
+
trailSchemas,
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const materializeSignalSchemas = (
|
|
566
|
+
db: Database,
|
|
567
|
+
saveId: string,
|
|
568
|
+
signals: readonly AnySignal[]
|
|
569
|
+
): Pick<MaterializedSchemas, 'rows' | 'signalPayloads'> => {
|
|
570
|
+
const rows: TopoSchemaRow[] = [];
|
|
571
|
+
const signalPayloads = new Map<string, JsonRecord>();
|
|
572
|
+
|
|
573
|
+
for (const signal of signals) {
|
|
574
|
+
const payloadSchema = resolveSchemaRow(
|
|
575
|
+
db,
|
|
576
|
+
signal.id,
|
|
577
|
+
'signal',
|
|
578
|
+
saveId,
|
|
579
|
+
'payload',
|
|
580
|
+
signal.payload as ZodSchemaInput
|
|
581
|
+
);
|
|
582
|
+
rows.push(payloadSchema.row);
|
|
583
|
+
signalPayloads.set(signal.id, payloadSchema.value);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
rows,
|
|
588
|
+
signalPayloads,
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const materializeSchemas = (
|
|
593
|
+
db: Database,
|
|
594
|
+
saveId: string,
|
|
595
|
+
signals: readonly AnySignal[],
|
|
596
|
+
trails: readonly AnyTrail[]
|
|
597
|
+
): MaterializedSchemas => {
|
|
598
|
+
const trailMaterial = materializeTrailSchemas(db, saveId, trails);
|
|
599
|
+
const signalMaterial = materializeSignalSchemas(db, saveId, signals);
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
rows: [...trailMaterial.rows, ...signalMaterial.rows],
|
|
603
|
+
signalPayloads: signalMaterial.signalPayloads,
|
|
604
|
+
trailSchemas: trailMaterial.trailSchemas,
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const extractTrailheads = (raw: Record<string, unknown>): string[] =>
|
|
609
|
+
Array.isArray(raw['trailheads'])
|
|
610
|
+
? (raw['trailheads'] as string[]).toSorted()
|
|
611
|
+
: [];
|
|
612
|
+
|
|
613
|
+
const addSafetyMarkers = (
|
|
614
|
+
entry: Record<string, unknown>,
|
|
615
|
+
trail: AnyTrail
|
|
616
|
+
): void => {
|
|
617
|
+
if (trail.intent !== 'write') {
|
|
618
|
+
entry['intent'] = trail.intent;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (trail.idempotent === true) {
|
|
622
|
+
entry['idempotent'] = true;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const addExtendedMetadata = (
|
|
627
|
+
entry: Record<string, unknown>,
|
|
628
|
+
raw: Record<string, unknown>,
|
|
629
|
+
trail: AnyTrail
|
|
630
|
+
): void => {
|
|
631
|
+
if (raw['deprecated'] === true) {
|
|
632
|
+
entry['deprecated'] = true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (typeof raw['replacedBy'] === 'string') {
|
|
636
|
+
entry['replacedBy'] = raw['replacedBy'];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (trail.detours !== undefined) {
|
|
640
|
+
const detours: Record<string, readonly string[]> = {};
|
|
641
|
+
for (const key of Object.keys(trail.detours).toSorted()) {
|
|
642
|
+
detours[key] = (trail.detours[key] ?? []).toSorted();
|
|
643
|
+
}
|
|
644
|
+
entry['detours'] = detours;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const addTrailRelations = (
|
|
649
|
+
entry: Record<string, unknown>,
|
|
650
|
+
trail: AnyTrail
|
|
651
|
+
): void => {
|
|
652
|
+
if (trail.crosses.length > 0) {
|
|
653
|
+
entry['crosses'] = trail.crosses.toSorted();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (trail.provisions.length > 0) {
|
|
657
|
+
entry['provisions'] = trail.provisions
|
|
658
|
+
.map((provision) => provision.id)
|
|
659
|
+
.toSorted();
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const buildTrailEntryBase = (
|
|
664
|
+
trail: AnyTrail,
|
|
665
|
+
trailSchema: Readonly<{
|
|
666
|
+
readonly input: JsonRecord;
|
|
667
|
+
readonly output?: JsonRecord;
|
|
668
|
+
}>
|
|
669
|
+
): {
|
|
670
|
+
readonly entry: Record<string, unknown>;
|
|
671
|
+
readonly raw: Record<string, unknown>;
|
|
672
|
+
} => {
|
|
673
|
+
const raw = trail as unknown as Record<string, unknown>;
|
|
674
|
+
const entry: Record<string, unknown> = {
|
|
675
|
+
cli: { path: deriveCliPath(trail.id) },
|
|
676
|
+
exampleCount: trail.examples?.length ?? 0,
|
|
677
|
+
id: trail.id,
|
|
678
|
+
input: trailSchema.input,
|
|
679
|
+
kind: trail.kind,
|
|
680
|
+
trailheads: extractTrailheads(raw),
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
if (trailSchema.output !== undefined) {
|
|
684
|
+
entry['output'] = trailSchema.output;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (trail.description !== undefined) {
|
|
688
|
+
entry['description'] = trail.description;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
entry,
|
|
693
|
+
raw,
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const trailToEntryRecord = (
|
|
698
|
+
trail: AnyTrail,
|
|
699
|
+
trailSchema: Readonly<{
|
|
700
|
+
readonly input: JsonRecord;
|
|
701
|
+
readonly output?: JsonRecord;
|
|
702
|
+
}>
|
|
703
|
+
): TrailheadMapEntryRecord => {
|
|
704
|
+
const { entry, raw } = buildTrailEntryBase(trail, trailSchema);
|
|
705
|
+
addSafetyMarkers(entry, trail);
|
|
706
|
+
addExtendedMetadata(entry, raw, trail);
|
|
707
|
+
addTrailRelations(entry, trail);
|
|
708
|
+
return sortKeys(entry) as TrailheadMapEntryRecord;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const signalToEntryRecord = (
|
|
712
|
+
signal: AnySignal,
|
|
713
|
+
payloadSchema: JsonRecord
|
|
714
|
+
): TrailheadMapEntryRecord => {
|
|
715
|
+
const raw = signal as unknown as Record<string, unknown>;
|
|
716
|
+
const entry: Record<string, unknown> = {
|
|
717
|
+
exampleCount: 0,
|
|
718
|
+
id: signal.id,
|
|
719
|
+
input: payloadSchema,
|
|
720
|
+
kind: 'signal',
|
|
721
|
+
trailheads: extractTrailheads(raw),
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
if (signal.description !== undefined) {
|
|
725
|
+
entry['description'] = signal.description;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (raw['deprecated'] === true) {
|
|
729
|
+
entry['deprecated'] = true;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (typeof raw['replacedBy'] === 'string') {
|
|
733
|
+
entry['replacedBy'] = raw['replacedBy'];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return sortKeys(entry) as TrailheadMapEntryRecord;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const provisionToEntryRecord = (
|
|
740
|
+
provision: AnyProvision
|
|
741
|
+
): TrailheadMapEntryRecord => {
|
|
742
|
+
const entry: Record<string, unknown> = {
|
|
743
|
+
exampleCount: 0,
|
|
744
|
+
id: provision.id,
|
|
745
|
+
kind: 'provision',
|
|
746
|
+
trailheads: [],
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if (provision.description !== undefined) {
|
|
750
|
+
entry['description'] = provision.description;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (provision.health !== undefined) {
|
|
754
|
+
entry['healthcheck'] = true;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return sortKeys(entry) as TrailheadMapEntryRecord;
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
const requireTrailSchema = (
|
|
761
|
+
trailSchemas: MaterializedSchemas['trailSchemas'],
|
|
762
|
+
trailId: string
|
|
763
|
+
): Readonly<{
|
|
764
|
+
readonly input: JsonRecord;
|
|
765
|
+
readonly output?: JsonRecord;
|
|
766
|
+
}> => {
|
|
767
|
+
const schema = trailSchemas.get(trailId);
|
|
768
|
+
if (schema === undefined) {
|
|
769
|
+
throw new Error(`Missing cached trail schema for "${trailId}"`);
|
|
770
|
+
}
|
|
771
|
+
return schema;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const requireSignalPayload = (
|
|
775
|
+
signalPayloads: MaterializedSchemas['signalPayloads'],
|
|
776
|
+
signalId: string
|
|
777
|
+
): JsonRecord => {
|
|
778
|
+
const payload = signalPayloads.get(signalId);
|
|
779
|
+
if (payload === undefined) {
|
|
780
|
+
throw new Error(`Missing cached signal schema for "${signalId}"`);
|
|
781
|
+
}
|
|
782
|
+
return payload;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const buildTrailheadMap = (
|
|
786
|
+
generatedAt: string,
|
|
787
|
+
provisions: readonly AnyProvision[],
|
|
788
|
+
signalPayloads: ReadonlyMap<string, JsonRecord>,
|
|
789
|
+
signals: readonly AnySignal[],
|
|
790
|
+
trailSchemas: ReadonlyMap<
|
|
791
|
+
string,
|
|
792
|
+
Readonly<{
|
|
793
|
+
readonly input: JsonRecord;
|
|
794
|
+
readonly output?: JsonRecord;
|
|
795
|
+
}>
|
|
796
|
+
>,
|
|
797
|
+
trails: readonly AnyTrail[]
|
|
798
|
+
): TrailheadMapRecord => {
|
|
799
|
+
const entries = [
|
|
800
|
+
...trails.map((trail) =>
|
|
801
|
+
trailToEntryRecord(trail, requireTrailSchema(trailSchemas, trail.id))
|
|
802
|
+
),
|
|
803
|
+
...signals.map((signal) =>
|
|
804
|
+
signalToEntryRecord(
|
|
805
|
+
signal,
|
|
806
|
+
requireSignalPayload(signalPayloads, signal.id)
|
|
807
|
+
)
|
|
808
|
+
),
|
|
809
|
+
...provisions.map((provision) => provisionToEntryRecord(provision)),
|
|
810
|
+
].toSorted((a, b) => a.id.localeCompare(b.id));
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
entries,
|
|
814
|
+
generatedAt,
|
|
815
|
+
version: '1.0',
|
|
816
|
+
};
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const hashTrailheadMapRecord = (trailheadMap: TrailheadMapRecord): string => {
|
|
820
|
+
const { generatedAt: _unused, ...rest } = trailheadMap;
|
|
821
|
+
return hashValue(rest);
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const entryPayload = (
|
|
825
|
+
entry: TrailheadMapEntryRecord
|
|
826
|
+
): Readonly<Record<string, unknown>> => {
|
|
827
|
+
const { id: _unusedId, kind: _unusedKind, ...rest } = entry;
|
|
828
|
+
return sortKeys(rest);
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const entriesForKind = (
|
|
832
|
+
entries: readonly TrailheadMapEntryRecord[],
|
|
833
|
+
kind: TrailheadMapEntryRecord['kind']
|
|
834
|
+
): Readonly<Record<string, Readonly<Record<string, unknown>>>> =>
|
|
835
|
+
Object.fromEntries(
|
|
836
|
+
entries
|
|
837
|
+
.filter((entry) => entry.kind === kind)
|
|
838
|
+
.map((entry) => [entry.id, entryPayload(entry)])
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const buildSerializedLock = (
|
|
842
|
+
hash: string,
|
|
843
|
+
topo: Topo,
|
|
844
|
+
trailheadMap: TrailheadMapRecord
|
|
845
|
+
): Readonly<Record<string, unknown>> =>
|
|
846
|
+
sortKeys({
|
|
847
|
+
apps: sortKeys({
|
|
848
|
+
[topo.name]: sortKeys({
|
|
849
|
+
provisions: entriesForKind(trailheadMap.entries, 'provision'),
|
|
850
|
+
signals: entriesForKind(trailheadMap.entries, 'signal'),
|
|
851
|
+
trails: entriesForKind(trailheadMap.entries, 'trail'),
|
|
852
|
+
}),
|
|
853
|
+
}),
|
|
854
|
+
generatedAt: trailheadMap.generatedAt,
|
|
855
|
+
hash,
|
|
856
|
+
version: 1,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const buildStoredTopoExport = (
|
|
860
|
+
db: Database,
|
|
861
|
+
save: TopoSaveRecord,
|
|
862
|
+
topo: Topo
|
|
863
|
+
): MaterializedTopoArtifacts => {
|
|
864
|
+
const trails = topo.list().toSorted((a, b) => a.id.localeCompare(b.id));
|
|
865
|
+
const provisions = topo
|
|
866
|
+
.listProvisions()
|
|
867
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
868
|
+
const signals = topo
|
|
869
|
+
.listSignals()
|
|
870
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
871
|
+
const schemas = materializeSchemas(db, save.id, signals, trails);
|
|
872
|
+
const trailheadMap = buildTrailheadMap(
|
|
873
|
+
save.createdAt,
|
|
874
|
+
provisions,
|
|
875
|
+
schemas.signalPayloads,
|
|
876
|
+
signals,
|
|
877
|
+
schemas.trailSchemas,
|
|
878
|
+
trails
|
|
879
|
+
);
|
|
880
|
+
const trailheadHash = hashTrailheadMapRecord(trailheadMap);
|
|
881
|
+
const serializedLock = `${JSON.stringify(
|
|
882
|
+
buildSerializedLock(trailheadHash, topo, trailheadMap),
|
|
883
|
+
null,
|
|
884
|
+
2
|
|
885
|
+
)}\n`;
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
exportRow: {
|
|
889
|
+
saveId: save.id,
|
|
890
|
+
serializedLock,
|
|
891
|
+
trailheadHash,
|
|
892
|
+
trailheadMap: `${JSON.stringify(trailheadMap, null, 2)}\n`,
|
|
893
|
+
},
|
|
894
|
+
schemaRows: schemas.rows,
|
|
895
|
+
};
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const insertRows = <TRow>(
|
|
899
|
+
db: Database,
|
|
900
|
+
rows: readonly TRow[],
|
|
901
|
+
statement: string,
|
|
902
|
+
toParams: (row: TRow) => SQLQueryBindings[]
|
|
903
|
+
): void => {
|
|
904
|
+
for (const row of rows) {
|
|
905
|
+
db.run(statement, toParams(row));
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const insertProjectedRows = (
|
|
910
|
+
db: Database,
|
|
911
|
+
projection: NormalizedTopoProjection
|
|
912
|
+
): void => {
|
|
913
|
+
insertRows(
|
|
914
|
+
db,
|
|
915
|
+
projection.trails,
|
|
916
|
+
`INSERT INTO topo_trails (
|
|
917
|
+
id, intent, idempotent, has_output, has_examples, example_count, description, meta, save_id
|
|
918
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
919
|
+
(row) => [
|
|
920
|
+
row.id,
|
|
921
|
+
row.intent,
|
|
922
|
+
row.idempotent,
|
|
923
|
+
row.hasOutput,
|
|
924
|
+
row.hasExamples,
|
|
925
|
+
row.exampleCount,
|
|
926
|
+
row.description,
|
|
927
|
+
row.meta,
|
|
928
|
+
row.saveId,
|
|
929
|
+
]
|
|
930
|
+
);
|
|
931
|
+
insertRows(
|
|
932
|
+
db,
|
|
933
|
+
projection.crossings,
|
|
934
|
+
'INSERT INTO topo_crossings (source_id, target_id, save_id) VALUES (?, ?, ?)',
|
|
935
|
+
(row) => [row.sourceId, row.targetId, row.saveId]
|
|
936
|
+
);
|
|
937
|
+
insertRows(
|
|
938
|
+
db,
|
|
939
|
+
projection.trailProvisions,
|
|
940
|
+
`INSERT INTO topo_trail_provisions (trail_id, provision_id, save_id)
|
|
941
|
+
VALUES (?, ?, ?)`,
|
|
942
|
+
(row) => [row.trailId, row.provisionId, row.saveId]
|
|
943
|
+
);
|
|
944
|
+
insertRows(
|
|
945
|
+
db,
|
|
946
|
+
projection.provisions,
|
|
947
|
+
`INSERT INTO topo_provisions (id, has_mock, has_health, save_id)
|
|
948
|
+
VALUES (?, ?, ?, ?)`,
|
|
949
|
+
(row) => [row.id, row.hasMock, row.hasHealth, row.saveId]
|
|
950
|
+
);
|
|
951
|
+
insertRows(
|
|
952
|
+
db,
|
|
953
|
+
projection.signals,
|
|
954
|
+
'INSERT INTO topo_signals (id, description, save_id) VALUES (?, ?, ?)',
|
|
955
|
+
(row) => [row.id, row.description, row.saveId]
|
|
956
|
+
);
|
|
957
|
+
insertRows(
|
|
958
|
+
db,
|
|
959
|
+
projection.trailSignals,
|
|
960
|
+
`INSERT INTO topo_trail_signals (trail_id, signal_id, save_id)
|
|
961
|
+
VALUES (?, ?, ?)`,
|
|
962
|
+
(row) => [row.trailId, row.signalId, row.saveId]
|
|
963
|
+
);
|
|
964
|
+
insertRows(
|
|
965
|
+
db,
|
|
966
|
+
projection.trailheads,
|
|
967
|
+
`INSERT INTO topo_trailheads (trail_id, trailhead, derived_name, method, save_id)
|
|
968
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
969
|
+
(row) => [
|
|
970
|
+
row.trailId,
|
|
971
|
+
row.trailhead,
|
|
972
|
+
row.derivedName,
|
|
973
|
+
row.method,
|
|
974
|
+
row.saveId,
|
|
975
|
+
]
|
|
976
|
+
);
|
|
977
|
+
insertRows(
|
|
978
|
+
db,
|
|
979
|
+
projection.examples,
|
|
980
|
+
`INSERT INTO topo_examples (
|
|
981
|
+
id, trail_id, ordinal, name, description, input, expected, error, save_id
|
|
982
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
983
|
+
(row) => [
|
|
984
|
+
row.id,
|
|
985
|
+
row.trailId,
|
|
986
|
+
row.ordinal,
|
|
987
|
+
row.name,
|
|
988
|
+
row.description,
|
|
989
|
+
row.input,
|
|
990
|
+
row.expected,
|
|
991
|
+
row.error,
|
|
992
|
+
row.saveId,
|
|
993
|
+
]
|
|
994
|
+
);
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const insertSchemaRows = (
|
|
998
|
+
db: Database,
|
|
999
|
+
rows: readonly TopoSchemaRow[]
|
|
1000
|
+
): void => {
|
|
1001
|
+
insertRows(
|
|
1002
|
+
db,
|
|
1003
|
+
rows,
|
|
1004
|
+
`INSERT INTO topo_schemas (
|
|
1005
|
+
owner_id, owner_kind, schema_kind, zod_hash, json_schema, save_id
|
|
1006
|
+
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
1007
|
+
(row) => [
|
|
1008
|
+
row.ownerId,
|
|
1009
|
+
row.ownerKind,
|
|
1010
|
+
row.schemaKind,
|
|
1011
|
+
row.zodHash,
|
|
1012
|
+
row.jsonSchema,
|
|
1013
|
+
row.saveId,
|
|
1014
|
+
]
|
|
1015
|
+
);
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
const insertStoredExport = (
|
|
1019
|
+
db: Database,
|
|
1020
|
+
exportRow: StoredTopoExportRow
|
|
1021
|
+
): void => {
|
|
1022
|
+
db.run(
|
|
1023
|
+
`INSERT INTO topo_exports (
|
|
1024
|
+
save_id, trailhead_map, trailhead_hash, serialized_lock
|
|
1025
|
+
) VALUES (?, ?, ?, ?)`,
|
|
1026
|
+
[
|
|
1027
|
+
exportRow.saveId,
|
|
1028
|
+
exportRow.trailheadMap,
|
|
1029
|
+
exportRow.trailheadHash,
|
|
1030
|
+
exportRow.serializedLock,
|
|
1031
|
+
]
|
|
1032
|
+
);
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
export const getStoredTopoExport = (
|
|
1036
|
+
db: Database,
|
|
1037
|
+
saveId: string
|
|
1038
|
+
): StoredTopoExport | undefined => {
|
|
1039
|
+
const row = db
|
|
1040
|
+
.query<StoredTopoExportDbRow, [string]>(
|
|
1041
|
+
`SELECT trailhead_map, trailhead_hash, serialized_lock
|
|
1042
|
+
FROM topo_exports
|
|
1043
|
+
WHERE save_id = ?`
|
|
1044
|
+
)
|
|
1045
|
+
.get(saveId);
|
|
1046
|
+
|
|
1047
|
+
if (row === undefined || row === null) {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return {
|
|
1052
|
+
lockContent: row.serialized_lock,
|
|
1053
|
+
trailheadHash: row.trailhead_hash,
|
|
1054
|
+
trailheadMapJson: row.trailhead_map,
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
export const persistEstablishedTopoSave = (
|
|
1059
|
+
db: Database,
|
|
1060
|
+
topo: Topo,
|
|
1061
|
+
input?: CreateTopoSaveInput
|
|
1062
|
+
): Result<TopoSaveRecord, Error> => {
|
|
1063
|
+
const validated = validateEstablishedTopo(topo);
|
|
1064
|
+
if (validated.isErr()) {
|
|
1065
|
+
return Result.err(validated.error);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
ensureTopoHistorySchema(db);
|
|
1069
|
+
|
|
1070
|
+
const saveInput: CreateTopoSaveInput = {
|
|
1071
|
+
...input,
|
|
1072
|
+
provisionCount: input?.provisionCount ?? topo.provisions.size,
|
|
1073
|
+
signalCount: input?.signalCount ?? topo.signals.size,
|
|
1074
|
+
trailCount: input?.trailCount ?? topo.trails.size,
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const save = db.transaction(() => {
|
|
1078
|
+
const record = insertTopoSaveRecord(db, saveInput);
|
|
1079
|
+
const artifacts = buildStoredTopoExport(db, record, topo);
|
|
1080
|
+
insertProjectedRows(db, normalizeTopoProjection(topo, record.id));
|
|
1081
|
+
insertSchemaRows(db, artifacts.schemaRows);
|
|
1082
|
+
insertStoredExport(db, artifacts.exportRow);
|
|
1083
|
+
return record;
|
|
1084
|
+
})();
|
|
1085
|
+
|
|
1086
|
+
return Result.ok(save);
|
|
1087
|
+
};
|