// Schema setup for site-sync's plugin tables. // // Three storage strategies, chosen by WHEN the state is written, so each survives // Saltcorn backup/restore without fighting the plugin lifecycle: // // * ENV IDENTITY -> _sc_config (lib/env.js + lib/common/configStore.js). Created during // onLoad; _sc_config is restored BEFORE onLoad, so a restored host keeps its // env_id and onLoad does not create a duplicate. // // * PEERS + TABLE MODES -> REGISTERED Saltcorn tables (auto `id` PK + the logical // keys kept as unique fields). Written only at runtime (not during // onLoad/restore), so being in _sc_tables they ride backup. Peer SECRETS must // survive backup, so they belong here, not in a raw table. // // * NONCE STORE (_ss_seen_nonces), PERSISTED PLANS (_ss_plans), rollback // RESTORE POINTS (_ss_rollback) and the APPLY LOCK (_ss_apply_lock) stay RAW: // they are a replay cache / short-lived plan tokens / short-lived restore points // / a lease, so losing them on a restored host is acceptable, and they must // never carry must-survive state. const db = require("@saltcorn/data/db"); const { PLUGIN_NAME, PEERS_TABLE, SEEN_NONCES_TABLE, APPLY_LOCK_TABLE } = require("./constants"); const { PEER_TABLE_FIELDS } = require("./common/peerStore"); const { createNonceStore } = require("./common/peerServer"); const { ensureRegisteredTable, physicalColumns } = require("./common/registeredTable"); // Field definitions (excluding the auto `id` PK) of the registered table-modes // table (the peers table's come from lib/common/peerStore.js). const TABLE_MODES_FIELDS = [ { name: "table_name", type: "String", is_unique: true, required: true }, { name: "data_mode", type: "String", required: true }, { name: "updated_at", type: "String", required: true }, { name: "starter_shipped_at", type: "String" } ]; // Make `name` a registered Saltcorn table with an auto `id` PK plus `fieldDefs` // (lib/common/registeredTable.js: decided from _sc_tables, never the stale // per-process cache; an unregistered stray table of that name is adopted with // its rows, keyed by keyField; racing workers are serialized). A cheap no-op // once registered. site-sync has no CRUD wrap layer, so (unlike dev-deploy) // there is nothing to suppress here. const registerTable = async (name, fieldDefs, keyField) => { await ensureRegisteredTable({ owner: PLUGIN_NAME, name: name, fields: fieldDefs, keyField: keyField }); }; // Idempotent migration: add `column` to a raw table created before it existed (the // CREATE TABLE IF NOT EXISTS leaves such a table as it was). Workers booting // together race here; losing the race (the column exists now) is fine. const addColumnIfMissing = async (table, column, sqlType) => { if ((await physicalColumns(table)).has(column)) { return; } try { await db.query(`ALTER TABLE ${db.getTenantSchemaPrefix()}${table} ADD COLUMN ${column} ${sqlType}`); } catch (e) { if (!(await physicalColumns(table)).has(column)) { throw e; } } }; // Persisted plans for the two-phase preview -> apply flow. A plan is computed on // the mutating (target) side, previewed, then applied by plan_id. Raw + regenerable // (a lost plan just has to be re-previewed). desired_pack + plan_json are TEXT JSON. // claimed_from = the status the last apply / rollback claimed the plan from: where // lib/siteLock.js returns a plan whose apply or rollback was abandoned. const createPlans = async () => { const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}_ss_plans ( plan_id TEXT PRIMARY KEY, peer_id INTEGER, source_env_id TEXT, direction TEXT, desired_pack TEXT, plan_json TEXT, precondition_hash TEXT, source_hash TEXT, status TEXT NOT NULL DEFAULT 'pending', claimed_from TEXT, created_at TEXT NOT NULL ) `); await db.query(`CREATE INDEX IF NOT EXISTS _ss_plans_created ON ${schema}_ss_plans (created_at)`); await addColumnIfMissing("_ss_plans", "claimed_from", "TEXT"); }; // The site-wide apply lock (lib/siteLock.js): at most one row per tenant schema, // the holder's lease ending at expires_ms (epoch milliseconds). const createApplyLock = async () => { await db.query(` CREATE TABLE IF NOT EXISTS ${db.getTenantSchemaPrefix()}${APPLY_LOCK_TABLE} ( lock_name TEXT PRIMARY KEY, holder TEXT NOT NULL, operation TEXT NOT NULL, plan_id TEXT, acquired_at TEXT NOT NULL, expires_ms BIGINT NOT NULL ) `); }; // 0.0.1 created a per-peer last-applied-hash table that nothing ever read; drop it. const dropLegacyPeerState = async () => { await db.query(`DROP TABLE IF EXISTS ${db.getTenantSchemaPrefix()}_ss_peer_state`); }; // Pre-apply restore points for rollback: the pre-apply metadata pack + raw rows of // the tables an apply will touch/drop, keyed by plan_id. Raw + short-lived (a lost // restore point just means that apply can no longer be one-click reverted). const createRollback = async () => { const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}_ss_rollback ( plan_id TEXT PRIMARY KEY, restore_pack TEXT, restore_rows TEXT, restore_files TEXT, created_at TEXT NOT NULL ) `); // Without the column (an install created before it) every saveRestorePoint // INSERT would throw and silently disable ALL rollback. await addColumnIfMissing("_ss_rollback", "restore_files", "TEXT"); }; const createAllTables = async () => { // Backed-up state -> registered Saltcorn tables. await registerTable(PEERS_TABLE, PEER_TABLE_FIELDS, "env_id"); await registerTable("_ss_table_modes", TABLE_MODES_FIELDS, "table_name"); // Regenerable -> raw tables. await createNonceStore(SEEN_NONCES_TABLE); await createPlans(); await createRollback(); await createApplyLock(); await dropLegacyPeerState(); }; module.exports = { createAllTables };