sc-site-sync/docs/architecture.md
2026-10-19 01:25:16 -05:00

13 KiB

site-sync architecture

site-sync is a one-way desired-state deployer. It delegates SERIALIZATION to Saltcorn's backup.create_pack_json and the bulk of APPLICATION to pack.install_pack, and adds three things of its own: a diff/plan/preview engine, a surgical policy-gated delete engine, and an opt-in reference-row layer. The peer transport, sealed secrets, config workflow, and per-tenant patterns are transplanted from the sibling dev-deploy plugin.

Module map (lib/)

Transport & identity. The peer protocol, peer store, at-rest sealing, admin helpers, registered-table bootstrap and sync-policy constants live in lib/common/ as generic infrastructure (they began as code shared with dev-deploy; site-sync now owns its copy). The site-sync modules below bind them:

Module Responsibility
constants.js Plugin name/version, SYNC_MODES, DELETE_POLICY, APPLY_POLICY, PLAN_STATUS, signed-request header/content-type names, the peer KEK salt/info (PEER_KEK_INFO) and table names; re-exports SYNCABLE_CONFIG_KEYS, ROW_SYNC_LOCKED_TABLES, DATA_MODES and the infra / lock / synced-mode predicates from common/syncConstants.js.
transport.js API paths and size/time limits, and the outbound client on common/peerClient.js -- one signedRequest (signed headers, application/vnd.site-sync+json, timeout, byte-capped read, response-signature verification before use) behind peerJson (2xx JSON + responder env_id check) and peerBytes (verified file bytes).
peerAuth.js Binding of common/peerServer.js: inbound requirePeerAuth (header check -> declared/streamed body cap -> skew -> peer lookup -> signature, host = Express req.host -> nonce replay -> JSON body -> signed TLS gate on req.protocol) and sendSigned/sendSignedJson, which sign every reply against the request's nonce.
peers.js _ss_peers binding of common/peerStore.js: CRUD; secrets sealed at rest (common/aesGcm.js, KEK from common/sessionKey.js -- only an explicitly configured session_secret); rotate (generate or install the other side's new secret); peer_id allocation retries on collision; deleting a peer deletes its plans (operations.deletePeer first detaches the ones that can still be rolled back).
env.js Env identity in _sc_config key site_sync_env via common/configStore.js (survives backup, restored before onLoad).
ids.js randomUuid (env_id, plan ids, row identities).
schema.js Idempotent table bootstrap: registered _ss_peers (fields from common/peerStore.js)/_ss_table_modes; raw regenerable _ss_seen_nonces (common/peerServer.js)/_ss_plans/_ss_rollback/_ss_apply_lock; race-tolerant column migrations (_ss_plans.claimed_from, _ss_rollback.restore_files) for workers booting together (and drops 0.0.1's unused _ss_peer_state).
render.js Admin-UI helpers: the admin URL table, theme-aware sendWrap, nav; re-exports the shared admin gate, escape, csrf field and flash-and-redirect.

New desired-state code:

Module Responsibility
capture.js buildDesiredState = create_pack_json -> sanitizeDesired (parity-only plugins, config allow-list, exclusion of locked tables and of _-prefixed infra tables AND fields, plain-JSON normalization) -> order-insensitive hash. Also sanitizes packs received from a peer.
diff.js Pure computePlan(desired, current, opts): per-kind create/update/delete (roles by id, isEqual with per-kind normalization), nested table field/constraint diff, structural TABLE-rename detection (views/pages: create + delete), code pages as a first-class kind, config overwrite set, destructive annotation.
plan.js Live row-count gathering, can_install_pack warnings, reference-row preview, plan persistence (_ss_plans; retention never prunes a plan whose restore point is kept) + precondition hash, plan listing, the data-drop tables an apply must name, and preview/apply-form HTML.
siteLock.js The site-wide apply lock: one _ss_apply_lock row per tenant schema under a renewed lease timed by the database clock; atomic acquire (insert, or take over a lapsed lease) that recovers plans a stopped holder left in flight to their claimed_from status; lease checkpoints (assertHeld) and finishClaimed (no status write after the lock was lost); read-only lockState for the dashboard / MCP.
apply.js Orchestrator: site lock -> atomic plan claim -> precondition re-check -> metadata snapshot + restore point -> confirmed-rename pre-pass -> deep-clone+prune -> install_pack -> delete pass -> reference-row sync -> non-silent getState().refresh(), checking the lease before each mutating phase; collects every non-fatal problem into result.warnings.
deletes.js applyRenames (confirmed renames only; data-preserving Table.rename, SQLite fallback) + applyDeletes (reverse-dependency prune incl. code pages, models, constraint removals; FK fields stripped before any table drop; live row re-count and the data-loss gates).
rowIdentity.js Registered _ss_row_uuid field, ensureManagedSchema, uuid<->pk resolution (via pk_name), locked-table gates.
rowPayload.js Portable row <-> local row (FK ids <-> referenced-row uuid; File fields <-> a content-hash placeholder; user-mode FKs nulled with a warning).
rows.js Data-mode storage (_ss_table_modes), manifest build (pre-pass schema), the apply engine (insert fixpoint -> converge/FK-fix, writing only changed rows -> managed prune that keeps rows user data still references), file materialization (fetch-by-hash + dedup; a failed fetch keeps the existing reference), and the source-side content-hash file index.
layoutFiles.js Always-on content-addressed transfer of files referenced inside page layouts / view configs / the site logo: placeholderize (capture), resolvePlaceholders (apply; image-mime allow-list + hash-verify + dedup; min_role = most permissive of the referencing pages/views), indexLocalFiles (extends the source file index).
rollback.js Pre-apply restore point (metadata pack + exactly the rows the gate can drop/change, in _ss_rollback) and rollbackApply (under the site lock, once per apply: mirror-mode re-sync of metadata/config -- an apply run under the same lease --, inverse renames, removal of inserted rows, restore of captured rows by pk, starter un-marking; file refs revert via retained old bytes).
operations.js The admin operations, transport-free -- the routes AND the MCP tools are thin adapters over them, so the rules live here: settings options + validation, peer resolution (id / env_id / label) and pairing, the pull orchestration (diffPluginsWithPeer parity check -> signed fetchPeerState -> buildPlan), plan listing and standing (can apply / stale / can roll back / interrupted), apply and rollback (the site lock is taken below, in apply.js / rollback.js, so push-apply shares it; a lock refusal is an OpError with no rollback offer), the lock's state for the dashboard and sitesync_status (idle / running / interrupted), table listing and data modes, this site's read-only state summary. Actionable failures throw OpError.
routes.js Route table + admin handlers (rendering over operations.js) + machine handlers (wrapped by peerRoute: auth, then a signed reply even on error).
mcpTools.js mcp_tools for the saltcorn-mcp server: 15 sitesync_* tools (read / write / admin) as adapters over operations.js; plan views trimmed (names, counts, gates) or one entity at a time, through ctx.redact; a generated peer secret returned once.

Apply order (why it is what it is)

  1. Lock + claim + precondition -- take the site lock (siteLock.js, below: a second apply or a rollback started meanwhile, in any process, is refused), atomically claim the plan (a double-submit is refused; the status it left is kept in claimed_from), re-hash the target's current state, and refuse if it changed since preview (optimistic concurrency -- airtight against other applies, which the lock serializes). Row inserts don't change the metadata hash, so the delete engine additionally re-counts rows live.
  2. Snapshot + restore point -- Saltcorn's Snapshot.take_if_changed (metadata) plus site-sync's own restore point (pre-apply metadata pack + raw rows of touched/dropped tables) so the apply is one-click revertible.
  3. Renames (before upsert, only those the admin ticked on the apply form; never on a remote push-apply) -- Table.rename preserves data and makes install_pack update the renamed table rather than create a fresh empty one.
  4. Upsert -- a deep-cloned, pruned pack fed to install_pack (name=undefined to skip the installed_packs append; plugins/event_logs/model_instances emptied; config re-intersected with the allow-list; existing desired-named tags deleted so the create path runs instead of the append-without-dedup path).
  5. Deletes (after upsert) -- surgical per-entity prune in reverse-dependency order (page_groups -> pages -> views -> triggers -> library -> tags -> models -> code pages -> constraint removals + field drops on surviving tables, so no surviving FK column points at a doomed table -> FK fields + triggers of every doomed table -> the tables), each data-bearing drop gated by on_delete + the table name TYPED into the apply form's allow-list (never pre-filled; empty on a remote push-apply) + the confirm tick, with users/locked tables never dropped and no name-sweep.
  6. Reference rows + files -- insert fixpoint (handles required cross-table FKs), converge (managed) / seed-once (starter), managed prune by uuid; File fields are materialized by fetching bytes from the source's GET /api/file/:hash (verified against the placeholder hash, deduped so unchanged files are not re-fetched; the response itself is signature-verified first).
  7. Refresh -- one non-silent getState().refresh() so cluster workers converge.

Steps 3-6 each start by renewing the lease (assertHeld); see below.

One apply at a time: the site lock

Saltcorn forks several workers even on SQLite, and several servers may share one database, so the guard cannot live in process memory. _ss_apply_lock holds at most one row per tenant schema: the holder (a random token), the operation and plan, and expires_ms. Every time in it is the DATABASE's clock (julianday('now') on SQLite, clock_timestamp() on Postgres), never a server's.

  • Acquire is one atomic statement each way: INSERT ... ON CONFLICT DO NOTHING RETURNING while the lock is free, else UPDATE ... WHERE expires_ms < now RETURNING (take over a lapsed lease). Of any number of racing callers exactly one wins; the rest get LockBusyError naming what runs. Nothing queues.
  • Lease: 60 s, renewed every 15 s by a background timer while the operation runs, so a holder that dies (crash, kill, restart) loses the lock within a minute.
  • Recovery: every apply and rollback runs under the lock, so its holder knows any plan still applying / rolling_back was abandoned. Acquiring therefore returns each to claimed_from (recorded atomically by the claim: SET status = $1, claimed_from = status), exactly what a failed apply or rollback restores -- an interrupted apply is pending again (its restore point rolls it back), an interrupted rollback can be retried. Plans claimed by 0.0.3 (no claimed_from) fall back to pending / applied. The operation's warnings name each recovered plan.
  • Reporting is read-only: lockState + the in-flight plans give the dashboard and sitesync_status their idle / running / interrupted state, and a plan's standing says interrupted (judged by the status it will be recovered to) when it is in flight with no live lease.
  • A stalled holder (event loop blocked or database unreachable past the lease) can lose the lock. assertHeld before each mutating phase and before the final status write throws LockLostError; finishClaimed then writes no plan status (the new holder recovered the plan and may be working on it).
  • Nesting: a rollback's metadata step is an apply run under the rollback's lease (withSiteLock({ lease })), not a second acquire.

Storage lifecycle (backup-safe by construction)

  • Identity -> _sc_config (restored before onLoad; no duplicate).
  • Must-survive runtime state (peer secrets, table modes) -> registered Saltcorn tables (_ss_peers, _ss_table_modes) that ride backup.
  • Regenerable state (nonce store, persisted plans, rollback restore points, the apply lock) -> raw portable DDL (TEXT/INTEGER/BIGINT, schema-prefixed), invisible to backup but safe to lose.
  • A managed table's _ss_row_uuid is a registered field (never a raw ALTER) so it survives restore instead of rolling back the whole table.