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)
- 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 inclaimed_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. - 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. - Renames (before upsert, only those the admin ticked on the apply form; never on a
remote push-apply) --
Table.renamepreserves data and makesinstall_packupdate the renamed table rather than create a fresh empty one. - Upsert -- a deep-cloned, pruned pack fed to
install_pack(name=undefinedto skip theinstalled_packsappend; 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). - 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, withusers/locked tables never dropped and no name-sweep. - 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). - 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 RETURNINGwhile the lock is free, elseUPDATE ... WHERE expires_ms < now RETURNING(take over a lapsed lease). Of any number of racing callers exactly one wins; the rest getLockBusyErrornaming 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_backwas abandoned. Acquiring therefore returns each toclaimed_from(recorded atomically by the claim:SET status = $1, claimed_from = status), exactly what a failed apply or rollback restores -- an interrupted apply ispendingagain (its restore point rolls it back), an interrupted rollback can be retried. Plans claimed by 0.0.3 (noclaimed_from) fall back topending/applied. The operation's warnings name each recovered plan. - Reporting is read-only:
lockState+ the in-flight plans give the dashboard andsitesync_statustheir idle / running / interrupted state, and a plan's standing saysinterrupted(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.
assertHeldbefore each mutating phase and before the final status write throwsLockLostError;finishClaimedthen 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 beforeonLoad; 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_uuidis a registered field (never a rawALTER) so it survives restore instead of rolling back the whole table.