|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, afterAll } from 'vitest'; |
| 4 | +import { spawnSync } from 'node:child_process'; |
| 5 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 6 | +import { tmpdir } from 'node:os'; |
| 7 | +import { dirname, join, resolve } from 'node:path'; |
| 8 | +import { fileURLToPath } from 'node:url'; |
| 9 | +import Database from 'better-sqlite3'; |
| 10 | + |
| 11 | +/** |
| 12 | + * [ADR-0131 D1; ruling record 6328195425, letter A, item 2] `os migrate plan` |
| 13 | + * lists the tenant columns a database still holds nullable, in an advisory |
| 14 | + * section of its own that names `os migrate organization-ownership`. |
| 15 | + * |
| 16 | + * A database the published 18.0.0 created is certified with its tenant columns |
| 17 | + * nullable; this one is brought to that shape by rebuilding a table a fresh |
| 18 | + * boot created with its `organization_id` nullable — the 18.0.0 DDL — keeping |
| 19 | + * its rows and indexes. |
| 20 | + * |
| 21 | + * What is pinned, through the public door (`serve`'s provision-and-exit, then |
| 22 | + * the command, from the source entry): |
| 23 | + * |
| 24 | + * - a fresh store lists nothing (`columns: []`, read — not unreadable); |
| 25 | + * - the 18.0.0-shaped store lists the nullable tenant column as one the |
| 26 | + * ceremony tightens, and a rotation shard created nullable as one it leaves; |
| 27 | + * - ⭐ it is advisory: the plan's `changes` and `total` are the fresh store's, |
| 28 | + * byte for byte — no drift entry and no op is added — and the exit code is |
| 29 | + * 0 in both faces; |
| 30 | + * - the human face prints the section and the ceremony's two commands. |
| 31 | + */ |
| 32 | + |
| 33 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 34 | +const CLI_ROOT = resolve(HERE, '..', '..', '..'); |
| 35 | +const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js'); |
| 36 | +const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx'); |
| 37 | +const CASE_TIMEOUT_MS = 300_000; |
| 38 | + |
| 39 | +const APP_OBJECT = 'os25134_deal'; |
| 40 | +const SHARD = `${APP_OBJECT}__r20261019`; |
| 41 | + |
| 42 | +const CONFIG = [ |
| 43 | + 'export default {', |
| 44 | + " manifest: { id: 'com.example.os25134advisory', name: 'advisory', version: '0.0.0', type: 'app' },", |
| 45 | + " requires: ['auth'],", |
| 46 | + ` objects: [{ name: '${APP_OBJECT}', fields: { title: { type: 'text' } } }],`, |
| 47 | + '};', |
| 48 | + '', |
| 49 | +].join('\n'); |
| 50 | + |
| 51 | +const dirs: string[] = []; |
| 52 | +afterAll(() => { |
| 53 | + for (const d of dirs) { |
| 54 | + try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } |
| 55 | + } |
| 56 | +}); |
| 57 | + |
| 58 | +interface Fixture { |
| 59 | + dir: string; |
| 60 | + db: string; |
| 61 | +} |
| 62 | + |
| 63 | +function fixture(): Fixture { |
| 64 | + const dir = mkdtempSync(join(tmpdir(), 'os-25134-advisory-')); |
| 65 | + dirs.push(dir); |
| 66 | + mkdirSync(join(dir, 'dist'), { recursive: true }); |
| 67 | + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG); |
| 68 | + return { dir, db: join(dir, 'boot.db') }; |
| 69 | +} |
| 70 | + |
| 71 | +/** The child's environment: the fixture's database, one file, nothing inherited that retargets it. */ |
| 72 | +function childEnv(f: Fixture, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { |
| 73 | + const env: NodeJS.ProcessEnv = { ...process.env }; |
| 74 | + for (const key of [ |
| 75 | + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', |
| 76 | + 'OS_TELEMETRY_DB', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'NO_COLOR', |
| 77 | + ]) { |
| 78 | + delete env[key]; |
| 79 | + } |
| 80 | + return { |
| 81 | + ...env, |
| 82 | + OS_DATABASE_URL: `file:${f.db}`, |
| 83 | + OS_ARTIFACT_PATH: join(f.dir, 'dist', 'objectstack.json'), |
| 84 | + OS_CLOUD_URL: 'off', |
| 85 | + OS_TELEMETRY_DISABLED: '1', |
| 86 | + OS_TELEMETRY_DB: '0', |
| 87 | + OS_AUTH_SECRET: 'os25134-advisory-secret-at-least-32-characters', |
| 88 | + OS_LOG_LEVEL: 'warn', |
| 89 | + NO_COLOR: '1', |
| 90 | + ...extra, |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +/** `os serve`'s own provision-and-exit: a fresh store, the full composition schema-synced into it. */ |
| 95 | +function serveBoot(f: Fixture): void { |
| 96 | + const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], { |
| 97 | + cwd: f.dir, env: childEnv(f, { OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000, |
| 98 | + }); |
| 99 | + expect(boot.status, `the serving boot did not provision the database:\n${(boot.stderr ?? '').slice(-6000)}`).toBe(0); |
| 100 | +} |
| 101 | + |
| 102 | +function plan(f: Fixture, json: boolean): { exit: number | null; payload: any; text: string } { |
| 103 | + const run = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', ...(json ? ['--json'] : [])], { |
| 104 | + cwd: f.dir, env: childEnv(f), encoding: 'utf8', timeout: 240_000, |
| 105 | + }); |
| 106 | + let payload: any; |
| 107 | + if (json) { |
| 108 | + try { payload = JSON.parse(run.stdout); } catch { payload = { unparsed: `${run.stdout}\n${run.stderr}`.slice(-800) }; } |
| 109 | + } |
| 110 | + return { exit: run.status, payload, text: `${run.stdout}\n${run.stderr}` }; |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Rebuild `table` with its `organization_id` nullable — the shape the 18.0.0 |
| 115 | + * driver created — keeping its rows and its indexes. |
| 116 | + */ |
| 117 | +function relaxTenantColumn(file: string, table: string): void { |
| 118 | + const db = new Database(file); |
| 119 | + try { |
| 120 | + const { sql } = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { sql: string }; |
| 121 | + const relaxed = sql.replace(/([`"]organization_id[`"][^,]*?)\s+not null/i, '$1'); |
| 122 | + if (relaxed === sql) throw new Error(`${table}.organization_id is not NOT NULL in ${sql}`); |
| 123 | + const indexes = (db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL").all(table) as Array<{ sql: string }>) |
| 124 | + .map((i) => i.sql); |
| 125 | + db.exec('PRAGMA foreign_keys = OFF'); |
| 126 | + db.transaction(() => { |
| 127 | + db.exec(`ALTER TABLE "${table}" RENAME TO "${table}__pre"`); |
| 128 | + db.exec(relaxed); |
| 129 | + db.exec(`INSERT INTO "${table}" SELECT * FROM "${table}__pre"`); |
| 130 | + db.exec(`DROP TABLE "${table}__pre"`); |
| 131 | + for (const index of indexes) db.exec(index); |
| 132 | + })(); |
| 133 | + } finally { |
| 134 | + db.close(); |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +function notNull(file: string, table: string): boolean { |
| 139 | + const db = new Database(file, { readonly: true }); |
| 140 | + try { |
| 141 | + const col = (db.prepare(`SELECT "notnull" AS nn FROM pragma_table_info(?) WHERE name = 'organization_id'`).get(table)) as { nn: number } | undefined; |
| 142 | + if (!col) throw new Error(`${table} carries no organization_id`); |
| 143 | + return col.nn === 1; |
| 144 | + } finally { |
| 145 | + db.close(); |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +describe('os migrate plan lists the nullable tenant columns as an advisory (ADR-0131 D1)', () => { |
| 150 | + it('a fresh store lists nothing; an 18.0.0-shaped one lists them, and neither the drift nor the exit code moves', () => { |
| 151 | + const f = fixture(); |
| 152 | + serveBoot(f); |
| 153 | + expect(notNull(f.db, APP_OBJECT), 'a table this release creates carries its tenant column NOT NULL').toBe(true); |
| 154 | + |
| 155 | + const fresh = plan(f, true); |
| 156 | + expect(fresh.exit, JSON.stringify(fresh.payload).slice(0, 800)).toBe(0); |
| 157 | + expect(fresh.payload.nullableTenantColumns).toEqual({ |
| 158 | + status: 'read', adr: 'ADR-0131 D1', remedy: 'os migrate organization-ownership', columns: [], |
| 159 | + }); |
| 160 | + |
| 161 | + // The 18.0.0 shape: the app's tenant column nullable, and a shard rotated in nullable. |
| 162 | + relaxTenantColumn(f.db, APP_OBJECT); |
| 163 | + expect(notNull(f.db, APP_OBJECT)).toBe(false); |
| 164 | + const db = new Database(f.db); |
| 165 | + try { db.exec(`CREATE TABLE "${SHARD}" ("id" varchar(255) PRIMARY KEY, "organization_id" varchar(255))`); } finally { db.close(); } |
| 166 | + |
| 167 | + const shaped = plan(f, true); |
| 168 | + expect(shaped.exit, JSON.stringify(shaped.payload).slice(0, 800)).toBe(0); |
| 169 | + expect(shaped.payload.nullableTenantColumns).toEqual({ |
| 170 | + status: 'read', |
| 171 | + adr: 'ADR-0131 D1', |
| 172 | + remedy: 'os migrate organization-ownership', |
| 173 | + columns: [ |
| 174 | + { table: APP_OBJECT, column: 'organization_id', ceremony: 'tightens' }, |
| 175 | + { table: SHARD, column: 'organization_id', ceremony: 'leaves', reason: expect.stringMatching(/^a rotation shard of os25134_deal/) }, |
| 176 | + ], |
| 177 | + }); |
| 178 | + // ⭐ Advisory, not drift: the plan's changes are the fresh store's, and no |
| 179 | + // entry names a tenant column. |
| 180 | + expect(shaped.payload.changes).toEqual(fresh.payload.changes); |
| 181 | + expect(shaped.payload.total).toBe(fresh.payload.total); |
| 182 | + expect(shaped.payload.changes.filter((c: any) => c.column === 'organization_id')).toEqual([]); |
| 183 | + |
| 184 | + const human = plan(f, false); |
| 185 | + expect(human.exit, human.text.slice(-2000)).toBe(0); |
| 186 | + expect(human.text).toContain('2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift'); |
| 187 | + expect(human.text).toContain(`• ${APP_OBJECT}.organization_id\n`); |
| 188 | + expect(human.text).toContain(`• ${SHARD}.organization_id — the ceremony leaves it nullable: a rotation shard of ${APP_OBJECT}`); |
| 189 | + expect(human.text).toContain('tightens 1 of them: run "os migrate organization-ownership --out PLAN"'); |
| 190 | + }, CASE_TIMEOUT_MS); |
| 191 | +}); |
0 commit comments