pmx-canvas 0.1.22 → 0.1.24

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