217 lines
8.4 KiB
JavaScript
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
|
|
};
|