@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.
@@ -0,0 +1,682 @@
1
+ import type { Database, SQLQueryBindings } from 'bun:sqlite';
2
+
3
+ import { ensureSubsystemSchema } from '@ontrails/core';
4
+
5
+ import type { TopoGraphOverlayRegistration } from '../types.js';
6
+
7
+ const TOPO_SUBSYSTEM = 'topo';
8
+ const TOPO_TABLE_STATEMENTS = [
9
+ `CREATE TABLE IF NOT EXISTS topo_snapshots (
10
+ id TEXT PRIMARY KEY,
11
+ git_sha TEXT,
12
+ git_dirty INTEGER NOT NULL DEFAULT 0,
13
+ trail_count INTEGER NOT NULL DEFAULT 0,
14
+ signal_count INTEGER NOT NULL DEFAULT 0,
15
+ resource_count INTEGER NOT NULL DEFAULT 0,
16
+ pinned_as TEXT,
17
+ app_name TEXT,
18
+ source_fingerprint TEXT,
19
+ created_at TEXT NOT NULL
20
+ )`,
21
+ `CREATE TABLE IF NOT EXISTS topo_trails (
22
+ id TEXT NOT NULL,
23
+ intent TEXT,
24
+ idempotent INTEGER NOT NULL DEFAULT 0,
25
+ has_output INTEGER NOT NULL DEFAULT 0,
26
+ has_examples INTEGER NOT NULL DEFAULT 0,
27
+ example_count INTEGER NOT NULL DEFAULT 0,
28
+ description TEXT,
29
+ pattern TEXT,
30
+ meta TEXT,
31
+ snapshot_id TEXT NOT NULL,
32
+ PRIMARY KEY (id, snapshot_id),
33
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
34
+ )`,
35
+ `CREATE TABLE IF NOT EXISTS topo_composings (
36
+ source_id TEXT NOT NULL,
37
+ target_id TEXT NOT NULL,
38
+ snapshot_id TEXT NOT NULL,
39
+ PRIMARY KEY (source_id, target_id, snapshot_id),
40
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
41
+ )`,
42
+ `CREATE TABLE IF NOT EXISTS topo_trail_resources (
43
+ trail_id TEXT NOT NULL,
44
+ resource_id TEXT NOT NULL,
45
+ snapshot_id TEXT NOT NULL,
46
+ PRIMARY KEY (trail_id, resource_id, snapshot_id),
47
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
48
+ )`,
49
+ `CREATE TABLE IF NOT EXISTS topo_resources (
50
+ id TEXT NOT NULL,
51
+ has_mock INTEGER NOT NULL DEFAULT 0,
52
+ has_health INTEGER NOT NULL DEFAULT 0,
53
+ snapshot_id TEXT NOT NULL,
54
+ PRIMARY KEY (id, snapshot_id),
55
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
56
+ )`,
57
+ `CREATE TABLE IF NOT EXISTS topo_signals (
58
+ id TEXT NOT NULL,
59
+ description TEXT,
60
+ snapshot_id TEXT NOT NULL,
61
+ PRIMARY KEY (id, snapshot_id),
62
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
63
+ )`,
64
+ `CREATE TABLE IF NOT EXISTS topo_trail_signals (
65
+ trail_id TEXT NOT NULL,
66
+ signal_id TEXT NOT NULL,
67
+ snapshot_id TEXT NOT NULL,
68
+ PRIMARY KEY (trail_id, signal_id, snapshot_id),
69
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
70
+ )`,
71
+ `CREATE TABLE IF NOT EXISTS topo_surfaces (
72
+ trail_id TEXT NOT NULL,
73
+ surface TEXT NOT NULL,
74
+ derived_name TEXT NOT NULL,
75
+ method TEXT,
76
+ snapshot_id TEXT NOT NULL,
77
+ PRIMARY KEY (trail_id, surface, snapshot_id),
78
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
79
+ )`,
80
+ `CREATE TABLE IF NOT EXISTS topo_examples (
81
+ id TEXT PRIMARY KEY,
82
+ trail_id TEXT NOT NULL,
83
+ ordinal INTEGER NOT NULL,
84
+ name TEXT NOT NULL,
85
+ description TEXT,
86
+ input TEXT NOT NULL,
87
+ expected TEXT,
88
+ expected_match TEXT,
89
+ error TEXT,
90
+ signals TEXT,
91
+ snapshot_id TEXT NOT NULL,
92
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
93
+ )`,
94
+ `CREATE TABLE IF NOT EXISTS topo_schemas (
95
+ owner_id TEXT NOT NULL,
96
+ owner_kind TEXT NOT NULL,
97
+ schema_kind TEXT NOT NULL,
98
+ zod_hash TEXT NOT NULL,
99
+ json_schema TEXT NOT NULL,
100
+ snapshot_id TEXT NOT NULL,
101
+ PRIMARY KEY (owner_id, owner_kind, schema_kind, snapshot_id),
102
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
103
+ )`,
104
+ `CREATE TABLE IF NOT EXISTS topo_exports (
105
+ snapshot_id TEXT PRIMARY KEY,
106
+ topo_graph TEXT NOT NULL,
107
+ topo_graph_hash TEXT NOT NULL,
108
+ lock_manifest TEXT NOT NULL,
109
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
110
+ )`,
111
+ `CREATE TABLE IF NOT EXISTS topo_trail_fires (
112
+ trail_id TEXT NOT NULL,
113
+ signal_id TEXT NOT NULL,
114
+ snapshot_id TEXT NOT NULL,
115
+ PRIMARY KEY (trail_id, signal_id, snapshot_id),
116
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
117
+ )`,
118
+ `CREATE TABLE IF NOT EXISTS topo_trail_on (
119
+ trail_id TEXT NOT NULL,
120
+ signal_id TEXT NOT NULL,
121
+ snapshot_id TEXT NOT NULL,
122
+ PRIMARY KEY (trail_id, signal_id, snapshot_id),
123
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
124
+ )`,
125
+ `CREATE TABLE IF NOT EXISTS topo_activation_sources (
126
+ source_key TEXT NOT NULL,
127
+ source_id TEXT NOT NULL,
128
+ source_kind TEXT NOT NULL,
129
+ source TEXT NOT NULL,
130
+ snapshot_id TEXT NOT NULL,
131
+ PRIMARY KEY (source_key, snapshot_id),
132
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
133
+ )`,
134
+ `CREATE TABLE IF NOT EXISTS topo_activation_edges (
135
+ source_key TEXT NOT NULL,
136
+ source_id TEXT NOT NULL,
137
+ source_kind TEXT NOT NULL,
138
+ trail_id TEXT NOT NULL,
139
+ has_where INTEGER NOT NULL DEFAULT 0,
140
+ edge TEXT NOT NULL,
141
+ snapshot_id TEXT NOT NULL,
142
+ PRIMARY KEY (source_key, trail_id, snapshot_id),
143
+ FOREIGN KEY (snapshot_id) REFERENCES topo_snapshots(id) ON DELETE CASCADE
144
+ )`,
145
+ ] as const;
146
+ const TOPO_INDEX_STATEMENTS = [
147
+ 'CREATE INDEX IF NOT EXISTS idx_topo_snapshots_created_at ON topo_snapshots(created_at DESC)',
148
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_topo_snapshots_pinned_as
149
+ ON topo_snapshots(pinned_as) WHERE pinned_as IS NOT NULL`,
150
+ 'CREATE INDEX IF NOT EXISTS idx_topo_trails_snapshot_id ON topo_trails(snapshot_id)',
151
+ 'CREATE INDEX IF NOT EXISTS idx_topo_composings_snapshot_id ON topo_composings(snapshot_id)',
152
+ 'CREATE INDEX IF NOT EXISTS idx_topo_trail_resources_snapshot_id ON topo_trail_resources(snapshot_id)',
153
+ 'CREATE INDEX IF NOT EXISTS idx_topo_resources_snapshot_id ON topo_resources(snapshot_id)',
154
+ 'CREATE INDEX IF NOT EXISTS idx_topo_signals_snapshot_id ON topo_signals(snapshot_id)',
155
+ 'CREATE INDEX IF NOT EXISTS idx_topo_trail_signals_snapshot_id ON topo_trail_signals(snapshot_id)',
156
+ 'CREATE INDEX IF NOT EXISTS idx_topo_surfaces_snapshot_id ON topo_surfaces(snapshot_id)',
157
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_topo_examples_snapshot_trail_ordinal ON topo_examples(snapshot_id, trail_id, ordinal)',
158
+ 'CREATE INDEX IF NOT EXISTS idx_topo_schemas_snapshot_id ON topo_schemas(snapshot_id)',
159
+ `CREATE INDEX IF NOT EXISTS idx_topo_schemas_lookup
160
+ ON topo_schemas(owner_id, owner_kind, schema_kind, zod_hash)`,
161
+ 'CREATE INDEX IF NOT EXISTS idx_topo_trail_fires_snapshot_id ON topo_trail_fires(snapshot_id)',
162
+ 'CREATE INDEX IF NOT EXISTS idx_topo_trail_on_snapshot_id ON topo_trail_on(snapshot_id)',
163
+ 'CREATE INDEX IF NOT EXISTS idx_topo_activation_sources_snapshot_id ON topo_activation_sources(snapshot_id)',
164
+ 'CREATE INDEX IF NOT EXISTS idx_topo_activation_edges_snapshot_id ON topo_activation_edges(snapshot_id)',
165
+ 'CREATE INDEX IF NOT EXISTS idx_topo_activation_edges_trail ON topo_activation_edges(snapshot_id, trail_id)',
166
+ ] as const;
167
+ interface TopoSnapshotRow {
168
+ readonly app_name: string | null;
169
+ readonly created_at: string;
170
+ readonly git_dirty: number;
171
+ readonly git_sha: string | null;
172
+ readonly id: string;
173
+ readonly pinned_as: string | null;
174
+ readonly resource_count: number;
175
+ readonly signal_count: number;
176
+ readonly source_fingerprint: string | null;
177
+ readonly trail_count: number;
178
+ }
179
+
180
+ export interface TopoSnapshot {
181
+ /**
182
+ * Optional name of the app the snapshot was captured for.
183
+ *
184
+ * Workspace-aware tooling sets this so a single trails-db can host
185
+ * snapshots from multiple apps without losing attribution. Legacy
186
+ * single-app snapshots leave this `undefined`.
187
+ */
188
+ readonly appName?: string;
189
+ readonly createdAt: string;
190
+ readonly gitDirty: boolean;
191
+ readonly gitSha?: string;
192
+ readonly id: string;
193
+ readonly pinnedAs?: string;
194
+ readonly resourceCount: number;
195
+ readonly signalCount: number;
196
+ /**
197
+ * Content fingerprint of the app source set the snapshot was derived
198
+ * from. Consumers serving stored exports compare it against a freshly
199
+ * derived fingerprint and treat a mismatch as stale.
200
+ */
201
+ readonly sourceFingerprint?: string;
202
+ readonly trailCount: number;
203
+ }
204
+
205
+ export interface CreateTopoSnapshotInput {
206
+ readonly appName?: string;
207
+ readonly createdAt?: string;
208
+ readonly gitDirty?: boolean;
209
+ readonly gitSha?: string;
210
+ readonly id?: string;
211
+ readonly resourceCount?: number;
212
+ readonly overlays?: readonly TopoGraphOverlayRegistration[] | undefined;
213
+ readonly signalCount?: number;
214
+ readonly sourceFingerprint?: string;
215
+ readonly trailCount?: number;
216
+ }
217
+
218
+ export interface ListTopoSnapshotsOptions {
219
+ readonly before?: string;
220
+ readonly limit?: number;
221
+ readonly pinned?: boolean;
222
+ }
223
+
224
+ const rowToSnapshot = (row: TopoSnapshotRow): TopoSnapshot => ({
225
+ createdAt: row.created_at,
226
+ gitDirty: row.git_dirty === 1,
227
+ id: row.id,
228
+ resourceCount: row.resource_count,
229
+ signalCount: row.signal_count,
230
+ trailCount: row.trail_count,
231
+ ...(row.app_name === null ? {} : { appName: row.app_name }),
232
+ ...(row.git_sha === null ? {} : { gitSha: row.git_sha }),
233
+ ...(row.pinned_as === null ? {} : { pinnedAs: row.pinned_as }),
234
+ ...(row.source_fingerprint === null
235
+ ? {}
236
+ : { sourceFingerprint: row.source_fingerprint }),
237
+ });
238
+
239
+ const tableExists = (db: Database, tableName: string): boolean => {
240
+ const row = db
241
+ .query<{ name: string }, [string]>(
242
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?"
243
+ )
244
+ .get(tableName);
245
+ return row?.name === tableName;
246
+ };
247
+
248
+ const columnExists = (
249
+ db: Database,
250
+ tableName: string,
251
+ columnName: string
252
+ ): boolean =>
253
+ db
254
+ .query<{ name: string }, []>(`PRAGMA table_info(${tableName})`)
255
+ .all()
256
+ .some((row) => row.name === columnName);
257
+
258
+ const addColumnIfMissing = (
259
+ db: Database,
260
+ tableName: string,
261
+ columnName: string,
262
+ definition: string
263
+ ): void => {
264
+ if (tableExists(db, tableName) && !columnExists(db, tableName, columnName)) {
265
+ db.run(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
266
+ }
267
+ };
268
+
269
+ const renameColumnIfNeeded = (
270
+ db: Database,
271
+ tableName: string,
272
+ from: string,
273
+ to: string
274
+ ): void => {
275
+ if (
276
+ tableExists(db, tableName) &&
277
+ columnExists(db, tableName, from) &&
278
+ !columnExists(db, tableName, to)
279
+ ) {
280
+ db.run(`ALTER TABLE ${tableName} RENAME COLUMN ${from} TO ${to}`);
281
+ }
282
+ };
283
+
284
+ const renameTableIfNeeded = (db: Database, from: string, to: string): void => {
285
+ if (tableExists(db, from) && !tableExists(db, to)) {
286
+ db.run(`ALTER TABLE ${from} RENAME TO ${to}`);
287
+ }
288
+ };
289
+
290
+ const dropIndexIfExists = (db: Database, indexName: string): void => {
291
+ db.run(`DROP INDEX IF EXISTS ${indexName}`);
292
+ };
293
+
294
+ const runStatements = (db: Database, statements: readonly string[]): void => {
295
+ for (const statement of statements) {
296
+ db.run(statement);
297
+ }
298
+ };
299
+
300
+ const createAllTopoTables = (db: Database): void => {
301
+ runStatements(db, TOPO_TABLE_STATEMENTS);
302
+ runStatements(db, TOPO_INDEX_STATEMENTS);
303
+ };
304
+
305
+ /**
306
+ * Current topo subsystem schema version.
307
+ *
308
+ * Version 14 adds optional `source_fingerprint` provenance to
309
+ * `topo_snapshots` so consumers serving stored exports can detect that the
310
+ * app source set changed since the snapshot was taken (TRL-1196).
311
+ *
312
+ * Version 13 renames `topo_crossings` to `topo_composings`.
313
+ *
314
+ * Version 12 renames the serialized export columns from surface-era names to
315
+ * topo-graph artifact-family names: `topo_graph`, `topo_graph_hash`, and
316
+ * `lock_manifest`.
317
+ *
318
+ * Version 11 adds optional `app_name` attribution to `topo_snapshots` so a
319
+ * single trails-db can host snapshots from multiple apps in workspace-aware
320
+ * tooling. Older snapshots remain valid with `app_name IS NULL`.
321
+ *
322
+ * Version 10 adds generic activation source catalog and activation edge tables.
323
+ *
324
+ * Version 9 adds structured example assertion columns to `topo_examples`.
325
+ *
326
+ * Version 8 adds `pattern TEXT` column to `topo_trails`.
327
+ *
328
+ * Version 7 defined the snapshot-first topo tables (`topo_snapshots`,
329
+ * `topo_surfaces`, and `snapshot_id` foreign keys) as the only supported
330
+ * schema. Older pre-release tables are ignored in place; we create the current
331
+ * tables and advance the subsystem version without translating or deleting
332
+ * legacy rows.
333
+ */
334
+ export const TOPO_SCHEMA_VERSION = 14;
335
+
336
+ export const ensureTopoSnapshotSchema = (db: Database): void => {
337
+ ensureSubsystemSchema(db, {
338
+ migrate: (currentVersion) => {
339
+ if (currentVersion >= 7 && currentVersion < 13) {
340
+ renameTableIfNeeded(db, 'topo_crossings', 'topo_composings');
341
+ dropIndexIfExists(db, 'idx_topo_crossings_snapshot_id');
342
+ }
343
+ createAllTopoTables(db);
344
+ if (currentVersion === 7) {
345
+ addColumnIfMissing(db, 'topo_trails', 'pattern', 'pattern TEXT');
346
+ }
347
+ if (currentVersion >= 7 && currentVersion < 9) {
348
+ addColumnIfMissing(
349
+ db,
350
+ 'topo_examples',
351
+ 'expected_match',
352
+ 'expected_match TEXT'
353
+ );
354
+ addColumnIfMissing(db, 'topo_examples', 'signals', 'signals TEXT');
355
+ }
356
+ if (currentVersion >= 7 && currentVersion < 11) {
357
+ addColumnIfMissing(db, 'topo_snapshots', 'app_name', 'app_name TEXT');
358
+ }
359
+ if (currentVersion < 12) {
360
+ renameColumnIfNeeded(db, 'topo_exports', 'surface_map', 'topo_graph');
361
+ renameColumnIfNeeded(
362
+ db,
363
+ 'topo_exports',
364
+ 'surface_hash',
365
+ 'topo_graph_hash'
366
+ );
367
+ renameColumnIfNeeded(
368
+ db,
369
+ 'topo_exports',
370
+ 'serialized_lock',
371
+ 'lock_manifest'
372
+ );
373
+ }
374
+ if (currentVersion >= 7 && currentVersion < 14) {
375
+ addColumnIfMissing(
376
+ db,
377
+ 'topo_snapshots',
378
+ 'source_fingerprint',
379
+ 'source_fingerprint TEXT'
380
+ );
381
+ }
382
+ },
383
+ subsystem: TOPO_SUBSYSTEM,
384
+ version: TOPO_SCHEMA_VERSION,
385
+ });
386
+ };
387
+
388
+ const snapshotRecordFromInput = (
389
+ input?: CreateTopoSnapshotInput
390
+ ): TopoSnapshot => ({
391
+ createdAt: input?.createdAt ?? new Date().toISOString(),
392
+ gitDirty: input?.gitDirty ?? false,
393
+ id: input?.id ?? Bun.randomUUIDv7(),
394
+ resourceCount: input?.resourceCount ?? 0,
395
+ signalCount: input?.signalCount ?? 0,
396
+ trailCount: input?.trailCount ?? 0,
397
+ ...(input?.appName === undefined ? {} : { appName: input.appName }),
398
+ ...(input?.gitSha === undefined ? {} : { gitSha: input.gitSha }),
399
+ ...(input?.sourceFingerprint === undefined
400
+ ? {}
401
+ : { sourceFingerprint: input.sourceFingerprint }),
402
+ });
403
+
404
+ export const insertTopoSnapshotRecord = (
405
+ db: Database,
406
+ input?: CreateTopoSnapshotInput
407
+ ): TopoSnapshot => {
408
+ const record = snapshotRecordFromInput(input);
409
+
410
+ db.run(
411
+ `INSERT INTO topo_snapshots (
412
+ id, git_sha, git_dirty, trail_count, signal_count, resource_count, pinned_as, app_name, source_fingerprint, created_at
413
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
414
+ [
415
+ record.id,
416
+ record.gitSha ?? null,
417
+ record.gitDirty ? 1 : 0,
418
+ record.trailCount,
419
+ record.signalCount,
420
+ record.resourceCount,
421
+ null,
422
+ record.appName ?? null,
423
+ record.sourceFingerprint ?? null,
424
+ record.createdAt,
425
+ ]
426
+ );
427
+
428
+ return record;
429
+ };
430
+
431
+ const normalizeLimit = (limit?: number): number | undefined => {
432
+ if (limit === undefined) {
433
+ return undefined;
434
+ }
435
+ return Math.max(0, Math.trunc(limit));
436
+ };
437
+
438
+ export const readTopoSnapshot = (
439
+ db: Database,
440
+ id: string
441
+ ): TopoSnapshot | undefined => {
442
+ if (!tableExists(db, 'topo_snapshots')) {
443
+ return undefined;
444
+ }
445
+ const row = db
446
+ .query<TopoSnapshotRow, [string]>(
447
+ `SELECT id, git_sha, git_dirty, trail_count, signal_count, resource_count, pinned_as, app_name, source_fingerprint, created_at
448
+ FROM topo_snapshots
449
+ WHERE id = ?`
450
+ )
451
+ .get(id);
452
+ return row === null || row === undefined ? undefined : rowToSnapshot(row);
453
+ };
454
+
455
+ export const readPinnedTopoSnapshot = (
456
+ db: Database,
457
+ name: string
458
+ ): TopoSnapshot | undefined => {
459
+ if (!tableExists(db, 'topo_snapshots')) {
460
+ return undefined;
461
+ }
462
+ const row = db
463
+ .query<TopoSnapshotRow, [string]>(
464
+ `SELECT id, git_sha, git_dirty, trail_count, signal_count, resource_count, pinned_as, app_name, source_fingerprint, created_at
465
+ FROM topo_snapshots
466
+ WHERE pinned_as = ?
467
+ LIMIT 1`
468
+ )
469
+ .get(name);
470
+ return row === null || row === undefined ? undefined : rowToSnapshot(row);
471
+ };
472
+
473
+ const applyBeforeSnapshotClause = (
474
+ db: Database,
475
+ beforeId: string | undefined,
476
+ bindings: SQLQueryBindings[],
477
+ conditions: string[]
478
+ ): boolean => {
479
+ if (beforeId === undefined) {
480
+ return true;
481
+ }
482
+
483
+ const before = readTopoSnapshot(db, beforeId);
484
+ if (before === undefined) {
485
+ return false;
486
+ }
487
+
488
+ conditions.push('(created_at < ? OR (created_at = ? AND id < ?))');
489
+ bindings.push(before.createdAt, before.createdAt, before.id);
490
+ return true;
491
+ };
492
+
493
+ const applyPinnedSnapshotClause = (
494
+ pinned: boolean | undefined,
495
+ conditions: string[]
496
+ ): void => {
497
+ if (pinned === true) {
498
+ conditions.push('pinned_as IS NOT NULL');
499
+ return;
500
+ }
501
+
502
+ if (pinned === false) {
503
+ conditions.push('pinned_as IS NULL');
504
+ }
505
+ };
506
+
507
+ const buildSnapshotWhereClause = (conditions: readonly string[]): string =>
508
+ conditions.length === 0 ? '' : ` WHERE ${conditions.join(' AND ')}`;
509
+
510
+ const buildSnapshotLimitClause = (
511
+ limit: number | undefined,
512
+ bindings: SQLQueryBindings[]
513
+ ): string => {
514
+ if (limit === undefined) {
515
+ return '';
516
+ }
517
+
518
+ bindings.push(limit);
519
+ return ' LIMIT ?';
520
+ };
521
+
522
+ const listSnapshotRows = (
523
+ db: Database,
524
+ options?: ListTopoSnapshotsOptions
525
+ ): readonly TopoSnapshotRow[] => {
526
+ if (!tableExists(db, 'topo_snapshots')) {
527
+ return [];
528
+ }
529
+
530
+ const bindings: SQLQueryBindings[] = [];
531
+ const conditions: string[] = [];
532
+ if (!applyBeforeSnapshotClause(db, options?.before, bindings, conditions)) {
533
+ return [];
534
+ }
535
+
536
+ applyPinnedSnapshotClause(options?.pinned, conditions);
537
+ const whereClause = buildSnapshotWhereClause(conditions);
538
+ const limitClause = buildSnapshotLimitClause(
539
+ normalizeLimit(options?.limit),
540
+ bindings
541
+ );
542
+
543
+ return db
544
+ .query<TopoSnapshotRow, SQLQueryBindings[]>(
545
+ `SELECT id, git_sha, git_dirty, trail_count, signal_count, resource_count, pinned_as, app_name, source_fingerprint, created_at
546
+ FROM topo_snapshots${whereClause}
547
+ ORDER BY created_at DESC, id DESC${limitClause}`
548
+ )
549
+ .all(...bindings);
550
+ };
551
+
552
+ export const listTopoSnapshots = (
553
+ db: Database,
554
+ options?: ListTopoSnapshotsOptions
555
+ ): readonly TopoSnapshot[] => listSnapshotRows(db, options).map(rowToSnapshot);
556
+
557
+ const countSnapshots = (db: Database, whereClause?: string): number => {
558
+ const query =
559
+ whereClause === undefined
560
+ ? 'SELECT COUNT(*) as count FROM topo_snapshots'
561
+ : `SELECT COUNT(*) as count FROM topo_snapshots WHERE ${whereClause}`;
562
+ const row = db.query<{ count: number }, []>(query).get();
563
+ return row?.count ?? 0;
564
+ };
565
+
566
+ export const countTopoSnapshots = (db: Database): number => {
567
+ if (!tableExists(db, 'topo_snapshots')) {
568
+ return 0;
569
+ }
570
+ return countSnapshots(db);
571
+ };
572
+
573
+ export const countPinnedSnapshots = (db: Database): number => {
574
+ if (!tableExists(db, 'topo_snapshots')) {
575
+ return 0;
576
+ }
577
+ return countSnapshots(db, 'pinned_as IS NOT NULL');
578
+ };
579
+
580
+ export const countPrunableSnapshots = (
581
+ db: Database,
582
+ options: { readonly keep: number }
583
+ ): number => {
584
+ if (!tableExists(db, 'topo_snapshots')) {
585
+ return 0;
586
+ }
587
+ const row = db
588
+ .query<{ count: number }, [number]>(
589
+ `SELECT COUNT(*) as count
590
+ FROM (
591
+ SELECT id
592
+ FROM topo_snapshots
593
+ WHERE pinned_as IS NULL
594
+ ORDER BY created_at DESC, id DESC
595
+ LIMIT -1 OFFSET ?
596
+ )`
597
+ )
598
+ .get(options.keep);
599
+ return row?.count ?? 0;
600
+ };
601
+
602
+ export const createTopoSnapshot = (
603
+ db: Database,
604
+ input?: CreateTopoSnapshotInput
605
+ ): TopoSnapshot => {
606
+ ensureTopoSnapshotSchema(db);
607
+ return insertTopoSnapshotRecord(db, input);
608
+ };
609
+
610
+ export const pinTopoSnapshot = (
611
+ db: Database,
612
+ input: { readonly id: string; readonly name: string }
613
+ ): TopoSnapshot | undefined => {
614
+ ensureTopoSnapshotSchema(db);
615
+
616
+ return db.transaction(() => {
617
+ const snapshot = readTopoSnapshot(db, input.id);
618
+ if (snapshot === undefined) {
619
+ return;
620
+ }
621
+
622
+ db.run('UPDATE topo_snapshots SET pinned_as = NULL WHERE pinned_as = ?', [
623
+ input.name,
624
+ ]);
625
+ db.run('UPDATE topo_snapshots SET pinned_as = ? WHERE id = ?', [
626
+ input.name,
627
+ input.id,
628
+ ]);
629
+
630
+ return readTopoSnapshot(db, input.id);
631
+ })();
632
+ };
633
+
634
+ export const unpinTopoSnapshot = (
635
+ db: Database,
636
+ nameOrId: string
637
+ ): TopoSnapshot | undefined => {
638
+ ensureTopoSnapshotSchema(db);
639
+
640
+ return db.transaction(() => {
641
+ const snapshot =
642
+ readPinnedTopoSnapshot(db, nameOrId) ?? readTopoSnapshot(db, nameOrId);
643
+ if (snapshot?.pinnedAs === undefined) {
644
+ return;
645
+ }
646
+
647
+ db.run('UPDATE topo_snapshots SET pinned_as = NULL WHERE id = ?', [
648
+ snapshot.id,
649
+ ]);
650
+
651
+ return readTopoSnapshot(db, snapshot.id);
652
+ })();
653
+ };
654
+
655
+ export const pruneUnpinnedSnapshots = (
656
+ db: Database,
657
+ options: { readonly keep: number }
658
+ ): number => {
659
+ if (!tableExists(db, 'topo_snapshots')) {
660
+ return 0;
661
+ }
662
+ if (countPrunableSnapshots(db, options) === 0) {
663
+ return 0;
664
+ }
665
+
666
+ db.run(
667
+ `DELETE FROM topo_snapshots
668
+ WHERE id IN (
669
+ SELECT id
670
+ FROM topo_snapshots
671
+ WHERE pinned_as IS NULL
672
+ ORDER BY created_at DESC, id DESC
673
+ LIMIT -1 OFFSET ?
674
+ )`,
675
+ [options.keep]
676
+ );
677
+
678
+ return (
679
+ db.query<{ changes: number }, []>('SELECT changes() as changes').get()
680
+ ?.changes ?? 0
681
+ );
682
+ };