pmx-canvas 0.4.7 → 0.4.8

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.
@@ -18,6 +18,38 @@ interface ExtAppHostDimensionsTarget {
18
18
  * everywhere we can test (Chrome / Codex / Playwright).
19
19
  */
20
20
  export declare function isWebKitOnlyHost(userAgent: string): boolean;
21
+ /**
22
+ * Finding N (0.4.7 report): the GitHub Copilot panel's WKWebView reports
23
+ * `document.visibilityState === 'hidden'` CONTINUOUSLY — the workbench document
24
+ * never transitions to 'visible' even with the panel open and on-screen. Two
25
+ * things break as a result: the `visibilitychange` re-arm below can never fire
26
+ * (there is no transition to listen for), and `paint-ok` becomes a false green —
27
+ * the app document happily answers the double-rAF paint probe while the
28
+ * compositor still shows black. The instrumented trail from that session carries
29
+ * `visibility: "hidden"` on essentially every recovery event.
30
+ *
31
+ * So: when the document CLAIMS hidden but the frame is demonstrably on-screen,
32
+ * do not trust the claim — spend one more recovery round as if visible. A host
33
+ * that ever reports 'visible' has a working signal and is left to the
34
+ * visibilitychange path, which keeps a genuinely backgrounded panel quiet.
35
+ */
36
+ export declare function shouldAssumeVisibleRearm(options: {
37
+ userAgent: string;
38
+ visibilityState: string;
39
+ rect: {
40
+ top: number;
41
+ left: number;
42
+ bottom: number;
43
+ right: number;
44
+ width: number;
45
+ height: number;
46
+ };
47
+ viewport: {
48
+ width: number;
49
+ height: number;
50
+ };
51
+ alreadyRearmed: boolean;
52
+ }): boolean;
21
53
  export declare const WEBKIT_REMOUNT_SETTLE_MS = 1000;
22
54
  export interface WebkitRemountTask {
23
55
  /** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
@@ -22,6 +22,20 @@ export interface PersistedCanvasState {
22
22
  }
23
23
  export declare function openCanvasDb(dbPath: string): Database;
24
24
  export declare function checkpointCanvasDb(db: Database): void;
25
+ /**
26
+ * Delete blob rows nothing references any more. Blobs are content-addressed and
27
+ * written with INSERT OR IGNORE, so editing a node's html supersedes its old blob
28
+ * and leaves it orphaned — without this, `canvas.db` (which is git-committable)
29
+ * would grow without bound once externalization applies to ordinary html nodes.
30
+ *
31
+ * References are collected by scanning the raw persisted JSON for sha256 fields
32
+ * rather than parsing every node. That deliberately OVER-approximates: a stray
33
+ * 64-hex string keeps a blob alive that could have been dropped, which wastes a
34
+ * little space. The opposite error would delete live content, so the scan is
35
+ * biased to the safe side on purpose. Snapshot rows are scanned too — a snapshot
36
+ * restore must still find its blobs.
37
+ */
38
+ export declare function gcBlobsInDB(db: Database): number;
25
39
  export declare function finalizeCanvasDbForClose(db: Database): void;
26
40
  export declare function saveStateToDB(db: Database, state: PersistedCanvasState): void;
27
41
  /** Check if the DB has been populated with canvas state at least once. */
package/docs/RELEASE.md CHANGED
@@ -44,6 +44,25 @@ discovery with a `.esm.preflight` loader error (ERR-20260508-001).
44
44
  and exercises the CLI flows from
45
45
  [`docs/evals/e2e-cli-coverage.md`](evals/e2e-cli-coverage.md).
46
46
 
47
+ **Bump the pinned version examples.** `Readme.md` pins an exact version in
48
+ its install/orb-setup snippets *because* it tells readers to pin. Three
49
+ releases in a row shipped pointing at the previous version — twice the
50
+ surrounding prose described features that pin does not have, so a reader
51
+ following it copy-pasted a broken setup.
52
+
53
+ ```bash
54
+ grep -rn "pmx-canvas@0\." Readme.md docs/ # every hit must be the version you are about to ship
55
+ ```
56
+
57
+ **If this release touches CI or the release workflows, review that as
58
+ release content.** A changed gate gets its first real run *on this
59
+ release*: `e2e-cli` once launched Playwright without installing the
60
+ browser, the publish gate once queried `GITHUB_SHA` (the tag object, not
61
+ the commit) so it would have blocked every annotated tag, and
62
+ `test:coverage` once lacked `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` so it
63
+ failed *and* opened a real browser. Ask what happens on a fresh runner
64
+ and on a tag event, not just on your machine.
65
+
47
66
  ## Versioning
48
67
 
49
68
  Semantic versioning, in 0.x semver honestly (see `docs/api-stability.md`).
@@ -87,6 +106,23 @@ Don't ship a release without a CHANGELOG entry. The GitHub release
87
106
  notes file (`/tmp/pmx-canvas-vX.Y.Z-release-notes.md` by convention)
88
107
  expands on the CHANGELOG with examples and migration notes.
89
108
 
109
+ **Check the entries are under a NEW heading.** Feature commits land bullets
110
+ at the top of the file, and three times in the 0.3.x–0.4.x cycles they were
111
+ appended under the *already-published* section instead — so the release
112
+ notes shipped empty while the previous release's published history claimed
113
+ features it never had. Before tagging:
114
+
115
+ ```bash
116
+ # every bullet added since the last tag must sit above the last released heading
117
+ git diff v<PREVIOUS>..HEAD -- CHANGELOG.md | grep '^+' | head -40
118
+ grep -n '^## \[' CHANGELOG.md | head -3
119
+ ```
120
+
121
+ Same rule in reverse: never retro-edit a published section to describe new
122
+ behavior (a theme's colour was changed in place once, so the released notes
123
+ described a build that never shipped it). Correct it forward under the new
124
+ version instead.
125
+
90
126
  **Release-blocker gate:** a breaking change without a `### Breaking`
91
127
  CHANGELOG entry blocks the release (`docs/api-stability.md`). Check the
92
128
  diff against the previous tag for removed/renamed public surface — MCP
@@ -95,6 +131,18 @@ anything broke and isn't named under `### Breaking`, add the entry first.
95
131
 
96
132
  ## Tag → publish
97
133
 
134
+ **Immediately before tagging, re-read the Test CI conclusion — never assert it
135
+ from memory.** v0.3.2 was tagged and published while its Test run had concluded
136
+ *failure*: an interruption landed between the watch finishing and the tag step,
137
+ and "CI was green" was recalled rather than checked. A `gh run watch` exiting 0
138
+ is only evidence if you actually read its output.
139
+
140
+ ```bash
141
+ gh run view <run-id> --json status,conclusion -q '.status + " / " + .conclusion'
142
+ gh run view <run-id> --json jobs -q '.jobs[] | .name + ": " + .conclusion'
143
+ # every job must read `success` — a matrix job added in THIS release has never run green before
144
+ ```
145
+
98
146
  The publish workflow ([`/.github/workflows/publish.yml`](../.github/workflows/publish.yml))
99
147
  triggers on tags matching `v*`:
100
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -356,8 +356,12 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
356
356
  affordance. The connecting overlay stays up until paint is confirmed, so a black layer is
357
357
  never presented as ready. The recovery trail
358
358
  (`GET /api/canvas/debug/ext-app-recovery` / `window.__PMX_EXTAPP_LOG`) now records
359
- `mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, and `recovery-exhausted` — when
360
- diagnosing, trust `paint-ok`/`paint-fail`, and never assert health from `settled` alone. If a
359
+ `mount-slot`, `paint-ok`, `paint-fail`, `soft-expand-cycle`, `recovery-exhausted`, and
360
+ `assume-visible-rearm` — when diagnosing, trust `paint-ok`/`paint-fail`, and never assert
361
+ health from `settled` alone. Exception: in a host that reports the document hidden (the
362
+ GitHub Copilot panel does so continuously), `paint-ok` is recorded as
363
+ `paint-ok (unverified: host hidden)` — the app answered the paint probe, but nothing is
364
+ proven composited, so do not treat it as a verified paint. If a
361
365
  tile still shows the Retry affordance, click Retry (fresh recovery budget) or expand-then-close;
362
366
  attach the recovery trail when reporting.
363
367
  - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
@@ -30,6 +30,16 @@ bun run test:web-canvas # Browser smoke against a real running app
30
30
  bun run test:all # Bun suite + browser smoke
31
31
  ```
32
32
 
33
+ **Direct `bun test` is side-effect-guarded by `tests/preload.ts`** (wired via `bunfig.toml`
34
+ `[test].preload`): it defaults `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` so the suite's open-as-site
35
+ tests cannot launch the developer's real browser, whichever way the tests are invoked. The
36
+ preload covers `bun test` only — when you boot a server yourself (`bun run src/cli/index.ts`),
37
+ pass `--no-open` and set `PMX_CANVAS_DISABLE_BROWSER_OPEN=1` explicitly.
38
+
39
+ **Never pipe a gating command through `tail`, `head`, or `grep`.** The pipeline reports the exit
40
+ code of the LAST command, so `bun run test | tail -5` exits 0 on a red suite. Redirect to a file
41
+ and check `$?`, then read the file.
42
+
33
43
  ## Which Command To Run
34
44
 
35
45
  - Server/state/API-only changes: run `bun run test`
@@ -79,6 +89,30 @@ Prefer extending the existing suites before inventing a one-off script.
79
89
  `pmx-canvas skills sync --yes` to refresh them (whole trees, whatever agent layout owns them) before
80
90
  trusting skill-guided results
81
91
 
92
+ ## Tests That Cannot Fail
93
+
94
+ A test that passes against the broken code is worse than no test — it certifies the bug. Every
95
+ regression test must be shown to discriminate: run it against the unfixed code (or hard-code the
96
+ broken value) and watch it go red BEFORE you trust the green.
97
+
98
+ Four ways a test silently stops discriminating in this repo:
99
+
100
+ - **Polled assertions pass on the first sample.** `expect.poll(fn).toBeGreaterThanOrEqual(180)`
101
+ succeeds the instant one sample qualifies, so it never observes the failure window. Wait for the
102
+ settle condition, then assert once.
103
+ - **Committed fixtures embed generated output.** `src/server/demo-state.json` carries each
104
+ primitive's generated markup; count and coverage assertions are satisfied by stale markup just
105
+ as well as fresh. Guard generated fixtures by rebuilding from the generator's INPUT and
106
+ byte-comparing against the current renderer.
107
+ - **Absolute assertions about globally-wired side effects are order-dependent.** Anything flowing
108
+ through a single-slot listener (architecture rule 8 in `CLAUDE.md`) is wired or not depending on
109
+ whether an earlier test file booted a server — so the test passes alone and fails in the suite,
110
+ or vice versa. Assert DIFFERENTIALLY: run the action disarmed and armed, require the deltas to
111
+ match. Always run the full suite before trusting a new test; a single-file run hides this.
112
+ - **A new node type passes every test and renders nothing.** Server, API, and client-unit
113
+ assertions all pass while `isCanvasNodeType` drops the type during layout apply. Only a DOM
114
+ census on a live board catches it.
115
+
82
116
  ## Layout And Embedded Content Checks
83
117
 
84
118
  - For seeded or generated boards, add API-level geometry assertions: expected node/edge counts,
@@ -95,6 +129,12 @@ Prefer extending the existing suites before inventing a one-off script.
95
129
  - When checking embedded frame fit manually, start from a clean seeded state, rebuild stale bundles,
96
130
  and inspect the actual iframe document in a browser. Server dimensions can look correct while the
97
131
  embedded content is still clipped.
132
+ - **A node's stored content is not render evidence.** An html/primitive node's stored HTML holds
133
+ every conditional branch — hidden banners, error states, empty states — so a content-level read
134
+ (search hit, pinned context, `node get`, any text summary) quotes strings the user never saw.
135
+ The 0.4.7 report filed an "AX bridge unavailable" banner as a live product gap on exactly this
136
+ basis; the served surface had the bridge injected ahead of the check and the banner was `hidden`
137
+ in the document. Before filing a rendering bug, fetch the served surface or inspect the DOM.
98
138
  - User-facing creation flows must end with the new nodes visible: after the create (plus the
99
139
  skill-mandated focus/fit), a screenshot must show them without any manual pan. A `panned: true`
100
140
  API result alone is not proof — verify the frame is on-screen and unobscured.
@@ -65,6 +65,36 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
65
65
  return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
66
66
  }
67
67
 
68
+ /**
69
+ * Finding N (0.4.7 report): the GitHub Copilot panel's WKWebView reports
70
+ * `document.visibilityState === 'hidden'` CONTINUOUSLY — the workbench document
71
+ * never transitions to 'visible' even with the panel open and on-screen. Two
72
+ * things break as a result: the `visibilitychange` re-arm below can never fire
73
+ * (there is no transition to listen for), and `paint-ok` becomes a false green —
74
+ * the app document happily answers the double-rAF paint probe while the
75
+ * compositor still shows black. The instrumented trail from that session carries
76
+ * `visibility: "hidden"` on essentially every recovery event.
77
+ *
78
+ * So: when the document CLAIMS hidden but the frame is demonstrably on-screen,
79
+ * do not trust the claim — spend one more recovery round as if visible. A host
80
+ * that ever reports 'visible' has a working signal and is left to the
81
+ * visibilitychange path, which keeps a genuinely backgrounded panel quiet.
82
+ */
83
+ export function shouldAssumeVisibleRearm(options: {
84
+ userAgent: string;
85
+ visibilityState: string;
86
+ rect: { top: number; left: number; bottom: number; right: number; width: number; height: number };
87
+ viewport: { width: number; height: number };
88
+ alreadyRearmed: boolean;
89
+ }): boolean {
90
+ const { userAgent, visibilityState, rect, viewport, alreadyRearmed } = options;
91
+ if (alreadyRearmed) return false;
92
+ if (!isWebKitOnlyHost(userAgent)) return false;
93
+ if (visibilityState !== 'hidden') return false;
94
+ if (rect.width <= 0 || rect.height <= 0) return false;
95
+ return rect.bottom > 0 && rect.right > 0 && rect.top < viewport.height && rect.left < viewport.width;
96
+ }
97
+
68
98
  // Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
69
99
  // The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
70
100
  // into an idle panel (like a live-created node, or expand+close), but several
@@ -352,6 +382,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
352
382
  // Recovery ran while the document was hidden (compositing suppressed) — re-arm
353
383
  // one fresh remount round when the panel becomes visible (Finding N).
354
384
  const bootedWhileHiddenRef = useRef(false);
385
+ // Finding N (0.4.7): at most one "assume visible" round per frame lifetime, so
386
+ // a host that is hidden forever cannot turn the re-arm into a remount loop.
387
+ const assumeVisibleRearmDoneRef = useRef(false);
355
388
  const unmountedRef = useRef(false);
356
389
  const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
357
390
  const [error, setError] = useState<string | null>(null);
@@ -567,21 +600,64 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
567
600
  });
