Skip to content
Merged
21 changes: 21 additions & 0 deletions .changeset/23848-migrate-apply-turso-remote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
'@objectstack/driver-turso': minor
---

fix(cli,driver-turso): `os migrate plan` and `os migrate apply` run on a Turso REMOTE database, and the apply names every drift entry it could not apply

Clause-②: yes

On a primary reached over the Turso REMOTE transport (`libsql://…`, every hosted tenant), `os migrate plan` and `os migrate apply` used to stop at their boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, so the remedy the drift report and the `OS_ARTIFACT_URL` boot gate print for destructive drift, `os migrate apply --allow-destructive`, could not run there at all. Both commands now boot the way `os migrate organization-ownership` does on that transport: with schema sync skipped instead of deferred. What an operator on a remote primary now sees:

- **The drift half runs.** `os migrate plan` lists the drift; `os migrate apply` applies what the transport can express, after the same confirmation: a column drop (with the indexes keyed on it, in one transaction) and every index entry. Nothing is written before the prompt, as before.
- **The additive half is reported as NOT examined, never as "nothing to create".** Both commands print a warning under the database line saying that tables and columns the metadata declares and the database lacks are neither listed nor created by this run, and that the serving boot (`os serve` / `os start`) creates them on this transport. The `--json` documents carry `schemaSync: "skipped"`, so their `pending: []` reads as not examined; an apply with no drift answers `message: "in_sync_partial"`, not `"in_sync"`.
- **Every entry the driver did not apply is named,** by table, column and kind, in both faces (`--json`: a new `notApplied` array on the post-apply document). The REMOTE transport runs no SQLite table rebuild, so a tightening, a relaxed NOT NULL, a width change or a dropped DEFAULT is not applied there.
- **A destructive entry confirmed with `--allow-destructive` that was not applied makes the run PARTIAL:** the human face ends `PARTIAL apply: …` and the command exits `1`; `--json` carries `message: "partial"` and exits `1`. A non-destructive entry that was not applied is named and the exit stays `0`.

On every other database the apply behaves as before, with one difference in the same direction: a destructive change confirmed with `--allow-destructive` that the driver could not apply (its DDL failed, or the dialect cannot perform it) now exits `1` and is named, where the run used to exit `0` with a count. Its human line for the entries held back without `--allow-destructive` now reads `Skipped N destructive change(s) (re-run with --allow-destructive to include them)`.

`@objectstack/driver-turso`: the REMOTE face's `driftReportCaveat` no longer says a destructive entry has no remedy on this transport; it says `os migrate apply --allow-destructive` applies a column drop or an index change there and names the rest.

The other `os migrate` commands whose dry run arms deferred DDL (`duplicates`, `account-issuer`, `unmapped-columns`, `multi-value-columns`, and the dry runs of the data commands) still refuse on the REMOTE transport, unchanged.

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions packages/cli/src/commands/migrate/apply.remote-posture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #23848 — `os migrate plan` and `os migrate apply` on a primary whose
* transport cannot defer schema DDL (the Turso REMOTE transport), pinned
* without a database. CI has no `sqld`, so the end-to-end run
* (`apply.remote-face.integration.test.ts`) skips there; these hold the two
* halves that decide it:
*
* 1. **The posture.** Both commands boot with `deferredDdlUnsupported:
* 'skip-schema-sync'` beside `deferSchemaDdl`, and compose identically, so
* the plan an operator reads is the one the apply confirms. Without the
* opt-in both refuse at the boot again on every hosted tenant.
* 2. **The verdict.** Every drift entry the driver was asked to apply and did
* not is named; a destructive one the operator confirmed makes the run
* PARTIAL (exit 1, `message: 'partial'`); a non-destructive one is named
* and the exit stays 0; an entry held back for lack of
* `--allow-destructive` is not "not applied". A boot that skipped schema
* sync says its additive half was not examined, in both faces.
*
* `bootSchemaStack` is stubbed with a stack answering a fixed drift report.
*/

import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { fileURLToPath } from 'node:url';

const boot = vi.hoisted(() => vi.fn(async (_opts: Record<string, unknown>): Promise<unknown> => {
throw new Error('the database was opened');
}));
vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/schema-migrate.js')>()),
bootSchemaStack: boot,
}));
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
OCCUPANCY_HINT: 'occupancy hint',
probeMigrationTarget: vi.fn(async () => ({ status: 'not_applicable' })),
}));

type ApplyCommand = (typeof import('./apply.js'))['default'];
type PlanCommand = (typeof import('./plan.js'))['default'];
let MigrateApply: ApplyCommand;
let MigratePlan: PlanCommand;
let SKIPPED_SCHEMA_SYNC_NOTE: string;

