@ontrails/topography 1.0.0-beta.41
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/CHANGELOG.md +543 -0
- package/README.md +196 -0
- package/package.json +33 -0
- package/src/activation-report.ts +385 -0
- package/src/backend-support.ts +11 -0
- package/src/derive.ts +690 -0
- package/src/diff.ts +1088 -0
- package/src/forces.ts +125 -0
- package/src/hash.ts +55 -0
- package/src/index.ts +272 -0
- package/src/internal/topo-snapshots.ts +682 -0
- package/src/internal/topo-store-read.ts +1102 -0
- package/src/internal/topo-store.ts +1651 -0
- package/src/io.ts +280 -0
- package/src/library-projection.ts +133 -0
- package/src/overlays.ts +155 -0
- package/src/permit.ts +19 -0
- package/src/source-fingerprint.ts +103 -0
- package/src/surface-bindings.ts +101 -0
- package/src/topo-store.ts +716 -0
- package/src/types.ts +749 -0
- package/src/versioning.ts +280 -0
- package/src/wayfind/error-facts.ts +363 -0
- package/src/wayfind/filters.ts +430 -0
- package/src/wayfind/loader.ts +443 -0
- package/src/wayfind/navigation.ts +265 -0
- package/src/wayfind/provenance.ts +152 -0
- package/src/wayfind/queries.ts +1656 -0
- package/src/wayfind/relations.ts +344 -0
- package/src/workspace-topos.ts +483 -0
package/src/io.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File I/O for topo graphs and lock files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
LockManifest,
|
|
10
|
+
ReadOptions,
|
|
11
|
+
TopoGraph,
|
|
12
|
+
TrailsLock,
|
|
13
|
+
WorkspaceTopoMetadata,
|
|
14
|
+
WorkspaceTrailIndex,
|
|
15
|
+
WriteOptions,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
import {
|
|
18
|
+
LOCK_MANIFEST_SCHEMA_VERSION,
|
|
19
|
+
lockManifestSchema,
|
|
20
|
+
trailsLockSchema,
|
|
21
|
+
topoGraphSchema,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const DEFAULT_DIR = '.trails';
|
|
29
|
+
const LOCK_MANIFEST_FILE = 'trails.lock';
|
|
30
|
+
const TOPO_GRAPH_FILE = 'topo.lock';
|
|
31
|
+
const REGENERATE_LOCK_MESSAGE =
|
|
32
|
+
'Unsupported trails.lock format; regenerate with `trails compile`.';
|
|
33
|
+
const REGENERATE_TOPO_GRAPH_MESSAGE =
|
|
34
|
+
'Unsupported topo.lock format; regenerate with `trails compile`.';
|
|
35
|
+
|
|
36
|
+
export const isTopoArtifactRegenerationError = (
|
|
37
|
+
error: unknown
|
|
38
|
+
): error is Error =>
|
|
39
|
+
error instanceof Error &&
|
|
40
|
+
(error.message.includes(REGENERATE_LOCK_MESSAGE) ||
|
|
41
|
+
error.message.includes(REGENERATE_TOPO_GRAPH_MESSAGE));
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const isNotFound = (err: unknown): boolean =>
|
|
48
|
+
typeof err === 'object' &&
|
|
49
|
+
err !== null &&
|
|
50
|
+
(err as NodeJS.ErrnoException).code === 'ENOENT';
|
|
51
|
+
|
|
52
|
+
const resolveDir = (options?: ReadOptions | WriteOptions): string =>
|
|
53
|
+
options?.dir ?? DEFAULT_DIR;
|
|
54
|
+
|
|
55
|
+
const ensureDir = async (dir: string): Promise<void> => {
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const readTextIfExists = async (filePath: string): Promise<string | null> => {
|
|
60
|
+
try {
|
|
61
|
+
return await Bun.file(filePath).text();
|
|
62
|
+
} catch (error: unknown) {
|
|
63
|
+
if (!isNotFound(error)) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const parseJson = (
|
|
71
|
+
content: string,
|
|
72
|
+
fileName: string,
|
|
73
|
+
regenerateMessage: string
|
|
74
|
+
): unknown => {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(content) as unknown;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`Invalid JSON in ${fileName}. ${regenerateMessage}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const parseLockManifest = (content: string): LockManifest => {
|
|
83
|
+
const parsed = parseJson(
|
|
84
|
+
content,
|
|
85
|
+
LOCK_MANIFEST_FILE,
|
|
86
|
+
REGENERATE_LOCK_MESSAGE
|
|
87
|
+
);
|
|
88
|
+
const result = lockManifestSchema.safeParse(parsed);
|
|
89
|
+
if (result.success) {
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(REGENERATE_LOCK_MESSAGE);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const parseTrailsLock = (content: string): TrailsLock => {
|
|
96
|
+
const parsed = parseJson(
|
|
97
|
+
content,
|
|
98
|
+
LOCK_MANIFEST_FILE,
|
|
99
|
+
REGENERATE_LOCK_MESSAGE
|
|
100
|
+
);
|
|
101
|
+
const result = trailsLockSchema.safeParse(parsed);
|
|
102
|
+
if (result.success) {
|
|
103
|
+
return result.data as TrailsLock;
|
|
104
|
+
}
|
|
105
|
+
throw new Error(REGENERATE_LOCK_MESSAGE);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const parseTopoGraph = (content: string): TopoGraph => {
|
|
109
|
+
const parsed = parseJson(
|
|
110
|
+
content,
|
|
111
|
+
TOPO_GRAPH_FILE,
|
|
112
|
+
REGENERATE_TOPO_GRAPH_MESSAGE
|
|
113
|
+
);
|
|
114
|
+
const result = topoGraphSchema.safeParse(parsed);
|
|
115
|
+
if (result.success) {
|
|
116
|
+
return result.data as TopoGraph;
|
|
117
|
+
}
|
|
118
|
+
throw new Error(REGENERATE_TOPO_GRAPH_MESSAGE);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const lockManifestFromTrailsLock = (lock: TrailsLock): LockManifest => ({
|
|
122
|
+
artifacts: [
|
|
123
|
+
{
|
|
124
|
+
path: TOPO_GRAPH_FILE,
|
|
125
|
+
role: 'topo',
|
|
126
|
+
sha256: lock.topoGraphHash,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
scope: lock.scope,
|
|
130
|
+
summary: lock.summary,
|
|
131
|
+
version: LOCK_MANIFEST_SCHEMA_VERSION,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// TopoGraph
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write a topo graph to `<dir>/topo.lock`.
|
|
140
|
+
*
|
|
141
|
+
* Creates the directory if it doesn't exist. Returns the file path.
|
|
142
|
+
*/
|
|
143
|
+
export const writeTopoGraph = async (
|
|
144
|
+
topoGraph: TopoGraph,
|
|
145
|
+
options?: WriteOptions
|
|
146
|
+
): Promise<string> => {
|
|
147
|
+
const dir = resolveDir(options);
|
|
148
|
+
await ensureDir(dir);
|
|
149
|
+
const filePath = join(dir, TOPO_GRAPH_FILE);
|
|
150
|
+
const json = `${JSON.stringify(topoGraph, null, 2)}\n`;
|
|
151
|
+
await Bun.write(filePath, json);
|
|
152
|
+
return filePath;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read a topo graph from `<dir>/trails.lock` or legacy `<dir>/topo.lock`.
|
|
157
|
+
*/
|
|
158
|
+
export const readTopoGraph = async (
|
|
159
|
+
options?: ReadOptions
|
|
160
|
+
): Promise<TopoGraph | null> => {
|
|
161
|
+
const dir = resolveDir(options);
|
|
162
|
+
const lockContent = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
|
|
163
|
+
if (lockContent !== null) {
|
|
164
|
+
try {
|
|
165
|
+
return parseTrailsLock(lockContent).topoGraph as TopoGraph;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
try {
|
|
168
|
+
parseLockManifest(lockContent);
|
|
169
|
+
} catch {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
const legacyTopoContent = await readTextIfExists(
|
|
173
|
+
join(dir, TOPO_GRAPH_FILE)
|
|
174
|
+
);
|
|
175
|
+
if (legacyTopoContent === null) {
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return parseTopoGraph(legacyTopoContent);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const content = await readTextIfExists(join(dir, TOPO_GRAPH_FILE));
|
|
183
|
+
return content ? parseTopoGraph(content) : null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Lock Manifest
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write a lock v4 manifest to `<dir>/trails.lock`.
|
|
192
|
+
*
|
|
193
|
+
* Creates the directory if it doesn't exist. Returns the file path.
|
|
194
|
+
*/
|
|
195
|
+
export const writeLockManifest = async (
|
|
196
|
+
lockManifest: LockManifest,
|
|
197
|
+
options?: WriteOptions
|
|
198
|
+
): Promise<string> => {
|
|
199
|
+
const dir = resolveDir(options);
|
|
200
|
+
await ensureDir(dir);
|
|
201
|
+
const filePath = join(dir, LOCK_MANIFEST_FILE);
|
|
202
|
+
const parsed = lockManifestSchema.parse(lockManifest);
|
|
203
|
+
await Bun.write(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
204
|
+
return filePath;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Write a v4 Trails lock envelope to `<dir>/trails.lock`.
|
|
209
|
+
*/
|
|
210
|
+
export const writeTrailsLock = async (
|
|
211
|
+
trailsLock: TrailsLock,
|
|
212
|
+
options?: WriteOptions
|
|
213
|
+
): Promise<string> => {
|
|
214
|
+
const dir = resolveDir(options);
|
|
215
|
+
await ensureDir(dir);
|
|
216
|
+
const filePath = join(dir, LOCK_MANIFEST_FILE);
|
|
217
|
+
const parsed = trailsLockSchema.parse(trailsLock);
|
|
218
|
+
await Bun.write(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
219
|
+
return filePath;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read a lock v4 manifest from `<dir>/trails.lock`.
|
|
224
|
+
*
|
|
225
|
+
* v5 root locks are projected back to the v4 manifest shape for compatibility.
|
|
226
|
+
*/
|
|
227
|
+
export const readLockManifest = async (
|
|
228
|
+
options?: ReadOptions
|
|
229
|
+
): Promise<LockManifest | null> => {
|
|
230
|
+
const dir = resolveDir(options);
|
|
231
|
+
const content = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
|
|
232
|
+
if (content === null) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
return lockManifestFromTrailsLock(parseTrailsLock(content));
|
|
238
|
+
} catch {
|
|
239
|
+
return parseLockManifest(content);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read a v4 Trails lock envelope from `<dir>/trails.lock`.
|
|
245
|
+
*/
|
|
246
|
+
export const readTrailsLock = async (
|
|
247
|
+
options?: ReadOptions
|
|
248
|
+
): Promise<TrailsLock | null> => {
|
|
249
|
+
const dir = resolveDir(options);
|
|
250
|
+
const content = await readTextIfExists(join(dir, LOCK_MANIFEST_FILE));
|
|
251
|
+
return content ? parseTrailsLock(content) : null;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read workspace metadata from the current topo graph artifact.
|
|
256
|
+
*/
|
|
257
|
+
export const readWorkspaceTopoMetadata = async (
|
|
258
|
+
options?: ReadOptions
|
|
259
|
+
): Promise<WorkspaceTopoMetadata | null> => {
|
|
260
|
+
const topoGraph = await readTopoGraph(options);
|
|
261
|
+
if (topoGraph === null) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return topoGraph.workspace ?? null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read the workspace trail-id index from the current topo graph artifact.
|
|
269
|
+
*/
|
|
270
|
+
export const readWorkspaceTrailIndex = async (
|
|
271
|
+
options?: ReadOptions
|
|
272
|
+
): Promise<WorkspaceTrailIndex | null> => {
|
|
273
|
+
const workspace = await readWorkspaceTopoMetadata(options);
|
|
274
|
+
return workspace?.trails ?? null;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** @deprecated Use {@link readWorkspaceTrailIndex}. */
|
|
278
|
+
export const readWorkspaceLock = async (
|
|
279
|
+
options?: ReadOptions
|
|
280
|
+
): Promise<WorkspaceTrailIndex | null> => readWorkspaceTrailIndex(options);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterSurfaceTrails,
|
|
3
|
+
isDraftId,
|
|
4
|
+
zodToJsonSchema,
|
|
5
|
+
} from '@ontrails/core';
|
|
6
|
+
import type { Topo, Trail } from '@ontrails/core';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
JsonSchema,
|
|
10
|
+
TopoGraphLibraryCollision,
|
|
11
|
+
TopoGraphLibraryExclusion,
|
|
12
|
+
TopoGraphLibraryExport,
|
|
13
|
+
TopoGraphLibraryProjection,
|
|
14
|
+
} from './types.js';
|
|
15
|
+
|
|
16
|
+
const sortKeys = <T extends Record<string, unknown>>(obj: T): T => {
|
|
17
|
+
const sorted: Record<string, unknown> = {};
|
|
18
|
+
for (const key of Object.keys(obj).toSorted()) {
|
|
19
|
+
sorted[key] = obj[key];
|
|
20
|
+
}
|
|
21
|
+
return sorted as T;
|
|
22
|
+
};
|
|
23
|
+
|
|
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
|
+
const toSortedJsonSchema = (schema: unknown): JsonSchema => {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
const raw = zodToJsonSchema(schema as any);
|
|
41
|
+
return deepSortKeys(raw) as JsonSchema;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const deriveLibraryExportName = (trailId: string): string => {
|
|
45
|
+
const words = trailId.split(/[.-]/u).filter((word) => word.length > 0);
|
|
46
|
+
return words
|
|
47
|
+
.map((word, index) =>
|
|
48
|
+
index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)
|
|
49
|
+
)
|
|
50
|
+
.join('');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isInternalTrail = (trail: Trail<unknown, unknown, unknown>): boolean =>
|
|
54
|
+
trail.visibility === 'internal' || trail.meta?.['internal'] === true;
|
|
55
|
+
|
|
56
|
+
const collectLibraryExclusions = (
|
|
57
|
+
topo: Topo,
|
|
58
|
+
selectedIds: ReadonlySet<string>
|
|
59
|
+
): readonly TopoGraphLibraryExclusion[] => {
|
|
60
|
+
const excluded: TopoGraphLibraryExclusion[] = [];
|
|
61
|
+
|
|
62
|
+
for (const trail of topo.trails.values()) {
|
|
63
|
+
if (selectedIds.has(trail.id)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (isDraftId(trail.id)) {
|
|
67
|
+
excluded.push({ reason: 'draft', trailId: trail.id });
|
|
68
|
+
} else if (trail.activationSources.length > 0) {
|
|
69
|
+
excluded.push({ reason: 'activation', trailId: trail.id });
|
|
70
|
+
} else if (isInternalTrail(trail as Trail<unknown, unknown, unknown>)) {
|
|
71
|
+
excluded.push({ reason: 'internal', trailId: trail.id });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return excluded.toSorted((a, b) => a.trailId.localeCompare(b.trailId));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const collectLibraryProjection = (
|
|
79
|
+
topo: Topo
|
|
80
|
+
): TopoGraphLibraryProjection => {
|
|
81
|
+
const selected = filterSurfaceTrails([...topo.trails.values()]).filter(
|
|
82
|
+
(trail) => !isDraftId(trail.id)
|
|
83
|
+
);
|
|
84
|
+
const selectedIds = new Set(selected.map((trail) => trail.id));
|
|
85
|
+
const namesToTrailIds = new Map<string, string[]>();
|
|
86
|
+
const collisionNames = new Set<string>();
|
|
87
|
+
const exports: TopoGraphLibraryExport[] = [];
|
|
88
|
+
|
|
89
|
+
for (const trail of selected.toSorted((a, b) => a.id.localeCompare(b.id))) {
|
|
90
|
+
const exportName = deriveLibraryExportName(trail.id);
|
|
91
|
+
const existing = namesToTrailIds.get(exportName);
|
|
92
|
+
if (existing !== undefined) {
|
|
93
|
+
existing.push(trail.id);
|
|
94
|
+
collisionNames.add(exportName);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
namesToTrailIds.set(exportName, [trail.id]);
|
|
99
|
+
exports.push(
|
|
100
|
+
sortKeys({
|
|
101
|
+
...(trail.description === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { description: trail.description }),
|
|
104
|
+
exportName,
|
|
105
|
+
...(trail.input === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { input: toSortedJsonSchema(trail.input) }),
|
|
108
|
+
intent: trail.intent,
|
|
109
|
+
nameSource: 'derived' as const,
|
|
110
|
+
...(trail.output === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { output: toSortedJsonSchema(trail.output) }),
|
|
113
|
+
resources: trail.resources.map((resource) => resource.id).toSorted(),
|
|
114
|
+
trailId: trail.id,
|
|
115
|
+
...(trail.version === undefined ? {} : { version: trail.version }),
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const collisions: TopoGraphLibraryCollision[] = [...collisionNames]
|
|
121
|
+
.map((name) => ({
|
|
122
|
+
exportName: name,
|
|
123
|
+
trailIds: namesToTrailIds.get(name) ?? [],
|
|
124
|
+
}))
|
|
125
|
+
.toSorted((a, b) => a.exportName.localeCompare(b.exportName));
|
|
126
|
+
|
|
127
|
+
return sortKeys({
|
|
128
|
+
app: topo.name,
|
|
129
|
+
collisions,
|
|
130
|
+
excluded: collectLibraryExclusions(topo, selectedIds),
|
|
131
|
+
exports,
|
|
132
|
+
}) as unknown as TopoGraphLibraryProjection;
|
|
133
|
+
};
|
package/src/overlays.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespaced overlay collection for topo graphs.
|
|
3
|
+
*
|
|
4
|
+
* Both derivation pipelines — `deriveTopoGraph` and the store-side
|
|
5
|
+
* `buildTopoGraph` — consume overlay registrations through this single
|
|
6
|
+
* collection path so compiled locks and fresh derivations cannot diverge.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SURFACES_OVERLAY_NAMESPACE, ValidationError } from '@ontrails/core';
|
|
10
|
+
import type { Topo } from '@ontrails/core';
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
TopoGraphOverlayRegistration,
|
|
14
|
+
TopoGraphOverlays,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
|
17
|
+
const OVERLAY_NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*$/u;
|
|
18
|
+
|
|
19
|
+
const DERIVE_REMEDIATION =
|
|
20
|
+
"Fix the contribution's derive output and rerun `trails compile`.";
|
|
21
|
+
|
|
22
|
+
/** Sort object keys recursively for deterministic overlay serialization. */
|
|
23
|
+
const deepSortKeys = (value: unknown): unknown => {
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
return value.map(deepSortKeys);
|
|
26
|
+
}
|
|
27
|
+
if (value !== null && typeof value === 'object') {
|
|
28
|
+
const sorted: Record<string, unknown> = {};
|
|
29
|
+
for (const key of Object.keys(value).toSorted()) {
|
|
30
|
+
sorted[key] = deepSortKeys((value as Record<string, unknown>)[key]);
|
|
31
|
+
}
|
|
32
|
+
return sorted;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const summarizeIssuePath = (path: readonly PropertyKey[]): string =>
|
|
38
|
+
path.length === 0 ? '(root)' : path.map(String).join('.');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Canonicalize schema-parsed facts into JSON-plain, deterministically ordered
|
|
42
|
+
* data: a JSON round-trip strips non-JSON residue, then a deep key-sort makes
|
|
43
|
+
* serialization order stable.
|
|
44
|
+
*/
|
|
45
|
+
const canonicalizeOverlayFacts = (
|
|
46
|
+
namespace: string,
|
|
47
|
+
parsed: unknown
|
|
48
|
+
): unknown => {
|
|
49
|
+
let serialized: string | undefined;
|
|
50
|
+
try {
|
|
51
|
+
serialized = JSON.stringify(parsed);
|
|
52
|
+
} catch {
|
|
53
|
+
serialized = undefined;
|
|
54
|
+
}
|
|
55
|
+
if (serialized === undefined) {
|
|
56
|
+
throw new ValidationError(
|
|
57
|
+
`Overlay namespace "${namespace}" derived facts are not JSON-serializable. ${DERIVE_REMEDIATION}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return deepSortKeys(JSON.parse(serialized));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const assertValidNamespace = (namespace: string): void => {
|
|
64
|
+
if (!OVERLAY_NAMESPACE_PATTERN.test(namespace)) {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
`Overlay namespace "${namespace}" must be dotted kebab-case (matching ${OVERLAY_NAMESPACE_PATTERN.source}). Fix the contribution's namespace and rerun \`trails compile\`.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Enforce the reserved `surfaces` namespace: surfaces obey app-authored
|
|
73
|
+
* overlays only, so an adapter-derived registration (absent provenance is
|
|
74
|
+
* adapter-derived) can never own it.
|
|
75
|
+
*/
|
|
76
|
+
const assertSurfacesProvenance = (
|
|
77
|
+
registration: TopoGraphOverlayRegistration
|
|
78
|
+
): void => {
|
|
79
|
+
if (
|
|
80
|
+
registration.namespace === SURFACES_OVERLAY_NAMESPACE &&
|
|
81
|
+
registration.provenance !== 'app-authored'
|
|
82
|
+
) {
|
|
83
|
+
throw new ValidationError(
|
|
84
|
+
`Adapter-derived overlays cannot own the "${SURFACES_OVERLAY_NAMESPACE}" namespace. Author bindings with \`surfaceOverlay()\` in the app module and rerun \`trails compile\`.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const deriveOverlayFacts = (
|
|
90
|
+
topo: Topo,
|
|
91
|
+
registration: TopoGraphOverlayRegistration
|
|
92
|
+
): unknown => {
|
|
93
|
+
const derived = registration.derive(topo);
|
|
94
|
+
const parsed = registration.schema.safeParse(derived);
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
const issues = parsed.error.issues
|
|
97
|
+
.map((issue) => `${summarizeIssuePath(issue.path)}: ${issue.message}`)
|
|
98
|
+
.join('; ');
|
|
99
|
+
throw new ValidationError(
|
|
100
|
+
`Overlay namespace "${registration.namespace}" derived facts failed schema validation (${issues}). ${DERIVE_REMEDIATION}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return canonicalizeOverlayFacts(registration.namespace, parsed.data);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Collect namespaced fact overlays from overlay registrations.
|
|
108
|
+
*
|
|
109
|
+
* Each registration owns one dotted-kebab namespace; its derived facts are
|
|
110
|
+
* validated against the registration's schema, canonicalized into JSON-plain
|
|
111
|
+
* deep-key-sorted data, and assembled with namespaces sorted
|
|
112
|
+
* lexicographically. Returns `undefined` when no registrations are supplied
|
|
113
|
+
* so graphs without overlays stay byte-identical to pre-overlays locks.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* const overlays = collectTopoGraphOverlays(app, [
|
|
118
|
+
* {
|
|
119
|
+
* derive: (topo) => ({ trailCount: topo.trails.size }),
|
|
120
|
+
* namespace: 'cloudflare',
|
|
121
|
+
* schema: z.object({ trailCount: z.number() }),
|
|
122
|
+
* },
|
|
123
|
+
* ]);
|
|
124
|
+
* // => { cloudflare: { trailCount: 3 } }
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export const collectTopoGraphOverlays = (
|
|
128
|
+
topo: Topo,
|
|
129
|
+
registrations: readonly TopoGraphOverlayRegistration[] | undefined
|
|
130
|
+
): TopoGraphOverlays | undefined => {
|
|
131
|
+
if (registrations === undefined || registrations.length === 0) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const collected = new Map<string, unknown>();
|
|
136
|
+
for (const registration of registrations) {
|
|
137
|
+
assertValidNamespace(registration.namespace);
|
|
138
|
+
assertSurfacesProvenance(registration);
|
|
139
|
+
if (collected.has(registration.namespace)) {
|
|
140
|
+
throw new ValidationError(
|
|
141
|
+
`Duplicate overlay namespace "${registration.namespace}". Each contribution owns one namespace; remove the duplicate registration and rerun \`trails compile\`.`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
collected.set(
|
|
145
|
+
registration.namespace,
|
|
146
|
+
deriveOverlayFacts(topo, registration)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const overlays: Record<string, unknown> = {};
|
|
151
|
+
for (const namespace of [...collected.keys()].toSorted()) {
|
|
152
|
+
overlays[namespace] = collected.get(namespace);
|
|
153
|
+
}
|
|
154
|
+
return overlays;
|
|
155
|
+
};
|
package/src/permit.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AnyTrail, PermitRequirement } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
export type ProjectedPermitRequirement =
|
|
4
|
+
| 'public'
|
|
5
|
+
| { readonly scopes: readonly string[] };
|
|
6
|
+
|
|
7
|
+
export const projectPermitRequirement = (
|
|
8
|
+
permit: PermitRequirement
|
|
9
|
+
): ProjectedPermitRequirement =>
|
|
10
|
+
permit === 'public' ? 'public' : { scopes: [...permit.scopes].toSorted() };
|
|
11
|
+
|
|
12
|
+
export const addPermitRequirement = (
|
|
13
|
+
entry: Record<string, unknown>,
|
|
14
|
+
trail: AnyTrail
|
|
15
|
+
): void => {
|
|
16
|
+
if (trail.permit !== undefined) {
|
|
17
|
+
entry['permit'] = projectPermitRequirement(trail.permit);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content fingerprint over an app's source file set.
|
|
3
|
+
*
|
|
4
|
+
* The per-user topo store records this fingerprint on every snapshot so
|
|
5
|
+
* consumers that serve stored exports can detect that the app sources
|
|
6
|
+
* changed since the snapshot was taken and self-invalidate instead of
|
|
7
|
+
* silently serving pre-edit facts (TRL-1196).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, relative } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/** Directories that never contribute to the app source set. */
|
|
14
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
15
|
+
'.git',
|
|
16
|
+
'.trails',
|
|
17
|
+
'.trails-tmp',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'dist',
|
|
20
|
+
'node_modules',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** Files that are derived outputs rather than sources. */
|
|
24
|
+
const EXCLUDED_FILES = new Set(['trails.lock', 'topo.lock', 'bun.lock']);
|
|
25
|
+
|
|
26
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
27
|
+
'.cjs',
|
|
28
|
+
'.cts',
|
|
29
|
+
'.js',
|
|
30
|
+
'.json',
|
|
31
|
+
'.jsx',
|
|
32
|
+
'.mjs',
|
|
33
|
+
'.mts',
|
|
34
|
+
'.ts',
|
|
35
|
+
'.tsx',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const hasSourceExtension = (fileName: string): boolean => {
|
|
39
|
+
const dot = fileName.lastIndexOf('.');
|
|
40
|
+
return dot !== -1 && SOURCE_EXTENSIONS.has(fileName.slice(dot));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const collectSourceFiles = (dir: string, out: string[]) => {
|
|
44
|
+
let entries: string[];
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(dir);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries.toSorted()) {
|
|
51
|
+
const entryPath = join(dir, entry);
|
|
52
|
+
let stats: ReturnType<typeof statSync>;
|
|
53
|
+
try {
|
|
54
|
+
stats = statSync(entryPath);
|
|
55
|
+
} catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (stats.isDirectory()) {
|
|
59
|
+
if (!EXCLUDED_DIRECTORIES.has(entry)) {
|
|
60
|
+
collectSourceFiles(entryPath, out);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
stats.isFile() &&
|
|
66
|
+
hasSourceExtension(entry) &&
|
|
67
|
+
!EXCLUDED_FILES.has(entry)
|
|
68
|
+
) {
|
|
69
|
+
out.push(entryPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Derive a SHA-256 fingerprint of the source files under `rootDir`.
|
|
76
|
+
*
|
|
77
|
+
* The fingerprint covers relative paths and file contents, so edits,
|
|
78
|
+
* additions, removals, and renames all change it. Excluded directories
|
|
79
|
+
* (`node_modules`, `dist`, `.git`, `.trails`, `.trails-tmp`, `.turbo`)
|
|
80
|
+
* and derived artifacts (`trails.lock`, `topo.lock`, `bun.lock`) never
|
|
81
|
+
* participate, so recompiling does not invalidate its own fingerprint.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const fingerprint = deriveSourceFingerprint('/path/to/app');
|
|
86
|
+
* // "3f8a…" — stable until a source file changes
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export const deriveSourceFingerprint = (rootDir: string): string => {
|
|
90
|
+
const files: string[] = [];
|
|
91
|
+
collectSourceFiles(rootDir, files);
|
|
92
|
+
|
|
93
|
+
const hasher = new Bun.CryptoHasher('sha256');
|
|
94
|
+
for (const filePath of files) {
|
|
95
|
+
const fileHasher = new Bun.CryptoHasher('sha256');
|
|
96
|
+
fileHasher.update(readFileSync(filePath));
|
|
97
|
+
hasher.update(relative(rootDir, filePath));
|
|
98
|
+
hasher.update('\0');
|
|
99
|
+
hasher.update(fileHasher.digest('hex'));
|
|
100
|
+
hasher.update('\n');
|
|
101
|
+
}
|
|
102
|
+
return hasher.digest('hex');
|
|
103
|
+
};
|