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

234 lines
8.2 KiB
JavaScript

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