diff --git a/.changeset/25031-blocked-unique-index-apply-limit.md b/.changeset/25031-blocked-unique-index-apply-limit.md new file mode 100644 index 00000000000..24dbcbb4dd0 --- /dev/null +++ b/.changeset/25031-blocked-unique-index-apply-limit.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-sql': patch +'@objectstack/cli': patch +--- + +fix(driver-sql,cli): `os migrate plan` / `apply` no longer offer `--allow-destructive` for a unique index the existing rows already violate + +Clause-②: yes (widening) +Metadata-protocol: none + +The duplicate pre-flight that blocks such a `create_index` (ADR-0120 D4) said so only in the entry's message, and the apply-limit seam the two commands read asked the driver by op type. So `os migrate plan` closed with `(add --allow-destructive for drops / tightenings)`, `os migrate apply --yes` said `re-run with --allow-destructive to include them`, and that re-run refused the entry and ended PARTIAL. + +- **`ManagedDriftEntry.applyLimit` (`@objectstack/driver-sql`, new and optional).** Why the reporting driver's apply door will not perform this entry on this database, read from its data. The duplicate pre-flight sets it on a blocked unique index, naming the conflicting groups. It is absent on every other entry, so a reader that ignores it sees the report as before. On `--json`, a blocked entry now carries this key. +- **`os migrate plan` / `os migrate apply` (`@objectstack/cli`).** The apply-limit seam reads the entry's own limit first, then the driver's per-op one. A blocked unique index is listed with the conflicting groups as the reason it cannot be applied, the footer and the skip lines stop offering `--allow-destructive` for it, and it is counted among the changes that remain drift. A database with no blocked index prints as before. + +Unchanged: `os migrate apply --allow-destructive` still names such an entry as not applied and reports the run PARTIAL with exit 1, because the database still differs from its metadata. To clear it, deduplicate the listed rows and run `os migrate plan` again. diff --git a/packages/cli/src/commands/migrate/apply.blocked-unique-index.integration.test.ts b/packages/cli/src/commands/migrate/apply.blocked-unique-index.integration.test.ts new file mode 100644 index 00000000000..b5de5790db3 --- /dev/null +++ b/packages/cli/src/commands/migrate/apply.blocked-unique-index.integration.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A unique index whose rows already violate it: `os migrate plan` and `os + * migrate apply` name why the apply cannot create it, and never offer + * `--allow-destructive` for it. + * + * ## What used to happen + * + * Measured on a SQLite file whose rows duplicate two newly declared unique + * indexes, one plain and one organization-scoped. The ADR-0120 D4 duplicate + * pre-flight marked each `create_index` BLOCKED in its message, and yet: + * + * os migrate plan → Apply with: os migrate apply (add --allow-destructive for drops / tightenings) + * os migrate apply --yes → ⚠ 2 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them). + * os migrate apply --allow-destructive --yes → ✗ PARTIAL apply … exit 1 + * + * The block lived in the message only, and the seam both doors read for "an + * entry the apply cannot perform" (`SchemaStack.driftApplyLimit`) asked the + * driver by op type. + * + * ## What is pinned + * + * The driver declares the block on the entry (`ManagedDriftEntry.applyLimit`, + * the conflicting groups as the reason) and the seam reads it, so the + * rendering PR #24954 and PR #25029 built for declared limits takes over: + * + * 1. `plan`: each blocked entry is listed with its limit, naming the + * conflicting group; the footer offers `--allow-destructive` for nothing + * and says those changes remain drift. + * 2. `apply --yes`: the same listing; no line before the prompt or after the + * apply offers a re-run with `--allow-destructive`. + * 3. `apply --allow-destructive --yes`: the same listing, and the entries are + * named as not applied. The verdict is unchanged: an entry the operator + * confirmed and the driver did not apply makes the run PARTIAL, exit 1, as + * on the Turso REMOTE transport (`apply.remote-face.integration.test.ts`). + * 4. CONTROL: a duplicate-free unique index the database lacks is listed and + * footed as it always was, and a plain apply creates it. + * + * Real boot, real commands, a real SQLite file read back through a connection + * of its own. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { isExitSignal } from '../../utils/format.js'; +import MigrateApply from './apply.js'; +import MigratePlan from './plan.js'; + +// Paid at module load, not inside a clocked case (`check:test-source-alias`). +import '@objectstack/runtime'; +import '@objectstack/objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 240_000; + +/** Env that would point a boot somewhere other than the fixture, or change what it composes. */ +const OVERRIDING_ENV = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN', + 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE', + 'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC', 'NODE_ENV', + 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', +] as const; + +const PLAIN = 'os25031_plain'; +const SCOPED = 'os25031_scoped'; +const PLAIN_INDEX = `uniq_${PLAIN}_code`; +const SCOPED_INDEX = `uniq_${SCOPED}_organization_id_code`; + +/** `code` is declared unique on both: platform-wide on the first, per organization on the second. */ +const HOST_CONFIG = [ + 'export default {', + " manifest: { id: 'com.example.os25031', name: 'os25031', version: '0.0.0', type: 'app' },", + ' objects: [', + ` { name: '${PLAIN}', tenancy: { enabled: false }, fields: { code: { type: 'text', unique: true } } },`, + ` { name: '${SCOPED}', fields: { code: { type: 'text', unique: true } } },`, + ' ],', + '};', + '', +].join('\n'); + +/** The two tables, made by hand before any index exists, with their rows. */ +const seedSql = (duplicates: boolean): string[] => [ + `CREATE TABLE "${PLAIN}" ("id" TEXT PRIMARY KEY, "code" TEXT)`, + `CREATE TABLE "${SCOPED}" ("id" TEXT PRIMARY KEY, "organization_id" TEXT, "code" TEXT)`, + `INSERT INTO "${PLAIN}" ("id", "code") VALUES ('p1', 'A'), ('p2', '${duplicates ? 'A' : 'B'}')`, + // `org_y` shares the code with `org_x`'s rows: no duplicate under the organization key. + `INSERT INTO "${SCOPED}" ("id", "organization_id", "code") VALUES ('s1', 'org_x', 'Q'), ('s2', 'org_x', '${duplicates ? 'Q' : 'R'}'), ('s3', 'org_y', 'Q')`, +]; + +let dir: string; +let projectDir: string; +const savedEnv: Record = {}; +const savedCwd = process.cwd(); + +/** Run statements against the file through a connection of their own. */ +function local(file: string, read: (db: Database.Database) => T): T { + const db = new Database(file); + try { + return read(db); + } finally { + db.close(); + } +} + +const indexesOf = (file: string, table: string): string[] => + local(file, (db) => (db.prepare(`PRAGMA index_list("${table}")`).all() as Array<{ name: string }>).map((r) => r.name)); + +/** Seed the hand-made tables, then the environment's own syncing boot over them. */ +async function buildFixture(file: string, duplicates: boolean): Promise { + local(file, (db) => { for (const sql of seedSql(duplicates)) db.exec(sql); }); + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${file}`, + projectRoot: projectDir, + composeHostStack: true, + composeServedPlatform: true, + }); + await stack.shutdown(); +} + +/** The real command, with its human lines captured. */ +async function run(command: typeof MigrateApply | typeof MigratePlan, args: string[]): Promise<{ lines: string; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + const said: string[] = []; + const capture = (...parts: unknown[]) => { said.push(parts.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'warn').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + process.chdir(projectDir); + let thrownExit: number | undefined; + try { + try { + await (command === MigrateApply ? MigrateApply.run(args, { root: CLI_ROOT }) : MigratePlan.run(args, { root: CLI_ROOT })); + } catch (error) { + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (!trapped && !isExitSignal(error)) throw error; + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + return { + lines: said.join('\n'), + exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0, + }; + } finally { + process.chdir(savedCwd); + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +/** The line the listing prints under an index entry's heading: its finding and its remedy. */ +function entryLine(lines: string, index: string): string { + const all = lines.split('\n'); + const at = all.findIndex((l) => l.includes(`[${index}]`) && l.includes('[create_index]')); + expect(at, `no ${index} [create_index] entry in:\n${lines.slice(-3000)}`).toBeGreaterThanOrEqual(0); + return all[at + 1]!; +} + +/** The plan's closing lines: what to apply with, and what this database cannot apply. */ +const planFooter = (lines: string) => + lines.split('\n').filter((l) => /Apply with:|cannot be applied on this database/.test(l)).join('\n'); + +/** Every line that offers a re-run with --allow-destructive. */ +const reRunOffers = (lines: string) => + lines.split('\n').filter((l) => /re-run with --allow-destructive to include them|add --allow-destructive/.test(l)); + +const LIMITED = '"os migrate apply" cannot apply it on this database'; + +/** Each blocked entry is listed with its limit, which names its conflicting group. */ +function expectBlockedListing(lines: string): void { + const plain = entryLine(lines, PLAIN_INDEX); + expect(plain).toContain(LIMITED); + expect(plain.slice(plain.indexOf(LIMITED))).toContain('code="A"'); + const scoped = entryLine(lines, SCOPED_INDEX); + expect(scoped).toContain(LIMITED); + expect(scoped.slice(scoped.indexOf(LIMITED))).toContain('organization_id="org_x", code="Q"'); +} + +beforeAll(() => { + for (const key of OVERRIDING_ENV) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + dir = mkdtempSync(join(tmpdir(), 'os-blocked-unique-')); + projectDir = join(dir, 'project'); + mkdirSync(join(projectDir, 'dist'), { recursive: true }); + writeFileSync(join(projectDir, 'objectstack.config.ts'), HOST_CONFIG); + process.env.OS_ARTIFACT_PATH = join(projectDir, 'dist', 'objectstack.json'); // deliberately absent + process.env.NODE_ENV = 'production'; + process.env.OS_TELEMETRY_DB = '0'; +}); + +afterAll(() => { + for (const key of OVERRIDING_ENV) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('a unique index its rows violate: listed with why, never offered --allow-destructive', () => { + it('plan: each blocked entry names its conflicting group, and the footer offers --allow-destructive for nothing', async () => { + const file = join(dir, 'plan.db'); + await buildFixture(file, true); + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); + + const plan = await run(MigratePlan, ['--database-url', `file:${file}`]); + expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0); + expectBlockedListing(plan.lines); + expect(reRunOffers(plan.lines)).toEqual([]); + expect(planFooter(plan.lines)).toContain('cannot be applied on this database'); + }, CASE_TIMEOUT_MS); + + it('apply --yes: the same listing, and no re-run with --allow-destructive is offered before the prompt or after', async () => { + const file = join(dir, 'apply.db'); + await buildFixture(file, true); + + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--yes']); + expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0); + expectBlockedListing(apply.lines); + expect(reRunOffers(apply.lines)).toEqual([]); + expect(apply.lines).toContain('2 destructive change(s) remain drift that a re-run with --allow-destructive cannot apply'); + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); + expect(indexesOf(file, SCOPED)).not.toContain(SCOPED_INDEX); + }, CASE_TIMEOUT_MS); + + it('apply --allow-destructive --yes: the same listing, both entries named as not applied, the run PARTIAL as before', async () => { + const file = join(dir, 'apply-destructive.db'); + await buildFixture(file, true); + + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--allow-destructive', '--yes']); + expectBlockedListing(apply.lines); + expect(reRunOffers(apply.lines)).toEqual([]); + expect(apply.lines).toContain(`${PLAIN}.code — create_index ${PLAIN_INDEX} (destructive)`); + expect(apply.lines).toContain(`create_index ${SCOPED_INDEX} (destructive)`); + expect(apply.lines).toContain('PARTIAL apply'); + expect(apply.exitCode).toBe(1); + expect(indexesOf(file, PLAIN)).not.toContain(PLAIN_INDEX); + }, CASE_TIMEOUT_MS); +}); + +describe('CONTROL: a duplicate-free unique index reads and applies as it always has', () => { + it('plan lists it with its own finding and the unchanged footer; a plain apply creates it', async () => { + const file = join(dir, 'clean.db'); + await buildFixture(file, false); + // The boot built both over clean rows; take them away so the plan has two creates. + local(file, (db) => db.exec(`DROP INDEX "${PLAIN_INDEX}"; DROP INDEX "${SCOPED_INDEX}";`)); + + const plan = await run(MigratePlan, ['--database-url', `file:${file}`]); + expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0); + expect(entryLine(plan.lines, PLAIN_INDEX)).not.toContain(LIMITED); + expect(entryLine(plan.lines, SCOPED_INDEX)).not.toContain(LIMITED); + expect(planFooter(plan.lines)).toBe(' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)'); + + const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--yes']); + expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0); + expect(indexesOf(file, PLAIN)).toContain(PLAIN_INDEX); + expect(indexesOf(file, SCOPED)).toContain(SCOPED_INDEX); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index b6bddf65d83..9c2feddcb7f 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -1564,8 +1564,9 @@ export async function bootSchemaStack( + 'has a database whose apply limit can be read.', ); } - // A driver that declares nothing applies what the differ means it to. - return owner.driftApplyLimit ? owner.driftApplyLimit(entry.op.type) : null; + // The entry's own limit first (a unique index its rows violate, ADR-0120 + // D4), then the driver's for its op type; neither, and it applies. + return entry.applyLimit ?? (owner.driftApplyLimit ? owner.driftApplyLimit(entry.op.type) : null); }, managedTableCount, kernel, diff --git a/packages/drivers/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts index 61addd4bfb5..fdf59ecfa03 100644 --- a/packages/drivers/driver-sql/src/schema-drift.ts +++ b/packages/drivers/driver-sql/src/schema-drift.ts @@ -404,6 +404,18 @@ export interface ManagedDriftEntry extends SchemaDiffEntry { op: DriftOp; /** Human one-liner with an actionable hint. */ message: string; + /** + * [ADR-0120 D4] Why the reporting driver's apply door will not perform THIS + * entry on this database, read from its data when the entry was reported — + * absent when nothing in the data stops it. The duplicate pre-flight sets it + * on a unique index the existing rows already violate (the conflicting + * groups are the reason): the apply refuses it however it is confirmed, so + * `os migrate plan` / `apply` list the entry with this reason and never offer + * `--allow-destructive` for it. The one structured form of that judgement; + * ⛔ never re-derived from {@link message}. A limit of a whole op type on a + * transport is the driver's own `driftApplyLimit`, not this. + */ + applyLimit?: string; } /** diff --git a/packages/drivers/driver-sql/src/sql-driver-25031-blocked-unique-apply-limit.test.ts b/packages/drivers/driver-sql/src/sql-driver-25031-blocked-unique-apply-limit.test.ts new file mode 100644 index 00000000000..199b083d078 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-25031-blocked-unique-apply-limit.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +/** + * A unique index its rows already violate declares, on its own drift entry, + * that the apply will not perform it (`ManagedDriftEntry.applyLimit`). + * + * ## The defect + * + * The ADR-0120 D4 duplicate pre-flight marks such a `create_index` BLOCKED in + * its message only (`category: 'destructive'`, "The op is BLOCKED: …"). The + * one seam `os migrate plan` / `apply` read for "an entry the apply cannot + * perform" asked the driver by op type, and a data-dependent block cannot + * pass through that. So both doors offered `--allow-destructive` for the + * entry, and the re-run they offered refused it and ended PARTIAL. + * + * ## What is pinned + * + * 1. Plain and organization-scoped: the blocked entry carries `applyLimit`, + * and it names the conflicting group — the duplicate report is the reason. + * 2. The apply door agrees with the declaration: under `allowDestructive` the + * entry is skipped and no index appears. + * 3. CONTROL: a duplicate-free unique index reports no `applyLimit` and a + * plain apply creates it; the same table, once deduplicated, re-grades. + * + * The CLI's reading of the field runs for real in + * `packages/cli/src/commands/migrate/apply.blocked-unique-index.integration.test.ts`. + */ +describe('a blocked unique index declares its apply limit on the entry', () => { + let driver: SqlDriver; + + beforeEach(() => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + (driver as any).logger = { warn() {}, info() {}, error() {}, debug() {} }; + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + const seed = async (withOrg: boolean, rows: Array>): Promise => { + const k = (driver as any).knex; + await k.schema.createTable('crm_quote', (t: any) => { + t.string('id').primary(); + t.timestamp('created_at'); + t.timestamp('updated_at'); + if (withOrg) t.string('organization_id'); + t.string('quote_number'); + }); + await k('crm_quote').insert(rows); + return k; + }; + + const indexNames = async (): Promise => { + const list: any = await (driver as any).knex.raw('PRAGMA index_list(crm_quote)'); + return list.filter((i: any) => i.origin !== 'pk').map((i: any) => i.name); + }; + + const PLAIN = [ + { name: 'crm_quote', tenancy: { enabled: false }, fields: { quote_number: { type: 'text', unique: true } } }, + ] as any[]; + const SCOPED = [ + { + name: 'crm_quote', + fields: { organization_id: { type: 'string' }, quote_number: { type: 'text', unique: true } }, + }, + ] as any[]; + + const createIndexEntry = async () => { + const entries = (await driver.detectManagedDrift()).filter((d) => d.op.type === 'create_index'); + expect(entries, JSON.stringify(entries.map((d) => d.op))).toHaveLength(1); + return entries[0]!; + }; + + it('plain unique: the blocked entry names the conflicting group as its limit, and the apply refuses it', async () => { + await seed(false, [ + { id: 'r1', quote_number: 'QUO-00009' }, + { id: 'r2', quote_number: 'QUO-00009' }, + ]); + await driver.initObjects(PLAIN); + + const entry = await createIndexEntry(); + expect(entry.category).toBe('destructive'); + expect(entry.applyLimit).toEqual(expect.any(String)); + expect(entry.applyLimit).toContain('quote_number="QUO-00009"'); + + const forced = await driver.applyMigrationEntries([entry], { allowDestructive: true }); + expect(forced.applied).toEqual([]); + expect(forced.skipped).toEqual([entry]); + expect(await indexNames()).not.toContain('uniq_crm_quote_quote_number'); + }); + + it('organization-scoped unique: the same declaration, keyed by the organization part', async () => { + await seed(true, [ + { id: 'r1', organization_id: 'org_x', quote_number: 'QUO-00010' }, + { id: 'r2', organization_id: 'org_x', quote_number: 'QUO-00010' }, + { id: 'r3', organization_id: 'org_y', quote_number: 'QUO-00010' }, + ]); + await driver.initObjects(SCOPED); + + const entry = await createIndexEntry(); + expect(entry.category).toBe('destructive'); + expect(entry.applyLimit).toContain('organization_id="org_x", quote_number="QUO-00010"'); + expect(entry.applyLimit).not.toContain('org_y'); + }); + + it('CONTROL: a duplicate-free unique index declares no limit and a plain apply creates it', async () => { + await seed(false, [ + { id: 'r1', quote_number: 'QUO-00009' }, + { id: 'r2', quote_number: 'QUO-00010' }, + ]); + await driver.initObjects(PLAIN); + // The boot built it over clean data; take it away so the plan has a create. + await (driver as any).knex.raw('DROP INDEX uniq_crm_quote_quote_number'); + + const entry = await createIndexEntry(); + expect(entry.category).toBe('safe'); + expect(entry).not.toHaveProperty('applyLimit'); + + const res = await driver.applyMigrationEntries([entry], { allowDestructive: false }); + expect(res.applied).toEqual([entry]); + expect(await indexNames()).toContain('uniq_crm_quote_quote_number'); + }); + + it('CONTROL: deduplicating the rows withdraws the limit on the next report', async () => { + const k = await seed(false, [ + { id: 'r1', quote_number: 'QUO-00009' }, + { id: 'r2', quote_number: 'QUO-00009' }, + ]); + await driver.initObjects(PLAIN); + expect((await createIndexEntry()).applyLimit).toEqual(expect.any(String)); + + await k('crm_quote').where({ id: 'r2' }).delete(); + + const entry = await createIndexEntry(); + expect(entry.category).toBe('safe'); + expect(entry).not.toHaveProperty('applyLimit'); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 24448296bf2..7f899e192da 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -14190,6 +14190,10 @@ export class SqlDriver implements IDataDriver { const report = formatDuplicateGroups(duplicates); d.category = 'destructive'; d.severity = 'error'; + // The block, declared on the entry: `os migrate plan` / `apply` read it + // here and never offer `--allow-destructive` for an op the apply refuses + // on this data however it is confirmed. + d.applyLimit = `existing rows already violate the unique key: ${report}`; d.message = nullSafeKey ? `${op.table}: cannot ${tighten ? 'tighten' : 'create'} '${op.indexName}' as ${signature} — existing rows ` + `already violate the NULL-safe unique constraint (duplicates the old index wrongly admitted: SQL ` + diff --git a/packages/drivers/driver-turso/src/turso-remote-drift-apply-limit.test.ts b/packages/drivers/driver-turso/src/turso-remote-drift-apply-limit.test.ts index 76d211a1fb6..845ab8e677a 100644 --- a/packages/drivers/driver-turso/src/turso-remote-drift-apply-limit.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-drift-apply-limit.test.ts @@ -26,6 +26,11 @@ * it, so it is no limit of this transport) and is still skipped. * 3. The local and embedded-replica faces declare no limit for any op: their * apply door is the SQL driver's. + * 4. A unique index its rows already violate is a limit of THAT entry, not of + * its op type: on the local and the remote face alike the shared duplicate + * pre-flight declares it on the entry (`applyLimit`, naming the conflicting + * group), `driftApplyLimit('create_index')` still answers `null`, and the + * apply door skips the entry. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -166,3 +171,38 @@ describe('the local and embedded-replica faces declare no limit: their apply doo stub.close(); }); }); + +describe('a unique index its rows violate: a limit of the entry, declared on both faces', () => { + /** `code` is declared unique; two rows already share a value. */ + const QUOTE = { name: 'quote', tenancy: { enabled: false }, fields: { code: { type: 'text', unique: true } } }; + + async function seedDuplicates(file: string): Promise { + const setup = createClient({ url: `file:${file}` }); + await setup.execute('CREATE TABLE "quote" ("id" TEXT PRIMARY KEY, "created_at" TEXT, "updated_at" TEXT, "code" TEXT)'); + await setup.execute(`INSERT INTO "quote" ("id", "code") VALUES ('q1', 'Q-1'), ('q2', 'Q-1')`); + setup.close(); + } + + for (const face of ['local', 'remote'] as const) { + it(face, async () => { + const file = nextFile(); + await seedDuplicates(file); + const driver = face === 'local' ? new TursoDriver({ url: `file:${file}` }) : await remoteOver(file); + if (face === 'local') { + open.push({ driver, client: null }); + await driver.connect(); + } + expect(driver.transportMode).toBe(face); + + const [entry, ...rest] = op(await driver.detectManagedDrift([QUOTE]), 'create_index'); + expect(rest).toEqual([]); + expect(entry!.category).toBe('destructive'); + expect(entry!.applyLimit).toContain('code="Q-1"'); + expect(driver.driftApplyLimit('create_index')).toBeNull(); + + const { applied, skipped } = await driver.applyMigrationEntries([entry!], { allowDestructive: true }); + expect(applied).toEqual([]); + expect(skipped).toEqual([entry]); + }); + } +});