Skip to content

Commit ec16dbe

Browse files
test(dogfood): the 18.x upgrade harness — an 18.0 database boots on HEAD with its data (step 0) (#24552)
Part of #24522 Clause-②: no — tests, a fixture and scripts only; nothing a package publishes changes (`@objectstack/dogfood` is private, `scripts/` ships nowhere) Step 0 of the 18.x line (ruling record 6266750393 on #15194): **the upgrade harness**, built, green on this branch and ablated. The card's second half, the measured plan for steps 1–7, is in the `os-dev-report` on #24522 under `step_plan`; the seat files the child cards from it, so this PR does not close the card. ## What it adds - **The fixture** — `packages/qa/dogfood/test/upgrade-harness/fixture-18.0/`. - `database.sql` is an 18.0 showcase database after real use, dumped as SQL text: 920 lines, 431 KB, 107 tables. - `manifest.json` records the ref and commit it came from, every table's row count, and every row (key columns only) of the 14 tables steps 1–7 reshape. - It was generated from `5b14b7f3ad8e`, this branch's base on `main`, which contains `f8ef14487` (#24513). The 18.0 release tag does not exist yet. - **The populator** — `upgrade-harness/populate-fixture.ts` and `run-populate.ts`. It boots the showcase over a SQLite file, composed as `os serve` composes it (the app's default profile, `AuditPlugin`, `OrganizationOwnershipBootGuardPlugin`). It then uses the install through the real HTTP doors: - two sign-ups; - the Default Organization made active; - an invitation to a person with no account; - a password reset (identity mail); - a branding change and an encrypted `ai` setting (audit and secret); - an inbound flow with a hook secret (flow credential); - the declared webhook activated (a customized mirror) and a task created and edited (its two deliveries, activity, audit); - a runtime sharing rule; - a share link. It then reboots once. Every door's answer is checked, and the census refuses to write a fixture in which any reshaped table is empty. - **The generator** — `node scripts/gen-upgrade-harness-fixture.mjs --ref REF [--checkout DIR]`. - Without `--checkout`, it makes a temporary worktree of REF, installs it, builds the dogfood package's closure, and runs HEAD's populator there. - With `--checkout`, it first proves the checkout equals REF everywhere outside `packages/qa/dogfood` and `scripts/`. - The output is not byte-reproducible (ids, timestamps and ciphertexts are minted per run). Its coverage and provenance are reproducible. - **The harness** — `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`. On a fresh copy of the fixture: 1. HEAD boots, guard included, with no ownership refusal. 2. The fixture's people sign in with their 18.0 passwords, and the fixture's task is served. 3. After HEAD stops, every reshaped table still holds every fixture row with identical key columns (`organization_id` among them). Counts are checked as "at least", so rows a boot adds pass. 4. **R3:** the same fixture with one v17 trait (a physical `organization_id` on `sys_metadata`) is refused, with the guard's code. ## Measured | reading | value | |:--|:--| | harness, on this branch | `Test Files 1 passed (1)` · `Tests 5 passed (5)` · `Duration 53.89s (… import 42.80s, tests 10.70s)` | | generator, `--checkout` over a built tree | 29–30 s under the verify lock | | dogfood build closure, cold | 66 tasks, 10m51s at `--concurrency=2` on the shared 4-vCPU box | | reshaped rows in the fixture | `sys_webhook`=1 (customized, active) · `sys_http_delivery`=2 · `sys_secret`=3 · `sys_setting`=2 · `sys_setting_audit`=4 (2 global, 2 tenant) · `sys_flow_credential`=2 · `sys_email`=2 (an invitation, a reset) · `sys_invitation`=3 · `sys_sharing_rule`=1 · `sys_share_link`=1 · `sys_activity`=164 · `sys_user_permission_set`=2 · `sys_member`=3 · `sys_user`=3 | | NULL `organization_id` in those tables | 0 everywhere: 8b stamped the Default Organization. Step 5's moves must therefore select by shape, never by NULL. | ## Ablation (one-time; no permanent mutation left) Each leg ran through `node scripts/ablation-replace.mjs` on the fixture's SQL (a data file, so there is no build or `dist/` leg). Each restore was proven by the tool as blob `e43efe830f55` == HEAD and `git diff HEAD` empty. - **Leg 0 was a no-op, reported.** The first replacement contained its own anchor, so the tool refused it (`anchor count moved 1 -> 1`) and restored the file. Nothing was measured. - **Leg A: a reshaped table loses a row.** The `sys_webhook` INSERT was commented out (anchor 1 → 0, blob → `5a8d318e7b45`). - Result: `× 3. every reshaped table keeps every 18.0 row …`, with `AssertionError: sys_webhook row whk_8a24917e-…: expected undefined to deeply equal { …(4) }`, so `Tests 1 failed | 4 passed (5)`. - The boot-time seeder re-created *a* webhook row, so a count check alone would have stayed green. The by-id check is what caught it. - **Leg B: a forced refusal.** `sys_metadata` was created with a physical `organization_id` (blob → `a5b65a723f9b`). - Result: `× 1` with `the ownership boot guard refused the 18.0 database: This database is still in the v17 organization-ownership shape: sys_metadata still carries …`, plus `× 2` and `× 4`, so `Tests 3 failed | 2 passed (5)`. ## Where it runs before merge Dogfood skips `pull_request` and `merge_group` today. The cheapest wiring measured is a label-gated opt-in lane, the `pack-smoke-optin.yml` shape: one file, its build closure, and the label `needs:upgrade-harness`. It touches `.github/workflows/**`, so it is a separate draft PR for the maintainer on `claude/issue-24522-harness-lane` (Tier H). Until that lands, this file runs in `main`'s full-battery dogfood like any other dogfood test. ## Acceptance notes - The fixture covers no `sys_sequences` or `__global__` counter rows, because the showcase writes none. Step 7's 8d PR regenerates the fixture with an autonumber write first (recorded in the step plan). - `sys_invitation` holds two `inv_verify_*` rows that `@objectstack/verify`'s `signUp` files against its audience-gate organization. They are harness residue, not product data, and kept as the populator produced them. - The populator unwraps the showcase's CJS-interop default under `tsx` (measured: without it, `requires` read `undefined` and no provider mounted). Under vitest the export is the stack itself, so the harness boots the identical configuration. - The fixture carries what the platform stores: password hashes, ciphertexts sealed under the populating process's throwaway key, and four session rows of that run. No clear-text secret: the two placeholder secrets the populator types (`…not-a-real-…`) have 0 hits in `database.sql`, and `PRIVATE KEY` has 0 hits. ## Gates Gate readings are cited at the final head in the `os-dev-report` on #24522. --- _Generated by [Claude Code](https://claude.ai/code/session_01SH2ojNSqz93pYiNSmUyQnf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c46b5f1 commit ec16dbe

8 files changed

Lines changed: 2769 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+
});

0 commit comments

Comments
 (0)