// Hidden _dd_row_uuid column infrastructure for managed/starter tables. // // When an admin marks a user table as managed or starter, this module // adds a TEXT column _dd_row_uuid to the underlying SQL table via raw ALTER // (NOT registered in _sc_fields, so Saltcorn's table builder doesn't show // it). 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. const crypto = require("crypto"); const db = require("@saltcorn/data/db"); const COLUMN_NAME = "_dd_row_uuid"; const tableSqlRef = (tableName) => { const schema = db.getTenantSchemaPrefix(); return `${schema}"${db.sqlsanitize(tableName)}"`; }; 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. Using it made this falsely report the column // missing on every call after the first, and the explicitly-qualified ALTER // then failed with 'column "_dd_row_uuid" already exists' (breaking apply, // which calls ensureManagedSchema once per set_table_mode + per insert_row). 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; }; const ensureManagedSchema = async (tableName) => { if (await columnExists(tableName)) { return { added: false }; } await db.query(`ALTER TABLE ${tableSqlRef(tableName)} ADD COLUMN ${COLUMN_NAME} TEXT`); // Backfill existing rows with random UUIDs. 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++; } // Index for fast lookups by uuid. await db.query( `CREATE INDEX IF NOT EXISTS "${db.sqlsanitize(tableName)}_dd_row_uuid_idx" ON ${tableSqlRef(tableName)} (${COLUMN_NAME})` ).catch(() => {}); return { added: true, backfilled: backfilled }; }; const dropManagedSchema = async (tableName) => { if (!(await columnExists(tableName))) { return { dropped: false }; } // SQLite 3.35+ and Postgres both support DROP COLUMN. If the SQLite is // older it will throw — surface the error. await db.query(`ALTER TABLE ${tableSqlRef(tableName)} DROP COLUMN ${COLUMN_NAME}`); return { dropped: true }; }; 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 };