Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/23925-remote-plan-remedy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/cli': patch
'@objectstack/driver-sql': minor
'@objectstack/driver-turso': minor
---

fix(cli,driver-turso): `os migrate plan` and `os migrate apply` on a Turso REMOTE database no longer offer `--allow-destructive` for a change that database cannot apply

Clause-②: yes (widening)

On a primary reached over the Turso REMOTE transport (`libsql://…`), `os migrate plan` listed a NOT NULL tightening with the remedy `Run "os migrate apply --allow-destructive".` and closed with `(add --allow-destructive for drops / tightenings)`. That transport runs no SQLite table rebuild, so the apply then named the tightening as not applied and exited 1. The plan now says so before the apply: each change the database cannot apply (a tightened or relaxed NOT NULL, a dropped DEFAULT) is still listed, with its finding and `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.` in place of the remedy. The footer offers `--allow-destructive` only for the destructive changes the database can apply (a column drop, an index change) and says how many listed changes remain drift after the apply. The listing `os migrate apply` prints before its prompt reads the same way. Its counts of held-back destructive changes, before the prompt and after the run, offer a re-run with `--allow-destructive` only for the ones that re-run could apply, and name the rest as drift it cannot apply either. On every other database both commands read exactly as before, and their `--json` documents are unchanged on every database.

`@objectstack/driver-turso`: `TursoDriver.driftApplyLimit(opType)` is new. It returns why the driver's `applyMigrationEntries` cannot perform a drift op of that type on this face, or `null` when it can. It answers from the same per-op declaration the REMOTE apply door dispatches by, and returns `null` for every op on the local and embedded-replica faces. A driver that does not implement it is read as applying every op the differ means it to.

`@objectstack/driver-sql`: `driftFindingWithoutRemedy(entry)` is a new export. It returns a drift entry's finding without the `os migrate apply --allow-destructive` sentence that a destructive column entry's message ends with, and returns every other message unchanged; the messages themselves keep their exact text.
2 changes: 2 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,8 @@ occupancy on its own.
| `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` — except `manual_column_type_change` (only `os migrate multi-value-columns --apply` runs it), and `manual_widen_varchar_to_text` and `unbuildable_index`, which nothing applies |
| `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE`, create a `UNIQUE` index existing rows already violate | `os migrate apply --allow-destructive` |

On a Turso remote primary (`libsql://`), a tightened or relaxed `NOT NULL` and a dropped DEFAULT stay drift, because that transport runs no SQLite table rebuild. `os migrate plan` and `os migrate apply` list each such entry with `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.`, offer `--allow-destructive` only for the destructive changes that database can apply, and count the rest as drift a re-run cannot apply.

`--allow-destructive` refuses some drops until the data they would lose is
safe. No flag skips these refusals:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ function bootWithIssuerDrop(): void {
telemetryDatabase: null,
composition: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null, notes: [] },
detectManagedDrift: async () => [ISSUER_DROP],
// [#23925] The SchemaStack member the listing reads: this driver declares no apply limit.
driftApplyLimit: () => null,
pendingSchemaWork: [],
kernel: { getService: () => ({}) },
flushSchemaDdl,
Expand Down Expand Up @@ -186,6 +188,8 @@ describe('controls', () => {
const { exit } = await runApply(ARGV);

expect(exit).toBe(1);
// Refused by the pre-flight, not by anything earlier on the human face.
expect(mocks.probeAccountIdentityCollisions).toHaveBeenCalledTimes(1);
expect(applyMigrationEntries).not.toHaveBeenCalled();
}, RUN_TIMEOUT);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
* commands, and every claim about the server is read back through a SECOND
* libSQL connection.
*
* [#23925] The human plan says the same thing up front, read from the driver's
* declaration (`TursoDriver.driftApplyLimit`): the tightening and the relax
* are listed with the transport's limit instead of a remedy it cannot run, and
* the drop keeps `--allow-destructive`. So does the apply's own listing before
* its prompt, whose held-back count offers a re-run only for the drop. The
* local file is the control: its plan and its apply read as they always have.
*
* ## How to run it
*
* It needs a `sqld` binary (libsql-server), so the remote cases are skipped
Expand Down Expand Up @@ -242,6 +249,28 @@ const DROP = `${TABLE}.legacy_23848 drop_column`;
const TIGHTEN = `${TABLE}.code tighten_not_null`;
const RELAX = `${TABLE}.memo relax_not_null`;

/**
* [#23925] The line the human plan prints under an entry's `[op]` heading on
* the fixture table: the entry's finding and its remedy.
*/
function planEntryLine(lines: string, op: string): string {
const all = lines.split('\n');
const at = all.findIndex((l) => l.includes(`${TABLE}.`) && l.includes(`[${op}]`));
expect(at, `no ${TABLE} [${op}] entry in:\n${lines.slice(-3000)}`).toBeGreaterThanOrEqual(0);
return all[at + 1]!;
}

/** [#23925] 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');

/** [#23925] The apply's held-back lines before its prompt: what a re-run would apply, and what it would not. */
const heldBackLines = (lines: string) =>
lines.split('\n').filter((l) => /will be SKIPPED|re-run with --allow-destructive cannot/.test(l));

/** [#23925] The apply's held-back lines after it ran. */
const postApplySkippedLines = (lines: string) => lines.split('\n').filter((l) => l.startsWith(' ⚠ Skipped '));

beforeAll(() => {
for (const key of OVERRIDING_ENV) {
savedEnv[key] = process.env[key];
Expand Down Expand Up @@ -271,6 +300,40 @@ afterAll(() => {
// control's document whenever a remote case before it failed at its boot (the
// positive control and the ablation are exactly such runs).
describe('CONTROL: the same apply on a local SQLite file applies all three, as it always has', () => {
// [#23925] The local face declares no limit, so its plan reads as it always
// has: the tightening keeps the differ's remedy, and the footer offers
// --allow-destructive for drops and tightenings.
it('plan on a file, human face: the tightening keeps "--allow-destructive", the footer is unchanged', async () => {
const file = join(dir, 'local-plan.db');
await buildFixture(`file:${file}`, (seed) => local(file, seed));

const plan = await run(MigratePlan, ['--database-url', `file:${file}`]);
expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0);
expect(planEntryLine(plan.lines, 'tighten_not_null')).toContain(
`${TABLE}.code: metadata declares \`storage.notNull\` but the column is nullable — existing nulls must be backfilled. `
+ 'Run "os migrate apply --allow-destructive".',
);
expect(planEntryLine(plan.lines, 'drop_column')).toContain('"os migrate apply --allow-destructive" to drop it.');
expect(plan.lines).not.toContain('cannot apply it on this database');
expect(planFooter(plan.lines)).toBe(' Apply with: os migrate apply (add --allow-destructive for drops / tightenings)');
}, CASE_TIMEOUT_MS);

// [#23925] …and so does its apply's listing before the prompt.
it('apply --yes on a file, human face: the tightening keeps "--allow-destructive", both destructive entries are offered a re-run', async () => {
const file = join(dir, 'local-apply.db');
await buildFixture(`file:${file}`, (seed) => local(file, seed));

const apply = await run(MigrateApply, ['--database-url', `file:${file}`, '--yes']);
expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0);
expect(planEntryLine(apply.lines, 'tighten_not_null')).toContain(
`${TABLE}.code: metadata declares \`storage.notNull\` but the column is nullable — existing nulls must be backfilled. `
+ 'Run "os migrate apply --allow-destructive".',
);
expect(apply.lines).not.toContain('cannot apply it on this database');
expect(heldBackLines(apply.lines)).toEqual([' ⚠ 2 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).']);
expect(postApplySkippedLines(apply.lines)).toEqual([' ⚠ Skipped 2 destructive change(s) (re-run with --allow-destructive to include them).']);
}, CASE_TIMEOUT_MS);

it('apply --allow-destructive on a file: exit 0, every entry applied, nothing skipped', async () => {
const file = join(dir, 'local.db');
await buildFixture(`file:${file}`, (seed) => local(file, seed));
Expand Down Expand Up @@ -319,6 +382,59 @@ describe.skipIf(!SQLD)('os migrate plan / apply on a Turso REMOTE database (sqld
expect(await logicalDump()).toBe(before);
}, CASE_TIMEOUT_MS);

// [#23925] Before the apply below names them as not applied, the plan says so:
// read from the driver's declaration, never from the dialect or the URL.
it('plan, human face: the tightening and the relax name the transport\'s limit, never --allow-destructive; the drop keeps it', async () => {
const before = await logicalDump();
const plan = await run(MigratePlan, ['--database-url', url]);
expect(plan.exitCode, plan.lines.slice(-1500)).toBe(0);

const tighten = planEntryLine(plan.lines, 'tighten_not_null');
expect(tighten).not.toContain('--allow-destructive');
expect(tighten).toContain(
`${TABLE}.code: metadata declares \`storage.notNull\` but the column is nullable — existing nulls must be backfilled. `
+ '"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE '
+ 'transport does not run.',
);
expect(planEntryLine(plan.lines, 'relax_not_null')).toContain('"os migrate apply" cannot apply it on this database');
const drop = planEntryLine(plan.lines, 'drop_column');
expect(drop).toContain('"os migrate apply --allow-destructive" to drop it.');
expect(drop).not.toContain('cannot apply it on this database');

const footer = planFooter(plan.lines);
expect(footer).not.toContain('drops / tightenings');
expect(footer).toContain('Apply with: os migrate apply (add --allow-destructive for the 1 destructive change(s) it can apply on this database)');
expect(footer).toMatch(/2 change\(s\) above cannot be applied on this database by "os migrate apply"/);
expect(await logicalDump()).toBe(before);
}, CASE_TIMEOUT_MS);

// [#23925] The apply's own listing says the same before its prompt, and offers
// a re-run with --allow-destructive only for the drop that re-run could apply.
it('apply --yes, human face: the listing names the limit, and only the drop is offered a re-run', async () => {
const apply = await run(MigrateApply, ['--database-url', url, '--yes']);
expect(apply.exitCode, apply.lines.slice(-1500)).toBe(0);
const tighten = planEntryLine(apply.lines, 'tighten_not_null');
expect(tighten).not.toContain('--allow-destructive');
expect(tighten).toContain(
'"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE '
+ 'transport does not run.',
);
expect(planEntryLine(apply.lines, 'relax_not_null')).toContain('"os migrate apply" cannot apply it on this database');
expect(planEntryLine(apply.lines, 'drop_column')).toContain('"os migrate apply --allow-destructive" to drop it.');
expect(heldBackLines(apply.lines)).toEqual([
' ⚠ 1 destructive change(s) will be SKIPPED (re-run with --allow-destructive to include them).',
' ⚠ 1 destructive change(s) remain drift that a re-run with --allow-destructive cannot apply on this database either (each says why above).',
]);
// Unchanged after the prompt: the relax the driver could not apply is still named.
expect(apply.lines).toContain(`${TABLE}.memo — relax_not_null`);
// The post-apply held-back line splits the same way.
expect(postApplySkippedLines(apply.lines)).toEqual([
' ⚠ Skipped 1 destructive change(s) (re-run with --allow-destructive to include them).',
' ⚠ Skipped 1 destructive change(s) that a re-run could not apply on this database either: they remain drift (each says why above).',
]);
expect(await remote(columnsOf)).toMatchObject({ code: false, memo: true, legacy_23848: false });
}, CASE_TIMEOUT_MS);

it('apply without --allow-destructive: names the entry it could not apply, keeps the destructive two back, exits 0', async () => {
const apply = await run(MigrateApply, ['--database-url', url, '--yes', '--json']);
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined();
Expand Down Expand Up @@ -353,6 +469,9 @@ describe.skipIf(!SQLD)('os migrate plan / apply on a Turso REMOTE database (sqld
const apply = await run(MigrateApply, ['--database-url', url, '--allow-destructive', '--yes']);
expect(apply.exitCode, apply.lines.slice(-1500)).toBe(1);
expect(apply.lines).toMatch(/additive schema work was NOT examined/i);
// [#23925] The listing before the prompt already said it could not apply the tightening.
expect(planEntryLine(apply.lines, 'tighten_not_null')).toContain('"os migrate apply" cannot apply it on this database');
expect(planEntryLine(apply.lines, 'tighten_not_null')).not.toContain('--allow-destructive');
expect(apply.lines).toContain(`${TABLE}.code — tighten_not_null`);
expect(apply.lines).toContain(`${TABLE}.memo — relax_not_null`);
expect(apply.lines).toMatch(/PARTIAL/);
Expand Down
Loading
Loading