|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The ceremony's `not-null` step (18.x step 5d, ADR-0131 8e) on a run that |
| 5 | + * resumes from its checkpoint, over a REAL SQLite driver: the NOT NULL is the |
| 6 | + * driver's own drift apply door (the table rebuild), read back from the |
| 7 | + * database's own catalog. |
| 8 | + * |
| 9 | + * The step leaves a table nullable for one of two reasons it states: the |
| 10 | + * composition the run booted declares no object for it (`not-declared`, "re-run |
| 11 | + * the ceremony where the deployment's composition is declared to apply it"), or |
| 12 | + * the table still holds a NULL row the operator must resolve by hand |
| 13 | + * (`UNATTRIBUTABLE_REMEDY`, "then re-run --apply"). Either way the step itself |
| 14 | + * finished, so it is checkpointed. The re-run each advice line names must then |
| 15 | + * apply the NOT NULL it left, not read the checkpoint and skip the step: |
| 16 | + * |
| 17 | + * - (1) certified with two tables `not-declared`, re-run under a composition |
| 18 | + * that declares them: both end NOT NULL; |
| 19 | + * - (2) the walled recovery path — apply, fix the held rows by hand, plan, |
| 20 | + * apply — ends with NOT NULL on the fixed table, and certifies; |
| 21 | + * - (3) control: a first run with nothing pending checkpoints exactly as |
| 22 | + * before, and a re-run of it stays `checkpointed` and rebuilds nothing; |
| 23 | + * - (4) a re-run whose NOT NULL fails blocks and does not certify, as the |
| 24 | + * same failure does on a first run. |
| 25 | + */ |
| 26 | + |
| 27 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 28 | +import Database from 'better-sqlite3'; |
| 29 | +import { mkdtempSync, rmSync } from 'node:fs'; |
| 30 | +import { tmpdir } from 'node:os'; |
| 31 | +import { join } from 'node:path'; |
| 32 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 33 | +import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; |
| 34 | +import { |
| 35 | + applyOrganizationOwnership, |
| 36 | + reviewedConflictsOf, |
| 37 | + type ApplyExecutor, |
| 38 | + type CeremonyCheckpoint, |
| 39 | + type CeremonyLedgerPort, |
| 40 | + type NotNullPort, |
| 41 | + type OrganizationOwnershipApplyReport, |
| 42 | +} from './organization-ownership-apply.js'; |
| 43 | +import { applyNotNullThroughDrift } from './organization-ownership-not-null.js'; |
| 44 | + |
| 45 | +// The dynamic imports the plan's module graph reaches, loaded at collection, |
| 46 | +// never inside a case's clocked window (`check:test-source-alias`). |
| 47 | +import '@objectstack/driver-turso'; |
| 48 | +import '@objectstack/metadata-protocol'; |
| 49 | +import '@objectstack/platform-objects/plugin'; |
| 50 | +import '@objectstack/plugin-security'; |
| 51 | +import '@objectstack/rest'; |
| 52 | +import '@objectstack/runtime'; |
| 53 | +import '@objectstack/service-datasource'; |
| 54 | + |
| 55 | +const ORG = 'org_default'; |
| 56 | +const TABLES = ['crm_deal', 'crm_note', 'crm_task'] as const; |
| 57 | + |
| 58 | +let dir: string; |
| 59 | +let file: string; |
| 60 | +let db: Database.Database; |
| 61 | +let driver: SqlDriver; |
| 62 | +/** Tables the drift apply door rebuilt, in order. */ |
| 63 | +let rebuilt: string[]; |
| 64 | + |
| 65 | +function executor(): ApplyExecutor { |
| 66 | + return { |
| 67 | + dialect: 'sqlite', |
| 68 | + client: 'better-sqlite3', |
| 69 | + async query(sql, params = []) { |
| 70 | + return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[]; |
| 71 | + }, |
| 72 | + async execute(sql, params = []) { |
| 73 | + db.prepare(sql).run(...(params as unknown[])); |
| 74 | + }, |
| 75 | + }; |
| 76 | +} |
| 77 | + |
| 78 | +function ledger(): CeremonyLedgerPort & { stored: () => CeremonyCheckpoint | null } { |
| 79 | + let stored: CeremonyCheckpoint | null = null; |
| 80 | + return { |
| 81 | + stored: () => stored, |
| 82 | + async read() { |
| 83 | + return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null; |
| 84 | + }, |
| 85 | + async write(checkpoint) { |
| 86 | + stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint; |
| 87 | + }, |
| 88 | + }; |
| 89 | +} |
| 90 | + |
| 91 | +/** Whether the table's tenant column is NOT NULL, read from the database's own catalog. */ |
| 92 | +const isNotNull = (table: string): boolean => |
| 93 | + (db.prepare(`SELECT "notnull" AS nn FROM pragma_table_info('${table}') WHERE name = 'organization_id'`).get() as { nn: number }).nn === 1; |
| 94 | + |
| 95 | +/** |
| 96 | + * The command's `notNull` port, as `os migrate organization-ownership` wires |
| 97 | + * it: the drift apply door of the driver, for the tables `declared` (the |
| 98 | + * composition this run booted), the column's nullability read from the catalog. |
| 99 | + */ |
| 100 | +function schemaDoor(declared: ReadonlySet<string>, opts: { failOn?: string } = {}): NotNullPort { |
| 101 | + return { |
| 102 | + apply: (columns) => |
| 103 | + applyNotNullThroughDrift( |
| 104 | + { |
| 105 | + isDeclared: (table) => declared.has(table), |
| 106 | + isNotNull: async (table) => isNotNull(table), |
| 107 | + driverFor: () => ({ |
| 108 | + applyMigrationEntries: async (entries: never[], options: never) => { |
| 109 | + const table = (entries[0] as { table: string }).table; |
| 110 | + if (table === opts.failOn) throw new Error(`SQLITE_BUSY: ${table} is locked`); |
| 111 | + rebuilt.push(table); |
| 112 | + return driver.applyMigrationEntries(entries, options); |
| 113 | + }, |
| 114 | + }) as never, |
| 115 | + }, |
| 116 | + columns, |
| 117 | + ), |
| 118 | + }; |
| 119 | +} |
| 120 | + |
| 121 | +async function apply( |
| 122 | + l: CeremonyLedgerPort, |
| 123 | + notNull: NotNullPort, |
| 124 | + posture = 'single', |
| 125 | +): Promise<OrganizationOwnershipApplyReport> { |
| 126 | + const reviewed = await buildOrganizationOwnershipPlan({ reader: executor(), posture, database: file }); |
| 127 | + return applyOrganizationOwnership({ |
| 128 | + executor: executor(), |
| 129 | + posture, |
| 130 | + database: file, |
| 131 | + reviewedPlan: { |
| 132 | + file: 'plan.json', |
| 133 | + generatedAt: reviewed.generatedAt, |
| 134 | + database: file, |
| 135 | + inventoryDigest: inventoryDigest(), |
| 136 | + posture, |
| 137 | + conflicts: reviewedConflictsOf(reviewed), |
| 138 | + }, |
| 139 | + catalog: { async resolve() { return undefined; } }, |
| 140 | + notNull, |
| 141 | + door: { |
| 142 | + async save(type, name) { |
| 143 | + throw new Error(`no environment save is expected here (${type} ${name})`); |
| 144 | + }, |
| 145 | + }, |
| 146 | + templates: { async find() { return undefined; } }, |
| 147 | + views: { async list() { return []; } }, |
| 148 | + lock: { async verdict() { return { editable: true }; } }, |
| 149 | + columnDrops: { async drop(columns) { return columns.map((c) => ({ ...c, outcome: 'dropped' as const })); } }, |
| 150 | + ledger: l, |
| 151 | + now: () => new Date('2026-11-08T00:00:00Z'), |
| 152 | + }); |
| 153 | +} |
| 154 | + |
| 155 | +const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'not-null')!; |
| 156 | +const certified = (report: OrganizationOwnershipApplyReport): boolean => report.complete && report.blocking === 0; |
| 157 | + |
| 158 | +beforeEach(async () => { |
| 159 | + dir = mkdtempSync(join(tmpdir(), 'os-d10-not-null-resume-')); |
| 160 | + file = join(dir, 'db.sqlite'); |
| 161 | + rebuilt = []; |
| 162 | + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); |
| 163 | + (driver as any).logger = { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} }; |
| 164 | + await driver.initObjects( |
| 165 | + TABLES.map((name) => ({ |
| 166 | + name, |
| 167 | + fields: { name: { type: 'text' }, organization_id: { type: 'lookup', reference: 'sys_organization', required: false } }, |
| 168 | + })) as never, |
| 169 | + ); |
| 170 | + // The tables an earlier release built: their tenant column nullable, which a |
| 171 | + // table this release creates never is (ADR-0131 D1). Relaxed through the |
| 172 | + // driver's own drift apply door, the shape of that store. |
| 173 | + for (const table of TABLES) { |
| 174 | + const { applied } = await driver.applyMigrationEntries([ |
| 175 | + { |
| 176 | + kind: 'nullability_mismatch', remoteName: table, table, column: 'organization_id', category: 'needs_confirm', |
| 177 | + op: { type: 'relax_not_null', table, column: 'organization_id' }, |
| 178 | + } as never, |
| 179 | + ]); |
| 180 | + if (applied.length !== 1) throw new Error(`${table} was not returned to its pre-18 shape`); |
| 181 | + } |
| 182 | + db = new Database(file); |
| 183 | + db.exec(` |
| 184 | + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); |
| 185 | + INSERT INTO sys_organization VALUES ('${ORG}', 'default'); |
| 186 | + INSERT INTO crm_deal (id, name, organization_id) VALUES ('deal_1', 'Acme', '${ORG}'); |
| 187 | + INSERT INTO crm_note (id, name, organization_id) VALUES ('note_1', 'Call back', '${ORG}'); |
| 188 | + INSERT INTO crm_task (id, name, organization_id) VALUES ('task_1', 'Send quote', '${ORG}'); |
| 189 | + `); |
| 190 | + for (const table of TABLES) expect(isNotNull(table)).toBe(false); |
| 191 | +}); |
| 192 | + |
| 193 | +afterEach(async () => { |
| 194 | + db.close(); |
| 195 | + await driver.disconnect?.(); |
| 196 | + rmSync(dir, { recursive: true, force: true }); |
| 197 | +}); |
| 198 | + |
| 199 | +describe('the not-null step on a resumed ceremony', () => { |
| 200 | + it('⭐ (1) certified with tables not-declared, re-run under a composition that declares them: they receive NOT NULL', async () => { |
| 201 | + const l = ledger(); |
| 202 | + const first = await apply(l, schemaDoor(new Set(['crm_deal']))); |
| 203 | + expect(step(first)).toMatchObject({ status: 'done', summary: { due: 3, applied: 1, notDeclared: 2 } }); |
| 204 | + expect(step(first).notes.filter((n) => /re-run the ceremony where the deployment's composition is declared/.test(n))).toHaveLength(2); |
| 205 | + expect(certified(first)).toBe(true); |
| 206 | + expect(TABLES.map(isNotNull)).toEqual([true, false, false]); |
| 207 | + |
| 208 | + const again = await apply(l, schemaDoor(new Set(TABLES))); |
| 209 | + expect(step(again)).toMatchObject({ status: 'done', summary: { due: 3, applied: 2, alreadyNotNull: 1, notDeclared: 0 } }); |
| 210 | + expect(TABLES.map(isNotNull)).toEqual([true, true, true]); |
| 211 | + // The table tightened by the first run is not rebuilt again. |
| 212 | + expect(rebuilt).toEqual(['crm_deal', 'crm_note', 'crm_task']); |
| 213 | + expect(l.stored()!.completed['not-null']!.summary).toMatchObject({ applied: 2, alreadyNotNull: 1, notDeclared: 0 }); |
| 214 | + expect(certified(again)).toBe(true); |
| 215 | + }); |
| 216 | + |
| 217 | + it('⭐ (2) the walled recovery path — apply, fix the held rows, plan, apply — ends with NOT NULL on the fixed table', async () => { |
| 218 | + db.exec(`INSERT INTO crm_note (id, name, organization_id) VALUES ('note_orphan', 'nobody', NULL)`); |
| 219 | + const l = ledger(); |
| 220 | + const door = schemaDoor(new Set(TABLES)); |
| 221 | + const first = await apply(l, door, 'isolated'); |
| 222 | + expect(step(first)).toMatchObject({ status: 'done', summary: { due: 2, applied: 2 } }); |
| 223 | + expect(TABLES.map(isNotNull)).toEqual([true, false, true]); |
| 224 | + // The held row stops certification, and names the remedy: resolve it by hand, re-run --apply. |
| 225 | + expect(certified(first)).toBe(false); |
| 226 | + expect(first.tables.find((t) => t.object === 'crm_note')!.held).toMatchObject({ rows: 1 }); |
| 227 | + |
| 228 | + db.exec(`UPDATE crm_note SET organization_id = '${ORG}' WHERE id = 'note_orphan'`); |
| 229 | + const plan = await buildOrganizationOwnershipPlan({ reader: executor(), posture: 'isolated', database: file }); |
| 230 | + expect(plan.tables.find((t) => t.object === 'crm_note')!.notNull.willReceive).toBe(true); |
| 231 | + |
| 232 | + const again = await apply(l, door, 'isolated'); |
| 233 | + expect(step(again)).toMatchObject({ status: 'done', summary: { due: 3, applied: 1, alreadyNotNull: 2 } }); |
| 234 | + expect(TABLES.map(isNotNull)).toEqual([true, true, true]); |
| 235 | + expect(certified(again)).toBe(true); |
| 236 | + }); |
| 237 | + |
| 238 | + it('(3) control — a first run with nothing pending checkpoints as before; its re-run stays checkpointed and rebuilds nothing', async () => { |
| 239 | + const l = ledger(); |
| 240 | + const door = schemaDoor(new Set(TABLES)); |
| 241 | + const first = await apply(l, door); |
| 242 | + const summary = { applied: 3, alreadyNotNull: 0, due: 3, notDeclared: 0, unsupported: 0 }; |
| 243 | + expect(step(first)).toEqual({ step: 'not-null', status: 'done', summary, blockers: [], notes: [] }); |
| 244 | + expect(l.stored()!.completed['not-null']).toEqual({ at: '2026-11-08T00:00:00.000Z', summary }); |
| 245 | + expect(certified(first)).toBe(true); |
| 246 | + expect(rebuilt).toEqual([...TABLES]); |
| 247 | + |
| 248 | + const again = await apply(l, door); |
| 249 | + expect(step(again)).toEqual({ step: 'not-null', status: 'checkpointed', summary, blockers: [], notes: [] }); |
| 250 | + expect(l.stored()!.completed['not-null']).toEqual({ at: '2026-11-08T00:00:00.000Z', summary }); |
| 251 | + expect(rebuilt).toEqual([...TABLES]); |
| 252 | + expect(certified(again)).toBe(true); |
| 253 | + }); |
| 254 | + |
| 255 | + it('(4) a re-run whose NOT NULL fails blocks and does not certify, as the same failure does on a first run', async () => { |
| 256 | + const l = ledger(); |
| 257 | + await apply(l, schemaDoor(new Set(['crm_deal']))); |
| 258 | + const again = await apply(l, schemaDoor(new Set(TABLES), { failOn: 'crm_task' })); |
| 259 | + expect(step(again)).toMatchObject({ status: 'blocked', blockers: [expect.stringMatching(/NOT NULL on crm_task\.organization_id failed: SQLITE_BUSY/)] }); |
| 260 | + expect(TABLES.map(isNotNull)).toEqual([true, true, false]); |
| 261 | + expect(certified(again)).toBe(false); |
| 262 | + expect(l.stored()!.completed['not-null']).toBeUndefined(); |
| 263 | + }); |
| 264 | +}); |
0 commit comments