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.
- package/CHANGELOG.md +17 -0
- package/Readme.md +1 -1
- package/dist/canvas/index.js +71 -71
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/server/canvas-db.d.ts +14 -0
- package/docs/RELEASE.md +48 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +6 -2
- package/skills/pmx-canvas-testing/SKILL.md +40 -0
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/server/canvas-db.ts +138 -32
|
@@ -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.
|
|
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`,
|
|
360
|
-
diagnosing, trust `paint-ok`/`paint-fail`, and never assert
|
|
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,
|
|
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, '
|
|
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
|
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
396
|
+
upsertMeta.run('state_populated', '1');
|
|
322
397
|
|
|
323
398
|
// Save nodes
|
|
324
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
367
|
-
|
|
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(
|
|
387
|
-
|
|
388
|
-
|
|
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();
|