sc-site-sync/test/registeredTableTest.js
2026-10-19 01:25:16 -05:00

272 lines
13 KiB
JavaScript

// Regression suite for lib/common/registeredTable.js against REAL Saltcorn
// (@saltcorn/data from the upstream checkout) on a THROWAWAY database: a temp
// SQLite file by default, or with RT_TEST_PG=1 a Postgres database rt_<pid>
// (createdb/dropdb over the local socket) -- never a shared instance's database.
//
// node test/registeredTableTest.js
// RT_TEST_PG=1 node test/registeredTableTest.js
//
// Covers: an unregistered orphan table (empty -> replaced; with rows ->
// adopted, rows and ids kept), an already-registered table (no-op, also with a
// stale cache), healing a damaged registration, merging a renamed-aside legacy
// copy by key, row transforms, rollback of a failed adoption, site-sync's real
// binding (createAllTables adopting _ss_table_modes), and WORKERS processes
// racing to register the same table. Exit code is 0 only if every check passed.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Module = require("node:module");
const { execFileSync, spawn } = require("node:child_process");
// The project root: the upstream Saltcorn checkout is ROOT/saltcorn.
const ROOT = path.resolve(__dirname, "..", "..");
const COMMON_DIR = path.resolve(__dirname, "..", "lib", "common");
const WORKER_FLAG = "--worker";
const IS_WORKER = process.argv.includes(WORKER_FLAG);
const USE_PG = process.env.RT_TEST_PG === "1";
const PG_HOST = "/var/run/postgresql";
const WORKERS = 3;
const OWNER = "registeredTableTest";
const RACE_TABLE = "_rt_race";
const RACE_ROWS = 3;
// Fields + the auto id primary key.
const FIELDS = [
{ name: "key", type: "String", is_unique: true, required: true },
{ name: "label", type: "String" },
{ name: "n", type: "Integer" }
];
const FIELD_COUNT_WITH_ID = FIELDS.length + 1;
// The parent creates the throwaway database and passes it to the workers.
if (!IS_WORKER) {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "rt-unit-"));
process.env.RT_STATE_DIR = stateDir;
if (USE_PG) {
delete process.env.SQLITE_FILEPATH;
delete process.env.FORCE_SQLITE;
delete process.env.SALTCORN_MULTI_TENANT;
process.env.PGHOST = PG_HOST;
process.env.PGUSER = process.env.PGUSER || os.userInfo().username;
process.env.PGPASSWORD = process.env.PGPASSWORD || "unused-peer-auth";
process.env.PGDATABASE = `rt_${process.pid}`;
execFileSync("createdb", ["-h", PG_HOST, process.env.PGDATABASE]);
} else {
process.env.FORCE_SQLITE = "true";
process.env.SQLITE_FILEPATH = path.join(stateDir, "rt.sqlite");
}
process.env.SALTCORN_FILE_STORE = path.join(stateDir, "files");
process.env.SALTCORN_SESSION_SECRET = "registered-table-test-FAKE-secret";
}
process.env.NODE_PATH = [path.join(ROOT, "saltcorn", "node_modules"), process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
Module._initPaths();
const { ensureRegisteredTable, physicalTableExists, tableRef } = require(path.join(COMMON_DIR, "registeredTable.js"));
let passed = 0;
let failed = 0;
const check = async (name, fn) => {
try {
await fn();
passed++;
console.log(` PASS ${name}`);
} catch (e) {
failed++;
console.log(` FAIL ${name}\n ${e && e.message ? e.message.split("\n")[0] : e}`);
}
};
const bootState = async () => {
const { getState } = require("@saltcorn/data/db/state");
getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
await getState().refresh();
};
const spec = (name, extra) => ({ owner: OWNER, name: name, fields: FIELDS, ...(extra || {}) });
// A raw (unregistered) table, the shape an orphan / legacy table has.
const createRaw = async (name, columnsSql) => {
const db = require("@saltcorn/data/db");
const idCol = db.isSQLite ? "id INTEGER PRIMARY KEY" : "id SERIAL PRIMARY KEY";
await db.query(`CREATE TABLE ${tableRef(name)} (${columnsSql.replace("$ID", idCol)})`);
};
const registeredRow = async (name) => {
return await require("@saltcorn/data/db").selectMaybeOne("_sc_tables", { name: name });
};
const fieldCount = async (name) => {
const db = require("@saltcorn/data/db");
const row = await registeredRow(name);
return row ? (await db.select("_sc_fields", { table_id: row.id })).length : 0;
};
const rowsOf = async (name) => {
const db = require("@saltcorn/data/db");
return (await db.query(`SELECT * FROM ${tableRef(name)} ORDER BY id`)).rows;
};
// Child: boot Saltcorn state and race to register RACE_TABLE; exit 0 on success.
const worker = async () => {
await bootState();
let code = 0;
try {
await ensureRegisteredTable(spec(RACE_TABLE, { keyField: "key" }));
} catch (e) {
console.log(` worker ${process.pid} ERROR ${e.message}`);
code = 1;
}
await require("@saltcorn/data/db").close();
process.exit(code);
};
const spawnWorker = () => {
return new Promise((resolve) => {
const p = spawn(process.execPath, [__filename, WORKER_FLAG], { env: process.env, stdio: ["ignore", "inherit", "inherit"] });
p.on("exit", (code) => resolve(code));
});
};
const main = async () => {
const db = require("@saltcorn/data/db");
console.log(`registeredTable suite -- ${USE_PG ? `postgres ${process.env.PGDATABASE}` : `sqlite ${process.env.SQLITE_FILEPATH}`}`);
await require("@saltcorn/data/db/reset_schema")(true);
await bootState();
const Table = require("@saltcorn/data/models/table");
const { getState } = require("@saltcorn/data/db/state");
console.log("\n[unregistered orphan tables]");
await check("control: plain Table.create throws on an orphan (the live bug)", async () => {
await createRaw("_rt_control", "$ID");
await assert.rejects(() => Table.create("_rt_control"), /exists/);
});
await check("an empty orphan is replaced by the registered table", async () => {
await createRaw("_rt_empty", "$ID, key TEXT");
assert.equal(await ensureRegisteredTable(spec("_rt_empty")), true);
assert.ok(await registeredRow("_rt_empty"), "not registered");
assert.equal(await fieldCount("_rt_empty"), FIELD_COUNT_WITH_ID);
assert.equal((await rowsOf("_rt_empty")).length, 0);
});
await check("an orphan with rows is adopted: rows (by column name) and ids preserved", async () => {
await createRaw("_rt_rows", "$ID, key TEXT, label TEXT, extra TEXT");
await db.query(`INSERT INTO ${tableRef("_rt_rows")} (id, key, label, extra) VALUES (5, 'a', 'A', 'x'), (7, 'b', NULL, 'y')`);
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), true);
const rows = await rowsOf("_rt_rows");
assert.deepEqual(rows.map((r) => [r.id, r.key, r.label]), [[5, "a", "A"], [7, "b", null]]);
const fresh = await db.insert("_rt_rows", { key: "later" });
assert.ok(fresh > 7, `new id ${fresh} collides with an adopted one`);
});
console.log("\n[registered tables]");
await check("an already-registered table is a no-op (rows and registration untouched)", async () => {
const before = await registeredRow("_rt_rows");
const rows = await rowsOf("_rt_rows");
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
assert.equal((await registeredRow("_rt_rows")).id, before.id);
assert.deepEqual(await rowsOf("_rt_rows"), rows);
});
await check("... also when this process's table cache is stale", async () => {
getState().tables = getState().tables.filter((t) => t.name !== "_rt_rows");
const rows = await rowsOf("_rt_rows");
assert.equal(await ensureRegisteredTable(spec("_rt_rows")), false);
assert.deepEqual(await rowsOf("_rt_rows"), rows);
await getState().refresh_tables(true);
});
await check("a damaged registration is healed (lost column re-added, new field registered)", async () => {
await db.query(`ALTER TABLE ${tableRef("_rt_rows")} DROP COLUMN "label"`);
const more = FIELDS.concat([{ name: "added", type: "String" }]);
assert.equal(await ensureRegisteredTable({ ...spec("_rt_rows"), fields: more }), false);
const cols = Object.keys((await rowsOf("_rt_rows"))[0]);
assert.ok(cols.includes("label") && cols.includes("added"), `columns now: ${cols}`);
assert.equal(await fieldCount("_rt_rows"), FIELD_COUNT_WITH_ID + 1);
});
console.log("\n[legacy copies, transforms, failures]");
await check("a renamed-aside legacy copy is merged by keyField, then dropped", async () => {
await db.query(`CREATE TABLE ${tableRef("_rt_rows__legacy")} (key TEXT, label TEXT)`);
await db.query(`INSERT INTO ${tableRef("_rt_rows__legacy")} (key, label) VALUES ('a', 'dup'), ('c', 'C')`);
assert.equal(await ensureRegisteredTable(spec("_rt_rows", { keyField: "key", legacyTables: ["_rt_rows__legacy"] })), false);
const rows = await rowsOf("_rt_rows");
const aRows = rows.filter((r) => r.key === "a");
assert.equal(aRows.length, 1, "a row present in both was duplicated");
assert.notEqual(aRows[0].label, "dup", "an existing row was overwritten");
assert.equal(rows.find((r) => r.key === "c").label, "C", "the legacy-only row was not adopted");
assert.ok(!(await physicalTableExists("_rt_rows__legacy")), "legacy copy left behind");
});
await check("rowTransform reshapes adopted rows (null drops one)", async () => {
await createRaw("_rt_xform", "$ID, key TEXT, legacy_n INTEGER");
await db.query(`INSERT INTO ${tableRef("_rt_xform")} (key, legacy_n) VALUES ('keep', 4), ('drop', 9)`);
const rowTransform = async (r) => (r.key === "drop" ? null : { key: r.key, n: r.legacy_n * 10 });
await ensureRegisteredTable(spec("_rt_xform", { rowTransform: rowTransform }));
assert.deepEqual((await rowsOf("_rt_xform")).map((r) => [r.key, r.n]), [["keep", 40]]);
});
await check("an adoption that cannot complete rolls back: orphan intact, nothing registered", async () => {
await createRaw("_rt_fail", "$ID, key TEXT");
await db.query(`INSERT INTO ${tableRef("_rt_fail")} (key) VALUES ('d'), ('d')`);
await assert.rejects(() => ensureRegisteredTable(spec("_rt_fail")), /could not register table _rt_fail/);
assert.ok(!(await registeredRow("_rt_fail")), "registered after a failed adoption");
assert.equal((await rowsOf("_rt_fail")).length, 2, "orphan rows lost");
});
const siteSyncSchema = path.join(__dirname, "..", "lib", "schema.js");
console.log("\n[site-sync binding]");
await check("createAllTables adopts an unregistered _ss_table_modes with its row", async () => {
await createRaw("_ss_table_modes", "$ID, table_name TEXT, data_mode TEXT, updated_at TEXT");
await db.query(`INSERT INTO ${tableRef("_ss_table_modes")} (id, table_name, data_mode, updated_at) VALUES (4, 'refdata', 'managed', 'x')`);
await require(siteSyncSchema).createAllTables();
assert.ok(await registeredRow("_ss_table_modes"), "not registered");
assert.deepEqual((await rowsOf("_ss_table_modes")).map((r) => [r.id, r.table_name, r.data_mode]), [[4, "refdata", "managed"]]);
await require(siteSyncSchema).createAllTables();
assert.equal((await rowsOf("_ss_table_modes")).length, 1, "a re-run duplicated the row");
});
console.log(`\n[${WORKERS} processes racing to register an orphan with rows]`);
await check(`${WORKERS} racing workers all succeed; registered once, rows adopted once`, async () => {
await createRaw(RACE_TABLE, "$ID, key TEXT, label TEXT");
for (let i = 1; i <= RACE_ROWS; i++) {
await db.query(`INSERT INTO ${tableRef(RACE_TABLE)} (id, key, label) VALUES ($1, $2, 'r')`, [i * 10, `k${i}`]);
}
const codes = await Promise.all(Array.from({ length: WORKERS }, () => spawnWorker()));
assert.ok(codes.every((c) => c === 0), `exit codes ${codes.join(",")}`);
const registrations = (await db.query("SELECT COUNT(*) AS c FROM _sc_tables WHERE name = $1", [RACE_TABLE])).rows[0].c;
assert.equal(Number(registrations), 1, "registered more than once");
assert.equal(await fieldCount(RACE_TABLE), FIELD_COUNT_WITH_ID);
assert.deepEqual((await rowsOf(RACE_TABLE)).map((r) => r.id), [10, 20, 30]);
});
};
const cleanup = async () => {
await require("@saltcorn/data/db").close();
fs.rmSync(process.env.RT_STATE_DIR, { recursive: true, force: true });
if (USE_PG) {
execFileSync("dropdb", ["-h", PG_HOST, "--if-exists", process.env.PGDATABASE]);
}
};
if (IS_WORKER) {
worker();
} else {
main().then(async () => {
console.log(`\n${passed} passed, ${failed} failed (${USE_PG ? "postgres" : "sqlite"})`);
await cleanup();
process.exit(failed === 0 ? 0 : 1);
}).catch(async (e) => {
console.error("suite crashed:", e);
await cleanup();
process.exit(1);
});
}