568
601
  });
569
602
 
603
+ // Finding N (0.4.7): the Copilot WKWebView never reports 'visible', so the
604
+ // visibilitychange re-arm below is dead code there and the ladder's own
605
+ // paint-ok cannot be trusted. When the document claims hidden but this frame
606
+ // is on-screen, spend one more full recovery round with a fresh budget —
607
+ // the same round the visibilitychange path would have run.
608
+ const ASSUME_VISIBLE_REARM_DELAY_MS = 600;
609
+ const maybeAssumeVisibleRearm = (): void => {
610
+ if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
611
+ const frame = iframeRef.current;
612
+ if (!frame || unmountedRef.current) return;
613
+ if (
614
+ !shouldAssumeVisibleRearm({
615
+ userAgent: navigator.userAgent,
616
+ visibilityState: document.visibilityState,
617
+ rect: frame.getBoundingClientRect(),
618
+ viewport: { width: window.innerWidth, height: window.innerHeight },
619
+ alreadyRearmed: assumeVisibleRearmDoneRef.current,
620
+ })
621
+ )
622
+ return;
623
+ assumeVisibleRearmDoneRef.current = true;
624
+ // Re-check after a beat: a host whose visibility signal merely lagged the
625
+ // panel opening resolves itself here, and the visibilitychange path owns it.
626
+ window.setTimeout(() => {
627
+ if (unmountedRef.current || document.visibilityState === 'visible') return;
628
+ webkitRemountAttemptsRef.current = 0;
629
+ setRecoveryExhausted(false);
630
+ bootedWhileHiddenRef.current = false;
631
+ extAppRecoveryLog(nodeId, 'assume-visible-rearm');
632
+ scheduleWebkitRemount('assume-visible');
633
+ }, ASSUME_VISIBLE_REARM_DELAY_MS);
634
+ };
635
+
636
+ // A paint-ok collected while the document claims hidden proves only that the
637
+ // app's rAF pipeline is alive — NOT that anything composited (Finding N,
638
+ // 0.4.7). Say so in the trail instead of recording an unqualified success.
639
+ const paintOkLabel = (suffix = ''): string =>
640
+ typeof document !== 'undefined' && document.visibilityState === 'hidden'
641
+ ? `paint-ok${suffix} (unverified: host hidden)`
642
+ : `paint-ok${suffix}`;
643
+
570
644
  const runPaintLadder = async (): Promise<void> => {
571
645
  if (expanded) return;
572
646
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
573
647
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
574
648
  setPaintPending(true);
575
649
  if (await probePaint()) {
576
- extAppRecoveryLog(nodeId, 'paint-ok');
650
+ extAppRecoveryLog(nodeId, paintOkLabel());
577
651
  setPaintPending(false);
652
+ maybeAssumeVisibleRearm();
578
653
  return;
579
654
  }
580
655
  extAppRecoveryLog(nodeId, 'paint-fail');
581
656
  await runSoftExpandCycle();
582
657
  if (await probePaint()) {
583
- extAppRecoveryLog(nodeId, 'paint-ok (after soft-expand)');
658
+ extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
584
659
  setPaintPending(false);
660
+ maybeAssumeVisibleRearm();
585
661
  return;
586
662
  }
587
663
  if (unmountedRef.current) return;
@@ -590,6 +666,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
590
666
  extAppRecoveryLog(nodeId, 'recovery-exhausted');
591
667
  setPaintPending(false);
592
668
  setRecoveryExhausted(true);
669
+ maybeAssumeVisibleRearm();
593
670
  }
