sc-dev-deploy/lib/schema.js
2026-06-17 17:37:16 -05:00

217 lines
8.4 KiB
JavaScript

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