testTypedTables
typed tables: writes are checked; changed row types are verified, migrated, or refused; old pages reload
import { call, crm, hashOf, waitFor } from "#codebase/tcmts/cli/test/e2e/data"; import { okIn, plainEnv, tcmBin, tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; export const testTypedTables = () => withChildren(async (keep) => { const dir = tempDir("data"); const tcm = (...args: string[]) => okIn(dir, ...args); tcm("init"); tcm("lib", "add", "tcm", "server", "--at", "lib.server"); crm(dir, {}); tcm("add"); assert.match(tcm("test", "crm"), /1 passed, 0 failed/); assert.match(tcm("view", "crm.users"), /runs on the server: it uses lib\.server\.table/); const child = keep( spawn(process.execPath, [tcmBin(), "start", "crm.main", "--port", "0"], { cwd: dir, env: plainEnv(), }), ); let out = ""; child.stdout.setEncoding("utf8").on("data", (d: string) => (out += d)); child.stderr.setEncoding("utf8").on("data", (d: string) => (out += d)); await waitFor(() => /url\s+http:\/\/\S+/.test(out)); const url = /url\s+(http:\/\/\S+)/.exec(out)![1]!; await call(url, hashOf(dir, "crm.addUser"), ["ada", "ada@x"]); await call(url, hashOf(dir, "crm.addUser"), ["grace", "grace@x"]); const v1List = hashOf(dir, "crm.listUsers"); assert.equal((await call(url, v1List, [])).body, `["ada","grace"]`); assert.equal((await call(url, hashOf(dir, "crm.badInsert"), [])).status, 500); await waitFor(() => /table "users": value\.email is missing/.test(out)); assert.match(tcm("data"), /users\s+2 row\(s\)/); // a compatible change: the stored rows still fit, so they just work crm(dir, { field: `; plan?: "free" | "pro"`, value: `, plan: "pro" as const` }); assert.match( tcm("update"), /table "users": 2 stored row\(s\) were written as an earlier type and still fit the new one/, ); await waitFor( async () => (await call(url, hashOf(dir, "crm.listUsers"), [])).body === `["ada","grace"]`, ); // an incompatible change: tcm says so at update time, and reads refuse rather than return the wrong shape crm(dir, { field: `; plan?: "free" | "pro"; active: boolean`, value: `, plan: "pro" as const, active: true`, }); const update = tcm("update"); assert.match( update, /table "users": 2 stored row\(s\) don't fit the new type, e\.g\. \S+: row\.active is missing/, ); assert.match(update, /reading them will fail until table\(\) gets a migrate function/); await waitFor(async () => (await call(url, hashOf(dir, "crm.listUsers"), [])).status === 500); await waitFor(() => /written as an older type and doesn't fit the current one \(row\.active is missing\)/.test( out, ), ); // with a migration: rows are upgraded as they're read, and the result is checked too crm(dir, { field: `; plan?: "free" | "pro"; active: boolean`, opts: `, { migrate: (old: Omit<User, "active">) => ({ ...old, active: true }) }`, value: `, plan: "pro" as const, active: true`, }); assert.match( tcm("update"), /migrate takes them as they.re read \(or all now: tcm data migrate users\)/, ); await waitFor( async () => (await call(url, hashOf(dir, "crm.listUsers"), [])).body === `["ada","grace"]`, ); assert.doesNotMatch( tcm("data"), /written under \d+ row types/, "every row now has the current row type", ); // a page still on v1 reads rows written by newer code: 410, so it reloads instead of misreading them const stale = await call(url, v1List, []); assert.equal(stale.status, 410); assert.match(stale.body, /written by a newer version of this app; reload/); })