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

113 lines
4.3 KiB
JavaScript

// dev-deploy: Saltcorn plugin for migrating metadata changes across
// Dev/Test/Prod environments via an ops journal with stable UUIDs.
const db = require("@saltcorn/data/db");
const { PLUGIN_NAME, PLUGIN_VERSION, DATA_MODES } = require("./lib/constants");
const { createAllTables } = require("./lib/schema");
const { getEnv, initEnvIfMissing, markBootstrapped } = require("./lib/env");
const { backfillAll } = require("./lib/entityIds");
const { ensureManagedSchema } = require("./lib/rowIdentity");
const { installAllWraps } = require("./lib/wrap");
const { routes } = require("./lib/routes");
const log = (msg) => {
// eslint-disable-next-line no-console
console.log(`[${PLUGIN_NAME}] ${msg}`);
};
const ensureCsrfBypass = async () => {
try {
const { getState } = require("@saltcorn/data/db/state");
const current = getState().getConfig("disable_csrf_routes", "");
const want = "/dev-deploy/api/";
const entries = current.split(",").map((s) => s.trim()).filter(Boolean);
if (!entries.includes(want)) {
entries.push(want);
await getState().setConfig("disable_csrf_routes", entries.join(","));
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] failed to register csrf bypass:`, e);
}
};
// One-time migration: managed/starter tables created by an older plugin version
// carry a RAW _dd_row_uuid column (added via ALTER, not registered in _sc_fields).
// Such a column is dumped by backup but absent from the restored table's field
// list, which fails the row import and rolls back the whole table (app-data
// loss). Adopt every existing managed table's column into _sc_fields so it
// round-trips. ensureManagedSchema is idempotent: a no-op once registered.
const migrateRowUuidFields = async () => {
const schema = db.getTenantSchemaPrefix();
let rows;
try {
rows = await db.query(
`SELECT e.current_name AS name
FROM ${schema}_dd_table_modes m
JOIN ${schema}_dd_entity_ids e ON e.uuid = m.table_uuid
WHERE e.kind = 'table'
AND m.data_mode IN ($1, $2)`,
[DATA_MODES.MANAGED, DATA_MODES.STARTER]
);
} catch (e) {
return 0;
}
let adopted = 0;
for (const r of rows.rows) {
try {
const res = await ensureManagedSchema(r.name);
if (res && res.adopted) {
adopted++;
}
} catch (e) {
console.error(`[${PLUGIN_NAME}] row-uuid field migration failed for ${r.name}:`, e.message);
}
}
return adopted;
};
const onLoad = async (cfg) => {
try {
await createAllTables();
const env = await initEnvIfMissing();
// Reconcile the entity-id cache on EVERY load, not just first bootstrap.
// _dd_entity_ids is a raw, un-backed-up cache; after a restore it is empty
// while the restored env reports bootstrapped, so a gated backfill would
// leave the map empty. backfillAll is idempotent (ensureUuid keys on
// (kind,current_id) and derives deterministic UUIDs from name), so running
// it always rebuilds the map against the restored host's live catalog.
const counts = await backfillAll();
const total = Object.values(counts).reduce((a, b) => a + b, 0);
if (!env.bootstrapped_at) {
await markBootstrapped(env.env_id);
log(`v${PLUGIN_VERSION} bootstrapped env_id=${env.env_id} backfilled ${total} entities ${JSON.stringify(counts)}`);
} else if (total > 0) {
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id}; reconciled entity-id cache (+${total})`);
} else {
log(`v${PLUGIN_VERSION} loaded env_id=${env.env_id}`);
}
installAllWraps();
const adopted = await migrateRowUuidFields();
if (adopted > 0) {
log(`migrated ${adopted} managed table(s) to a registered _dd_row_uuid field`);
}
await ensureCsrfBypass();
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[${PLUGIN_NAME}] onLoad failed:`, err);
throw err;
}
};
module.exports = {
sc_plugin_api_version: 1,
plugin_name: PLUGIN_NAME,
onLoad: onLoad,
routes: routes
};