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