@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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +139 -0
- package/dist/diff.d.ts +6 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +243 -0
- package/dist/diff.js.map +1 -0
- package/dist/generate.d.ts +13 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +171 -0
- package/dist/generate.js.map +1 -0
- package/dist/hash.d.ts +14 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +44 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/io.d.ts +29 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +91 -0
- package/dist/io.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +20 -0
- package/src/__tests__/diff.test.ts +333 -0
- package/src/__tests__/generate.test.ts +252 -0
- package/src/__tests__/hash.test.ts +115 -0
- package/src/__tests__/io.test.ts +137 -0
- package/src/diff.ts +398 -0
- package/src/generate.ts +219 -0
- package/src/hash.ts +51 -0
- package/src/index.ts +23 -0
- package/src/io.ts +115 -0
- package/src/types.ts +71 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/diff.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic diffing of surface maps.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DiffEntry,
|
|
7
|
+
DiffResult,
|
|
8
|
+
JsonSchema,
|
|
9
|
+
SurfaceMap,
|
|
10
|
+
SurfaceMapEntry,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
type Severity = DiffEntry['severity'];
|
|
18
|
+
|
|
19
|
+
interface DetailAccumulator {
|
|
20
|
+
readonly details: string[];
|
|
21
|
+
severity: Severity;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const escalate = (acc: DetailAccumulator, severity: Severity): void => {
|
|
25
|
+
const rank: Record<Severity, number> = { breaking: 2, info: 0, warning: 1 };
|
|
26
|
+
if (rank[severity] > rank[acc.severity]) {
|
|
27
|
+
acc.severity = severity;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const addDetail = (
|
|
32
|
+
acc: DetailAccumulator,
|
|
33
|
+
severity: Severity,
|
|
34
|
+
message: string
|
|
35
|
+
): void => {
|
|
36
|
+
acc.details.push(message);
|
|
37
|
+
escalate(acc, severity);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Utility
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const capitalize = (s: string): string =>
|
|
45
|
+
`${s.charAt(0).toUpperCase()}${s.slice(1)}`;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Schema field diffing
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
interface SchemaProperties {
|
|
52
|
+
readonly properties?: Readonly<Record<string, JsonSchema>>;
|
|
53
|
+
readonly required?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const getProperties = (
|
|
57
|
+
schema: JsonSchema | undefined
|
|
58
|
+
): Record<string, JsonSchema> => {
|
|
59
|
+
if (!schema) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
const props = (schema as SchemaProperties).properties;
|
|
63
|
+
return props ? { ...props } : {};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getRequired = (schema: JsonSchema | undefined): ReadonlySet<string> => {
|
|
67
|
+
if (!schema) {
|
|
68
|
+
return new Set();
|
|
69
|
+
}
|
|
70
|
+
const req = (schema as SchemaProperties).required;
|
|
71
|
+
return new Set(req);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const inferSchemaType = (schema: JsonSchema): string => {
|
|
75
|
+
if (typeof schema['type'] === 'string') {
|
|
76
|
+
return schema['type'];
|
|
77
|
+
}
|
|
78
|
+
if (schema['const'] !== undefined) {
|
|
79
|
+
return `const(${String(schema['const'])})`;
|
|
80
|
+
}
|
|
81
|
+
if (schema['anyOf']) {
|
|
82
|
+
return 'union';
|
|
83
|
+
}
|
|
84
|
+
if (schema['enum']) {
|
|
85
|
+
return 'enum';
|
|
86
|
+
}
|
|
87
|
+
return 'unknown';
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const getType = (schema: JsonSchema | undefined): string =>
|
|
91
|
+
schema ? inferSchemaType(schema) : 'unknown';
|
|
92
|
+
|
|
93
|
+
/** Diff a single field that was added. */
|
|
94
|
+
const diffAddedField = (
|
|
95
|
+
acc: DetailAccumulator,
|
|
96
|
+
direction: 'input' | 'output',
|
|
97
|
+
key: string,
|
|
98
|
+
currRequired: ReadonlySet<string>
|
|
99
|
+
): void => {
|
|
100
|
+
if (direction === 'input' && currRequired.has(key)) {
|
|
101
|
+
addDetail(acc, 'breaking', `Required ${direction} field "${key}" added`);
|
|
102
|
+
} else {
|
|
103
|
+
const label = direction === 'input' ? `Optional ${direction}` : 'Output';
|
|
104
|
+
addDetail(acc, 'info', `${label} field "${key}" added`);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Diff a single field present in both prev and curr. */
|
|
109
|
+
const diffModifiedField = (
|
|
110
|
+
acc: DetailAccumulator,
|
|
111
|
+
direction: 'input' | 'output',
|
|
112
|
+
key: string,
|
|
113
|
+
prevProps: Record<string, JsonSchema>,
|
|
114
|
+
currProps: Record<string, JsonSchema>,
|
|
115
|
+
prevRequired: ReadonlySet<string>,
|
|
116
|
+
currRequired: ReadonlySet<string>
|
|
117
|
+
): void => {
|
|
118
|
+
const prevType = getType(prevProps[key]);
|
|
119
|
+
const currType = getType(currProps[key]);
|
|
120
|
+
if (prevType !== currType) {
|
|
121
|
+
addDetail(
|
|
122
|
+
acc,
|
|
123
|
+
'breaking',
|
|
124
|
+
`${capitalize(direction)} field "${key}" type changed: ${prevType} -> ${currType}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
direction === 'input' &&
|
|
129
|
+
!prevRequired.has(key) &&
|
|
130
|
+
currRequired.has(key)
|
|
131
|
+
) {
|
|
132
|
+
addDetail(
|
|
133
|
+
acc,
|
|
134
|
+
'breaking',
|
|
135
|
+
`Input field "${key}" changed from optional to required`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Diff a single key across prev/curr schemas. */
|
|
141
|
+
const diffKey = (
|
|
142
|
+
acc: DetailAccumulator,
|
|
143
|
+
direction: 'input' | 'output',
|
|
144
|
+
key: string,
|
|
145
|
+
prevProps: Record<string, JsonSchema>,
|
|
146
|
+
currProps: Record<string, JsonSchema>,
|
|
147
|
+
prevRequired: ReadonlySet<string>,
|
|
148
|
+
currRequired: ReadonlySet<string>
|
|
149
|
+
): void => {
|
|
150
|
+
const inPrev = key in prevProps;
|
|
151
|
+
const inCurr = key in currProps;
|
|
152
|
+
if (!inPrev && inCurr) {
|
|
153
|
+
diffAddedField(acc, direction, key, currRequired);
|
|
154
|
+
} else if (inPrev && !inCurr) {
|
|
155
|
+
addDetail(
|
|
156
|
+
acc,
|
|
157
|
+
'breaking',
|
|
158
|
+
`${capitalize(direction)} field "${key}" removed`
|
|
159
|
+
);
|
|
160
|
+
} else if (inPrev && inCurr) {
|
|
161
|
+
diffModifiedField(
|
|
162
|
+
acc,
|
|
163
|
+
direction,
|
|
164
|
+
key,
|
|
165
|
+
prevProps,
|
|
166
|
+
currProps,
|
|
167
|
+
prevRequired,
|
|
168
|
+
currRequired
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const diffSchemaFields = (
|
|
174
|
+
acc: DetailAccumulator,
|
|
175
|
+
direction: 'input' | 'output',
|
|
176
|
+
prev: JsonSchema | undefined,
|
|
177
|
+
curr: JsonSchema | undefined
|
|
178
|
+
): void => {
|
|
179
|
+
const prevProps = getProperties(prev);
|
|
180
|
+
const currProps = getProperties(curr);
|
|
181
|
+
const prevRequired = getRequired(prev);
|
|
182
|
+
const currRequired = getRequired(curr);
|
|
183
|
+
const allKeys = new Set([
|
|
184
|
+
...Object.keys(prevProps),
|
|
185
|
+
...Object.keys(currProps),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
for (const key of [...allKeys].toSorted()) {
|
|
189
|
+
diffKey(
|
|
190
|
+
acc,
|
|
191
|
+
direction,
|
|
192
|
+
key,
|
|
193
|
+
prevProps,
|
|
194
|
+
currProps,
|
|
195
|
+
prevRequired,
|
|
196
|
+
currRequired
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Per-entry diffing
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** Diff surface additions and removals. */
|
|
206
|
+
const diffSurfaces = (
|
|
207
|
+
acc: DetailAccumulator,
|
|
208
|
+
prev: SurfaceMapEntry,
|
|
209
|
+
curr: SurfaceMapEntry
|
|
210
|
+
): void => {
|
|
211
|
+
const prevSurfaces = new Set(prev.surfaces);
|
|
212
|
+
const currSurfaces = new Set(curr.surfaces);
|
|
213
|
+
for (const s of [...currSurfaces].toSorted()) {
|
|
214
|
+
if (!prevSurfaces.has(s)) {
|
|
215
|
+
addDetail(acc, 'info', `Surface "${s}" added`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const s of [...prevSurfaces].toSorted()) {
|
|
219
|
+
if (!currSurfaces.has(s)) {
|
|
220
|
+
addDetail(acc, 'breaking', `Surface "${s}" removed`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Diff safety markers, description, and deprecation. */
|
|
226
|
+
const diffMetadata = (
|
|
227
|
+
acc: DetailAccumulator,
|
|
228
|
+
prev: SurfaceMapEntry,
|
|
229
|
+
curr: SurfaceMapEntry
|
|
230
|
+
): void => {
|
|
231
|
+
for (const marker of ['readOnly', 'destructive', 'idempotent'] as const) {
|
|
232
|
+
if (prev[marker] !== curr[marker]) {
|
|
233
|
+
addDetail(
|
|
234
|
+
acc,
|
|
235
|
+
'warning',
|
|
236
|
+
`${marker} changed: ${String(prev[marker] ?? false)} -> ${String(curr[marker] ?? false)}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (prev.description !== curr.description) {
|
|
241
|
+
addDetail(acc, 'info', 'Description updated');
|
|
242
|
+
}
|
|
243
|
+
if (!prev.deprecated && curr.deprecated) {
|
|
244
|
+
const msg = curr.replacedBy
|
|
245
|
+
? `Deprecated (replaced by ${curr.replacedBy})`
|
|
246
|
+
: 'Deprecated';
|
|
247
|
+
addDetail(acc, 'warning', msg);
|
|
248
|
+
} else if (prev.deprecated && !curr.deprecated) {
|
|
249
|
+
addDetail(acc, 'info', 'Undeprecated');
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** Build a follows-changed description from added/removed arrays. */
|
|
254
|
+
const buildFollowsMessage = (added: string[], removed: string[]): string => {
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
if (added.length > 0) {
|
|
257
|
+
parts.push(`added "${added.join('", "')}"`);
|
|
258
|
+
}
|
|
259
|
+
if (removed.length > 0) {
|
|
260
|
+
parts.push(`removed "${removed.join('", "')}"`);
|
|
261
|
+
}
|
|
262
|
+
return `Follows changed: ${parts.join(', ')}`;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Diff follows arrays. */
|
|
266
|
+
const diffFollows = (
|
|
267
|
+
acc: DetailAccumulator,
|
|
268
|
+
prev: SurfaceMapEntry,
|
|
269
|
+
curr: SurfaceMapEntry
|
|
270
|
+
): void => {
|
|
271
|
+
const prevFollows = new Set(prev.follows);
|
|
272
|
+
const currFollows = new Set(curr.follows);
|
|
273
|
+
const added = [...currFollows].filter((f) => !prevFollows.has(f)).toSorted();
|
|
274
|
+
const removed = [...prevFollows]
|
|
275
|
+
.filter((f) => !currFollows.has(f))
|
|
276
|
+
.toSorted();
|
|
277
|
+
if (added.length > 0 || removed.length > 0) {
|
|
278
|
+
addDetail(acc, 'warning', buildFollowsMessage(added, removed));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const diffEntry = (
|
|
283
|
+
prev: SurfaceMapEntry,
|
|
284
|
+
curr: SurfaceMapEntry
|
|
285
|
+
): DiffEntry | undefined => {
|
|
286
|
+
const acc: DetailAccumulator = { details: [], severity: 'info' };
|
|
287
|
+
|
|
288
|
+
diffSchemaFields(acc, 'input', prev.input, curr.input);
|
|
289
|
+
diffSchemaFields(acc, 'output', prev.output, curr.output);
|
|
290
|
+
diffSurfaces(acc, prev, curr);
|
|
291
|
+
diffMetadata(acc, prev, curr);
|
|
292
|
+
diffFollows(acc, prev, curr);
|
|
293
|
+
|
|
294
|
+
if (acc.details.length === 0) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
change: 'modified',
|
|
300
|
+
details: acc.details,
|
|
301
|
+
id: curr.id,
|
|
302
|
+
kind: curr.kind,
|
|
303
|
+
severity: acc.severity,
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Public API
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compute a semantic diff between two surface maps.
|
|
313
|
+
*
|
|
314
|
+
* Classifies each change with a severity:
|
|
315
|
+
* - `info`: new trail, optional field added, output field added, description change
|
|
316
|
+
* - `warning`: safety marker change, deprecation, follows change
|
|
317
|
+
* - `breaking`: trail removed, required input added, field removed, type change, surface removed
|
|
318
|
+
*/
|
|
319
|
+
/** Find entries added in curr that don't exist in prev. */
|
|
320
|
+
const findAdded = (
|
|
321
|
+
prevById: Map<string, SurfaceMapEntry>,
|
|
322
|
+
currById: Map<string, SurfaceMapEntry>
|
|
323
|
+
): DiffEntry[] =>
|
|
324
|
+
[...currById.entries()]
|
|
325
|
+
.filter(([id]) => !prevById.has(id))
|
|
326
|
+
.map(([id, entry]) => ({
|
|
327
|
+
change: 'added' as const,
|
|
328
|
+
details: [`Trail "${id}" added`],
|
|
329
|
+
id,
|
|
330
|
+
kind: entry.kind,
|
|
331
|
+
severity: 'info' as const,
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
/** Find entries removed from prev that don't exist in curr. */
|
|
335
|
+
const findRemoved = (
|
|
336
|
+
prevById: Map<string, SurfaceMapEntry>,
|
|
337
|
+
currById: Map<string, SurfaceMapEntry>
|
|
338
|
+
): DiffEntry[] =>
|
|
339
|
+
[...prevById.entries()]
|
|
340
|
+
.filter(([id]) => !currById.has(id))
|
|
341
|
+
.map(([id, entry]) => ({
|
|
342
|
+
change: 'removed' as const,
|
|
343
|
+
details: [`Trail "${id}" removed`],
|
|
344
|
+
id,
|
|
345
|
+
kind: entry.kind,
|
|
346
|
+
severity: 'breaking' as const,
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
/** Find entries modified between prev and curr. */
|
|
350
|
+
const findModified = (
|
|
351
|
+
prevById: Map<string, SurfaceMapEntry>,
|
|
352
|
+
currById: Map<string, SurfaceMapEntry>
|
|
353
|
+
): DiffEntry[] => {
|
|
354
|
+
const results: DiffEntry[] = [];
|
|
355
|
+
for (const [id, currEntry] of currById) {
|
|
356
|
+
const prevEntry = prevById.get(id);
|
|
357
|
+
if (prevEntry) {
|
|
358
|
+
const diff = diffEntry(prevEntry, currEntry);
|
|
359
|
+
if (diff) {
|
|
360
|
+
results.push(diff);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return results;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** Collect all diff entries (added, removed, modified) between two maps. */
|
|
368
|
+
const collectDiffEntries = (
|
|
369
|
+
prevById: Map<string, SurfaceMapEntry>,
|
|
370
|
+
currById: Map<string, SurfaceMapEntry>
|
|
371
|
+
): DiffEntry[] => [
|
|
372
|
+
...findAdded(prevById, currById),
|
|
373
|
+
...findRemoved(prevById, currById),
|
|
374
|
+
...findModified(prevById, currById),
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
export const diffSurfaceMaps = (
|
|
378
|
+
prev: SurfaceMap,
|
|
379
|
+
curr: SurfaceMap
|
|
380
|
+
): DiffResult => {
|
|
381
|
+
const prevById = new Map(prev.entries.map((e) => [e.id, e]));
|
|
382
|
+
const currById = new Map(curr.entries.map((e) => [e.id, e]));
|
|
383
|
+
const sorted = collectDiffEntries(prevById, currById).toSorted((a, b) =>
|
|
384
|
+
a.id.localeCompare(b.id)
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const breaking = sorted.filter((e) => e.severity === 'breaking');
|
|
388
|
+
const warnings = sorted.filter((e) => e.severity === 'warning');
|
|
389
|
+
const info = sorted.filter((e) => e.severity === 'info');
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
breaking,
|
|
393
|
+
entries: sorted,
|
|
394
|
+
hasBreaking: breaking.length > 0,
|
|
395
|
+
info,
|
|
396
|
+
warnings,
|
|
397
|
+
};
|
|
398
|
+
};
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a deterministic surface map from a Topo.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { zodToJsonSchema } from '@ontrails/core';
|
|
6
|
+
import type { Event, Hike, Topo, Trail } from '@ontrails/core';
|
|
7
|
+
|
|
8
|
+
import type { JsonSchema, SurfaceMap, SurfaceMapEntry } from './types.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Sort object keys lexicographically (shallow). */
|
|
15
|
+
const sortKeys = <T extends Record<string, unknown>>(obj: T): T => {
|
|
16
|
+
const sorted: Record<string, unknown> = {};
|
|
17
|
+
for (const key of Object.keys(obj).toSorted()) {
|
|
18
|
+
sorted[key] = obj[key];
|
|
19
|
+
}
|
|
20
|
+
return sorted as T;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Sort object keys recursively for deterministic JSON Schema output. */
|
|
24
|
+
const deepSortKeys = (value: unknown): unknown => {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value.map(deepSortKeys);
|
|
27
|
+
}
|
|
28
|
+
if (value !== null && typeof value === 'object') {
|
|
29
|
+
const sorted: Record<string, unknown> = {};
|
|
30
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
31
|
+
sorted[key] = deepSortKeys((value as Record<string, unknown>)[key]);
|
|
32
|
+
}
|
|
33
|
+
return sorted;
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Convert a Zod schema to a deterministically-keyed JSON Schema. */
|
|
39
|
+
const toSortedJsonSchema = (schema: unknown): JsonSchema => {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
const raw = zodToJsonSchema(schema as any);
|
|
42
|
+
return deepSortKeys(raw) as JsonSchema;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Entry builders
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** Extract surfaces from a raw object. */
|
|
50
|
+
const extractSurfaces = (raw: Record<string, unknown>): string[] =>
|
|
51
|
+
Array.isArray(raw['surfaces'])
|
|
52
|
+
? (raw['surfaces'] as string[]).toSorted()
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
/** Add optional schemas to an entry. */
|
|
56
|
+
const addSchemas = (
|
|
57
|
+
entry: Record<string, unknown>,
|
|
58
|
+
t: Trail<unknown, unknown>
|
|
59
|
+
): void => {
|
|
60
|
+
if (t.input) {
|
|
61
|
+
entry['input'] = toSortedJsonSchema(t.input);
|
|
62
|
+
}
|
|
63
|
+
if (t.output) {
|
|
64
|
+
entry['output'] = toSortedJsonSchema(t.output);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Add safety markers to an entry. */
|
|
69
|
+
const addSafetyMarkers = (
|
|
70
|
+
entry: Record<string, unknown>,
|
|
71
|
+
t: Trail<unknown, unknown>
|
|
72
|
+
): void => {
|
|
73
|
+
if (t.readOnly === true) {
|
|
74
|
+
entry['readOnly'] = true;
|
|
75
|
+
}
|
|
76
|
+
if (t.destructive === true) {
|
|
77
|
+
entry['destructive'] = true;
|
|
78
|
+
}
|
|
79
|
+
if (t.idempotent === true) {
|
|
80
|
+
entry['idempotent'] = true;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Add deprecation and detours to an entry. */
|
|
85
|
+
const addExtendedMetadata = (
|
|
86
|
+
entry: Record<string, unknown>,
|
|
87
|
+
t: Trail<unknown, unknown>,
|
|
88
|
+
raw: Record<string, unknown>
|
|
89
|
+
): void => {
|
|
90
|
+
if (raw['deprecated'] === true) {
|
|
91
|
+
entry['deprecated'] = true;
|
|
92
|
+
}
|
|
93
|
+
if (typeof raw['replacedBy'] === 'string') {
|
|
94
|
+
entry['replacedBy'] = raw['replacedBy'];
|
|
95
|
+
}
|
|
96
|
+
if (t.detours) {
|
|
97
|
+
const detoursSorted: Record<string, readonly string[]> = {};
|
|
98
|
+
for (const key of Object.keys(t.detours).toSorted()) {
|
|
99
|
+
detoursSorted[key] = (t.detours[key] ?? []).toSorted();
|
|
100
|
+
}
|
|
101
|
+
entry['detours'] = detoursSorted;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Add optional metadata fields to an entry. */
|
|
106
|
+
const addMetadata = (
|
|
107
|
+
entry: Record<string, unknown>,
|
|
108
|
+
t: Trail<unknown, unknown>,
|
|
109
|
+
raw: Record<string, unknown>
|
|
110
|
+
): void => {
|
|
111
|
+
if (t.description !== undefined) {
|
|
112
|
+
entry['description'] = t.description;
|
|
113
|
+
}
|
|
114
|
+
addSafetyMarkers(entry, t);
|
|
115
|
+
addExtendedMetadata(entry, t, raw);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const trailToEntry = (t: Trail<unknown, unknown>): SurfaceMapEntry => {
|
|
119
|
+
const raw = t as unknown as Record<string, unknown>;
|
|
120
|
+
const entry: Record<string, unknown> = {
|
|
121
|
+
exampleCount: Array.isArray(t.examples) ? t.examples.length : 0,
|
|
122
|
+
id: t.id,
|
|
123
|
+
kind: t.kind,
|
|
124
|
+
surfaces: extractSurfaces(raw),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
addSchemas(entry, t);
|
|
128
|
+
addMetadata(entry, t, raw);
|
|
129
|
+
|
|
130
|
+
return sortKeys(entry) as unknown as SurfaceMapEntry;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const hikeToEntry = (r: Hike<unknown, unknown>): SurfaceMapEntry => {
|
|
134
|
+
const base = trailToEntry(r as unknown as Trail<unknown, unknown>);
|
|
135
|
+
const raw = r as unknown as Record<string, unknown>;
|
|
136
|
+
|
|
137
|
+
const entry: Record<string, unknown> = {
|
|
138
|
+
...base,
|
|
139
|
+
follows: r.follows.toSorted(),
|
|
140
|
+
kind: 'hike',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Re-check surfaces on the route itself
|
|
144
|
+
if (Array.isArray(raw['surfaces'])) {
|
|
145
|
+
entry['surfaces'] = (raw['surfaces'] as string[]).toSorted();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return sortKeys(entry) as unknown as SurfaceMapEntry;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Add optional event-specific fields. */
|
|
152
|
+
const addEventFields = (
|
|
153
|
+
entry: Record<string, unknown>,
|
|
154
|
+
e: Event<unknown>,
|
|
155
|
+
raw: Record<string, unknown>
|
|
156
|
+
): void => {
|
|
157
|
+
if (e.payload) {
|
|
158
|
+
entry['input'] = toSortedJsonSchema(e.payload);
|
|
159
|
+
}
|
|
160
|
+
if (e.description !== undefined) {
|
|
161
|
+
entry['description'] = e.description;
|
|
162
|
+
}
|
|
163
|
+
if (raw['deprecated'] === true) {
|
|
164
|
+
entry['deprecated'] = true;
|
|
165
|
+
}
|
|
166
|
+
if (typeof raw['replacedBy'] === 'string') {
|
|
167
|
+
entry['replacedBy'] = raw['replacedBy'];
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const eventToEntry = (e: Event<unknown>): SurfaceMapEntry => {
|
|
172
|
+
const raw = e as unknown as Record<string, unknown>;
|
|
173
|
+
const entry: Record<string, unknown> = {
|
|
174
|
+
exampleCount: 0,
|
|
175
|
+
id: e.id,
|
|
176
|
+
kind: 'event',
|
|
177
|
+
surfaces: extractSurfaces(raw),
|
|
178
|
+
};
|
|
179
|
+
addEventFields(entry, e, raw);
|
|
180
|
+
return sortKeys(entry) as unknown as SurfaceMapEntry;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Public API
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Generate a deterministic surface map from a Topo.
|
|
189
|
+
*
|
|
190
|
+
* Entries are sorted alphabetically by id. Object keys within each entry
|
|
191
|
+
* are sorted lexicographically for stable serialization.
|
|
192
|
+
*/
|
|
193
|
+
export const generateSurfaceMap = (topo: Topo): SurfaceMap => {
|
|
194
|
+
const entries: SurfaceMapEntry[] = [];
|
|
195
|
+
|
|
196
|
+
// Collect all trails
|
|
197
|
+
for (const t of topo.trails.values()) {
|
|
198
|
+
entries.push(trailToEntry(t as Trail<unknown, unknown>));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Collect all hikes
|
|
202
|
+
for (const r of topo.hikes.values()) {
|
|
203
|
+
entries.push(hikeToEntry(r as Hike<unknown, unknown>));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Collect all events
|
|
207
|
+
for (const e of topo.events.values()) {
|
|
208
|
+
entries.push(eventToEntry(e as Event<unknown>));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Sort alphabetically by id
|
|
212
|
+
const sorted = entries.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
entries: sorted,
|
|
216
|
+
generatedAt: new Date().toISOString(),
|
|
217
|
+
version: '1.0',
|
|
218
|
+
};
|
|
219
|
+
};
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 hashing for surface maps.
|
|
3
|
+
*
|
|
4
|
+
* Uses Bun.CryptoHasher for native hashing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SurfaceMap } from './types.js';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively sort all object keys for deterministic JSON serialization.
|
|
15
|
+
* Arrays preserve order; primitives pass through.
|
|
16
|
+
*/
|
|
17
|
+
const canonicalize = (value: unknown): unknown => {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map(canonicalize);
|
|
20
|
+
}
|
|
21
|
+
if (value !== null && typeof value === 'object') {
|
|
22
|
+
const sorted: Record<string, unknown> = {};
|
|
23
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
24
|
+
sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
|
|
25
|
+
}
|
|
26
|
+
return sorted;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Public API
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a SHA-256 hash of a surface map.
|
|
37
|
+
*
|
|
38
|
+
* The `generatedAt` field is excluded so that identical topos always
|
|
39
|
+
* produce the same hash regardless of when they were generated.
|
|
40
|
+
*/
|
|
41
|
+
export const hashSurfaceMap = (surfaceMap: SurfaceMap): string => {
|
|
42
|
+
// Strip generatedAt before hashing
|
|
43
|
+
const { generatedAt: _unused, ...rest } = surfaceMap;
|
|
44
|
+
|
|
45
|
+
const canonical = canonicalize(rest);
|
|
46
|
+
const json = JSON.stringify(canonical);
|
|
47
|
+
|
|
48
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
49
|
+
hasher.update(json);
|
|
50
|
+
return hasher.digest('hex');
|
|
51
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Generation
|
|
2
|
+
export { generateSurfaceMap } from './generate.js';
|
|
3
|
+
export { hashSurfaceMap } from './hash.js';
|
|
4
|
+
export { diffSurfaceMaps } from './diff.js';
|
|
5
|
+
|
|
6
|
+
// File I/O
|
|
7
|
+
export {
|
|
8
|
+
writeSurfaceMap,
|
|
9
|
+
readSurfaceMap,
|
|
10
|
+
writeSurfaceLock,
|
|
11
|
+
readSurfaceLock,
|
|
12
|
+
} from './io.js';
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
export type {
|
|
16
|
+
SurfaceMap,
|
|
17
|
+
SurfaceMapEntry,
|
|
18
|
+
DiffEntry,
|
|
19
|
+
DiffResult,
|
|
20
|
+
JsonSchema,
|
|
21
|
+
WriteOptions,
|
|
22
|
+
ReadOptions,
|
|
23
|
+
} from './types.js';
|