|
| 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