Skip to content

perf: incremental site saves; hoist runtime builds out of the publish transaction#27

Merged
DavidBabinec merged 2 commits into
mainfrom
perf/incremental-site-save
Jun 11, 2026
Merged

perf: incremental site saves; hoist runtime builds out of the publish transaction#27
DavidBabinec merged 2 commits into
mainfrom
perf/incremental-site-save

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #24 (stacked on perf/critical-performance-fixes — merge that first; this diff is the last two commits). Fixes the two remaining adversarially-verified server-side findings from the performance audit: the full-roster autosave and the publish transaction blocking every concurrent write.

Benchmarks — before / after

Same bench discipline as #24: the Site save round-trip scenario was committed first and run on unmodified code, then re-run after the fix (full-size: 60 pages × ~300 nodes, one-prop edit — the canonical autosave trigger).

Metric Before After Δ
Autosave payload (one-prop edit) 2.81 MB 49.1 KB 57×
Server validate + reconcile per save 32.6 ms 0.87 ms 38×

The publish-transaction hoist isn't a wall-time win for the publish itself — it removes a stall: with a cold dependency cache, bun install (60 s timeout) plus one esbuild run per page all executed inside the publish transaction, and the SQLite adapter serializes every transaction in the process through one chain. Every concurrent write — autosaves, row publishes, plugin records, sessions — queued behind it. Now the transaction contains only the DB writes.

What changed

Incremental saves (every autosave/Cmd+S used to ship and rewrite the full page + component roster; the server re-validated and DOMPurify-sanitized every node of every page):

  • The editor store derives which pages/VCs changed from the same Mutative patches that power undo (slices/site/dirtyTracking.ts) — wired into historic mutations, undo/redo, and lifecycle resets. Anything unattributable marks all (conservative full save); over-marking is safe, under-marking would lose edits. Autosave snapshots the marks before the request and merges them back on failure, so edits landing mid-save are never lost.
  • Wire protocol: PUT /pages{ changedPages, pageIds, baselinePageIds? }, PUT /components{ changedComponents, componentIds }. Full id rosters always ship, so delete-what's-missing reconcile semantics (including the ISS-041 concurrency baseline) are byte-identical.
  • Server validates only the changed batch (validatePagesForPartialSave; slug uniqueness against a light id+slug projection) — outside the transaction. Cross-VC rules (identity, refs, dependency cycles) are inherently roster-wide, so they run against the merged post-save roster (validateVisualComponentsForPartialWrite); the old full-write validator is deleted.
  • updateDataRowDraftCells drops the hydrated per-row re-read both reconcilers always discarded; listDataRowIdSlugs replaces full cells_json hydration in the reconcile loops (the third of the three wasted full-tree JSON round-trips per page per save).
  • deletePage now splices in place — the wholesale array replacement it used emitted a patch dirty-tracking couldn't attribute, silently escalating every post-delete save to a full save (found by the new test suite). Page-roster mutations extracted to page-tree/pageMutations.ts; mutations.ts ratchets 880 → 760 lines.

Publish transaction hoist (verified high/high): dependency-cache priming, the importmap build, and all per-page esbuild runs now execute before the transaction opens; the transaction is writes-only, with an orphan-version guard for pages reaped between the read and the write (withPublishLock already serializes publishes, so the read is stable against other publishers).

Verification

  • bun test: 5,156 pass / 0 fail (45 new tests: dirty-collector units + store integration, adapter wire-shape pins, 12 handler-level partial-save cases on real SQLite incl. both ISS-041 directions, slug conflicts vs unchanged rows, and merged-roster VC ref rejection)
  • bun run build + bun run lint: clean. Docs updated (site-shell.md save flow, publisher.md publish flow).

🤖 Generated with Claude Code

Base automatically changed from perf/critical-performance-fixes to main June 11, 2026 09:22
DavidBabinec and others added 2 commits June 11, 2026 13:22
Measures the JSON payload the editor ships to PUT /pages plus the
server-side validate + reconcile work, for a one-prop edit. Captures the
before/after evidence for the incremental-save change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… transaction

Incremental saves (every autosave/Cmd+S previously shipped and rewrote the
FULL page + component roster, and the server re-validated and DOMPurify-
sanitized every node of every page):
- The editor store derives which pages/VCs changed from the same Mutative
  patches that power undo (slices/site/dirtyTracking.ts) — wired into
  historic mutations, undo/redo, and lifecycle resets, with a conservative
  mark-all fallback for anything unattributable. Autosave snapshots the
  marks before the request and merges them back on failure.
- Wire protocol: PUT /pages takes { changedPages, pageIds, baselinePageIds? },
  PUT /components takes { changedComponents, componentIds }. Full id rosters
  always ship, so reap semantics (incl. ISS-041 baseline) are unchanged.
- Server validates ONLY the changed batch (validatePagesForPartialSave;
  slug uniqueness against a light id+slug projection) — outside the
  transaction, since the SQLite adapter serializes all transactions through
  one chain. Cross-VC rules run on the merged post-save roster
  (validateVisualComponentsForPartialWrite); the old full-write validator
  is deleted.
- updateDataRowDraftCells writes without the hydrated re-read the roster
  reconcilers always discarded; listDataRowIdSlugs replaces full-roster
  hydration in both reconcile loops.
- deletePage now splices in place — wholesale array replacement emitted a
  patch dirty-tracking couldn't attribute, escalating every post-delete
  save to a full save. Page-roster mutations extracted to
  page-tree/pageMutations.ts (mutations.ts ratchets 880 → 760 lines).

Publish transaction hoist (verified high-impact):
- ensureRuntimeDependencyCache (bun install), the importmap build, and all
  per-page esbuild runs now execute BEFORE the publish transaction; the
  transaction is DB-writes-only with an orphan-version guard for pages
  reaped between the read and the write. Every concurrent write used to
  queue behind multi-second builds on the global SQLite transaction chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavidBabinec DavidBabinec force-pushed the perf/incremental-site-save branch from 90e7cff to 97779cd Compare June 11, 2026 11:24
@DavidBabinec DavidBabinec merged commit 9e84c3b into main Jun 11, 2026
6 checks passed
@DavidBabinec DavidBabinec deleted the perf/incremental-site-save branch June 11, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant