Skip to content

Commit 8c73398

Browse files
fix(driver-sql,cli): a unique index its rows violate is listed as not appliable, never offered --allow-destructive (#25217)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgULzXHHREefG78bonpN6vqt)_ Fixes #25031 Clause-②: yes (widening) Metadata-protocol: none Card thread of `domain:engine` seat 2 (seat post #20966), session `session_01YRXQdFH1TrCyDQ5L1xDQRg`, claim 6340150456. Cross-lane declaration for the one CLI line: comment 6340209215 on #22648. ## What changes `driver-sql`'s ADR-0120 D4 duplicate pre-flight now declares a blocked unique index's apply limit **on the entry**: a new optional `ManagedDriftEntry.applyLimit`, set in the same branch that marks the entry BLOCKED, with the conflicting groups as the reason. The CLI's apply-limit seam (`SchemaStack.driftApplyLimit`, `schema-migrate.ts`) reads the entry's own limit first, then the owning driver's per-op one. One line of CLI code, as routing correction 6307307994 invited. Nothing in `plan.ts` / `apply.ts` changes. The rendering PR #24954 and PR #25029 built for declared limits takes over: the entry is listed with its reason, the footer and the skip lines stop offering `--allow-destructive` for it, and it is counted among the changes that remain drift. ## Measured (SQLite file, rows duplicating a plain and an organization-scoped unique index) | Door | `main` @ `e5f194b` | This branch | |:--|:--|:--| | `os migrate plan` | footer `Apply with: os migrate apply (add --allow-destructive for drops / tightenings)`, exit 0 | each entry ends `"os migrate apply" cannot apply it on this database: existing rows already violate the unique key: (code="A") × 2 rows.`; footer `Apply with: os migrate apply` and `2 change(s) above cannot be applied on this database …`, exit 0 | | `os migrate apply --yes` | `2 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).` and `Skipped 2 destructive change(s) (re-run …)`, exit 0 | `2 destructive change(s) remain drift that a re-run with --allow-destructive cannot apply on this database either` and `Skipped 2 … that a re-run could not apply on this database either`, exit 0 | | `os migrate apply --allow-destructive --yes` | both named NOT applied, `PARTIAL apply`, exit 1 | listing carries the limit; both named NOT applied, `PARTIAL apply`, exit 1 (unchanged, see below) | | re-run of `plan` | as the first row | as the first row of this column | `driver-turso`, measured at the driver: on the local and the remote face alike the blocked `create_index` was `destructive` with BLOCKED in the message, no structured field, and `driftApplyLimit('create_index')` answered `null`. Both faces inherit the pre-flight (`detectTableDrift` → `applyNullSafeUniquePreflight`; the remote face overrides only the probe's statement), so both now carry `applyLimit`. `TursoDriver` is untouched and still answers by op type. ## Why a structured field rather than a widened `driftApplyLimit(entry)` - **One judgement.** The pre-flight already decides the block on that entry. The field records that decision where it is made. A per-entry `driftApplyLimit` would still need the driver to remember it per entry (a field or a side table) and add a second door to the same fact. - **Smaller published surface, no narrowing.** One optional key on an exported interface (`yes (widening)`, measured: `applyLimit` appears once in `packages/drivers/driver-sql/dist/index.d.ts`). Widening the parameter of `TursoDriver.driftApplyLimit(opType)`, which is published, would narrow it for every existing caller, and an older CLI calling a newer driver with an op-type string would read `.op` of a string. - **Version skew is benign both ways.** An older CLI ignores the field and prints as before. A newer CLI over an older driver finds no field and asks by op type. - `--json` documents now carry `applyLimit` on a blocked entry. That is an additive key, and no other entry gains it. ## Not changed, deliberately: the PARTIAL verdict under `--allow-destructive` The seat's mechanism note expected `apply --allow-destructive` to stop ending PARTIAL on the blocked entry's account. It still does, exit 1. `apply.ts` documents that `notApplied`, PARTIAL and the exit code never read declared limits: an entry the operator confirmed and the driver did not apply makes the run PARTIAL, because the database still differs from its metadata. It is the same verdict the Turso REMOTE face gives a tightening (`apply.remote-face.integration.test.ts`). Excluding declared limits from PARTIAL would change the CLI's exit-code contract for both cases, which is the `domain:cli` lane's call and more than the one line this card carries. The new integration pin records today's verdict, so a later ruling flips one assertion. Raised in the report as an open question. ## Tests - `packages/drivers/driver-sql/src/sql-driver-25031-blocked-unique-apply-limit.test.ts` (new): plain and organization-scoped blocked entries carry `applyLimit` naming the conflicting group, and the apply skips them under `allowDestructive`. Controls: a duplicate-free unique index has no `applyLimit` and a plain apply creates it, and deduplicating withdraws the limit. - `packages/drivers/driver-turso/src/turso-remote-drift-apply-limit.test.ts` (+1 describe): local and remote faces each declare the limit on the entry, while `driftApplyLimit('create_index')` stays `null`. - `packages/cli/src/commands/migrate/apply.blocked-unique-index.integration.test.ts` (new, integration tier): real boot, real `plan` / `apply` over a SQLite file. Each blocked entry lists its conflicting group as the limit. No line offers `--allow-destructive` in `plan`, `apply --yes` or `apply --allow-destructive --yes`, and the last still reports PARTIAL with exit 1. Control: duplicate-free unique indexes keep the unchanged footer, and a plain apply creates them. Runs at `fa53728a8`: `driver-sql` vitest 232 files / 3752 passed, 12 files skipped; `driver-turso` 94 / 2485 passed; `cli --project unit` 324 / 4777 passed; the new integration file 4/4; `typecheck` green for all three packages. **Ablation** (`scripts/ablation-replace.mjs` deleting the `d.applyLimit = …` line, `driver-sql` rebuilt, `ablation-dist-preflight --absent` passing): the driver-sql pins went red (3 of 4: both pins plus the dedup control, which first asserts the limit), with the duplicate-free control green. The Turso pins went red (2 of 6), with the 4 pre-existing cases green. The CLI pins went red (3 of 4), with the control green. Restored: blob equals `HEAD`, `git diff HEAD` empty, `driver-sql` rebuilt, and the preflight marker is present again in `dist/`. ## Gates `dispatch-gates --commands` derived 73 families at `fa53728a8`. 71 exited 0. `check-plugin-teardown-shape --self-test` exited 3 on the shallow clone, then 0 after fetching its fixture commit. **NOT MEASURED locally:** `check:dual-build-cjs-loads` and `check:i18n-coverage` (exit 3, PREREQUISITE NOT MET: they need the whole workspace built). CI runs both. `--ran` reconciliation: 73 derived, 71 run, 2 NOT-MEASURED. `check:driver-conformance`: OK, 55 covered cells, 0 DEBT, 0 exempt. No ledger file is touched. ## Acceptance notes - The listing still places a blocked entry under `Destructive (requires --allow-destructive)`, because `renderPlan` groups by `category`. The entry's own line and the footer now say it cannot be applied. The heading is CLI rendering and was out of scope. The Turso REMOTE tightening reads the same way today. - `category: 'destructive'` itself is unchanged. It is what keeps a plain apply, dev `autoMigrate: 'safe'` and the artifact boot gate from attempting the CREATE. Reclassifying it is a separate question, and this PR does not answer it. - The tightening arm of the pre-flight (`recreate_index` with `tightenNullSafeOnly`) sets the same field in the same branch. Since ADR-0131 D1 made the organization key part plain, no fixture here reaches that arm, so it is covered by construction, not by a pin. - Unrelated to this diff: on a database with a blocked unique index, the deferred boot's flush still tries the CREATE and logs `[sql-driver] cannot create unique index …` at every `os migrate apply`, as on `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRXQdFH1TrCyDQ5L1xDQRg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03de83b commit 8c73398

7 files changed

Lines changed: 494 additions & 2 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(driver-sql,cli): `os migrate plan` / `apply` no longer offer `--allow-destructive` for a unique index the existing rows already violate
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
- **`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.
14+
- **`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.
15+
16+
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.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,8 +1564,9 @@ export async function bootSchemaStack(
15641564
+ 'has a database whose apply limit can be read.',
15651565
);
15661566
}
1567-
// A driver that declares nothing applies what the differ means it to.
1568-
return owner.driftApplyLimit ? owner.driftApplyLimit(entry.op.type) : null;
1567+
// The entry's own limit first (a unique index its rows violate, ADR-0120
1568+
// D4), then the driver's for its op type; neither, and it applies.
1569+
return entry.applyLimit ?? (owner.driftApplyLimit ? owner.driftApplyLimit(entry.op.type) : null);
15691570
},
15701571
managedTableCount,
15711572
kernel,

‎packages/drivers/driver-sql/src/schema-drift.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ export interface ManagedDriftEntry extends SchemaDiffEntry {
404404
op: DriftOp;
405405
/** Human one-liner with an actionable hint. */
406406
message: string;
407+
/**
408+
* [ADR-0120 D4] Why the reporting driver's apply door will not perform THIS
409+
* entry on this database, read from its data when the entry was reported —
410+
* absent when nothing in the data stops it. The duplicate pre-flight sets it
411+
* on a unique index the existing rows already violate (the conflicting
412+
* groups are the reason): the apply refuses it however it is confirmed, so
413+
* `os migrate plan` / `apply` list the entry with this reason and never offer
414+
* `--allow-destructive` for it. The one structured form of that judgement;
415+
* ⛔ never re-derived from {@link message}. A limit of a whole op type on a
416+
* transport is the driver's own `driftApplyLimit`, not this.
417+
*/
418+
applyLimit?: string;
407419
}
408420

409421
/**

0 commit comments

Comments
 (0)