beforeAll(async () => {
({ default: MigrateApply } = await import('./apply.js'));
({ default: MigratePlan } = await import('./plan.js'));
({ SKIPPED_SCHEMA_SYNC_NOTE } = await import('../../utils/schema-migrate.js'));
}, 60_000);

const CASE_TIMEOUT_MS = 60_000;
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));

afterEach(() => {
boot.mockReset();
boot.mockImplementation(async () => {
throw new Error('the database was opened');
});
});

type Entry = {
kind: string; remoteName: string; table: string; column?: string; severity: string;
category: 'safe' | 'needs_confirm' | 'destructive'; op: Record<string, unknown> & { type: string }; message: string;
};

const entry = (column: string, type: string, category: Entry['category']): Entry => ({
kind: 'nullability_mismatch', remoteName: 't23848', table: 't23848', column, severity: 'warning', category,
op: { type, table: 't23848', column }, message: `t23848.${column}: ${type}`,
});

/**
* A booted stack as `bootSchemaStack` answers it: `drift` is the report, and
* the driver applies exactly the entries in `applies` — any other entry it is
* asked to apply comes back skipped, as a driver reports one its transport
* cannot perform. A destructive entry without `allowDestructive` is skipped
* unattempted, as the base contract says.
*/
function stackWith(drift: Entry[], applies: Entry[], schemaSync: 'skipped' | 'deferred') {
const applyMigrationEntries = vi.fn(async (entries: Entry[], opts: { allowDestructive?: boolean }) => {
const applied: Entry[] = [];
const skipped: Entry[] = [];
for (const e of entries) {
if (e.category === 'destructive' && !opts.allowDestructive) skipped.push(e);
else (applies.includes(e) ? applied : skipped).push(e);
}
return { applied, skipped };
});
return {
applyMigrationEntries,
stack: {
driver: { config: { url: 'libsql://tenant.example' } },
dbLabel: 'libsql://tenant.example',
dbSource: { kind: 'flag' },
schemaSync,
telemetryDatabase: null,
drivers: [],
detectManagedDrift: vi.fn(async () => drift),
applyMigrationEntries,
pendingSchemaWork: [],
flushSchemaDdl: vi.fn(async () => []),
managedTableCount: 1,
kernel: { getService: () => { throw new Error('no kernel in this pin'); } },
tableAbsent: () => false,
allObjects: () => [],
composition: {
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
servedBoot: { mirrored: false, reason: 'not-composed' }, notes: [], coverage: null,
},
shutdown: async () => {},
},
};
}

/** The real command, with its output captured: the JSON document, or the human lines. */
async function run(
command: ApplyCommand | PlanCommand,
argv: string[],
): Promise<{ payload: any; lines: string; exitCode: number }> {
const chunks: string[] = [];
const said: string[] = [];
const savedExit = process.exitCode;
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
chunks.push(String(chunk));
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
if (cb) cb();
return true;
}) as typeof process.stdout.write);
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
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);
let thrownExit: number | undefined;
try {
await (command === MigrateApply ? MigrateApply.run(argv, { root: CLI_ROOT }) : MigratePlan.run(argv, { root: CLI_ROOT }));
} catch (error) {
const oclif = (error as { oclif?: { exit?: number } }).oclif;
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
if (oclif === undefined && !trapped) throw error;
if (oclif) thrownExit = oclif.exit;
}
const exitCode = processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0;
process.exitCode = savedExit;
vi.restoreAllMocks();
const out = chunks.join('').trim();
return { payload: argv.includes('--json') ? JSON.parse(out) : null, lines: said.join('\n'), exitCode };
}

const where = (entries: Entry[] | undefined) => (entries ?? []).map((e) => `${e.column} ${e.op.type}`);

describe('plan and apply boot with the skip-schema-sync posture, identically', () => {
const POSTURE = { deferSchemaDdl: true, deferredDdlUnsupported: 'skip-schema-sync' };
const COMPOSITION = { composeHostStack: true, composeServedPlatform: true };

it('the plan boot', async () => {
await run(MigratePlan, ['--json']);
expect(boot).toHaveBeenCalledTimes(1);
expect(boot.mock.calls[0]![0]).toEqual(expect.objectContaining({ ...POSTURE, ...COMPOSITION, readOnlyProbe: true }));
}, CASE_TIMEOUT_MS);

it('the apply boot — and it differs from the plan\'s only by the read-only probe', async () => {
await run(MigrateApply, ['--json']);
await run(MigratePlan, ['--json']);
expect(boot).toHaveBeenCalledTimes(2);
const [applyOpts, planOpts] = [boot.mock.calls[0]![0], boot.mock.calls[1]![0]];
expect(applyOpts).toEqual(expect.objectContaining({ ...POSTURE, ...COMPOSITION }));
const { readOnlyProbe, ...planRest } = planOpts;
expect(readOnlyProbe).toBe(true);
expect(applyOpts).toEqual(planRest);
}, CASE_TIMEOUT_MS);
});

