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

124 lines
13 KiB
Markdown

# 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.