dev-deploy/dev-deploy/lib/rowIdentity.js
2026-05-17 17:31:49 -05:00

124 lines
3.7 KiB
JavaScript

// 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);
}
const rs = await db.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = $1
AND column_name = $2`,
[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
};