// Schema setup for dev-deploy's plugin tables. // // Two 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/configStore.js). It is 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 old // logical keys kept as unique fields). They are written only at runtime (not // during onLoad/restore), so the catalog-recreate-on-restore path is clean, // and being in _sc_tables they ride backup. Existing db.select/insert code // keeps working unchanged (the logical keys are now ordinary columns). // // * JOURNAL (_dd_ops), ENTITY-ID CACHE (_dd_entity_ids), SYNC WATERMARKS // (_dd_anchors) stay RAW: they are a log / a rebuildable cache / re-syncable // watermarks, so regenerating them on a restored host is acceptable, and the // journal must NOT be registered (dev-deploy's wraps would journal the // restore's own entity-creation into it). const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const Field = require("@saltcorn/data/models/field"); const { runSuppressed } = require("./context"); const { readKey, writeKey } = require("./configStore"); const ENV_KEY = "dev_deploy_env"; // Field definitions (excluding the auto `id` PK) for the registered tables. const PEERS_FIELDS = [ { name: "peer_id", type: "Integer", is_unique: true }, { name: "env_id", type: "String", is_unique: true, required: true }, { name: "label", type: "String" }, { name: "base_url", type: "String", required: true }, { name: "peer_secret_ciphertext", type: "String" }, { name: "peer_secret_iv", type: "String" }, { name: "peer_secret_tag", type: "String" }, { name: "require_tls", type: "Integer" }, { name: "created_at", type: "String", required: true }, { name: "last_seen_at", type: "String" } ]; const TABLE_MODES_FIELDS = [ { name: "table_uuid", 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" } ]; const rawTableExists = async (name) => { if (db.isSQLite) { const rs = await db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${name}'`); return rs.rows.length > 0; } const rs = await db.query( `SELECT 1 FROM information_schema.tables WHERE table_schema='${db.getTenantSchema()}' AND table_name='${name}'` ); return rs.rows.length > 0; }; // Make `name` a registered Saltcorn table with an auto `id` PK plus `fieldDefs`. // Idempotent: a no-op once registered. If a legacy RAW table exists, its rows are // migrated into the registered table (logical-key columns preserved). Table.create // + Field.create run under runSuppressed so dev-deploy's own wraps don't journal // the plugin's bookkeeping tables. const registerTable = async (name, fieldDefs) => { if (Table.findOne({ name })) { return; } const schema = db.getTenantSchemaPrefix(); let rows = []; if (await rawTableExists(name)) { rows = (await db.query(`SELECT * FROM ${schema}"${name}"`)).rows; await db.query(`DROP TABLE ${schema}"${name}"`); } await runSuppressed(async () => { const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 }); for (const f of fieldDefs) { await Field.create({ table: t, name: f.name, label: f.label || f.name, type: f.type, is_unique: !!f.is_unique, required: !!f.required, attributes: f.attributes || {} }); } }); for (const r of rows) { const row = {}; for (const f of fieldDefs) { if (r[f.name] !== undefined && r[f.name] !== null) { row[f.name] = r[f.name]; } } await db.insert(name, row, { noid: true }); } }; // Move a legacy _dd_env table into the _sc_config identity key, then drop it. const migrateEnvToConfig = async () => { if (await readKey(ENV_KEY)) { return; } if (!(await rawTableExists("_dd_env"))) { return; } const schema = db.getTenantSchemaPrefix(); const rows = (await db.query(`SELECT * FROM ${schema}_dd_env`)).rows; if (rows.length > 0) { const e = rows[0]; await writeKey(ENV_KEY, { env_id: e.env_id, env_label: e.env_label, on_destructive_op: e.on_destructive_op, require_tls: e.require_tls, created_at: e.created_at, bootstrapped_at: e.bootstrapped_at }); } await db.query(`DROP TABLE IF EXISTS ${schema}_dd_env`); }; const createDdEntityIds = async () => { const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}_dd_entity_ids ( uuid TEXT PRIMARY KEY, kind TEXT NOT NULL, current_name TEXT NOT NULL, current_id INTEGER NOT NULL, parent_uuid TEXT, created_at TEXT NOT NULL, UNIQUE (kind, current_id) ) `); await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_kind_name ON ${schema}_dd_entity_ids (kind, current_name)`); await db.query(`CREATE INDEX IF NOT EXISTS _dd_entity_ids_parent ON ${schema}_dd_entity_ids (parent_uuid)`); }; const createDdOps = async () => { const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}_dd_ops ( op_id TEXT PRIMARY KEY, source_env_id TEXT NOT NULL, op_type TEXT NOT NULL, entity_kind TEXT, entity_uuid TEXT, payload TEXT NOT NULL, parent_op_id TEXT, correlation_id TEXT, schema_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, applied_at TEXT, status TEXT NOT NULL DEFAULT 'committed', conflict_with_op_id TEXT ) `); await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_created ON ${schema}_dd_ops (created_at)`); await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_source ON ${schema}_dd_ops (source_env_id, created_at)`); await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_entity ON ${schema}_dd_ops (entity_uuid)`); await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_correlation ON ${schema}_dd_ops (correlation_id)`); await db.query(`CREATE INDEX IF NOT EXISTS _dd_ops_status ON ${schema}_dd_ops (status) WHERE status = 'conflict'`).catch(() => {}); // Idempotent migration for instances installed before conflict_with_op_id existed. if (db.isSQLite) { try { await db.query(`ALTER TABLE ${schema}_dd_ops ADD COLUMN conflict_with_op_id TEXT`); } catch (e) { // column already exists; ignore } } else { await db.query(`ALTER TABLE ${schema}_dd_ops ADD COLUMN IF NOT EXISTS conflict_with_op_id TEXT`); } }; const createDdAnchors = async () => { const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}_dd_anchors ( peer_id INTEGER NOT NULL, direction TEXT NOT NULL, last_op_id TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (peer_id, direction) ) `); }; const createAllTables = async () => { // Identity -> _sc_config (migrate any legacy table first). await migrateEnvToConfig(); // Backed-up state -> registered Saltcorn tables. await registerTable("_dd_peers", PEERS_FIELDS); await registerTable("_dd_table_modes", TABLE_MODES_FIELDS); // Regenerable -> raw tables. await createDdEntityIds(); await createDdOps(); await createDdAnchors(); }; module.exports = { createAllTables };