pmx-canvas 0.1.22 → 0.1.23

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,710 @@
1
+ /**
2
+ * SQLite persistence layer for canvas state.
3
+ *
4
+ * Uses Bun's built-in `bun:sqlite` for zero-dependency, synchronous,
5
+ * WAL-mode persistence. Replaces the previous JSON file-based approach.
6
+ */
7
+
8
+ import { Database } from 'bun:sqlite';
9
+ import { existsSync, mkdirSync } from 'node:fs';
10
+ import { dirname } from 'node:path';
11
+ import { gzipSync, gunzipSync } from 'node:zlib';
12
+ import type {
13
+ CanvasAnnotation,
14
+ CanvasEdge,
15
+ CanvasNodeState,
16
+ CanvasSnapshot,
17
+ CanvasSnapshotListOptions,
18
+ ViewportState,
19
+ } from './canvas-state.js';
20
+
21
+ // ── Schema ──────────────────────────────────────────────────────
22
+
23
+ const SCHEMA_VERSION = 1;
24
+
25
+ const SCHEMA_SQL = `
26
+ CREATE TABLE IF NOT EXISTS meta (
27
+ key TEXT PRIMARY KEY,
28
+ value TEXT NOT NULL
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS nodes (
32
+ id TEXT PRIMARY KEY,
33
+ type TEXT NOT NULL,
34
+ pos_x REAL NOT NULL,
35
+ pos_y REAL NOT NULL,
36
+ width REAL NOT NULL,
37
+ height REAL NOT NULL,
38
+ z_index INTEGER NOT NULL DEFAULT 0,
39
+ collapsed INTEGER NOT NULL DEFAULT 0,
40
+ pinned INTEGER NOT NULL DEFAULT 0,
41
+ dock_position TEXT,
42
+ data TEXT NOT NULL
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS edges (
46
+ id TEXT PRIMARY KEY,
47
+ from_node TEXT NOT NULL,
48
+ to_node TEXT NOT NULL,
49
+ type TEXT NOT NULL,
50
+ label TEXT,
51
+ style TEXT,
52
+ animated INTEGER NOT NULL DEFAULT 0
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS annotations (
56
+ id TEXT PRIMARY KEY,
57
+ type TEXT NOT NULL,
58
+ points TEXT NOT NULL,
59
+ bounds TEXT NOT NULL,
60
+ color TEXT NOT NULL,
61
+ width REAL NOT NULL,
62
+ text TEXT,
63
+ label TEXT,
64
+ created_at TEXT NOT NULL
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS context_pins (
68
+ node_id TEXT PRIMARY KEY
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS snapshots (
72
+ id TEXT PRIMARY KEY,
73
+ name TEXT NOT NULL,
74
+ created_at TEXT NOT NULL,
75
+ node_count INTEGER NOT NULL,
76
+ edge_count INTEGER NOT NULL
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS snapshot_nodes (
80
+ snapshot_id TEXT NOT NULL,
81
+ id TEXT NOT NULL,
82
+ type TEXT NOT NULL,
83
+ pos_x REAL NOT NULL,
84
+ pos_y REAL NOT NULL,
85
+ width REAL NOT NULL,
86
+ height REAL NOT NULL,
87
+ z_index INTEGER NOT NULL DEFAULT 0,
88
+ collapsed INTEGER NOT NULL DEFAULT 0,
89
+ pinned INTEGER NOT NULL DEFAULT 0,
90
+ dock_position TEXT,
91
+ data TEXT NOT NULL,
92
+ PRIMARY KEY (snapshot_id, id)
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS snapshot_edges (
96
+ snapshot_id TEXT NOT NULL,
97
+ id TEXT NOT NULL,
98
+ from_node TEXT NOT NULL,
99
+ to_node TEXT NOT NULL,
100
+ type TEXT NOT NULL,
101
+ label TEXT,
102
+ style TEXT,
103
+ animated INTEGER NOT NULL DEFAULT 0,
104
+ PRIMARY KEY (snapshot_id, id)
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS snapshot_annotations (
108
+ snapshot_id TEXT NOT NULL,
109
+ id TEXT NOT NULL,
110
+ type TEXT NOT NULL,
111
+ points TEXT NOT NULL,
112
+ bounds TEXT NOT NULL,
113
+ color TEXT NOT NULL,
114
+ width REAL NOT NULL,
115
+ text TEXT,
116
+ label TEXT,
117
+ created_at TEXT NOT NULL,
118
+ PRIMARY KEY (snapshot_id, id)
119
+ );
120
+
121
+ CREATE TABLE IF NOT EXISTS snapshot_pins (
122
+ snapshot_id TEXT NOT NULL,
123
+ node_id TEXT NOT NULL,
124
+ PRIMARY KEY (snapshot_id, node_id)
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS snapshot_meta (
128
+ snapshot_id TEXT NOT NULL,
129
+ key TEXT NOT NULL,
130
+ value TEXT NOT NULL,
131
+ PRIMARY KEY (snapshot_id, key)
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS blobs (
135
+ sha256 TEXT PRIMARY KEY,
136
+ data BLOB NOT NULL,
137
+ json_bytes INTEGER NOT NULL
138
+ );
139
+ `;
140
+
141
+ function normalizePositiveInteger(value: number | undefined): number | undefined {
142
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
143
+ return Math.floor(value);
144
+ }
145
+
146
+ function normalizeSnapshotTimestamp(value: string | undefined): string | undefined {
147
+ if (!value) return undefined;
148
+ const parsed = Date.parse(value);
149
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
150
+ }
151
+
152
+ // ── Persisted State Interface ───────────────────────────────────
153
+
154
+ export interface PersistedCanvasState {
155
+ version: number;
156
+ viewport: ViewportState;
157
+ nodes: CanvasNodeState[];
158
+ edges: CanvasEdge[];
159
+ annotations?: CanvasAnnotation[];
160
+ contextPins: string[];
161
+ }
162
+
163
+ // ── Database Management ─────────────────────────────────────────
164
+
165
+ export function openCanvasDb(dbPath: string): Database {
166
+ const dir = dirname(dbPath);
167
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
168
+
169
+ const db = new Database(dbPath);
170
+ db.exec('PRAGMA journal_mode=WAL');
171
+ db.exec('PRAGMA synchronous=NORMAL');
172
+ db.exec('PRAGMA busy_timeout=5000');
173
+ db.exec(SCHEMA_SQL);
174
+
175
+ // Set schema version if not present
176
+ const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
177
+ if (!row) {
178
+ db.run('INSERT INTO meta (key, value) VALUES (?, ?)', ['schema_version', String(SCHEMA_VERSION)]);
179
+ }
180
+
181
+ return db;
182
+ }
183
+
184
+ export function checkpointCanvasDb(db: Database): void {
185
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
186
+ }
187
+
188
+ export function finalizeCanvasDbForClose(db: Database): void {
189
+ checkpointCanvasDb(db);
190
+ db.exec('PRAGMA journal_mode=DELETE');
191
+ }
192
+
193
+ // ── State Persistence ───────────────────────────────────────────
194
+
195
+ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
196
+ const transaction = db.transaction(() => {
197
+ // Clear current state tables
198
+ db.run('DELETE FROM nodes');
199
+ db.run('DELETE FROM edges');
200
+ db.run('DELETE FROM annotations');
201
+ db.run('DELETE FROM context_pins');
202
+
203
+ // Save viewport
204
+ db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
205
+ db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
206
+ db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
207
+
208
+ // Mark DB as populated (for migration detection)
209
+ db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['state_populated', '1']);
210
+
211
+ // Save nodes
212
+ const insertNode = db.prepare(
213
+ `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
214
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
215
+ );
216
+ for (const node of state.nodes) {
217
+ insertNode.run(
218
+ node.id,
219
+ node.type,
220
+ node.position.x,
221
+ node.position.y,
222
+ node.size.width,
223
+ node.size.height,
224
+ node.zIndex,
225
+ node.collapsed ? 1 : 0,
226
+ node.pinned ? 1 : 0,
227
+ node.dockPosition,
228
+ JSON.stringify(node.data),
229
+ );
230
+ }
231
+
232
+ // Save edges
233
+ const insertEdge = db.prepare(
234
+ `INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
235
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
236
+ );
237
+ for (const edge of state.edges) {
238
+ insertEdge.run(
239
+ edge.id,
240
+ edge.from,
241
+ edge.to,
242
+ edge.type,
243
+ edge.label ?? null,
244
+ edge.style ?? null,
245
+ edge.animated ? 1 : 0,
246
+ );
247
+ }
248
+
249
+ // Save annotations
250
+ const insertAnnotation = db.prepare(
251
+ `INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
252
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
253
+ );
254
+ for (const annotation of state.annotations ?? []) {
255
+ insertAnnotation.run(
256
+ annotation.id,
257
+ annotation.type,
258
+ JSON.stringify(annotation.points),
259
+ JSON.stringify(annotation.bounds),
260
+ annotation.color,
261
+ annotation.width,
262
+ annotation.text ?? null,
263
+ annotation.label ?? null,
264
+ annotation.createdAt,
265
+ );
266
+ }
267
+
268
+ // Save context pins
269
+ const insertPin = db.prepare('INSERT INTO context_pins (node_id) VALUES (?)');
270
+ for (const pinId of state.contextPins) {
271
+ insertPin.run(pinId);
272
+ }
273
+ });
274
+
275
+ transaction();
276
+ }
277
+
278
+ /** Check if the DB has been populated with canvas state at least once. */
279
+ export function isDbPopulated(db: Database): boolean {
280
+ const row = db.query<{ value: string }, [string]>(
281
+ 'SELECT value FROM meta WHERE key = ?',
282
+ ).get('state_populated');
283
+ return row?.value === '1';
284
+ }
285
+
286
+ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
287
+ const schemaVersion = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('schema_version');
288
+ if (!schemaVersion) return null;
289
+
290
+ // Load viewport
291
+ const getMetaValue = (key: string): number => {
292
+ const row = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get(key);
293
+ return row ? Number(row.value) : 0;
294
+ };
295
+
296
+ const viewport: ViewportState = {
297
+ x: getMetaValue('viewport_x'),
298
+ y: getMetaValue('viewport_y'),
299
+ scale: getMetaValue('viewport_scale') || 1,
300
+ };
301
+
302
+ // Load nodes
303
+ interface NodeRow {
304
+ id: string;
305
+ type: string;
306
+ pos_x: number;
307
+ pos_y: number;
308
+ width: number;
309
+ height: number;
310
+ z_index: number;
311
+ collapsed: number;
312
+ pinned: number;
313
+ dock_position: string | null;
314
+ data: string;
315
+ }
316
+ const nodeRows = db.query<NodeRow, []>('SELECT * FROM nodes').all();
317
+ const nodes: CanvasNodeState[] = nodeRows.map((row) => ({
318
+ id: row.id,
319
+ type: row.type as CanvasNodeState['type'],
320
+ position: { x: row.pos_x, y: row.pos_y },
321
+ size: { width: row.width, height: row.height },
322
+ zIndex: row.z_index,
323
+ collapsed: row.collapsed === 1,
324
+ pinned: row.pinned === 1,
325
+ dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
326
+ data: JSON.parse(row.data) as Record<string, unknown>,
327
+ }));
328
+
329
+ // Load edges
330
+ interface EdgeRow {
331
+ id: string;
332
+ from_node: string;
333
+ to_node: string;
334
+ type: string;
335
+ label: string | null;
336
+ style: string | null;
337
+ animated: number;
338
+ }
339
+ const edgeRows = db.query<EdgeRow, []>('SELECT * FROM edges').all();
340
+ const edges: CanvasEdge[] = edgeRows.map((row) => ({
341
+ id: row.id,
342
+ from: row.from_node,
343
+ to: row.to_node,
344
+ type: row.type as CanvasEdge['type'],
345
+ ...(row.label ? { label: row.label } : {}),
346
+ ...(row.style ? { style: row.style as CanvasEdge['style'] } : {}),
347
+ ...(row.animated ? { animated: true } : {}),
348
+ }));
349
+
350
+ // Load annotations
351
+ interface AnnotationRow {
352
+ id: string;
353
+ type: string;
354
+ points: string;
355
+ bounds: string;
356
+ color: string;
357
+ width: number;
358
+ text: string | null;
359
+ label: string | null;
360
+ created_at: string;
361
+ }
362
+ const annotationRows = db.query<AnnotationRow, []>('SELECT * FROM annotations').all();
363
+ const annotations: CanvasAnnotation[] = annotationRows.map((row) => ({
364
+ id: row.id,
365
+ type: row.type as CanvasAnnotation['type'],
366
+ points: JSON.parse(row.points),
367
+ bounds: JSON.parse(row.bounds),
368
+ color: row.color,
369
+ width: row.width,
370
+ ...(row.text ? { text: row.text } : {}),
371
+ ...(row.label ? { label: row.label } : {}),
372
+ createdAt: row.created_at,
373
+ }));
374
+
375
+ // Load context pins
376
+ interface PinRow { node_id: string }
377
+ const pinRows = db.query<PinRow, []>('SELECT node_id FROM context_pins').all();
378
+ const contextPins = pinRows.map((row) => row.node_id);
379
+
380
+ return {
381
+ version: 1,
382
+ viewport,
383
+ nodes,
384
+ edges,
385
+ annotations,
386
+ contextPins,
387
+ };
388
+ }
389
+
390
+ // ── Snapshot Persistence ────────────────────────────────────────
391
+
392
+ export function saveSnapshotToDB(
393
+ db: Database,
394
+ snapshot: CanvasSnapshot,
395
+ state: PersistedCanvasState,
396
+ ): void {
397
+ const transaction = db.transaction(() => {
398
+ // Insert snapshot metadata
399
+ db.run(
400
+ 'INSERT INTO snapshots (id, name, created_at, node_count, edge_count) VALUES (?, ?, ?, ?, ?)',
401
+ [snapshot.id, snapshot.name, snapshot.createdAt, state.nodes.length, state.edges.length],
402
+ );
403
+
404
+ // Insert snapshot viewport meta
405
+ db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_x', String(state.viewport.x)]);
406
+ db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_y', String(state.viewport.y)]);
407
+ db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_scale', String(state.viewport.scale)]);
408
+
409
+ // Insert snapshot nodes
410
+ const insertNode = db.prepare(
411
+ `INSERT INTO snapshot_nodes (snapshot_id, id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
413
+ );
414
+ for (const node of state.nodes) {
415
+ insertNode.run(
416
+ snapshot.id,
417
+ node.id,
418
+ node.type,
419
+ node.position.x,
420
+ node.position.y,
421
+ node.size.width,
422
+ node.size.height,
423
+ node.zIndex,
424
+ node.collapsed ? 1 : 0,
425
+ node.pinned ? 1 : 0,
426
+ node.dockPosition,
427
+ JSON.stringify(node.data),
428
+ );
429
+ }
430
+
431
+ // Insert snapshot edges
432
+ const insertEdge = db.prepare(
433
+ `INSERT INTO snapshot_edges (snapshot_id, id, from_node, to_node, type, label, style, animated)
434
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
435
+ );
436
+ for (const edge of state.edges) {
437
+ insertEdge.run(
438
+ snapshot.id,
439
+ edge.id,
440
+ edge.from,
441
+ edge.to,
442
+ edge.type,
443
+ edge.label ?? null,
444
+ edge.style ?? null,
445
+ edge.animated ? 1 : 0,
446
+ );
447
+ }
448
+
449
+ // Insert snapshot annotations
450
+ const insertAnnotation = db.prepare(
451
+ `INSERT INTO snapshot_annotations (snapshot_id, id, type, points, bounds, color, width, text, label, created_at)
452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
453
+ );
454
+ for (const annotation of state.annotations ?? []) {
455
+ insertAnnotation.run(
456
+ snapshot.id,
457
+ annotation.id,
458
+ annotation.type,
459
+ JSON.stringify(annotation.points),
460
+ JSON.stringify(annotation.bounds),
461
+ annotation.color,
462
+ annotation.width,
463
+ annotation.text ?? null,
464
+ annotation.label ?? null,
465
+ annotation.createdAt,
466
+ );
467
+ }
468
+
469
+ // Insert snapshot pins
470
+ const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id) VALUES (?, ?)');
471
+ for (const pinId of state.contextPins) {
472
+ insertPin.run(snapshot.id, pinId);
473
+ }
474
+ });
475
+
476
+ transaction();
477
+ }
478
+
479
+ export function loadSnapshotFromDB(
480
+ db: Database,
481
+ idOrName: string,
482
+ ): { snapshot: CanvasSnapshot; state: PersistedCanvasState } | null {
483
+ // Try by ID first, then by name (most recent match)
484
+ interface SnapshotRow {
485
+ id: string;
486
+ name: string;
487
+ created_at: string;
488
+ node_count: number;
489
+ edge_count: number;
490
+ }
491
+ let snapshotRow = db.query<SnapshotRow, [string]>(
492
+ 'SELECT * FROM snapshots WHERE id = ?',
493
+ ).get(idOrName);
494
+
495
+ if (!snapshotRow) {
496
+ snapshotRow = db.query<SnapshotRow, [string]>(
497
+ 'SELECT * FROM snapshots WHERE name = ? ORDER BY created_at DESC LIMIT 1',
498
+ ).get(idOrName);
499
+ }
500
+
501
+ if (!snapshotRow) return null;
502
+
503
+ const snapshot: CanvasSnapshot = {
504
+ id: snapshotRow.id,
505
+ name: snapshotRow.name,
506
+ createdAt: snapshotRow.created_at,
507
+ nodeCount: snapshotRow.node_count,
508
+ edgeCount: snapshotRow.edge_count,
509
+ };
510
+
511
+ // Load snapshot viewport
512
+ interface MetaRow { key: string; value: string }
513
+ const metaRows = db.query<MetaRow, [string]>(
514
+ 'SELECT key, value FROM snapshot_meta WHERE snapshot_id = ?',
515
+ ).all(snapshotRow.id);
516
+ const metaMap = new Map(metaRows.map((r) => [r.key, r.value]));
517
+
518
+ const viewport: ViewportState = {
519
+ x: Number(metaMap.get('viewport_x') ?? '0'),
520
+ y: Number(metaMap.get('viewport_y') ?? '0'),
521
+ scale: Number(metaMap.get('viewport_scale') ?? '1') || 1,
522
+ };
523
+
524
+ // Load snapshot nodes
525
+ interface NodeRow {
526
+ id: string;
527
+ type: string;
528
+ pos_x: number;
529
+ pos_y: number;
530
+ width: number;
531
+ height: number;
532
+ z_index: number;
533
+ collapsed: number;
534
+ pinned: number;
535
+ dock_position: string | null;
536
+ data: string;
537
+ }
538
+ const nodeRows = db.query<NodeRow, [string]>(
539
+ 'SELECT * FROM snapshot_nodes WHERE snapshot_id = ?',
540
+ ).all(snapshotRow.id);
541
+ const nodes: CanvasNodeState[] = nodeRows.map((row) => ({
542
+ id: row.id,
543
+ type: row.type as CanvasNodeState['type'],
544
+ position: { x: row.pos_x, y: row.pos_y },
545
+ size: { width: row.width, height: row.height },
546
+ zIndex: row.z_index,
547
+ collapsed: row.collapsed === 1,
548
+ pinned: row.pinned === 1,
549
+ dockPosition: row.dock_position as CanvasNodeState['dockPosition'],
550
+ data: JSON.parse(row.data) as Record<string, unknown>,
551
+ }));
552
+
553
+ // Load snapshot edges
554
+ interface EdgeRow {
555
+ id: string;
556
+ from_node: string;
557
+ to_node: string;
558
+ type: string;
559
+ label: string | null;
560
+ style: string | null;
561
+ animated: number;
562
+ }
563
+ const edgeRows = db.query<EdgeRow, [string]>(
564
+ 'SELECT * FROM snapshot_edges WHERE snapshot_id = ?',
565
+ ).all(snapshotRow.id);
566
+ const edges: CanvasEdge[] = edgeRows.map((row) => ({
567
+ id: row.id,
568
+ from: row.from_node,
569
+ to: row.to_node,
570
+ type: row.type as CanvasEdge['type'],
571
+ ...(row.label ? { label: row.label } : {}),
572
+ ...(row.style ? { style: row.style as CanvasEdge['style'] } : {}),
573
+ ...(row.animated ? { animated: true } : {}),
574
+ }));
575
+
576
+ // Load snapshot annotations
577
+ interface AnnotationRow {
578
+ id: string;
579
+ type: string;
580
+ points: string;
581
+ bounds: string;
582
+ color: string;
583
+ width: number;
584
+ text: string | null;
585
+ label: string | null;
586
+ created_at: string;
587
+ }
588
+ const annotationRows = db.query<AnnotationRow, [string]>(
589
+ 'SELECT * FROM snapshot_annotations WHERE snapshot_id = ?',
590
+ ).all(snapshotRow.id);
591
+ const annotations: CanvasAnnotation[] = annotationRows.map((row) => ({
592
+ id: row.id,
593
+ type: row.type as CanvasAnnotation['type'],
594
+ points: JSON.parse(row.points),
595
+ bounds: JSON.parse(row.bounds),
596
+ color: row.color,
597
+ width: row.width,
598
+ ...(row.text ? { text: row.text } : {}),
599
+ ...(row.label ? { label: row.label } : {}),
600
+ createdAt: row.created_at,
601
+ }));
602
+
603
+ // Load snapshot pins
604
+ interface PinRow { node_id: string }
605
+ const pinRows = db.query<PinRow, [string]>(
606
+ 'SELECT node_id FROM snapshot_pins WHERE snapshot_id = ?',
607
+ ).all(snapshotRow.id);
608
+ const contextPins = pinRows.map((row) => row.node_id);
609
+
610
+ return {
611
+ snapshot,
612
+ state: {
613
+ version: 1,
614
+ viewport,
615
+ nodes,
616
+ edges,
617
+ annotations,
618
+ contextPins,
619
+ },
620
+ };
621
+ }
622
+
623
+ export function listSnapshotsFromDB(db: Database, options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
624
+ const query = options.query?.trim().toLowerCase();
625
+ const before = normalizeSnapshotTimestamp(options.before);
626
+ const after = normalizeSnapshotTimestamp(options.after);
627
+ const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
628
+
629
+ let sql = 'SELECT * FROM snapshots WHERE 1=1';
630
+ const params: string[] = [];
631
+
632
+ if (query) {
633
+ sql += ' AND (LOWER(id) LIKE ? OR LOWER(name) LIKE ?)';
634
+ params.push(`%${query}%`, `%${query}%`);
635
+ }
636
+ if (before) {
637
+ sql += ' AND created_at <= ?';
638
+ params.push(before);
639
+ }
640
+ if (after) {
641
+ sql += ' AND created_at >= ?';
642
+ params.push(after);
643
+ }
644
+
645
+ sql += ' ORDER BY created_at DESC';
646
+ if (limit !== undefined) {
647
+ sql += ` LIMIT ${limit}`;
648
+ }
649
+
650
+ interface SnapshotRow {
651
+ id: string;
652
+ name: string;
653
+ created_at: string;
654
+ node_count: number;
655
+ edge_count: number;
656
+ }
657
+
658
+ const stmt = db.prepare<SnapshotRow, string[]>(sql);
659
+ const rows = stmt.all(...params);
660
+
661
+ return rows.map((row) => ({
662
+ id: row.id,
663
+ name: row.name,
664
+ createdAt: row.created_at,
665
+ nodeCount: row.node_count,
666
+ edgeCount: row.edge_count,
667
+ }));
668
+ }
669
+
670
+ export function deleteSnapshotFromDB(db: Database, id: string): boolean {
671
+ const transaction = db.transaction(() => {
672
+ db.run('DELETE FROM snapshot_nodes WHERE snapshot_id = ?', [id]);
673
+ db.run('DELETE FROM snapshot_edges WHERE snapshot_id = ?', [id]);
674
+ db.run('DELETE FROM snapshot_annotations WHERE snapshot_id = ?', [id]);
675
+ db.run('DELETE FROM snapshot_pins WHERE snapshot_id = ?', [id]);
676
+ db.run('DELETE FROM snapshot_meta WHERE snapshot_id = ?', [id]);
677
+ const result = db.run('DELETE FROM snapshots WHERE id = ?', [id]);
678
+ return result.changes > 0;
679
+ });
680
+
681
+ return transaction();
682
+ }
683
+
684
+ // ── Blob Persistence ────────────────────────────────────────────
685
+
686
+ export function writeBlobToDB(db: Database, sha256: string, jsonValue: string): number {
687
+ const compressed = gzipSync(jsonValue);
688
+ db.run(
689
+ 'INSERT OR IGNORE INTO blobs (sha256, data, json_bytes) VALUES (?, ?, ?)',
690
+ [sha256, compressed, Buffer.byteLength(jsonValue)],
691
+ );
692
+ return compressed.byteLength;
693
+ }
694
+
695
+ export function readBlobFromDB(db: Database, sha256: string): string | null {
696
+ interface BlobRow { data: Buffer; json_bytes: number }
697
+ const row = db.query<BlobRow, [string]>(
698
+ 'SELECT data, json_bytes FROM blobs WHERE sha256 = ?',
699
+ ).get(sha256);
700
+ if (!row) return null;
701
+ return gunzipSync(row.data).toString('utf-8');
702
+ }
703
+
704
+ export function hasBlobInDB(db: Database, sha256: string): boolean {
705
+ interface CountRow { c: number }
706
+ const row = db.query<CountRow, [string]>(
707
+ 'SELECT COUNT(*) as c FROM blobs WHERE sha256 = ?',
708
+ ).get(sha256);
709
+ return (row?.c ?? 0) > 0;
710
+ }
@@ -1032,7 +1032,7 @@ export function removeCanvasNode(id: string): {
1032
1032
  }
1033
1033
 
1034
1034
  function isArrangeLocked(node: CanvasNodeState): boolean {
1035
- return node.pinned || node.data.arrangeLocked === true;
1035
+ return node.pinned || node.dockPosition !== null || node.data.arrangeLocked === true;
1036
1036
  }
1037
1037
 
1038
1038
  function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {