sc-site-sync/lib/schema.js
2026-10-19 01:25:16 -05:00

151 lines
6.3 KiB
JavaScript

// 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
};