@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.
Files changed (59) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +2 -1
  4. package/dist/derive.d.ts +6 -0
  5. package/dist/derive.d.ts.map +1 -1
  6. package/dist/derive.js +29 -5
  7. package/dist/derive.js.map +1 -1
  8. package/dist/draft.d.ts +28 -0
  9. package/dist/draft.d.ts.map +1 -0
  10. package/dist/draft.js +156 -0
  11. package/dist/draft.js.map +1 -0
  12. package/dist/index.d.ts +6 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/topo-saves.d.ts +47 -0
  17. package/dist/internal/topo-saves.d.ts.map +1 -0
  18. package/dist/internal/topo-saves.js +310 -0
  19. package/dist/internal/topo-saves.js.map +1 -0
  20. package/dist/internal/topo-store-read.d.ts +67 -0
  21. package/dist/internal/topo-store-read.d.ts.map +1 -0
  22. package/dist/internal/topo-store-read.js +222 -0
  23. package/dist/internal/topo-store-read.js.map +1 -0
  24. package/dist/internal/topo-store.d.ts +12 -0
  25. package/dist/internal/topo-store.d.ts.map +1 -0
  26. package/dist/internal/topo-store.js +571 -0
  27. package/dist/internal/topo-store.js.map +1 -0
  28. package/dist/internal/trails-db.d.ts +16 -0
  29. package/dist/internal/trails-db.d.ts.map +1 -0
  30. package/dist/internal/trails-db.js +118 -0
  31. package/dist/internal/trails-db.js.map +1 -0
  32. package/dist/topo-store.d.ts +48 -0
  33. package/dist/topo-store.d.ts.map +1 -0
  34. package/dist/topo-store.js +175 -0
  35. package/dist/topo-store.js.map +1 -0
  36. package/dist/validate-established-topo.d.ts +76 -0
  37. package/dist/validate-established-topo.d.ts.map +1 -0
  38. package/dist/validate-established-topo.js +43 -0
  39. package/dist/validate-established-topo.js.map +1 -0
  40. package/dist/validate-topo.d.ts.map +1 -1
  41. package/dist/validate-topo.js +5 -3
  42. package/dist/validate-topo.js.map +1 -1
  43. package/package.json +4 -1
  44. package/src/__tests__/derive.test.ts +58 -1
  45. package/src/__tests__/topo-store-read.test.ts +251 -0
  46. package/src/__tests__/topo-store.test.ts +469 -0
  47. package/src/__tests__/trails-db.test.ts +191 -0
  48. package/src/__tests__/validate-topo.test.ts +167 -0
  49. package/src/derive.ts +39 -8
  50. package/src/draft.ts +334 -0
  51. package/src/index.ts +30 -1
  52. package/src/internal/topo-saves.ts +429 -0
  53. package/src/internal/topo-store-read.ts +473 -0
  54. package/src/internal/topo-store.ts +1087 -0
  55. package/src/internal/trails-db.ts +189 -0
  56. package/src/topo-store.ts +301 -0
  57. package/src/validate-established-topo.ts +63 -0
  58. package/src/validate-topo.ts +7 -3
  59. 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
+ });