Skip to content

Commit 6935f77

Browse files
committed
test(dogfood): the 18.x upgrade harness — populator, census and harness test (WIP, fixture pending)
Claude-Session: https://claude.ai/code/session_01SH2ojNSqz93pYiNSmUyQnf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b14b7f commit 6935f77

6 files changed

Lines changed: 678 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24522] The 18.x line's upgrade harness: a database an 18.0 install created
4+
// upgrades to THIS commit, boots, and keeps its data (R2), and a v17-shaped one
5+
// is still refused (R3). Ruling record 6266750393 on the v18 epic makes R2 a
6+
// check every PR on the line passes before it merges.
7+
//
8+
// The fixture (`upgrade-harness/fixture-18.0/`) is a showcase install of the
9+
// 18.0 code after real use — sign-ups, an invitation, settings with a secret, a
10+
// flow credential, the declared webhook customized and delivering, a runtime
11+
// sharing rule, a share link, record activity — dumped to SQL text, with a
12+
// manifest of every row of the tables steps 1–7 reshape. Regenerate it with
13+
// `node scripts/gen-upgrade-harness-fixture.mjs --ref <ref>`; the manifest
14+
// records the ref and commit it came from.
15+
//
16+
// What is asserted, on a fresh copy of the fixture:
17+
//
18+
// 1. HEAD boots over it, composed the way `os serve` composes it — the
19+
// ownership boot guard included — and nothing refuses it.
20+
// 2. It serves the fixture's data: the fixture's people sign in with their
21+
// 18.0 passwords and read the fixture's own task.
22+
// 3. Once HEAD has stopped, every reshaped table still holds every row the
23+
// fixture gave it, with the same key columns (`organization_id` among
24+
// them): a step that moved, deleted or re-stamped 18.0 rows outside the
25+
// upgrade ceremony goes red here, naming the table and the row.
26+
// 4. The same fixture with one v17 trait added — a physical `organization_id`
27+
// on `sys_metadata`, an object that declares none — is refused at boot
28+
// with the guard's code. Until step 6 opens the ceremony that is the
29+
// answer a v17 database gets; step 6 rewrites this case, by name.
30+
//
31+
// ⛔ Additive change passes: a row HEAD's boot ADDS (an activity shard, a
32+
// seeder's row) is not a failure, so counts are compared as "at least", and
33+
// every fixture row is looked up by id.
34+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
35+
import { tmpdir } from 'node:os';
36+
import { join } from 'node:path';
37+
import { DatabaseSync } from 'node:sqlite';
38+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
39+
import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime';
40+
import type { VerifyStack } from '@objectstack/verify';
41+
import { columnsOf, keyRowsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js';
42+
import { FIXTURE_NAMES, FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js';
43+
import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js';
44+
45+
const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url);
46+
const manifest = JSON.parse(readFileSync(new URL('manifest.json', FIXTURE), 'utf8')) as FixtureManifest;
47+
const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8');
48+
49+
/** The guard's refusal down a failed boot's `cause` chain (the kernel wraps a failed `start()`). */
50+
function refusalIn(error: unknown): unknown {
51+
for (let e = error, depth = 0; !!e && typeof e === 'object' && depth < 8; e = (e as { cause?: unknown }).cause, depth++) {
52+
if (isOrganizationOwnershipBootRefusal(e)) return e;
53+
}
54+
return undefined;
55+
}
56+
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
const rowsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.records ?? body?.data ?? []));
59+
60+
describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.sha.slice(0, 12)}) upgrades to HEAD and keeps its data`, () => {
61+
const dir = mkdtempSync(join(tmpdir(), 'dogfood-upgrade-18x-'));
62+
const file = join(dir, 'upgraded.db');
63+
let stack: VerifyStack | undefined;
64+
let bootError: unknown;
65+
66+
beforeAll(async () => {
67+
loadSqlIntoSqlite(fixtureSql, file);
68+
try {
69+
stack = await bootFixture(file);
70+
} catch (error) {
71+
bootError = error;
72+
}
73+
}, 240_000);
74+
75+
afterAll(async () => {
76+
await stack?.stop();
77+
rmSync(dir, { recursive: true, force: true });
78+
});
79+
80+
it('the fixture covers every reshaped table (a vacuous fixture is no harness)', () => {
81+
for (const t of manifest.reshaped) expect(t.count, `${t.table} holds rows in the fixture`).toBeGreaterThan(0);
82+
});
83+
84+
it('1. HEAD boots over the 18.0 database, and no ownership refusal stops it', () => {
85+
const refusal = refusalIn(bootError);
86+
expect(refusal, `the ownership boot guard refused the 18.0 database: ${String((refusal as Error | undefined)?.message)}`).toBeUndefined();
87+
expect(bootError, `the boot failed: ${String((bootError as Error | undefined)?.stack ?? bootError)}`).toBeUndefined();
88+
});
89+
90+
it('2. it serves the 18.0 data: the fixture\'s people sign in, and the fixture\'s task is there', async () => {
91+
expect(stack).toBeDefined();
92+
const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password);
93+
const memberTok = await stack!.signIn(FIXTURE_PEOPLE.memberA.email, FIXTURE_PEOPLE.memberA.password);
94+
expect(memberTok).toBeTruthy();
95+
const res = await stack!.apiAs(adminTok, 'GET', '/data/showcase_task');
96+
expect(res.status).toBe(200);
97+
const titles = rowsOf(await res.json()).map((r) => String(r?.title));
98+
expect(titles).toContain(`${FIXTURE_NAMES.task} — edited`);
99+
});
100+
101+
it('3. every reshaped table keeps every 18.0 row, with its key columns, once HEAD has stopped', async () => {
102+
await stack?.stop();
103+
stack = undefined;
104+
const db = new DatabaseSync(file, { readOnly: true });
105+
try {
106+
for (const t of manifest.reshaped) {
107+
for (const physical of t.physical) {
108+
const cols = columnsOf(db, physical);
109+
expect(cols.length, `${physical} still exists`).toBeGreaterThan(0);
110+
for (const c of t.keyColumns) expect(cols, `${physical}.${c} still exists`).toContain(c);
111+
}
112+
const now = keyRowsOf(db, t.physical, t.keyColumns);
113+
const byId = new Map(now.map((r) => [r.id, r]));
114+
expect(now.length, `${t.table}: ${now.length} rows now, ${t.count} in the fixture`).toBeGreaterThanOrEqual(t.count);
115+
for (const row of t.rows) expect(byId.get(row.id), `${t.table} row ${row.id}`).toEqual(row);
116+
}
117+
} finally {
118+
db.close();
119+
}
120+
});
121+
122+
it('4. R3: the same database with a v17 trait is still refused at boot, with the guard\'s code', async () => {
123+
const v17 = join(dir, 'v17-shaped.db');
124+
loadSqlIntoSqlite(fixtureSql, v17);
125+
const db = new DatabaseSync(v17);
126+
try {
127+
expect(columnsOf(db, 'sys_metadata'), 'the 18.0 fixture carries no organization column on sys_metadata').not.toContain('organization_id');
128+
db.exec('ALTER TABLE sys_metadata ADD COLUMN organization_id TEXT');
129+
} finally {
130+
db.close();
131+
}
132+
let error: unknown;
133+
try {
134+
const refused = await bootFixture(v17);
135+
await refused.stop();
136+
} catch (e) {
137+
error = e;
138+
}
139+
const refusal = refusalIn(error);
140+
expect(refusal, 'the v17-shaped database was refused by the ownership boot guard').toBeDefined();
141+
expect((refusal as { tables?: string[] }).tables).toContain('sys_metadata');
142+
}, 240_000);
143+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24522] What the upgrade harness holds the fixture to, in one place: the
4+
// tables steps 1–7 of the 18.x line reshape (ruling record 6266750393 on the v18
5+
// epic), the columns that identify a row of each, and the census both sides
6+
// take — the populator when it writes the fixture, the harness when HEAD has
7+
// booted over a copy of it.
8+
//
9+
// A table is named here because a step will move, delete, re-source or
10+
// constrain its rows, so it is where an upgrade loses data if one is going to.
11+
// The step numbers are the line's; the plan that cut them is the step-0 report
12+
// on #24522. ⛔ A row here is never an assertion that the step's code exists —
13+
// only that the 18.0 data it will reshape is in the fixture, and still there.
14+
import { DatabaseSync } from 'node:sqlite';
15+
16+
export interface ReshapedTable {
17+
/** The physical table, or the prefix of a date-sharded family (`sys_activity__r…`). */
18+
table: string;
19+
/** Matches every physical shard whose name starts with `table` + `__`. */
20+
sharded?: boolean;
21+
/** The 18.x steps that reshape it. */
22+
steps: readonly number[];
23+
/** Why the step touches it, in one line. */
24+
why: string;
25+
/**
26+
* The columns whose values a row must keep across the upgrade. Only columns
27+
* no boot rewrites: never `updated_at`, never a delivery's `status`.
28+
*/
29+
keyColumns: readonly string[];
30+
}
31+
32+
/**
33+
* The reshaped tables. Every one must hold at least one row in the fixture —
34+
* the populator refuses to write a fixture otherwise, so the harness can never
35+
* go green over an empty table.
36+
*/
37+
export const RESHAPED_TABLES: readonly ReshapedTable[] = [
38+
{ table: 'sys_webhook', steps: [1, 5], why: 'the declared webhook mirror rows the registry read replaces; the ceremony deletes them (D10 fate 2)', keyColumns: ['id', 'name', 'organization_id', 'is_active'] },
39+
{ table: 'sys_http_delivery', steps: [1, 5], why: 'the mirror rows\' delivery tails, re-sourced by the ceremony', keyColumns: ['id', 'webhook_id', 'organization_id'] },
40+
{ table: 'sys_secret', steps: [2, 5], why: 'loses its organization column; ownership follows the referencing row', keyColumns: ['id', 'organization_id'] },
41+
{ table: 'sys_setting', steps: [2], why: 'the referencing row a secret\'s ownership follows', keyColumns: ['id', 'organization_id'] },
42+
{ table: 'sys_setting_audit', steps: [2, 5], why: 'its global rung moves to a deployment-level sibling', keyColumns: ['id', 'organization_id'] },
43+
{ table: 'sys_flow_credential', steps: [2, 5], why: 'becomes deployment-level (no organization column)', keyColumns: ['id', 'organization_id'] },
44+
{ table: 'sys_email', steps: [3, 5], why: 'identity mail moves to a deployment-level sibling', keyColumns: ['id', 'organization_id'] },
45+
{ table: 'sys_invitation', steps: [3], why: 'the invitation whose mail is identity mail', keyColumns: ['id', 'organization_id', 'email'] },
46+
{ table: 'sys_sharing_rule', steps: [5], why: '9c: the same-name guard and the sharing-rule copy migration', keyColumns: ['id', 'name', 'organization_id'] },
47+
{ table: 'sys_share_link', steps: [5], why: '8e: organization_id NOT NULL', keyColumns: ['id', 'organization_id'] },
48+
{ table: 'sys_activity', sharded: true, steps: [5, 7], why: '8e NOT NULL; 8c retires the organization-less read branch', keyColumns: ['id', 'organization_id'] },
49+
{ table: 'sys_user_permission_set', steps: [5, 7], why: 'grants: 8e NOT NULL, 8c retires the NULL grant readers', keyColumns: ['id', 'organization_id'] },
50+
{ table: 'sys_member', steps: [5], why: 'membership, the anchor attribution reads', keyColumns: ['id', 'organization_id', 'user_id'] },
51+
{ table: 'sys_user', steps: [3], why: 'the identity mail\'s anchor; the fixture\'s people sign in with it', keyColumns: ['id', 'email'] },
52+
];
53+
54+
export interface ManifestTable {
55+
table: string;
56+
physical: string[];
57+
steps: readonly number[];
58+
keyColumns: string[];
59+
count: number;
60+
rows: Array<Record<string, string | null>>;
61+
}
62+
63+
export interface FixtureManifest {
64+
/** The git ref the generator was asked for, as given. */
65+
ref: string;
66+
/** The commit it resolved to. */
67+
sha: string;
68+
generatedAt: string;
69+
/** Every table's row count, physical names. */
70+
tables: Record<string, number>;
71+
reshaped: ManifestTable[];
72+
}
73+
74+
const ident = (name: string): string => `"${name.replace(/"/g, '""')}"`;
75+
const text = (v: unknown): string | null => (v === null || v === undefined ? null : String(v));
76+
77+
/** The physical tables one reshaped entry names, in name order. */
78+
export function physicalTablesOf(db: DatabaseSync, entry: ReshapedTable): string[] {
79+
const names = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name);
80+
return names.filter((n) => n === entry.table || (entry.sharded === true && n.startsWith(`${entry.table}__`)));
81+
}
82+
83+
/** The physical columns of `table`. */
84+
export function columnsOf(db: DatabaseSync, table: string): string[] {
85+
return (db.prepare('SELECT name FROM pragma_table_info(?)').all(table) as Array<{ name: string }>).map((r) => r.name);
86+
}
87+
88+
/** Every row of a reshaped entry, projected onto its key columns, ordered by id. */
89+
export function keyRowsOf(db: DatabaseSync, physical: readonly string[], keyColumns: readonly string[]): Array<Record<string, string | null>> {
90+
const rows: Array<Record<string, string | null>> = [];
91+
for (const t of physical) {
92+
for (const r of db.prepare(`SELECT ${keyColumns.map(ident).join(',')} FROM ${ident(t)} ORDER BY id`).all() as Array<Record<string, unknown>>) {
93+
rows.push(Object.fromEntries(keyColumns.map((c) => [c, text(r[c])])));
94+
}
95+
}
96+
return rows;
97+
}
98+
99+
/**
100+
* The census the populator writes into the manifest. Throws when a reshaped
101+
* table is missing or empty, or names a key column the table does not carry.
102+
*/
103+
export function takeCensus(file: string, ref: string, sha: string): FixtureManifest {
104+
const db = new DatabaseSync(file, { readOnly: true });
105+
try {
106+
const all = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name);
107+
const tables: Record<string, number> = {};
108+
for (const t of all) tables[t] = Number((db.prepare(`SELECT COUNT(*) AS n FROM ${ident(t)}`).get() as { n: number }).n);
109+
const problems: string[] = [];
110+
const reshaped: ManifestTable[] = [];
111+
for (const entry of RESHAPED_TABLES) {
112+
const physical = physicalTablesOf(db, entry);
113+
if (physical.length === 0) {
114+
problems.push(`${entry.table}: no such table`);
115+
continue;
116+
}
117+
const missing = entry.keyColumns.filter((c) => physical.some((t) => !columnsOf(db, t).includes(c)));
118+
if (missing.length > 0) {
119+
problems.push(`${entry.table}: no column ${missing.join(', ')}`);
120+
continue;
121+
}
122+
const rows = keyRowsOf(db, physical, entry.keyColumns);
123+
if (rows.length === 0) problems.push(`${entry.table}: empty — the populator did not reach it`);
124+
reshaped.push({ table: entry.table, physical, steps: entry.steps, keyColumns: [...entry.keyColumns], count: rows.length, rows });
125+
}
126+
if (problems.length > 0) throw new Error(`the fixture does not cover what steps 1–7 reshape:\n - ${problems.join('\n - ')}`);
127+
return { ref, sha, generatedAt: new Date().toISOString(), tables, reshaped };
128+
} finally {
129+
db.close();
130+
}
131+
}

0 commit comments

Comments
 (0)