|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * A unique index whose rows already violate it: `os migrate plan` and `os |
| 5 | + * migrate apply` name why the apply cannot create it, and never offer |
| 6 | + * `--allow-destructive` for it. |
| 7 | + * |
| 8 | + * ## What used to happen |
| 9 | + * |
| 10 | + * Measured on a SQLite file whose rows duplicate two newly declared unique |
| 11 | + * indexes, one plain and one organization-scoped. The ADR-0120 D4 duplicate |
| 12 | + * pre-flight marked each `create_index` BLOCKED in its message, and yet: |
| 13 | + * |
| 14 | + * os migrate plan → Apply with: os migrate apply (add --allow-destructive for drops / tightenings) |
| 15 | + * os migrate apply --yes → ⚠ 2 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them). |
| 16 | + * os migrate apply --allow-destructive --yes → ✗ PARTIAL apply … exit 1 |
| 17 | + * |
| 18 | + * The block lived in the message only, and the seam both doors read for "an |
| 19 | + * entry the apply cannot perform" (`SchemaStack.driftApplyLimit`) asked the |
| 20 | + * driver by op type. |
| 21 | + * |
| 22 | + * ## What is pinned |
| 23 | + * |
| 24 | + * The driver declares the block on the entry (`ManagedDriftEntry.applyLimit`, |
| 25 | + * the conflicting groups as the reason) and the seam reads it, so the |
| 26 | + * rendering PR #24954 and PR #25029 built for declared limits takes over: |
| 27 | + * |
| 28 | + * 1. `plan`: each blocked entry is listed with its limit, naming the |
| 29 | + * conflicting group; the footer offers `--allow-destructive` for nothing |
| 30 | + * and says those changes remain drift. |
| 31 | + * 2. `apply --yes`: the same listing; no line before the prompt or after the |
| 32 | + * apply offers a re-run with `--allow-destructive`. |
| 33 | + * 3. `apply --allow-destructive --yes`: the same listing, and the entries are |
| 34 | + * named as not applied. The verdict is unchanged: an entry the operator |
| 35 | + * confirmed and the driver did not apply makes the run PARTIAL, exit 1, as |
| 36 | + * on the Turso REMOTE transport (`apply.remote-face.integration.test.ts`). |
| 37 | + * 4. CONTROL: a duplicate-free unique index the database lacks is listed and |
| 38 | + * footed as it always was, and a plain apply creates it. |
| 39 | + * |
| 40 | + * Real boot, real commands, a real SQLite file read back through a connection |
| 41 | + * of its own. |
| 42 | + */ |
| 43 | + |
| 44 | +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; |
| 45 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 46 | +import { tmpdir } from 'node:os'; |
| 47 | +import { dirname, join, resolve } from 'node:path'; |
| 48 | +import { fileURLToPath } from 'node:url'; |
| 49 | +import Database from 'better-sqlite3'; |
| 50 | +import { bootSchemaStack } from '../../utils/schema-migrate.js'; |
| 51 | +import { isExitSignal } from '../../utils/format.js'; |
| 52 | +import MigrateApply from './apply.js'; |
| 53 | +import MigratePlan from './plan.js'; |
| 54 | + |
| 55 | +// Paid at module load, not inside a clocked case (`check:test-source-alias`). |
| 56 | +import '@objectstack/runtime'; |
| 57 | +import '@objectstack/objectql'; |
| 58 | + |
| 59 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 60 | +const CLI_ROOT = resolve(HERE, '..', '..', '..'); |
| 61 | +const CASE_TIMEOUT_MS = 240_000; |
| 62 | + |
| 63 | +/** Env that would point a boot somewhere other than the fixture, or change what it composes. */ |
| 64 | +const OVERRIDING_ENV = [ |
| 65 | + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN', |
| 66 | + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE', |
| 67 | + 'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC', 'NODE_ENV', |
| 68 | + 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', |
| 69 | +] as const; |
| 70 | + |
| 71 | +const PLAIN = 'os25031_plain'; |
| 72 | +const SCOPED = 'os25031_scoped'; |
| 73 | +const PLAIN_INDEX = `uniq_${PLAIN}_code`; |
| 74 | +const SCOPED_INDEX = `uniq_${SCOPED}_organization_id_code`; |
| 75 | + |
| 76 | +/** `code` is declared unique on both: platform-wide on the first, per organization on the second. */ |
| 77 | +const HOST_CONFIG = [ |
| 78 | + 'export default {', |
| 79 | + " manifest: { id: 'com.example.os25031', name: 'os25031', version: '0.0.0', type: 'app' },", |
| 80 | + ' objects: [', |
| 81 | + ` { name: '${PLAIN}', tenancy: { enabled: false }, fields: { code: { type: 'text', unique: true } } },`, |
| 82 | + ` { name: '${SCOPED}', fields: { code: { type: 'text', unique: true } } },`, |
| 83 | + ' ],', |
| 84 | + '};', |
| 85 | + '', |
| 86 | +].join('\n'); |
| 87 | + |
| 88 | +/** The two tables, made by hand before any index exists, with their rows. */ |
| 89 | +const seedSql = (duplicates: boolean): string[] => [ |
| 90 | + `CREATE TABLE "${PLAIN}" ("id" TEXT PRIMARY KEY, "code" TEXT)`, |
| 91 | + `CREATE TABLE "${SCOPED}" ("id" TEXT PRIMARY KEY, "organization_id" TEXT, "code" TEXT)`, |
| 92 | + `INSERT INTO "${PLAIN}" ("id", "code") VALUES ('p1', 'A'), ('p2', '${duplicates ? 'A' : 'B'}')`, |
| 93 | + // `org_y` shares the code with `org_x`'s rows: no duplicate under the organization key. |
| 94 | + `INSERT INTO "${SCOPED}" ("id", "organization_id", "code") VALUES ('s1', 'org_x', 'Q'), ('s2', 'org_x', '${duplicates ? 'Q' : 'R'}'), ('s3', 'org_y', 'Q')`, |
| 95 | +]; |
| 96 | + |
| 97 | +let dir: string; |
| 98 | +let projectDir: string; |
| 99 | +const savedEnv: Record<string, string | undefined> = {}; |
| 100 | +const savedCwd = process.cwd(); |
| 101 | + |
| 102 | +/** Run statements against the file through a connection of their own. */ |
| 103 | +function local<T>(file: string, read: (db: Database.Database) => T): T { |
| 104 | + const db = new Database(file); |
| 105 | + try { |
| 106 | + return read(db); |
| 107 | + } finally { |
| 108 | + db.close(); |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +const indexesOf = (file: string, table: string): string[] => |
| 113 | + local(file, (db) => (db.prepare(`PRAGMA index_list("${table}")`).all() as Array<{ name: string }>).map((r) => r.name)); |
| 114 | + |
| 115 | +/** Seed the hand-made tables, then the environment's own syncing boot over them. */ |
| 116 | +async function buildFixture(file: string, duplicates: boolean): Promise<void> { |
| 117 | + local(file, (db) => { for (const sql of seedSql(duplicates)) db.exec(sql); }); |
| 118 | + const stack = await bootSchemaStack({ |
| 119 | + jsonOutput: false, |
| 120 | + databaseUrl: `file:${file}`, |
| 121 | + projectRoot: projectDir, |
| 122 | + composeHostStack: true, |
| 123 | + composeServedPlatform: true, |
| 124 | + }); |
| 125 | + await stack.shutdown(); |
| 126 | +} |
| 127 | + |
| 128 | +/** The real command, with its human lines captured. */ |
| 129 | +async function run(command: typeof MigrateApply | typeof MigratePlan, args: string[]): Promise<{ lines: string; exitCode: number }> { |
| 130 | + const savedExit = process.exitCode; |
| 131 | + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { |
| 132 | + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; |
| 133 | + if (cb) cb(); |
| 134 | + return true; |
| 135 | + }) as typeof process.stdout.write; |
| 136 | + vi.spyOn(process.stdout, 'write').mockImplementation(swallow); |
| 137 | + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); |
| 138 | + const said: string[] = []; |
| 139 | + const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); }; |
| 140 | + vi.spyOn(console, 'log').mockImplementation(capture); |
| 141 | + vi.spyOn(console, 'warn').mockImplementation(capture); |
| 142 | + vi.spyOn(console, 'error').mockImplementation(capture); |
| 143 | + let processExit: number | undefined; |
| 144 | + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { |
| 145 | + processExit = code ?? 0; |
| 146 | + throw new Error(`__PROCESS_EXIT__:${processExit}`); |
| 147 | + }) as never); |
| 148 | + process.chdir(projectDir); |
| 149 | + let thrownExit: number | undefined; |
| 150 | + try { |
| 151 | + try { |
| 152 | + await (command === MigrateApply ? MigrateApply.run(args, { root: CLI_ROOT }) : MigratePlan.run(args, { root: CLI_ROOT })); |
| 153 | + } catch (error) { |
| 154 | + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); |
| 155 | + if (!trapped && !isExitSignal(error)) throw error; |
| 156 | + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; |
| 157 | + } |
| 158 | + return { |
| 159 | + lines: said.join('\n'), |
| 160 | + exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0, |
| 161 | + }; |
| 162 | + } finally { |
| 163 | + process.chdir(savedCwd); |
| 164 | + process.exitCode = savedExit; |
| 165 | + vi.restoreAllMocks(); |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +/** The line the listing prints under an index entry's heading: its finding and its remedy. */ |
| 170 | +function entryLine(lines: string, index: string): string { |
| 171 | + const all = lines.split('\n'); |
| 172 | + const at = all.findIndex((l) => l.includes(`[${index}]`) && l.includes('[create_index]')); |
| 173 | + expect(at, `no ${index} [create_index] entry in:\n${lines.slice(-3000)}`).toBeGreaterThanOrEqual(0); |
| 174 | + return all[at + 1]!; |
| 175 | +} |
| 176 | + |
| 177 | +/** The plan's closing lines: what to apply with, and what this database cannot apply. */ |
| 178 | +const planFooter = (lines: string) => |
| 179 | + lines.split('\n').filter((l) => /Apply with:|cannot be applied on this database/.test(l)).join('\n'); |
| 180 | + |
| 181 | +/** Every line that offers a re-run with --allow-destructive. */ |
| 182 | +const reRunOffers = (lines: string) => |
| 183 | + lines.split('\n').filter((l) => /re-run with --allow-destructive to include them|add --allow-destructive/.test(l)); |
| 184 | + |
| 185 | +const LIMITED = '"os migrate apply" cannot apply it on this database'; |
| 186 | + |
| 187 | +/** Each blocked entry is listed with its limit, which names its conflicting group. */ |
| 188 | +function expectBlockedListing(lines: string): void { |
| 189 | + const plain = entryLine(lines, PLAIN_INDEX); |
| 190 | + expect(plain).toContain(LIMITED); |
| 191 | + expect(plain.slice(plain.indexOf(LIMITED))).toContain('code="A"'); |
| 192 | + const scoped = entryLine(lines, SCOPED_INDEX); |
| 193 | + expect(scoped).toContain(LIMITED); |
| 194 | + expect(scoped.slice(scoped.indexOf(LIMITED))).toContain('organization_id="org_x", code="Q"'); |
| 195 | +} |
| 196 | + |
| 197 | +beforeAll(() => { |
| 198 | + for (const key of OVERRIDING_ENV) { |
| 199 | + savedEnv[key] = process.env[key]; |
| 200 | + delete process.env[key]; |
| 201 | + } |
| 202 | + dir = mkdtempSync(join(tmpdir(), 'os-blocked-unique-')); |
| 203 | + projectDir = join(dir, 'project'); |
| 204 | + mkdirSync(join(projectDir, 'dist'), { recursive: true }); |
| 205 | + writeFileSync(join(projectDir, 'objectstack.config.ts'), HOST_CONFIG); |
| 206 | + process.env.OS_ARTIFACT_PATH = join(projectDir, 'dist', 'objectstack.json'); // deliberately absent |
| 207 | + process.env.NODE_ENV = 'production'; |
| 208 | + process.env.OS_TELEMETRY_DB = '0'; |
| 209 | +}); |
| 210 | + |
| 211 | +afterAll(() => { |
| 212 | + for (const key of OVERRIDING_ENV) { |
| 213 | + if (savedEnv[key] === undefined) delete process.env[key]; |
| 214 | + else process.env[key] = savedEnv[key]; |
| 215 | + } |
| 216 | + if (dir) rmSync(dir, { recursive: true, force: true }); |
| 217 | +}); |
| 218 | + |
| 219 | +describe('a unique index its rows violate: listed with why, never offered --allow-destructive', () => { |
| 220 | + it('plan: each blocked entry names its conflicting group, and the footer offers --allow-destructive for nothing', async () => { |
| 221 | + const file = join(dir, 'plan.db'); |
| 222 | + await buildFixture(file, true); |
| 223 | + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); |
| 224 | + |
| 225 | + const plan = await run(MigratePlan, ['--database-url', `file:${file}`]); |
| 226 | + expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0); |
| 227 | + expectBlockedListing(plan.lines); |
| 228 | + expect(reRunOffers(plan.lines)).toEqual([]); |
| 229 | + expect(planFooter(plan.lines)).toContain('cannot be applied on this database'); |
| 230 | + }, CASE_TIMEOUT_MS); |
| 231 | + |
| 232 | + it('apply --yes: the same listing, and no re-run with --allow-destructive is offered before the prompt or after', async () => { |
| 233 | + const file = join(dir, 'apply.db'); |
| 234 | + await buildFixture(file, true); |
| 235 | + |
| 236 | + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--yes']); |
| 237 | + expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0); |
| 238 | + expectBlockedListing(apply.lines); |
| 239 | + expect(reRunOffers(apply.lines)).toEqual([]); |
| 240 | + expect(apply.lines).toContain('2 destructive change(s) remain drift that a re-run with --allow-destructive cannot apply'); |
| 241 | + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); |
| 242 | + expect(indexesOf(file, SCOPED)).not.toContain(SCOPED_INDEX); |
| 243 | + }, CASE_TIMEOUT_MS); |
| 244 | + |
| 245 | + it('apply --allow-destructive --yes: the same listing, both entries named as not applied, the run PARTIAL as before', async () => { |
| 246 | + const file = join(dir, 'apply-destructive.db'); |
| 247 | + await buildFixture(file, true); |
| 248 | + |
| 249 | + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--allow-destructive', '--yes']); |
| 250 | + expectBlockedListing(apply.lines); |
| 251 | + expect(reRunOffers(apply.lines)).toEqual([]); |
| 252 | + expect(apply.lines).toContain(`${PLAIN}.code — create_index ${PLAIN_INDEX} (destructive)`); |
| 253 | + expect(apply.lines).toContain(`create_index ${SCOPED_INDEX} (destructive)`); |
| 254 | + expect(apply.lines).toContain('PARTIAL apply'); |
| 255 | + expect(apply.exitCode).toBe(1); |
| 256 | + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); |
| 257 | + }, CASE_TIMEOUT_MS); |
| 258 | +}); |
| 259 | + |
| 260 | +describe('CONTROL: a duplicate-free unique index reads and applies as it always has', () => { |
| 261 | + it('plan lists it with its own finding and the unchanged footer; a plain apply creates it', async () => { |
| 262 | + const file = join(dir, 'clean.db'); |
| 263 | + await buildFixture(file, false); |
| 264 | + // The boot built both over clean rows; take them away so the plan has two creates. |
| 265 | + local(file, (db) => db.exec(`DROP INDEX "${PLAIN_INDEX}"; DROP INDEX "${SCOPED_INDEX}";`)); |
| 266 | + |
| 267 | + const plan = await run(MigratePlan, ['--database-url', `file:${file}`]); |
| 268 | + expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0); |
| 269 | + expect(entryLine(plan.lines, PLAIN_INDEX)).not.toContain(LIMITED); |
| 270 | + expect(entryLine(plan.lines, SCOPED_INDEX)).not.toContain(LIMITED); |
| 271 | + expect(planFooter(plan.lines)).toBe(' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)'); |
| 272 | + |
| 273 | + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--yes']); |
| 274 | + expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0); |
| 275 | + expect(indexesOf(file, PLAIN)).toContain(PLAIN_INDEX); |
| 276 | + expect(indexesOf(file, SCOPED)).toContain(SCOPED_INDEX); |
| 277 | + }, CASE_TIMEOUT_MS); |
| 278 | +}); |
0 commit comments