124 lines
13 KiB
Markdown
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.
|