@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,469 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import { Result, provision, signal, topo, trail } from '../index.js';
|
|
9
|
+
import {
|
|
10
|
+
ensureTopoHistorySchema,
|
|
11
|
+
pinTopoSave,
|
|
12
|
+
pruneUnpinnedTopoSaves,
|
|
13
|
+
} from '../internal/topo-saves.js';
|
|
14
|
+
import {
|
|
15
|
+
getStoredTopoExport,
|
|
16
|
+
persistEstablishedTopoSave,
|
|
17
|
+
} from '../internal/topo-store.js';
|
|
18
|
+
import { openWriteTrailsDb } from '../internal/trails-db.js';
|
|
19
|
+
|
|
20
|
+
const noop = () => Result.ok({ ok: true });
|
|
21
|
+
|
|
22
|
+
/** Unwrap a Result in tests, throwing on Err. */
|
|
23
|
+
const unwrap = <T>(result: Result<T, Error>): T => {
|
|
24
|
+
if (result.isErr()) {
|
|
25
|
+
throw result.error;
|
|
26
|
+
}
|
|
27
|
+
return result.value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const countRows = (
|
|
31
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
32
|
+
tableName: string,
|
|
33
|
+
saveId?: string
|
|
34
|
+
): number => {
|
|
35
|
+
const row =
|
|
36
|
+
saveId === undefined
|
|
37
|
+
? db
|
|
38
|
+
.query<{ count: number }, []>(
|
|
39
|
+
`SELECT COUNT(*) as count FROM ${tableName}`
|
|
40
|
+
)
|
|
41
|
+
.get()
|
|
42
|
+
: db
|
|
43
|
+
.query<{ count: number }, [string]>(
|
|
44
|
+
`SELECT COUNT(*) as count FROM ${tableName} WHERE save_id = ?`
|
|
45
|
+
)
|
|
46
|
+
.get(saveId);
|
|
47
|
+
return row?.count ?? 0;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const tableExists = (
|
|
51
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
52
|
+
tableName: string
|
|
53
|
+
): boolean => {
|
|
54
|
+
const row = db
|
|
55
|
+
.query<{ name: string }, [string]>(
|
|
56
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
|
|
57
|
+
)
|
|
58
|
+
.get(tableName);
|
|
59
|
+
return row?.name === tableName;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const exampleApp = () => {
|
|
63
|
+
const dbMain = provision('db.main', {
|
|
64
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
65
|
+
description: 'Primary database',
|
|
66
|
+
health: () => Result.ok({ ok: true }),
|
|
67
|
+
mock: () => ({ source: 'mock' }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const searchIndex = provision('search.index', {
|
|
71
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
72
|
+
mock: () => ({ source: 'mock' }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const entityAdded = signal('entity.added', {
|
|
76
|
+
description: 'An entity was added',
|
|
77
|
+
from: ['entity.add'],
|
|
78
|
+
payload: z.object({ id: z.string() }),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const entityAdd = trail('entity.add', {
|
|
82
|
+
blaze: (input: { readonly name: string }) =>
|
|
83
|
+
Result.ok({ id: input.name.toLowerCase(), ok: true }),
|
|
84
|
+
description: 'Add a new entity',
|
|
85
|
+
examples: [
|
|
86
|
+
{
|
|
87
|
+
expected: { id: 'ada', ok: true },
|
|
88
|
+
input: { name: 'Ada' },
|
|
89
|
+
name: 'Add Ada',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
error: 'ConflictError',
|
|
93
|
+
input: { name: 'Existing' },
|
|
94
|
+
name: 'Conflict on duplicate',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
input: z.object({ name: z.string() }),
|
|
98
|
+
meta: { owner: 'core', tags: ['write', 'entity'] },
|
|
99
|
+
output: z.object({ id: z.string(), ok: z.boolean() }),
|
|
100
|
+
provisions: [dbMain, searchIndex],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const entityList = trail('entity.list', {
|
|
104
|
+
blaze: () => Result.ok({ items: ['ada'] }),
|
|
105
|
+
crosses: ['entity.add'],
|
|
106
|
+
description: 'List entities',
|
|
107
|
+
idempotent: true,
|
|
108
|
+
input: z.object({}),
|
|
109
|
+
intent: 'read',
|
|
110
|
+
output: z.object({ items: z.array(z.string()) }),
|
|
111
|
+
provisions: [dbMain],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return topo('projection-app', {
|
|
115
|
+
dbMain,
|
|
116
|
+
entityAdd,
|
|
117
|
+
entityAdded,
|
|
118
|
+
entityList,
|
|
119
|
+
searchIndex,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const readTrailRows = (
|
|
124
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
125
|
+
saveId: string
|
|
126
|
+
) =>
|
|
127
|
+
db
|
|
128
|
+
.query<
|
|
129
|
+
{
|
|
130
|
+
description: string | null;
|
|
131
|
+
example_count: number;
|
|
132
|
+
has_output: number;
|
|
133
|
+
id: string;
|
|
134
|
+
idempotent: number;
|
|
135
|
+
intent: string;
|
|
136
|
+
meta: string | null;
|
|
137
|
+
},
|
|
138
|
+
[string]
|
|
139
|
+
>(
|
|
140
|
+
`SELECT id, intent, idempotent, has_output, example_count, description, meta
|
|
141
|
+
FROM topo_trails
|
|
142
|
+
WHERE save_id = ?
|
|
143
|
+
ORDER BY id ASC`
|
|
144
|
+
)
|
|
145
|
+
.all(saveId);
|
|
146
|
+
|
|
147
|
+
const readTrailSignalRows = (
|
|
148
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
149
|
+
saveId: string
|
|
150
|
+
) =>
|
|
151
|
+
db
|
|
152
|
+
.query<{ signal_id: string; trail_id: string }, [string]>(
|
|
153
|
+
`SELECT trail_id, signal_id
|
|
154
|
+
FROM topo_trail_signals
|
|
155
|
+
WHERE save_id = ?`
|
|
156
|
+
)
|
|
157
|
+
.all(saveId);
|
|
158
|
+
|
|
159
|
+
const readTrailheadRows = (
|
|
160
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
161
|
+
saveId: string
|
|
162
|
+
) =>
|
|
163
|
+
db
|
|
164
|
+
.query<{ derived_name: string; trail_id: string }, [string]>(
|
|
165
|
+
`SELECT trail_id, derived_name
|
|
166
|
+
FROM topo_trailheads
|
|
167
|
+
WHERE save_id = ?
|
|
168
|
+
ORDER BY trail_id ASC`
|
|
169
|
+
)
|
|
170
|
+
.all(saveId);
|
|
171
|
+
|
|
172
|
+
const readExampleRows = (
|
|
173
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
174
|
+
saveId: string
|
|
175
|
+
) =>
|
|
176
|
+
db
|
|
177
|
+
.query<
|
|
178
|
+
{
|
|
179
|
+
error: string | null;
|
|
180
|
+
expected: string | null;
|
|
181
|
+
input: string;
|
|
182
|
+
name: string;
|
|
183
|
+
ordinal: number;
|
|
184
|
+
},
|
|
185
|
+
[string]
|
|
186
|
+
>(
|
|
187
|
+
`SELECT ordinal, name, input, expected, error
|
|
188
|
+
FROM topo_examples
|
|
189
|
+
WHERE save_id = ?
|
|
190
|
+
ORDER BY ordinal ASC`
|
|
191
|
+
)
|
|
192
|
+
.all(saveId);
|
|
193
|
+
|
|
194
|
+
const readProjectedTrailIds = (
|
|
195
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
196
|
+
saveId: string
|
|
197
|
+
) =>
|
|
198
|
+
db
|
|
199
|
+
.query<{ id: string }, [string]>(
|
|
200
|
+
'SELECT id FROM topo_trails WHERE save_id = ? ORDER BY id ASC'
|
|
201
|
+
)
|
|
202
|
+
.all(saveId)
|
|
203
|
+
.map((row) => row.id);
|
|
204
|
+
|
|
205
|
+
const requireStoredExport = (
|
|
206
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
207
|
+
saveId: string
|
|
208
|
+
) => {
|
|
209
|
+
const stored = getStoredTopoExport(db, saveId);
|
|
210
|
+
if (stored === undefined) {
|
|
211
|
+
throw new Error(`Expected stored topo export for save "${saveId}"`);
|
|
212
|
+
}
|
|
213
|
+
return stored;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const expectProjectionCounts = (
|
|
217
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
218
|
+
saveId: string
|
|
219
|
+
): void => {
|
|
220
|
+
expect(countRows(db, 'topo_trails', saveId)).toBe(2);
|
|
221
|
+
expect(countRows(db, 'topo_crossings', saveId)).toBe(1);
|
|
222
|
+
expect(countRows(db, 'topo_trail_provisions', saveId)).toBe(3);
|
|
223
|
+
expect(countRows(db, 'topo_provisions', saveId)).toBe(2);
|
|
224
|
+
expect(countRows(db, 'topo_signals', saveId)).toBe(1);
|
|
225
|
+
expect(countRows(db, 'topo_trail_signals', saveId)).toBe(1);
|
|
226
|
+
expect(countRows(db, 'topo_trailheads', saveId)).toBe(2);
|
|
227
|
+
expect(countRows(db, 'topo_examples', saveId)).toBe(2);
|
|
228
|
+
expect(countRows(db, 'topo_schemas', saveId)).toBe(5);
|
|
229
|
+
expect(countRows(db, 'topo_exports')).toBeGreaterThanOrEqual(1);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const expectProjectedFixtureRows = (
|
|
233
|
+
db: ReturnType<typeof openWriteTrailsDb>,
|
|
234
|
+
saveId: string
|
|
235
|
+
): void => {
|
|
236
|
+
expect(readTrailRows(db, saveId)).toEqual([
|
|
237
|
+
{
|
|
238
|
+
description: 'Add a new entity',
|
|
239
|
+
example_count: 2,
|
|
240
|
+
has_output: 1,
|
|
241
|
+
id: 'entity.add',
|
|
242
|
+
idempotent: 0,
|
|
243
|
+
intent: 'write',
|
|
244
|
+
meta: JSON.stringify({
|
|
245
|
+
owner: 'core',
|
|
246
|
+
tags: ['write', 'entity'],
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
description: 'List entities',
|
|
251
|
+
example_count: 0,
|
|
252
|
+
has_output: 1,
|
|
253
|
+
id: 'entity.list',
|
|
254
|
+
idempotent: 1,
|
|
255
|
+
intent: 'read',
|
|
256
|
+
meta: null,
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
expect(readTrailSignalRows(db, saveId)).toEqual([
|
|
260
|
+
{ signal_id: 'entity.added', trail_id: 'entity.add' },
|
|
261
|
+
]);
|
|
262
|
+
expect(readTrailheadRows(db, saveId)).toEqual([
|
|
263
|
+
{ derived_name: 'entity add', trail_id: 'entity.add' },
|
|
264
|
+
{ derived_name: 'entity list', trail_id: 'entity.list' },
|
|
265
|
+
]);
|
|
266
|
+
expect(readExampleRows(db, saveId)).toEqual([
|
|
267
|
+
{
|
|
268
|
+
error: null,
|
|
269
|
+
expected: JSON.stringify({ id: 'ada', ok: true }),
|
|
270
|
+
input: JSON.stringify({ name: 'Ada' }),
|
|
271
|
+
name: 'Add Ada',
|
|
272
|
+
ordinal: 0,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
error: 'ConflictError',
|
|
276
|
+
expected: null,
|
|
277
|
+
input: JSON.stringify({ name: 'Existing' }),
|
|
278
|
+
name: 'Conflict on duplicate',
|
|
279
|
+
ordinal: 1,
|
|
280
|
+
},
|
|
281
|
+
]);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const simpleProjectionApp = (withList: boolean) =>
|
|
285
|
+
topo('projection-app', {
|
|
286
|
+
entityAdd: trail('entity.add', {
|
|
287
|
+
blaze: noop,
|
|
288
|
+
input: z.object({}),
|
|
289
|
+
output: z.object({ ok: z.boolean() }),
|
|
290
|
+
}),
|
|
291
|
+
...(withList
|
|
292
|
+
? {
|
|
293
|
+
entityList: trail('entity.list', {
|
|
294
|
+
blaze: () => Result.ok({ items: ['one'] }),
|
|
295
|
+
input: z.object({}),
|
|
296
|
+
intent: 'read',
|
|
297
|
+
output: z.object({ items: z.array(z.string()) }),
|
|
298
|
+
}),
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const seedHistoryOnlyTopoSchema = (
|
|
304
|
+
db: ReturnType<typeof openWriteTrailsDb>
|
|
305
|
+
): void => {
|
|
306
|
+
db.run(
|
|
307
|
+
`INSERT INTO meta_schema_versions (subsystem, version, updated_at)
|
|
308
|
+
VALUES ('topo', 1, ?)`,
|
|
309
|
+
['2026-04-03T11:00:00.000Z']
|
|
310
|
+
);
|
|
311
|
+
db.run(`CREATE TABLE IF NOT EXISTS topo_saves (
|
|
312
|
+
id TEXT PRIMARY KEY,
|
|
313
|
+
git_sha TEXT,
|
|
314
|
+
git_dirty INTEGER NOT NULL DEFAULT 0,
|
|
315
|
+
trail_count INTEGER NOT NULL DEFAULT 0,
|
|
316
|
+
signal_count INTEGER NOT NULL DEFAULT 0,
|
|
317
|
+
provision_count INTEGER NOT NULL DEFAULT 0,
|
|
318
|
+
created_at TEXT NOT NULL
|
|
319
|
+
)`);
|
|
320
|
+
db.run(`CREATE TABLE IF NOT EXISTS topo_pins (
|
|
321
|
+
name TEXT PRIMARY KEY,
|
|
322
|
+
save_id TEXT NOT NULL UNIQUE,
|
|
323
|
+
created_at TEXT NOT NULL
|
|
324
|
+
)`);
|
|
325
|
+
db.run(
|
|
326
|
+
`INSERT INTO topo_saves (
|
|
327
|
+
id, git_sha, git_dirty, trail_count, signal_count, provision_count, created_at
|
|
328
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
329
|
+
['seed-save', 'seed123', 0, 1, 0, 0, '2026-04-03T11:00:00.000Z']
|
|
330
|
+
);
|
|
331
|
+
db.run('INSERT INTO topo_pins (name, save_id, created_at) VALUES (?, ?, ?)', [
|
|
332
|
+
'seed-pin',
|
|
333
|
+
'seed-save',
|
|
334
|
+
'2026-04-03T11:01:00.000Z',
|
|
335
|
+
]);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
describe('topo store projection', () => {
|
|
339
|
+
let tmpRoot: string | undefined;
|
|
340
|
+
|
|
341
|
+
afterEach(() => {
|
|
342
|
+
if (tmpRoot) {
|
|
343
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
344
|
+
tmpRoot = undefined;
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const makeRoot = (): string => {
|
|
349
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'topo-store-'));
|
|
350
|
+
return tmpRoot;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const withProjectionDb = (
|
|
354
|
+
run: (db: ReturnType<typeof openWriteTrailsDb>) => void
|
|
355
|
+
): void => {
|
|
356
|
+
const db = openWriteTrailsDb({ rootDir: makeRoot() });
|
|
357
|
+
try {
|
|
358
|
+
run(db);
|
|
359
|
+
} finally {
|
|
360
|
+
db.close();
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
test('projects a save-scoped relational topo from the established app graph', () => {
|
|
365
|
+
withProjectionDb((db) => {
|
|
366
|
+
const save = unwrap(
|
|
367
|
+
persistEstablishedTopoSave(db, exampleApp(), {
|
|
368
|
+
createdAt: '2026-04-03T12:00:00.000Z',
|
|
369
|
+
gitDirty: false,
|
|
370
|
+
gitSha: 'abc123',
|
|
371
|
+
})
|
|
372
|
+
);
|
|
373
|
+
expectProjectionCounts(db, save.id);
|
|
374
|
+
expectProjectedFixtureRows(db, save.id);
|
|
375
|
+
|
|
376
|
+
const stored = requireStoredExport(db, save.id);
|
|
377
|
+
expect(JSON.parse(stored.trailheadMapJson)).toMatchObject({
|
|
378
|
+
entries: expect.any(Array),
|
|
379
|
+
generatedAt: '2026-04-03T12:00:00.000Z',
|
|
380
|
+
version: '1.0',
|
|
381
|
+
});
|
|
382
|
+
expect(JSON.parse(stored.lockContent)).toMatchObject({
|
|
383
|
+
apps: {
|
|
384
|
+
'projection-app': {
|
|
385
|
+
provisions: expect.any(Object),
|
|
386
|
+
signals: expect.any(Object),
|
|
387
|
+
trails: expect.any(Object),
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
generatedAt: '2026-04-03T12:00:00.000Z',
|
|
391
|
+
hash: stored.trailheadHash,
|
|
392
|
+
version: 1,
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('keeps projected rows isolated across successive saves', () => {
|
|
398
|
+
withProjectionDb((db) => {
|
|
399
|
+
const firstSave = unwrap(
|
|
400
|
+
persistEstablishedTopoSave(db, simpleProjectionApp(false), {
|
|
401
|
+
createdAt: '2026-04-03T12:00:00.000Z',
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
const secondSave = unwrap(
|
|
405
|
+
persistEstablishedTopoSave(db, simpleProjectionApp(true), {
|
|
406
|
+
createdAt: '2026-04-03T12:05:00.000Z',
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(firstSave.id).not.toBe(secondSave.id);
|
|
411
|
+
expect(countRows(db, 'topo_trails', firstSave.id)).toBe(1);
|
|
412
|
+
expect(countRows(db, 'topo_trails', secondSave.id)).toBe(2);
|
|
413
|
+
expect(readProjectedTrailIds(db, firstSave.id)).toEqual(['entity.add']);
|
|
414
|
+
expect(readProjectedTrailIds(db, secondSave.id)).toEqual([
|
|
415
|
+
'entity.add',
|
|
416
|
+
'entity.list',
|
|
417
|
+
]);
|
|
418
|
+
expect(requireStoredExport(db, firstSave.id).trailheadHash).not.toBe(
|
|
419
|
+
requireStoredExport(db, secondSave.id).trailheadHash
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("pruning an unpinned save removes only that save's projected rows", () => {
|
|
425
|
+
withProjectionDb((db) => {
|
|
426
|
+
const app = exampleApp();
|
|
427
|
+
const pinned = unwrap(
|
|
428
|
+
persistEstablishedTopoSave(db, app, {
|
|
429
|
+
createdAt: '2026-04-03T12:00:00.000Z',
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
pinTopoSave(db, { name: 'before-auth', saveId: pinned.id });
|
|
433
|
+
|
|
434
|
+
const disposable = unwrap(
|
|
435
|
+
persistEstablishedTopoSave(db, app, {
|
|
436
|
+
createdAt: '2026-04-03T12:05:00.000Z',
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect(pruneUnpinnedTopoSaves(db, { keep: 0 })).toBe(1);
|
|
441
|
+
expectProjectionCounts(db, pinned.id);
|
|
442
|
+
expect(countRows(db, 'topo_trails', disposable.id)).toBe(0);
|
|
443
|
+
expect(countRows(db, 'topo_crossings', disposable.id)).toBe(0);
|
|
444
|
+
expect(countRows(db, 'topo_examples', disposable.id)).toBe(0);
|
|
445
|
+
expect(countRows(db, 'topo_schemas', disposable.id)).toBe(0);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('upgrades a history-only topo schema to the projected topo schema', () => {
|
|
450
|
+
withProjectionDb((db) => {
|
|
451
|
+
seedHistoryOnlyTopoSchema(db);
|
|
452
|
+
ensureTopoHistorySchema(db);
|
|
453
|
+
expect(
|
|
454
|
+
db
|
|
455
|
+
.query<{ version: number }, []>(
|
|
456
|
+
"SELECT version FROM meta_schema_versions WHERE subsystem = 'topo'"
|
|
457
|
+
)
|
|
458
|
+
.get()?.version
|
|
459
|
+
).toBe(3);
|
|
460
|
+
expect(tableExists(db, 'topo_trails')).toBe(true);
|
|
461
|
+
expect(tableExists(db, 'topo_crossings')).toBe(true);
|
|
462
|
+
expect(tableExists(db, 'topo_examples')).toBe(true);
|
|
463
|
+
expect(tableExists(db, 'topo_exports')).toBe(true);
|
|
464
|
+
expect(tableExists(db, 'topo_schemas')).toBe(true);
|
|
465
|
+
expect(countRows(db, 'topo_saves')).toBe(1);
|
|
466
|
+
expect(countRows(db, 'topo_pins')).toBe(1);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureSubsystemSchema,
|
|
8
|
+
openReadTrailsDb,
|
|
9
|
+
openWriteTrailsDb,
|
|
10
|
+
resolveTrailsDbPath,
|
|
11
|
+
} from '../internal/trails-db.js';
|
|
12
|
+
import {
|
|
13
|
+
createTopoSave,
|
|
14
|
+
ensureTopoHistorySchema,
|
|
15
|
+
listTopoPins,
|
|
16
|
+
listTopoSaves,
|
|
17
|
+
pinTopoSave,
|
|
18
|
+
pruneUnpinnedTopoSaves,
|
|
19
|
+
} from '../internal/topo-saves.js';
|
|
20
|
+
|
|
21
|
+
describe('trails db foundation', () => {
|
|
22
|
+
let tmpRoot: string | undefined;
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (tmpRoot) {
|
|
26
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
27
|
+
tmpRoot = undefined;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const makeRoot = (): string => {
|
|
32
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'trails-db-'));
|
|
33
|
+
return tmpRoot;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const expectWorkspaceLayout = (rootDir: string): void => {
|
|
37
|
+
expect(existsSync(join(rootDir, '.trails', '.gitignore'))).toBe(true);
|
|
38
|
+
expect(existsSync(join(rootDir, '.trails', 'config'))).toBe(true);
|
|
39
|
+
expect(existsSync(join(rootDir, '.trails', 'dev'))).toBe(true);
|
|
40
|
+
expect(existsSync(join(rootDir, '.trails', 'generated'))).toBe(true);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
test('resolveTrailsDbPath places the database in .trails/trails.db', () => {
|
|
44
|
+
const rootDir = '/tmp/example-app';
|
|
45
|
+
expect(resolveTrailsDbPath({ rootDir })).toBe(
|
|
46
|
+
'/tmp/example-app/.trails/trails.db'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('openWriteTrailsDb creates the database with WAL and NORMAL defaults', () => {
|
|
51
|
+
const rootDir = makeRoot();
|
|
52
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
expect(existsSync(resolveTrailsDbPath({ rootDir }))).toBe(true);
|
|
56
|
+
expectWorkspaceLayout(rootDir);
|
|
57
|
+
|
|
58
|
+
const journal = db
|
|
59
|
+
.query<{ journal_mode: string }, []>('PRAGMA journal_mode')
|
|
60
|
+
.get();
|
|
61
|
+
const synchronous = db
|
|
62
|
+
.query<{ synchronous: number }, []>('PRAGMA synchronous')
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
expect(journal?.journal_mode.toLowerCase()).toBe('wal');
|
|
66
|
+
expect(synchronous?.synchronous).toBe(1);
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('openReadTrailsDb blocks writes at the SQLite connection level', () => {
|
|
73
|
+
const rootDir = makeRoot();
|
|
74
|
+
const writer = openWriteTrailsDb({ rootDir });
|
|
75
|
+
writer.close();
|
|
76
|
+
|
|
77
|
+
const reader = openReadTrailsDb({ rootDir });
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
expect(() => reader.run('CREATE TABLE readonly_probe (id TEXT)')).toThrow(
|
|
81
|
+
/readonly|read-only/i
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
reader.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ensureSubsystemSchema only migrates when the subsystem version changes', () => {
|
|
89
|
+
const rootDir = makeRoot();
|
|
90
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
91
|
+
let calls = 0;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
ensureSubsystemSchema(db, {
|
|
95
|
+
migrate: () => {
|
|
96
|
+
calls += 1;
|
|
97
|
+
db.run(
|
|
98
|
+
'CREATE TABLE IF NOT EXISTS track_records (id TEXT PRIMARY KEY)'
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
subsystem: 'track',
|
|
102
|
+
version: 1,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ensureSubsystemSchema(db, {
|
|
106
|
+
migrate: () => {
|
|
107
|
+
calls += 1;
|
|
108
|
+
},
|
|
109
|
+
subsystem: 'track',
|
|
110
|
+
version: 1,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
ensureSubsystemSchema(db, {
|
|
114
|
+
migrate: (currentVersion) => {
|
|
115
|
+
calls += 1;
|
|
116
|
+
expect(currentVersion).toBe(1);
|
|
117
|
+
db.run('ALTER TABLE track_records ADD COLUMN status TEXT');
|
|
118
|
+
},
|
|
119
|
+
subsystem: 'track',
|
|
120
|
+
version: 2,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(calls).toBe(2);
|
|
124
|
+
} finally {
|
|
125
|
+
db.close();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('topo save primitives', () => {
|
|
131
|
+
let tmpRoot: string | undefined;
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
if (tmpRoot) {
|
|
135
|
+
rmSync(tmpRoot, { force: true, recursive: true });
|
|
136
|
+
tmpRoot = undefined;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const makeRoot = (): string => {
|
|
141
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'topo-saves-'));
|
|
142
|
+
return tmpRoot;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const seedHistory = (db: ReturnType<typeof openWriteTrailsDb>) => {
|
|
146
|
+
ensureTopoHistorySchema(db);
|
|
147
|
+
|
|
148
|
+
const pinned = createTopoSave(db, {
|
|
149
|
+
createdAt: '2026-04-01T00:00:00.000Z',
|
|
150
|
+
gitDirty: false,
|
|
151
|
+
gitSha: 'abc123',
|
|
152
|
+
provisionCount: 2,
|
|
153
|
+
signalCount: 1,
|
|
154
|
+
trailCount: 3,
|
|
155
|
+
});
|
|
156
|
+
const disposable = createTopoSave(db, {
|
|
157
|
+
createdAt: '2026-04-02T00:00:00.000Z',
|
|
158
|
+
gitDirty: true,
|
|
159
|
+
gitSha: 'def456',
|
|
160
|
+
provisionCount: 3,
|
|
161
|
+
signalCount: 2,
|
|
162
|
+
trailCount: 4,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
disposable,
|
|
167
|
+
pin: pinTopoSave(db, { name: 'before-auth', saveId: pinned.id }),
|
|
168
|
+
pinned,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
test('creates saves, pins them, and prunes only unpinned history', () => {
|
|
173
|
+
const rootDir = makeRoot();
|
|
174
|
+
const db = openWriteTrailsDb({ rootDir });
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const { disposable, pin, pinned } = seedHistory(db);
|
|
178
|
+
|
|
179
|
+
expect(listTopoPins(db)).toEqual([pin]);
|
|
180
|
+
expect(listTopoSaves(db).map((save) => save.id)).toEqual([
|
|
181
|
+
disposable.id,
|
|
182
|
+
pinned.id,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
expect(pruneUnpinnedTopoSaves(db, { keep: 0 })).toBe(1);
|
|
186
|
+
expect(listTopoSaves(db).map((save) => save.id)).toEqual([pinned.id]);
|
|
187
|
+
} finally {
|
|
188
|
+
db.close();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|