594
671
  };
595
672
 
@@ -273,7 +273,13 @@ export function openCanvasDb(dbPath: string): Database {
273
273
 
274
274
  const db = new Database(dbPath);
275
275
  db.exec('PRAGMA journal_mode=WAL');
276
- db.exec('PRAGMA synchronous=NORMAL');
276
+ // FULL, not NORMAL: under WAL, NORMAL lets a host/OS-level crash lose the most
277
+ // recent commits (a clean process exit loses nothing either way). The reporter of
278
+ // issue #22 declined FULL specifically because a save rewrote the whole board, so
279
+ // an fsync per commit scaled with board size. Saves are incremental now — a
280
+ // typical save is a row or two — so that objection is gone and the durability is
281
+ // worth the fsync.
282
+ db.exec('PRAGMA synchronous=FULL');
277
283
  db.exec('PRAGMA busy_timeout=5000');
278
284
  db.exec(SCHEMA_SQL);
279
285
 
@@ -295,38 +301,117 @@ export function checkpointCanvasDb(db: Database): void {
295
301
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
296
302
  }
297
303
 
304
+ /**
305
+ * Delete blob rows nothing references any more. Blobs are content-addressed and
306
+ * written with INSERT OR IGNORE, so editing a node's html supersedes its old blob
307
+ * and leaves it orphaned — without this, `canvas.db` (which is git-committable)
308
+ * would grow without bound once externalization applies to ordinary html nodes.
309
+ *
310
+ * References are collected by scanning the raw persisted JSON for sha256 fields
311
+ * rather than parsing every node. That deliberately OVER-approximates: a stray
312
+ * 64-hex string keeps a blob alive that could have been dropped, which wastes a
313
+ * little space. The opposite error would delete live content, so the scan is
314
+ * biased to the safe side on purpose. Snapshot rows are scanned too — a snapshot
315
+ * restore must still find its blobs.
316
+ */
317
+ export function gcBlobsInDB(db: Database): number {
318
+ const referenced = new Set<string>();
319
+ const collect = (sql: string): void => {
320
+ for (const row of db.query<{ data: string }, []>(sql).all()) {
321
+ if (!row.data) continue;
322
+ for (const match of row.data.matchAll(/"sha256"\s*:\s*"([0-9a-f]{64})"/g)) referenced.add(match[1]);
323
+ }
324
+ };
325
+ collect('SELECT data FROM nodes');
326
+ collect('SELECT data FROM snapshot_nodes');
327
+
328
+ const all = db.query<{ sha256: string }, []>('SELECT sha256 FROM blobs').all();
329
+ const orphans = all.filter((row) => !referenced.has(row.sha256));
330
+ if (orphans.length === 0) return 0;
331
+ const remove = db.prepare('DELETE FROM blobs WHERE sha256 = ?');
332
+ const transaction = db.transaction(() => {
333
+ for (const row of orphans) remove.run(row.sha256);
334
+ });
335
+ transaction();
336
+ return orphans.length;
337
+ }
338
+
298
339
  export function finalizeCanvasDbForClose(db: Database): void {
299
340
  checkpointCanvasDb(db);
300
- db.exec('PRAGMA journal_mode=DELETE');
341
+ // The WAL→DELETE switch takes an exclusive lock, so it succeeds only when no
342
+ // other connection has the DB open — and that makes it the GC gate. GC deletes
343
+ // blob rows a second live process may still depend on: its in-memory nodes
344
+ // hold content-addressed refs, not content, so a GC'd blob is unrecoverable
345
+ // once that process re-saves (review of #22, reproduced). Locked ⇒ someone
346
+ // else is on this canvas: leave WAL in place and keep every blob.
347
+ try {
348
+ db.exec('PRAGMA journal_mode=DELETE');
349
+ } catch {
350
+ return;
351
+ }
352
+ gcBlobsInDB(db);
301
353
  }
302
354
 
303
355
  // ── State Persistence ───────────────────────────────────────────
304
356
 
357
+ /**
358
+ * Drop rows whose id is no longer in the live state. Reads only the id column
359
+ * (index-only scan) so it stays cheap on large boards.
360
+ */
361
+ function deleteMissingRows(
362
+ db: Database,
363
+ table: 'nodes' | 'edges' | 'annotations' | 'context_pins',
364
+ idColumn: 'id' | 'node_id',
365
+ keep: Set<string>,
366
+ ): void {
367
+ const rows = db.query<{ id: string }, []>(`SELECT ${idColumn} AS id FROM ${table}`).all();
368
+ if (rows.length === 0) return;
369
+ const remove = db.prepare(`DELETE FROM ${table} WHERE ${idColumn} = ?`);
370
+ for (const row of rows) {
371
+ if (!keep.has(row.id)) remove.run(row.id);
372
+ }
373
+ }
374
+
305
375
  export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
306
376
  const transaction = db.transaction(() => {
307
- // Clear current state tables
308
- db.run('DELETE FROM nodes');
309
- db.run('DELETE FROM edges');
310
- db.run('DELETE FROM annotations');
311
- db.run('DELETE FROM context_pins');
312
- db.run('DELETE FROM ax_state');
313
-
314
- // Save viewport and UI preferences
315
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['theme', normalizeCanvasTheme(state.theme)]);
316
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
317
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
318
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
319
-
377
+ // Rows are upserted only when a column actually differs, and rows that are
378
+ // gone are deleted by id. This used to be DELETE-all + INSERT-all, which
379
+ // rewrote every row on every debounced save: dragging one node rewrote the
380
+ // whole board, multi-MB html payloads included, so the write cost scaled
381
+ // with board size instead of change size (issue #22). The
382
+ // `WHERE ... IS NOT excluded....` guard makes an unchanged row a true
383
+ // no-op — SQLite performs no update, so no page is written. The comparison
384
+ // is against the DB itself rather than an in-memory cache, so nothing can
385
+ // go stale and silently skip a write that was actually needed.
386
+ const upsertMeta = db.prepare(
387
+ `INSERT INTO meta (key, value) VALUES (?, ?)
388
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
389
+ WHERE value IS NOT excluded.value`,
390
+ );
391
+ upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
392
+ upsertMeta.run('viewport_x', String(state.viewport.x));
393
+ upsertMeta.run('viewport_y', String(state.viewport.y));
394
+ upsertMeta.run('viewport_scale', String(state.viewport.scale));
320
395
  // Mark DB as populated (for migration detection)
321
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['state_populated', '1']);
396
+ upsertMeta.run('state_populated', '1');
322
397
 
323
398
  // Save nodes
324
- const insertNode = db.prepare(
399
+ const upsertNode = db.prepare(
325
400
  `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
326
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
401
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
402
+ ON CONFLICT(id) DO UPDATE SET
403
+ type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
404
+ width = excluded.width, height = excluded.height, z_index = excluded.z_index,
405
+ collapsed = excluded.collapsed, pinned = excluded.pinned,
406
+ dock_position = excluded.dock_position, data = excluded.data
407
+ WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
408
+ OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
409
+ OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
410
+ OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
411
+ OR dock_position IS NOT excluded.dock_position OR data IS NOT excluded.data`,
327
412
  );
328
413
  for (const node of state.nodes) {
329
- insertNode.run(
414
+ upsertNode.run(
330
415
  node.id,
331
416
  node.type,
332
417
  node.position.x,
@@ -340,14 +425,21 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
340
425
  JSON.stringify(node.data),
341
426
  );
342
427
  }
428
+ deleteMissingRows(db, 'nodes', 'id', new Set(state.nodes.map((node) => node.id)));
343
429
 
344
430
  // Save edges
345
- const insertEdge = db.prepare(
431
+ const upsertEdge = db.prepare(
346
432
  `INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
347
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
433
+ VALUES (?, ?, ?, ?, ?, ?, ?)
434
+ ON CONFLICT(id) DO UPDATE SET
435
+ from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
436
+ label = excluded.label, style = excluded.style, animated = excluded.animated
437
+ WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
438
+ OR type IS NOT excluded.type OR label IS NOT excluded.label
439
+ OR style IS NOT excluded.style OR animated IS NOT excluded.animated`,
348
440
  );
349
441
  for (const edge of state.edges) {
350
- insertEdge.run(
442
+ upsertEdge.run(
351
443
  edge.id,
352
444
  edge.from,
353
445
  edge.to,
@@ -357,14 +449,24 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
357
449
  edge.animated ? 1 : 0,
358
450
  );
359
451
  }
452
+ deleteMissingRows(db, 'edges', 'id', new Set(state.edges.map((edge) => edge.id)));
360
453
 
361
454
  // Save annotations
362
- const insertAnnotation = db.prepare(
455
+ const annotations = state.annotations ?? [];
456
+ const upsertAnnotation = db.prepare(
363
457
  `INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
364
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
458
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
459
+ ON CONFLICT(id) DO UPDATE SET
460
+ type = excluded.type, points = excluded.points, bounds = excluded.bounds,
461
+ color = excluded.color, width = excluded.width, text = excluded.text,
462
+ label = excluded.label, created_at = excluded.created_at
463
+ WHERE type IS NOT excluded.type OR points IS NOT excluded.points
464
+ OR bounds IS NOT excluded.bounds OR color IS NOT excluded.color
465
+ OR width IS NOT excluded.width OR text IS NOT excluded.text
466
+ OR label IS NOT excluded.label OR created_at IS NOT excluded.created_at`,
365
467
  );
366
- for (const annotation of state.annotations ?? []) {
367
- insertAnnotation.run(
468
+ for (const annotation of annotations) {
469
+ upsertAnnotation.run(
368
470
  annotation.id,
369
471
  annotation.type,
370
472
  JSON.stringify(annotation.points),
@@ -376,17 +478,21 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
376
478
  annotation.createdAt,
377
479
  );
378
480
  }
481
+ deleteMissingRows(db, 'annotations', 'id', new Set(annotations.map((annotation) => annotation.id)));
379
482
 
380
- // Save context pins
381
- const insertPin = db.prepare('INSERT INTO context_pins (node_id) VALUES (?)');
483
+ // Save context pins (node_id is the whole row — nothing to update)
484
+ const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (node_id) VALUES (?)');
382
485
  for (const pinId of state.contextPins) {
383
486
  insertPin.run(pinId);
384
487
  }
488
+ deleteMissingRows(db, 'context_pins', 'node_id', new Set(state.contextPins));
385
489
 
386
- db.run('INSERT INTO ax_state (key, value) VALUES (?, ?)', [
387
- 'state',
388
- JSON.stringify(state.ax ?? createEmptyAxState()),
389
- ]);
490
+ db.run(
491
+ `INSERT INTO ax_state (key, value) VALUES (?, ?)
492
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
493
+ WHERE value IS NOT excluded.value`,
494
+ ['state', JSON.stringify(state.ax ?? createEmptyAxState())],
495
+ );
390
496
  });
391
497
 
392
498
  transaction();