Skip to content

Commit ae4dfaf

Browse files
fix(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database (#23843)
Fixes #23816 Clause-②: yes (the remote face's refused drift and migration doors become accepted ones; triage: not the protocol-first class unless a spec key moves) ## Summary The v18 organization-ownership ceremony (`os migrate organization-ownership`, the plan and `--apply --backup-taken`) now completes on a Turso database reached over the REMOTE transport, which is the transport every hosted tenant uses. It was measured end to end on a real `sqld` (libsql-server 0.24.32), reached as `libsql://127.0.0.1:PORT?tls=0`. Two things stopped it, and both are in this repository: 1. **Deferred DDL.** Both ceremony boots armed `deferSchemaDdl` unconditionally, and the remote face refuses that. `bootSchemaStack` gains `deferredDdlUnsupported: 'skip-schema-sync'`. When the PRIMARY datasource resolves to the Turso remote transport (the boot asks `TursoDriver.detectMode` about the URL it resolved), the boot passes `skipSchemaSync` to the standalone stack instead of arming the deferral on that driver. - No DDL is attempted, so the plan stays read-only. - Every object reaches the driver through the DDL-free `registerObjectMetadata`; `measureComposedCoverage` binds a composed host's objects the same way on such a boot. - Every OTHER datasource the boot connects is still armed. - Only the ceremony opts in. Every other `os migrate` command still refuses on the remote face: their dry runs list held-back schema work, which a boot that holds nothing back cannot list. 2. **Drift detection and migration entries.** These are the driver-turso remote face changes. - `detectManagedDrift()` answers. The verdict is the shared differ's. Its physical-schema reads go to the remote catalog: `pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe. - The declarations it diffs against are recorded by every remote schema door and by `registerObjectMetadata`. - `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column`. Each entry runs as ONE libSQL `write` batch: - a `drop_column` drops the indexes keyed on its column in the same transaction; - a re-create that fails leaves the old index in place; - an entry is reported `applied` only after a read-back. - Every other column entry needs the SQLite table rebuild, which this transport does not run, so it is `skipped` and logged. - An applied index entry (`create_index`, `recreate_index`, `replace_unique_index`) is read back by its live unique flag and key-column order, not by its name alone. - The remote catalog is read in batched round trips per call: one `sqlite_master` read, then `pragma_table_info` / `pragma_index_list` in one read batch, then `pragma_index_info` for the indexes whose DDL does not parse. Batches are chunked at 200 statements, so it takes 3 round trips up to 100 tables and one more per further 100. - The remote face declares `driftReportCaveat`: its drift report cannot be trusted for a boot verdict yet, because the remote sync creates no declared `NOT NULL` (#23847) and `os migrate apply` refuses there (#23848). The local and embedded-replica faces declare `null`. There is one more change. The remote driver's `config` now carries its `url`, so the CLI labels a remote database `libsql://host:port` (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database. While every remote database was labelled `better-sqlite3`, that refusal could not tell two tenants apart. The remote face does NOT implement deferred DDL. A remote preview would also have to preview the remote canonical-temporal and codec-residue backfills, which have no read-only probe today. The embedded-replica route was not taken (the card measured it as a dead end). ## Evidence — real `sqld`, `libsql://127.0.0.1:PORT?tls=0` **The v17 fixture**, built once, snapshotted, and copied fresh for each run: - a served `os start` boot against the server: the additive sync on the remote face, 70 objects; - then, through a SECOND libSQL connection: `organization_id` plus its tenant index on `sys_job` and on `sys_metadata_activation`, and two `sys_job` rows. **Positive control, unchanged `main` (`1ecb08a1`).** The plan exits 1: ``` {"error":"boot_failed","reason":"boot-failed","detail":"Deferred schema DDL is not supported by the Turso REMOTE transport (this datasource's transport mode is `remote`), so a command that promises a dry run or a confirmation before any schema change cannot keep that promise against it. ..."} ``` **With the fix:** first measured at `39b9abdd`, then re-run at `cc73b809` after the contract-review round. `cc73b809` is code-identical to the head `26b6acef`. Both runs read the same: | step | reading | |:--|:--| | `os migrate organization-ownership --json` | exit 0 | | the plan is read-only | logical dump (every catalog row and every table's rows, hashed) `c0790dff…` before = `c0790dff…` after, 341 objects / 12 rows | | the plan's `database` | `libsql://127.0.0.1:38187` (it was `better-sqlite3` before the label change) | | `--apply --plan-file … --backup-taken --json` | exit 0 · `complete: true` · `blocking: 0` · `advisory: 0`; every step `done`; `column-drops` `{"dropped": 2, "due": 2}` | | second connection: the columns | `sys_job.organization_id`, `sys_metadata_activation.organization_id`: absent | | second connection: their indexes | `idx_sys_job_organization_id`, `idx_sys_metadata_activation_organization_id`: absent | | second connection: the marker | `sys_migration` row `adr-0131-organization-ownership`: `applied_at` and `verified_at` set | | second connection: the rows | `sys_job`: 2 rows, kept | An earlier run over a fixture that also put the column on `sys_audit_log` ended with `blocking 1`. The cause is not the transport: the composition booted from an empty project declares no object for `sys_audit_log`, so the step reports `not-declared` there. The same happens on `file:`. The fixture was corrected to tables that composition declares. **Ablation.** Both runs used `scripts/ablation-replace.mjs` (WRAP mode); each mutation was proven to land on disk, restored to `HEAD`, and the tree checked clean. 1. **The driver arm removed** (`if (!this.isRemote || this.isRemote) return super.detectManagedDrift(objects);`). driver-turso was rebuilt, and `ablation-dist-preflight` found the marker in `dist/index.js` and `dist/index.mjs`. - The two remote driver suites: 18 of 18 red. - The sqld e2e: red, `{"error":"apply_failed","detail":"Unable to acquire a connection"}`. - Restore leg: rebuild, then `--absent` (marker gone from all 6 built files, tree clean); 19/19 and 1/1 green. 2. **The ceremony's posture removed** from the plan boot. The sqld e2e is red at the plan with the positive-control refusal (`boot_failed`, `Deferred schema DDL is not supported by the Turso REMOTE transport …`). Restored blob = `HEAD`. The contract-review round added three ablations, made the same way, each restored to `HEAD` with `git diff HEAD` empty: 3. **The apply boot's `deferredDdlUnsupported: 'skip-schema-sync'` removed** from `organization-ownership.ts`: `organization-ownership.remote-posture.test.ts` red, 1 failed (the apply-boot case). 4. **The exemption line in `DeferSchemaDdlPlugin.arm` removed:** `schema-migrate.remote-posture.test.ts` red, 2 failed. The no-exemption control stayed green. 5. **The gate's caveat branch disabled** in `artifact-boot-migration.ts`: `artifact-boot-migration.test.ts` red, 1 failed (the caveat case). The null-caveat control stayed green. ## Tests (head `26b6acef`) - `@objectstack/driver-turso`: typecheck exit 0; `vitest run` 90 files, 2415 passed / 33 skipped. - `turso-remote-drift-detection.test.ts` also carries: - the embedded-replica control: a replica-mode `TursoDriver` over a drifted file reports the destructive `drop_column`, for the no-argument and the explicit-objects call. This is the same-layer replacement for the replica case of the deleted `turso-remote-drift-detection-refusal.test.ts`; - the `driftReportCaveat` case: a sentence naming `NOT NULL` on the remote face, `null` on local and replica; - the round-trip case: 3 client calls for 1 table and for 5 tables. - `turso-remote-drift-detection.test.ts` (was `turso-remote-drift-detection-refusal.test.ts`): the remote face over a real `@libsql/client` `file:` client, asserted EQUAL to the local Knex face over the same file. It covers: - a clean database synced by each remote door; - four drifted shapes; - the `registerObjectMetadata` registry; - an absent table. - `turso-remote-migration-entries.test.ts`: each outcome is read back through a second connection. It covers: - `drop_column` with its indexes, and the rolled-back refusal; - `drop_index`, `create_index` and `recreate_index`, with the D4 re-probe over duplicates; - the skipped rebuild-only entry; - convergence; - an overlay-index rebuild through the two members; - the config label. - `turso-remote-inherited-members.test.ts`: `applyMigrationEntries` is no longer in the refused set. - `@objectstack/cli`: typecheck (and the test-layer typecheck) exit 0; `--project unit` 299 files, 4453 passed. A targeted `--project integration` run (20 files: every `organization-ownership*` and `schema-migrate*` integration suite plus `unmanaged-tables`, the sqld e2e included with `OS_TEST_SQLD_BIN` set) had 182 passed, 0 skipped. It ran locally because this diff touches the boot path. - `organization-ownership.remote-posture.test.ts` (new, unit tier, runs in CI) pins two things: both ceremony boots pass `deferredDdlUnsupported: 'skip-schema-sync'`, and the apply reads its ledger from the catalog when the boot skipped schema sync. - `schema-migrate.remote-posture.test.ts` (new, unit tier, runs in CI): - `transportRefusesDeferredDdl` over 8 resolved databases; - the primary exemption, which leaves only the engine's default driver un-armed, at init and on a later `registerDriver`; - the no-exemption and other-refusing-driver controls. - `artifact-boot-migration.test.ts`: the caveat case (warn with the reason; no detect, no apply; the boot proceeds) and the null-caveat control (the same report refuses the boot). - `organization-ownership.remote-face.integration.test.ts` (new) runs the real command, plan then apply, against a `sqld` it starts itself. It is skipped unless `OS_TEST_SQLD_BIN` names a sqld binary: it ran green here, and it reads `1 skipped` without the variable. - `schema-migration-plugins.test.ts` gains a case for the DDL-free binding. - `@objectstack/runtime`: typecheck exit 0; `vitest run` 402 files, 6787 passed / 19 skipped. - Gates: `node scripts/pm/dispatch-gates.mjs --ran` reports 70 derived families, 69 run with exit 0, and 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET, because it needs a whole-repo `pnpm build` and only the closure was built here; CI runs it. Two families first read exit 3 for environment reasons and were re-run alone with exit 0: - `check-plugin-teardown-shape --self-test`: the shallow clone lacked its pinned fixture commit; - `check:i18n-coverage`: contention. - eslint over the changed `.ts` files: exit 0. ## Acceptance notes - **File surface.** The claim named `packages/cli/src/utils/organization-ownership-*.ts`. The boot posture lives one file over: `packages/cli/src/utils/schema-migrate.ts` (`bootSchemaStack`, the option and the primary exemption) and `packages/cli/src/utils/schema-migration-plugins.ts` (`measureComposedCoverage`'s DDL-free binding), with that file's test. - **The `OS_ARTIFACT_URL` boot migration gate is unchanged on a Turso remote primary.** It still answers "cannot judge": it warns and boots, with no `detectManagedDrift` call, no apply and no refusal. It decides that from the driver's declared `driftReportCaveat`, not from the transport-mode string. The contract review measured why it must not judge yet: the remote sync creates no declared `NOT NULL`, so every `storage.notNull` field would read as destructive drift, and `os migrate apply` refuses on that transport. The gate judges remote databases again only after the follow-ups #23847 and #23848, which this PR does not close. The local, embedded-replica and other SQL drivers are unchanged. - Cloud's overlay-index step (`rebuildTenantOverlayIndex`: `detectManagedDrift([subject])`, apply the one `recreate_index`, read again) is the shape the new overlay-index test drives on the remote face. It has not been run against cloud's code. - The sqld e2e does not run in CI, because no `sqld` is provisioned there. One way to provision it is a devDependency on the npm package that ships the binary (`@sqld/linux-x64`), which is a dependency decision this PR does not make. --- _Generated by [Claude Code](https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0af559e commit ae4dfaf

17 files changed

Lines changed: 2063 additions & 332 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database
8+
9+
Clause-②: yes (widening)
10+
11+
`os migrate organization-ownership`, the plan and `--apply --backup-taken`, now completes against a Turso database reached over the remote transport (`libsql://…`), the transport hosted environments use. Before, the plan stopped at its boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, and the apply's column drops had no remote path either.
12+
13+
- **`@objectstack/driver-turso`**, remote face:
14+
- `detectManagedDrift()` answers instead of refusing with `NOT_IMPLEMENTED` / 501. The verdict is the shared differ's, the one the local face runs; the physical schema it diffs is read from the remote catalog (`pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe). The declarations it diffs against are recorded by every remote schema door and by the DDL-free `registerObjectMetadata`, so a boot that skipped schema sync is covered too.
15+
- `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column` instead of refusing. Each entry runs as one libSQL `write` batch, so it lands whole or not at all: a `drop_column` drops the column's indexes with it in the same transaction, and a re-created index that fails to build leaves the old one in place. An entry is reported `applied` only once a read-back shows its target state. Every other column entry (nullability, width, a dropped DEFAULT) needs the SQLite table rebuild, which this transport does not run, so it is reported `skipped` and logged, as the in-place dialects report an op they cannot perform.
16+
- The remote face declares `driftReportCaveat`: a sentence saying its drift report is for reading only and naming the reason. The OS_ARTIFACT_URL boot gate reads it (below). The local and embedded-replica faces declare `null`.
17+
- The remote driver's `config` now carries its `url`, so the CLI labels a remote database by its URL (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database; that refusal can now tell two remote databases apart.
18+
- **`@objectstack/cli`**: the ceremony's plan and apply boots skip schema sync, instead of arming deferred DDL, when the primary datasource is a Turso remote one. No DDL is attempted, so the plan stays read-only, and every other datasource the boot connects is still deferred. The environment's own boot has already run the additive sync the ceremony reads. Every other `os migrate` command keeps refusing on that transport: their dry runs list held-back schema work, which a boot that holds nothing back cannot list.
19+
- **`@objectstack/runtime`**: `createStandaloneStack` takes `skipSchemaSync`, passed to `ObjectQLPlugin` as the option `OS_SKIP_SCHEMA_SYNC=1` names. Unset, the plugin reads the variable as before.
20+
21+
On a Turso remote primary, the `OS_ARTIFACT_URL` boot migration gate on `os serve` keeps its earlier answer: cannot judge, warn and boot. It applies no drift and refuses no boot. Only its warning's wording changes, from `Could not check the physical schema` to `Did not check the physical schema against the artifact: …`, followed by the driver's reason. The gate now decides this from a capability the driver declares (`driftReportCaveat`, a string on the remote face and `null` on the local and embedded-replica faces), not from a refusal it catches. Two follow-ups, filed separately and not closed by this change, have to land before that gate may act on a remote report:
22+
- #23847: the remote transport's schema sync honours `storage.notNull` on create and add column, so the report stops naming a tightening its own sync produced;
23+
- #23848: `os migrate apply` works on a Turso remote database, so the destructive drift the gate would refuse on has a remedy there.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23816 — the v18 organization-ownership ceremony, plan and apply, completes
5+
* on a Turso REMOTE database: a real `sqld` reached as
6+
* `libsql://127.0.0.1:<port>?tls=0`, the transport every hosted tenant uses.
7+
*
8+
* ## What used to happen
9+
*
10+
* Both boots armed deferred schema DDL unconditionally, and the remote face
11+
* refuses that (`Deferred schema DDL is not supported by the Turso REMOTE
12+
* transport`), so the plan exited 1 before reading a table; the column drops
13+
* then went through `detectManagedDrift` + `applyMigrationEntries`, which the
14+
* remote face refused too. Every hosted environment holding 17.x data stopped
15+
* at its first v18 request.
16+
*
17+
* ## The case
18+
*
19+
* The 17.x database is built the way an environment gets one: an ordinary,
20+
* syncing boot of the composition the ceremony boots, against the server (the
21+
* additive sync on the remote face), then the 17.x leftovers a second libSQL
22+
* connection adds — an `organization_id` column and the tenant index keyed on
23+
* it, on `column-drop` tables, and rows. Then the real command, plan then
24+
* `--apply --backup-taken`, and every claim is read back through a SECOND
25+
* connection to the server's own database:
26+
*
27+
* - the plan exits 0 and leaves the database byte-identical (a logical dump of
28+
* every catalog row and every table's rows, before and after), and names the
29+
* remote database, not the placeholder Knex client;
30+
* - the apply completes with `blocking 0`;
31+
* - each column and the index keyed on it are gone from the server, the rows
32+
* are not, and the `adr-0131-organization-ownership` marker is verified.
33+
*
34+
* ## How to run it
35+
*
36+
* It needs a `sqld` binary (libsql-server), so it is skipped unless
37+
* `OS_TEST_SQLD_BIN` names one; the case starts its own server on a free port
38+
* over a temporary directory and stops it afterwards. One way to get the binary
39+
* is the npm package that ships it (`npm pack @sqld/linux-x64` or
40+
* `@sqld/darwin-arm64`, then `tar -xzf` and point the variable at
41+
* `package/sqld`); the `ghcr.io/tursodatabase/libsql-server` image and the
42+
* Turso CLI's `turso dev` carry the same server.
43+
*
44+
* OS_TEST_SQLD_BIN=/path/to/sqld pnpm --filter @objectstack/cli exec \
45+
* vitest run --project integration src/commands/migrate/organization-ownership.remote-face.integration.test.ts
46+
*/
47+
48+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
49+
import { spawn, type ChildProcess } from 'node:child_process';
50+
import { createHash } from 'node:crypto';
51+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
52+
import { createServer } from 'node:net';
53+
import { tmpdir } from 'node:os';
54+
import { dirname, join, resolve } from 'node:path';
55+
import { fileURLToPath } from 'node:url';
56+
import { TursoDriver } from '@objectstack/driver-turso';
57+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
58+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
59+
import { isExitSignal } from '../../utils/format.js';
60+
import MigrateOrganizationOwnership from './organization-ownership.js';
61+
62+
// Paid at module load, not inside a clocked case (`check:test-source-alias`).
63+
import '@objectstack/runtime';
64+
import '@objectstack/objectql';
65+
66+
const SQLD = process.env.OS_TEST_SQLD_BIN;
67+
const HERE = dirname(fileURLToPath(import.meta.url));
68+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
69+
const CASE_TIMEOUT_MS = 240_000;
70+
71+
/** Env that would point a boot somewhere other than the fixture, or change what it composes. */
72+
const OVERRIDING_ENV = [
73+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'TURSO_AUTH_TOKEN', 'OS_DATABASE_AUTH_TOKEN',
74+
'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_ARTIFACT_PATH', 'OS_TELEMETRY_DB', 'OS_TENANCY_POSTURE',
75+
'OS_MULTI_ORG_ENABLED', 'OS_SKIP_SCHEMA_SYNC',
76+
] as const;
77+
78+
/** `column-drop` tables the 17.x leftovers are put on, when the composition has them. */
79+
const LEFTOVER_CANDIDATES = ['sys_job', 'sys_metadata_activation', 'sys_presence'] as const;
80+
81+
let dir: string;
82+
let projectDir: string;
83+
let server: ChildProcess | undefined;
84+
let url: string;
85+
let leftovers: string[] = [];
86+
const savedEnv: Record<string, string | undefined> = {};
87+
const savedCwd = process.cwd();
88+
89+
const freePort = (): Promise<number> =>
90+
new Promise((done, fail) => {
91+
const probe = createServer();
92+
probe.once('error', fail);
93+
probe.listen(0, '127.0.0.1', () => {
94+
const { port } = probe.address() as { port: number };
95+
probe.close(() => done(port));
96+
});
97+
});
98+
99+
type Rows = Array<Record<string, unknown>>;
100+
type Exec = (sql: string, args?: unknown[]) => Promise<Rows>;
101+
102+
/**
103+
* A second connection to the server, separate from every driver the command
104+
* boots: a remote-mode driver of its own, so its own libSQL client.
105+
*/
106+
async function secondConnection<T>(read: (exec: Exec) => Promise<T>): Promise<T> {
107+
const driver = new TursoDriver({ url });
108+
await driver.connect();
109+
try {
110+
return await read(async (sql, args = []) => (await driver.execute(sql, args)) as Rows);
111+
} finally {
112+
await driver.disconnect();
113+
}
114+
}
115+
116+
/** Every catalog row and every table's rows, hashed: equal means nothing moved. */
117+
const logicalDump = () =>
118+
secondConnection(async (exec) => {
119+
const hash = createHash('sha256');
120+
const master = await exec('SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name');
121+
hash.update(JSON.stringify(master));
122+
for (const t of master.filter((r) => r.type === 'table' && !String(r.name).startsWith('sqlite_'))) {
123+
hash.update(String(t.name));
124+
hash.update(JSON.stringify(await exec(`SELECT * FROM "${String(t.name)}" ORDER BY rowid`)));
125+
}
126+
return hash.digest('hex');
127+
});
128+
129+
const hasOrganizationColumn = (table: string) =>
130+
secondConnection(async (exec) =>
131+
(await exec('SELECT name FROM pragma_table_info(?)', [table])).some((r) => r.name === 'organization_id'));
132+
133+
/** The real command, its stdout parsed, and the exit it asked for. */
134+
async function run(args: string[]): Promise<{ payload: any; exitCode: number }> {
135+
const savedExit = process.exitCode;
136+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
137+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
138+
if (cb) cb();
139+
return true;
140+
}) as typeof process.stdout.write;
141+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
142+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
143+
vi.spyOn(console, 'log').mockImplementation(() => {});
144+
vi.spyOn(console, 'warn').mockImplementation(() => {});
145+
vi.spyOn(console, 'error').mockImplementation(() => {});
146+
process.chdir(projectDir);
147+
let exitCode: number | undefined;
148+
try {
149+
try {
150+
await MigrateOrganizationOwnership.run(args, { root: CLI_ROOT });
151+
} catch (error) {
152+
if (!isExitSignal(error)) throw error;
153+
exitCode = (error as { oclif?: { exit?: number } }).oclif?.exit;
154+
}
155+
const payload = JSON.parse(stdout.mock.calls.map((c) => String(c[0])).join('').trim());
156+
return { payload, exitCode: exitCode ?? (process.exitCode as number | undefined) ?? 0 };
157+
} finally {
158+
process.chdir(savedCwd);
159+
process.exitCode = savedExit;
160+
vi.restoreAllMocks();
161+
}
162+
}
163+
164+
describe.skipIf(!SQLD)('os migrate organization-ownership on a Turso REMOTE database (sqld)', () => {
165+
beforeAll(async () => {
166+
for (const key of OVERRIDING_ENV) {
167+
savedEnv[key] = process.env[key];
168+
delete process.env[key];
169+
}
170+
dir = mkdtempSync(join(tmpdir(), 'os-ceremony-remote-'));
171+
projectDir = join(dir, 'project');
172+
mkdirSync(projectDir, { recursive: true });
173+
const port = await freePort();
174+
url = `libsql://127.0.0.1:${port}?tls=0`;
175+
server = spawn(SQLD!, ['--http-listen-addr', `127.0.0.1:${port}`, '--db-path', join(dir, 'sqld')], { stdio: 'ignore' });
176+
for (let i = 0; i < 100; i++) {
177+
try {
178+
await secondConnection((exec) => exec('SELECT 1'));
179+
break;
180+
} catch {
181+
await new Promise((r) => setTimeout(r, 100));
182+
}
183+
}
184+
185+
// The environment's own boot: the composition the ceremony boots, syncing.
186+
const stack = await bootSchemaStack({
187+
jsonOutput: false,
188+
databaseUrl: url,
189+
projectRoot: projectDir,
190+
composeHostStack: true,
191+
composeServedPlatform: true,
192+
composeAuthGatedSecurity: true,
193+
});
194+
await stack.shutdown();
195+
196+
// The 17.x leftovers, through a connection of their own.
197+
await secondConnection(async (exec) => {
198+
const tables = new Set((await exec("SELECT name FROM sqlite_master WHERE type = 'table'")).map((r) => String(r.name)));
199+
leftovers = LEFTOVER_CANDIDATES.filter((t) => tables.has(t));
200+
for (const t of leftovers) {
201+
await exec(`ALTER TABLE "${t}" ADD COLUMN "organization_id" TEXT`);
202+
await exec(`CREATE INDEX "idx_${t}_organization_id" ON "${t}" ("organization_id")`);
203+
}
204+
if (leftovers.includes('sys_job')) {
205+
await exec(`INSERT INTO "sys_job" ("id", "organization_id") VALUES ('job_v17_a', NULL), ('job_v17_b', NULL)`);
206+
}
207+
});
208+
expect(leftovers.length).toBeGreaterThan(0);
209+
}, CASE_TIMEOUT_MS);
210+
211+
afterAll(() => {
212+
server?.kill();
213+
for (const key of OVERRIDING_ENV) {
214+
if (savedEnv[key] === undefined) delete process.env[key];
215+
else process.env[key] = savedEnv[key];
216+
}
217+
if (dir) rmSync(dir, { recursive: true, force: true });
218+
});
219+
220+
it('plans read-only, applies with blocking 0, and the drops land on the server', async () => {
221+
for (const t of leftovers) expect(await hasOrganizationColumn(t)).toBe(true);
222+
const planFile = join(dir, 'plan.json');
223+
224+
const before = await logicalDump();
225+
const plan = await run(['--database-url', url, '--json', '--out', planFile]);
226+
expect(plan.payload.error, JSON.stringify(plan.payload).slice(0, 800)).toBeUndefined();
227+
expect(plan.exitCode).toBe(0);
228+
expect(await logicalDump()).toBe(before);
229+
expect(plan.payload.plan.database).toBe(`libsql://127.0.0.1:${new URL(url).port}`);
230+
231+
const apply = await run([
232+
'--apply', '--backup-taken', '--plan-file', planFile, '--database-url', url, '--json', '--out', join(dir, 'apply.json'),
233+
]);
234+
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 800)).toBeUndefined();
235+
expect([apply.exitCode, apply.payload.report.complete, apply.payload.report.blocking]).toEqual([0, true, 0]);
236+
237+
for (const t of leftovers) expect(await hasOrganizationColumn(t)).toBe(false);
238+
const after = await secondConnection(async (exec) => ({
239+
indexes: (await exec("SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%_organization_id'"))
240+
.map((r) => String(r.name))
241+
.filter((n) => leftovers.some((t) => n === `idx_${t}_organization_id`)),
242+
marker: (await exec(
243+
'SELECT applied_at IS NOT NULL AS applied, verified_at IS NOT NULL AS verified FROM sys_migration WHERE id = ?',
244+
[ORGANIZATION_OWNERSHIP_MIGRATION_ID],
245+
)).map((r) => ({ applied: Number(r.applied), verified: Number(r.verified) })),
246+
jobs: leftovers.includes('sys_job') ? Number((await exec('SELECT count(*) AS n FROM "sys_job"'))[0]!.n) : 2,
247+
}));
248+
expect(after).toEqual({ indexes: [], marker: [{ applied: 1, verified: 1 }], jobs: 2 });
249+
}, CASE_TIMEOUT_MS);
250+
});

0 commit comments

Comments
 (0)