234 lines
8.2 KiB
JavaScript
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
|
|
};
|