// _dd_row_uuid column infrastructure for managed/starter tables. // // When an admin marks a user table as managed or starter, this module adds a // _dd_row_uuid column to the underlying table as a REGISTERED Saltcorn Field // (in _sc_fields) -- NOT a raw ALTER outside the catalog. Registration is what // makes the column survive Saltcorn backup/restore/snapshots: backup dumps a // row's physical columns (SELECT * / row_to_json), so a raw, unregistered // column lands in the dump but the restored table -- recreated from the pack's // field list -- has no such column, and the row insert then fails and rolls // back the whole table (real app-data loss). A registered Field round-trips: // install_pack recreates the column before the rows are imported. // // The field is created with hidden:true so it stays out of the edit/show form // builders (it is still visible in the raw table builder field list -- Saltcorn // has no system/hidden-from-catalog flag, an accepted trade-off for backup // safety). Existing rows are backfilled with random UUIDs. From that point, the // wrap layer reads/writes _dd_row_uuid as the cross-environment identity for // each row. // // Creating/deleting this field goes through Field.create / field.delete, which // the dev-deploy wraps (lib/wrap.js) journal. We run those inside runSuppressed // so the plugin's own row-identity column is never journaled or propagated to // peers -- every environment manages its own _dd_row_uuid independently. const crypto = require("crypto"); 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 COLUMN_NAME = "_dd_row_uuid"; const tableSqlRef = (tableName) => { const schema = db.getTenantSchemaPrefix(); return `${schema}"${db.sqlsanitize(tableName)}"`; }; const refreshCatalog = async () => { await require("@saltcorn/data/db/state").getState().refresh_tables(true); }; const columnExists = async (tableName) => { if (db.isSQLite) { const rs = await db.query(`PRAGMA table_info("${db.sqlsanitize(tableName)}")`); return rs.rows.some((r) => r.name === COLUMN_NAME); } // Check the tenant's OWN schema -- the same one tableSqlRef()/ALTER target. // current_schema() is NOT reliable: Saltcorn qualifies queries with // getTenantSchemaPrefix() rather than SET search_path, so current_schema() is // "public" even inside a tenant. const rs = await db.query( `SELECT 1 FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 AND column_name = $3`, [db.getTenantSchema(), tableName, COLUMN_NAME] ); return rs.rows.length > 0; }; // Is _dd_row_uuid already a registered Saltcorn field on this table? Checked via // _sc_fields directly so we do not depend on a possibly-stale in-memory catalog. const fieldIsRegistered = async (tableId) => { const row = await db.selectMaybeOne("_sc_fields", { table_id: tableId, name: COLUMN_NAME }); return row || null; }; // Adopt a pre-existing raw _dd_row_uuid column (added by an older plugin version // via raw ALTER) into _sc_fields WITHOUT re-issuing ADD COLUMN. Field.create // always emits the ADD COLUMN DDL, which would fail with "column already // exists", so for the legacy case we insert the field metadata directly. This // mirrors the _sc_fields shape Field.create writes (field.ts). const adoptExistingColumn = async (table) => { await db.insert("_sc_fields", { table_id: table.id, name: COLUMN_NAME, label: "dev-deploy row id", type: "String", required: false, is_unique: false, attributes: { hidden: true }, calculated: false, expression: null, stored: false, description: "Managed by dev-deploy: stable cross-environment row identity." }); await refreshCatalog(); }; // Backfill any rows missing a _dd_row_uuid with a fresh random UUID. const backfillNulls = async (tableName) => { const rs = await db.query(`SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} IS NULL`); let backfilled = 0; for (const r of rs.rows) { await db.query( `UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`, [crypto.randomUUID(), r.id] ); backfilled++; } return backfilled; }; const ensureIndex = async (tableName) => { await db.query( `CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_dd_row_uuid_idx" ON ${tableSqlRef(tableName)} (${COLUMN_NAME})` ).catch(() => {}); }; // Ensure the managed table has a registered _dd_row_uuid field. Idempotent: // - already registered -> no-op // - legacy raw column present -> adopt it into _sc_fields (no DDL) // - neither -> Field.create (adds column + registers) // Then backfill UUIDs and ensure the lookup index. const ensureManagedSchema = async (tableName) => { const table = Table.findOne({ name: tableName }); if (!table) { throw new Error(`ensureManagedSchema: table ${tableName} not in Saltcorn catalog`); } if (await fieldIsRegistered(table.id)) { return { added: false }; } const legacyColumn = await columnExists(tableName); await runSuppressed(async () => { if (legacyColumn) { await adoptExistingColumn(table); } else { await Field.create({ table: table, name: COLUMN_NAME, label: "dev-deploy row id", type: "String", required: false, is_unique: false, attributes: { hidden: true }, description: "Managed by dev-deploy: stable cross-environment row identity." }); } }); const backfilled = await backfillNulls(tableName); await ensureIndex(tableName); return { added: true, adopted: !!legacyColumn, backfilled: backfilled }; }; // Remove the _dd_row_uuid field/column when a table reverts to 'user' mode. // Prefers the Field model (drops column + _sc_fields entry); falls back to a raw // DROP COLUMN for a legacy unregistered column. const dropManagedSchema = async (tableName) => { const table = Table.findOne({ name: tableName }); if (!table) { return { dropped: false }; } const registered = await fieldIsRegistered(table.id); if (registered) { const field = await Field.findOne({ id: registered.id }); if (field) { await runSuppressed(() => field.delete()); return { dropped: true }; } } if (await columnExists(tableName)) { // SQLite 3.35+ and Postgres both support DROP COLUMN. await db.query(`ALTER TABLE ${tableSqlRef(tableName)} DROP COLUMN ${COLUMN_NAME}`); return { dropped: true }; } return { dropped: false }; }; const getRowUuid = async (tableName, id) => { const rs = await db.query( `SELECT ${COLUMN_NAME} AS uuid FROM ${tableSqlRef(tableName)} WHERE id = $1`, [id] ); return rs.rows.length > 0 ? rs.rows[0].uuid : null; }; const findIdByRowUuid = async (tableName, uuid) => { const rs = await db.query( `SELECT id FROM ${tableSqlRef(tableName)} WHERE ${COLUMN_NAME} = $1`, [uuid] ); return rs.rows.length > 0 ? rs.rows[0].id : null; }; const setRowUuid = async (tableName, id, uuid) => { await db.query( `UPDATE ${tableSqlRef(tableName)} SET ${COLUMN_NAME} = $1 WHERE id = $2`, [uuid, id] ); }; const newRowUuid = () => crypto.randomUUID(); // All rows currently in the table, including their _dd_row_uuid. Used during // the initial managed/starter ship to journal an insert_row op for each. const allRowsWithUuid = async (tableName) => { const rs = await db.query( `SELECT * FROM ${tableSqlRef(tableName)} ORDER BY id ASC` ); return rs.rows; }; module.exports = { COLUMN_NAME, columnExists, ensureManagedSchema, dropManagedSchema, getRowUuid, findIdByRowUuid, setRowUuid, newRowUuid, allRowsWithUuid };