describe('apply names every entry the driver did not apply', () => {
const drop = entry('legacy', 'drop_column', 'destructive');
const tighten = entry('code', 'tighten_not_null', 'destructive');
const relax = entry('memo', 'relax_not_null', 'needs_confirm');

it('--allow-destructive, a destructive entry not applied: named, message partial, exit 1', async () => {
const { stack } = stackWith([drop, tighten, relax], [drop], 'skipped');
boot.mockResolvedValueOnce(stack);
const { payload, exitCode } = await run(MigrateApply, ['--allow-destructive', '--yes', '--json']);
expect(exitCode).toBe(1);
expect(payload).toMatchObject({ message: 'partial', schemaSync: 'skipped', database: 'libsql://tenant.example' });
expect(where(payload.applied)).toEqual(['legacy drop_column']);
expect(where(payload.notApplied)).toEqual(['code tighten_not_null', 'memo relax_not_null']);
}, CASE_TIMEOUT_MS);

it('the human face: each one by table, column and kind, and the run says PARTIAL; exit 1', async () => {
const { stack } = stackWith([drop, tighten, relax], [drop], 'skipped');
boot.mockResolvedValueOnce(stack);
const { lines, exitCode } = await run(MigrateApply, ['--allow-destructive', '--yes']);
expect(exitCode).toBe(1);
expect(lines).toContain(SKIPPED_SCHEMA_SYNC_NOTE);
expect(lines).toContain('t23848.code — tighten_not_null (destructive)');
expect(lines).toContain('t23848.memo — relax_not_null');
expect(lines).toMatch(/PARTIAL apply: 1 destructive change\(s\)/);
}, CASE_TIMEOUT_MS);

it('without --allow-destructive: the held-back destructive two are not "not applied"; the needs-confirm one is named; exit 0', async () => {
const { stack } = stackWith([drop, tighten, relax], [], 'skipped');
boot.mockResolvedValueOnce(stack);
const { payload, exitCode } = await run(MigrateApply, ['--yes', '--json']);
expect(exitCode).toBe(0);
expect(payload.message).toBeUndefined();
expect(where(payload.notApplied)).toEqual(['memo relax_not_null']);
expect(where(payload.skipped)).toEqual(['legacy drop_column', 'code tighten_not_null', 'memo relax_not_null']);
}, CASE_TIMEOUT_MS);

it('CONTROL: every entry applied: notApplied is empty, no message, exit 0, no schemaSync key on a deferring boot', async () => {
const { stack } = stackWith([drop, tighten, relax], [drop, tighten, relax], 'deferred');
boot.mockResolvedValueOnce(stack);
const { payload, exitCode } = await run(MigrateApply, ['--allow-destructive', '--yes', '--json']);
expect(exitCode).toBe(0);
expect(payload.message).toBeUndefined();
expect(payload).not.toHaveProperty('schemaSync');
expect(payload.notApplied).toEqual([]);
expect(where(payload.applied)).toEqual(['legacy drop_column', 'code tighten_not_null', 'memo relax_not_null']);
}, CASE_TIMEOUT_MS);
});

describe('a boot that skipped schema sync never reads as "in sync"', () => {
it('apply: in_sync_partial and schemaSync skipped; a deferring boot still says in_sync', async () => {
boot.mockResolvedValueOnce(stackWith([], [], 'skipped').stack);
const skipped = await run(MigrateApply, ['--json']);
expect(skipped.payload).toMatchObject({ message: 'in_sync_partial', schemaSync: 'skipped' });

boot.mockResolvedValueOnce(stackWith([], [], 'deferred').stack);
const deferred = await run(MigrateApply, ['--json']);
expect(deferred.payload.message).toBe('in_sync');
expect(deferred.payload).not.toHaveProperty('schemaSync');
}, CASE_TIMEOUT_MS);

it('plan: the JSON carries schemaSync skipped, and the human face says PARTIAL and why', async () => {
boot.mockResolvedValueOnce(stackWith([], [], 'skipped').stack);
const json = await run(MigratePlan, ['--json']);
expect(json.exitCode).toBe(0);
expect(json.payload).toMatchObject({ schemaSync: 'skipped', pending: [], total: 0 });

boot.mockResolvedValueOnce(stackWith([], [], 'skipped').stack);
const human = await run(MigratePlan, []);
expect(human.lines).toContain(SKIPPED_SCHEMA_SYNC_NOTE);
expect(human.lines).toMatch(/PARTIAL plan: it is not evidence that every declared table and column exists/);
expect(human.lines).not.toMatch(/in sync with metadata — nothing to migrate/);
}, CASE_TIMEOUT_MS);
});
Loading
Loading