Skip to content

Commit 19484af

Browse files
fix(cli): os migrate apply on an empty database writes the organization-ownership birth marker, so the first os serve starts (#24875)
Fixes #24827 Clause-②: yes Basis for the Clause-② line (the claim's): `os serve` admits a database that `os migrate apply` created from empty, which it refuses today. That widens the boot guard's admission set by one writer. The guard's rule and its refusal are unchanged, and no published schema or export moves. The review of record judges that no database the guard should refuse can gain a marker. ## What changed PR #24681 states the contract: "Every CLI boot writes the birth half too. … Without it, an install whose first command is `os migrate apply` … would be refused by its first `os serve`." For `os migrate apply` it did not hold. Its boot defers schema DDL, so each driver stores and skips every object before counting it. The birth plugin in its boot saw nothing created and wrote nothing. The tables were created later, at the confirmed flush, and nothing recorded the birth after that. - **`packages/cli/src/utils/schema-migrate.ts`**: `SchemaStack.flushSchemaDdl` performs the deferred DDL, then runs the runtime's own birth half, `recordOrganizationOwnershipBirth` (already exported by `@objectstack/runtime`). The new `recordBirthOfFlushedStore` resolves the engine and runs it through the kernel logger, which `DeferSchemaDdlPlugin` now keeps from its `init()`. A failed write throws: the error names the row, the cause and the ceremony, so `os migrate apply` exits 1 and never reports success. It runs only when the boot armed a deferral, because only a deferred boot can have created the store at its flush. This is the one shared seam. `os migrate apply` is its only caller (see "Other deferred boots" below). - **The decision is not new.** `recordOrganizationOwnershipBirth` decides exactly as it does at boot. The ledger's store must read as created from empty on its own driver's counts (`existing` is 0 and `created` is above 0), with no marker and no table in the v17 shape. The flush re-enters the driver's `initObjects` with every deferred object. Its `hasTable` probe counts each declared table as created or found, so after the flush the counts describe this run's own observation of the database. - ⛔ **Not judged from the work the flush returns.** That list names only tables the flush created. A table already in sync produces no entry. So "every entry is `create_table`" also describes a populated database that merely gained a table, and that is the database that must never receive a marker (measured below). - **`packages/runtime/src/organization-ownership-boot-guard.ts`**: docblock only. The birth plugin's note now says that the one deferred boot that creates tables records the birth after its flush, on the same counts. - **`packages/cli/src/commands/migrate/apply.ts`**: one comment at the flush. - **Changeset**: `@objectstack/cli` minor (`Clause-②: yes`). The runtime change is a comment, so it has no changeset. ## Contract The boot guard's verdict on each database state, before (`main` at `41646a578`) and after (this head). "Apply" is `os migrate apply --yes`; "serve" is `os serve` over the same file. | Database before the apply | What the apply writes, `main` | `os serve`, `main` | What the apply writes, this PR | `os serve`, this PR | Evidence | |---|---|---|---|---|---| | **Fresh** (the file does not exist), then apply | the tables, no marker | **refused**: "This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) created it, …" | the tables, then **one verified birth row** (`details.birth.shapeGeneration` 1) | **starts** | repro steps 1-3, both trees; pin (a), red on `main` | | **Never touched** (no apply; the first command is `os serve`) | (nothing) | starts: its own boot writes the birth marker | (nothing) | starts (unchanged) | repro step 4, both trees | | **17.x shape** (no marker, a physical `organization_id` the object no longer declares) | no marker | refused, naming the ceremony and the table | **no marker**, though the flush creates a table in it | **refused**, naming the ceremony and the table (unchanged) | pin (b) 17.x, green on both trees | | **18.0 pre-release shape** (no marker, no retired column) | no marker | refused, naming the ceremony | **no marker**, though the flush creates a table in it and creates nothing else | **refused**, naming the ceremony (unchanged) | pin (b) 18.0, green on both trees, red under the ablation; e2e over a real unmarked database below | | **Already born** (a verified birth or ceremony marker) | the row unchanged | starts | the row unchanged (byte-equal) | starts (unchanged) | pin (c, d) | | **Apply run twice** on a fresh file | no marker after either run | refused | one row after the first run, unchanged by the second | starts | pin (c, d), red on `main`; the second apply finds the database in sync and returns before any flush | Paths through the same seam, by construction (not separately pinned): - Confirmation declined, or `--json` without `--yes`: the command returns before the flush, so nothing is created and nothing is written. The first `os serve` creates the store and writes the birth marker, as before. - A Turso remote (`libsql://`) primary: the apply skips schema sync there (`deferredDdlUnsupported: 'skip-schema-sync'`). The deferral exempts the primary and the flush creates nothing on it, so its counts show nothing created and no marker is written. The first serving boot creates the tables, and the remote face's own counts decide its birth. The rule PR #24681's review settled in its B1 finding (comment 6279650792) is kept as it is. - A telemetry sibling or a declared datasource: judged on the ledger store's driver alone, as at boot. - The birth write fails: `flushSchemaDdl` throws, naming the row, the cause and the ceremony's two steps, and the apply exits 1. Pinned by `schema-migrate.birth-after-flush.test.ts`. ## Zone 2 measurements (the PM's mechanism assumptions) 1. **The card's chain holds.** Repro on `main` `41646a578`, over a minimal project (one object, `dist/objectstack.json`; not `examples/app-showcase`): - Step 1: `os migrate apply --yes --database-url file:RUN/mig.db` exited 0 and printed `Created/extended 74 table(s)`. - Step 2: `sys_migration` had 0 rows. - Step 3: `os serve` exited 1 with the refusal quoted in the table. - Step 4 (control): `os serve` over a never-touched file listened, and its birth row was written. - The ledger store's counts on the deferred boot were `{"created":0,"existing":0}`. - On this head, the same four steps give: exit 0; 1 verified birth row; the apply log line `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, platform 18.0.0-next.5)`; `os serve` listening; the control unchanged. 2. **How "empty before" is known on a deferred boot.** The ledger store's driver counts, read after the flush: | file | counts before the flush | counts after the flush | the flush's returned work | birth rows | |---|---|---|---|---| | empty | `{created: 0, existing: 0}` | `{created: 36, existing: 0}` | 37 `create_table` | 1 | | populated, unmarked (the `main`-created file, one table dropped) | `{created: 0, existing: 0}` | `{created: 1, existing: 35}` | 1 `create_table` | 0 | The returned work reads the same in both rows ("only `create_table`"). The counts do not. So the reading is the counts, which name every table the flush found. - End to end over that populated, unmarked file: `os migrate apply` created `repro_widget` and wrote no marker. `os serve` then exited 1, refusing it as a database "17.x or an 18.0 pre-release" created. 3. **Other deferred boots.** `flushSchemaDdl` has one caller, `os migrate apply` (`git grep` over `packages/**` sources). The other `deferSchemaDdl` callers are the no-write modes of 18 commands (`readOnlyProbe`, which never create a table) and the ceremony's two boots (skip-schema-sync, never flushed). None of them creates a table on a deferred boot, so none has this defect. The write modes (`--apply`, `--run`, `--delete`) boot without a deferral, and their own birth plugin decides at boot. Measured once: `os migrate meta --stored --apply` on an empty file wrote the birth row itself, and `os serve` then started. The fix is in the shared seam (`flushSchemaDdl`), so a future caller of the flush gets the birth half too. 4. **Drivers.** The marker goes through the engine into the ledger the guard reads (`engine.insert('sys_migration', …)`, the guard's own writer). The Turso remote path is covered under Contract above. Not measured here against a live `libsql://` store. ## Tests Run on this branch, from the merged head (`0be22e895`, `main` `abd5acdaf` merged in), then again on the final head `51849a38a` where noted. Exit codes were read from each run's own verdict line, never through a pipe. - **New pins**, `apply.organization-ownership-birth.integration.test.ts`: the real command, a real SQLite file, and the guard's verdict from `assertOrganizationOwnershipMigrated` on a boot that creates nothing. 4 passed. - (a) An empty file gets one verified birth row at the current generation, and the guard admits it. - (b) An 18.0 pre-release-shaped database and a 17.x-shaped one: the flush creates a table in each and creates nothing else, neither gets a marker, and the guard still refuses each, naming the ceremony (and, for 17.x, the table). - (c, d) A second apply over the database it created, with a table to create: one row, unchanged. - (b)'s refusal is the control that the verdict is not inert. A boot without the served platform registers no `sys_migration`, the guard is inert there and would admit every file, so the verdict boot composes the platform as the apply does. - **Red on `main`**: the pre-fix `schema-migrate.ts` restored from `41646a578` (blob `f97922f7`), then the test file run. (a) and (c, d) went red: `expected [] to have a length of 1`. Both (b) cases stayed green. The file was restored to `HEAD` (blob `a951e927`), and `git diff HEAD` was empty. - **Ablation, emptiness check** (`scripts/ablation-replace.mjs --delete`): `if (!ledgerStoreCreatedFromEmpty(engine)) return null;` was deleted from `recordOrganizationOwnershipBirth`, and the runtime was rebuilt. - `ablation-dist-preflight --absent` passed. - (b) 18.0 pre-release went red (`expected [ { …(14) } ] to deeply equal []`: a false birth row). The other 3 stayed green. The 17.x case held because the retired-column check also stops a birth there. - Restored to `HEAD` (blob `cf2e9113`, `git diff HEAD` empty), the runtime rebuilt, and the preflight found the anchor back in `dist`. - **Unit pin**, `schema-migrate.birth-after-flush.test.ts`: the failed write fails the flush, naming the row, the cause and the ceremony's two steps; with no engine, nothing is asked. 3 passed. - Ablation: the rethrow was replaced with `return`, and 1 went red. Restored to the `HEAD` blob `89c61790`, `git diff HEAD` empty. - **Fixture touched**: `apply-organization-ownership-gate.integration.test.ts` builds its v17 stand-in with `os migrate apply` on an empty file. That apply now writes the birth marker, which reads as verified to the drop gate. The stand-in deletes the marker, as `organization-ownership-platform-global-column-drop.integration.test.ts` already does for its served boot. - `@objectstack/cli` unit project, in full: 313 files, 4,635 passed. - `@objectstack/cli` integration project, filtered to `migrate` and `organization-ownership`: 61 files (60 passed, 1 skipped), 579 passed, 9 skipped. - After the merge: the new pins, the drop-gate suite, `sqlite-wasm.integration`, `schema-migrate.deferred-ddl.integration` and the platform-global column-drop suite: 5 files, 24 passed. - Runtime guard: `organization-ownership-boot-guard.test.ts` and `.kernel.test.ts`, 2 files, 32 passed (before and after the merge). - On `51849a38a`: the unit pin and `test/vitest-tiers-partition.test.ts`, 2 files, 25 passed. - `typecheck`: `@objectstack/cli` (with `check:test-typecheck`) and `@objectstack/runtime`, both exit 0. `@objectstack/cli` was re-run on `51849a38a`, exit 0. The new test files are in the `tsconfig.json` program (`--listFilesOnly`: 2 of 2). ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` on `51849a38a` derived 68 commands, and all 68 exited 0. `--ran` reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. - On `0be22e895`, `check:dual-build-cjs-loads` and `check:i18n-coverage` had exited 3 (PREREQUISITE NOT MET: no `dist` for packages outside the CLI closure). Both exited 0 on the final head. - `pnpm lint` (`eslint . --no-inline-config`): exit 0 at `51849a38a`. - Not measured locally, and left to CI on this PR: the four type-check lanes; `Test Core` / `Build Core`; the full `@objectstack/runtime` suite (its change is a docblock); the CLI integration tier outside the `migrate` / `organization-ownership` filter. ## Acceptance notes - **Files outside the claimed surface**: - `apply-organization-ownership-gate.integration.test.ts` (its fixture, above). - The two new test files. - **Not touched** (the hold-offs): the guard's refusal rule and its text; the ceremony; `os dev`'s recover branch and its "v17 shape" text (#24833's, which can now follow this card); the `[schema-drift]` lines; `packages/drivers/**`; `packages/spec/**`; `content/docs/releases/`. - **`content/docs/deployment/cli.mdx` is not edited.** It says "the first boot that creates a datastore from empty writes a birth marker", which is now true of `os migrate apply` too. A line naming the apply would be a docs nicety, not a correction. - **The repro** used a one-object project, not `examples/app-showcase`. Same mechanism and the same refusal text; 74 tables against the card's 71. - **Interrupted runs.** If a confirmed apply fails partway through its flush, the next apply finds tables and writes no marker. The serving boot already behaves this way after a crash mid-sync. It fails closed (the database is refused and the ceremony certifies it), never open. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81e6edd commit 19484af

7 files changed

Lines changed: 412 additions & 5 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
fix(cli): `os migrate apply` on an empty database writes the organization-ownership birth marker, so the fresh install's first `os serve` starts
6+
7+
Clause-②: yes
8+
9+
`os serve` (and so `os dev` and `os start`) serves a database this release created itself, recognised by the birth marker in `sys_migration` (row `adr-0131-organization-ownership`), or one the ownership ceremony certified. `os migrate apply` holds its schema DDL back until the operator confirms, so its boot saw no table created and wrote no marker, and the tables it then created carried none. A deploy script that ran `os migrate apply` before the first `os serve` was refused as a database "17.x or an 18.0 pre-release created", and sent through the upgrade ceremony.
10+
11+
- **Now.** When `os migrate apply` creates the database from empty, it writes the birth marker right after it creates the tables, and the first `os serve` starts. The log line is the one a serving boot prints: `[organization-ownership] a datastore created from empty: birth marker written (shape generation 1, …)`.
12+
- **Never on a database that was not empty.** The decision is the serving boot's own: the database counts as created from empty only when none of the tables this release declares existed before the apply. A database an earlier release created gains no marker from `os migrate apply`, even when the apply creates a table in it, and is still refused until the ceremony runs: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`.
13+
- **A failed write fails the command.** If the tables are created and the marker cannot be written, `os migrate apply` exits `1` and names the ceremony, which certifies such a database.
14+
- **Unchanged.** A database that already carries a marker keeps it as it is. `os migrate plan` writes nothing. On a Turso remote (`libsql://`) primary, `os migrate apply` creates no tables (its schema sync is skipped there), so the first serving boot creates them and writes the marker, as before.

‎packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ beforeAll(async () => {
125125
} finally {
126126
process.chdir(savedCwd);
127127
}
128+
// This apply of THIS tree created the store from empty, so it wrote this
129+
// release's birth marker; the v17 database each case stands in for carries
130+
// none, and the drop gate below reads that absence.
131+
withDb(v18Db, (db) => db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID));
128132
expect(columnsOf(v18Db, 'sys_platform_setting'), 'fixture: v18 declares no organization column here').not.toContain('organization_id');
129133
}, CASE_TIMEOUT_MS);
130134

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D10; ruling record 6269955919, ① A] `os migrate apply` is the
5+
* boot that creates a fresh install's tables when an operator's deploy script
6+
* runs it before the first `os serve`, so it writes the organization-ownership
7+
* BIRTH MARKER — and only on a database it created from empty.
8+
*
9+
* Its boot defers schema DDL: each object is stored and skipped before the
10+
* driver counts it, so the birth plugin its boot composes sees nothing created
11+
* and writes nothing. The tables are created at the confirmed flush, and the
12+
* birth half runs there (`SchemaStack.flushSchemaDdl`). The served boot's guard
13+
* then judges the file as `os serve` does — read here through
14+
* `assertOrganizationOwnershipMigrated` on a boot that creates nothing, so the
15+
* verdict is the marker's alone:
16+
*
17+
* (a) an empty file: the apply writes one verified birth row, and the guard
18+
* admits the database;
19+
* (b) a database the apply did not create — no marker, the shape an 18.0
20+
* pre-release leaves, and a 17.x one with a retired organization column —
21+
* gets NO birth marker, though the flush creates a table in it and creates
22+
* nothing else, and the guard still refuses it, naming the ceremony;
23+
* (c, d) the apply run again over the database it created and marked, with a
24+
* table to create: one row, unchanged.
25+
*
26+
* Case (b) is the rule that must not break: a false birth would let a database
27+
* an earlier release created skip the ownership ceremony.
28+
*/
29+
30+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
31+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
32+
import { tmpdir } from 'node:os';
33+
import { dirname, join, resolve } from 'node:path';
34+
import { fileURLToPath } from 'node:url';
35+
import Database from 'better-sqlite3';
36+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system';
37+
import { assertOrganizationOwnershipMigrated, isOrganizationOwnershipBootRefusal } from '@objectstack/runtime';
38+
import { isExitSignal } from '../../utils/format.js';
39+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
40+
import MigrateApply from './apply.js';
41+
42+
// Pay the first transform of the dist-resolved workspace deps the command
43+
// reaches through dynamic `import()`s at MODULE LOAD, not inside a case.
44+
import '@objectstack/objectql';
45+
46+
const HERE = dirname(fileURLToPath(import.meta.url));
47+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
48+
const CASE_TIMEOUT_MS = 240_000;
49+
50+
/** The ceremony's first step, as the boot refusal prescribes it. */
51+
const CEREMONY_PLAN = 'os migrate organization-ownership --out PLAN';
52+
53+
/** A table the composition declares, dropped from a copy so the apply's flush has one to create. */
54+
const DROPPED_TABLE = 'sys_platform_setting_audit';
55+
56+
const OVERRIDING_ENV = [
57+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
58+
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH',
59+
] as const;
60+
61+
async function runJson(argv: string[]): Promise<{ payload: any; exitCode: number }> {
62+
const savedExit = process.exitCode;
63+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
64+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
65+
if (cb) cb();
66+
return true;
67+
}) as typeof process.stdout.write;
68+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
69+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
70+
vi.spyOn(console, 'log').mockImplementation(() => {});
71+
vi.spyOn(console, 'warn').mockImplementation(() => {});
72+
vi.spyOn(console, 'error').mockImplementation(() => {});
73+
let processExit: number | undefined;
74+
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
75+
processExit = code ?? 0;
76+
throw new Error(`__PROCESS_EXIT__:${processExit}`);
77+
}) as never);
78+
let thrownExit: number | undefined;
79+
try {
80+
try {
81+
await MigrateApply.run(argv, { root: CLI_ROOT });
82+
} catch (error) {
83+
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
84+
if (!trapped && !isExitSignal(error)) throw error;
85+
if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
86+
}
87+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
88+
let payload: any;
89+
try { payload = JSON.parse(out); } catch { payload = { unparsed: out.slice(-400) }; }
90+
return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
91+
} finally {
92+
process.exitCode = savedExit;
93+
vi.restoreAllMocks();
94+
}
95+
}
96+
97+
const applyArgv = (file: string) => ['--database-url', `file:${file}`, '--yes', '--json'];
98+
99+
function withDb<T>(file: string, fn: (db: Database.Database) => T): T {
100+
const db = new Database(file);
101+
try {
102+
return fn(db);
103+
} finally {
104+
db.close();
105+
}
106+
}
107+
108+
/** Every `sys_migration` row naming the ceremony: the marker, read straight from the file. */
109+
const markerRows = (file: string): Array<Record<string, unknown>> =>
110+
withDb(file, (db) => db.prepare('SELECT * FROM sys_migration WHERE id = ?').all(ORGANIZATION_OWNERSHIP_MIGRATION_ID) as Array<Record<string, unknown>>);
111+
112+
/**
113+
* The served boot's verdict on `file`, as `os serve`'s guard reaches it, from a
114+
* boot that holds its DDL back: it creates nothing, so the guard cannot read the
115+
* store as created from empty and decides on the marker alone. Resolves when the
116+
* database would be served; rejects with the refusal when it would not.
117+
*
118+
* Composed as the apply composes, so the ledger object is registered: a boot of
119+
* the data stack alone registers no `sys_migration`, and the guard is inert
120+
* there — it would admit every file. Case (b)'s refusal is the control.
121+
*/
122+
async function guardVerdict(file: string): Promise<void> {
123+
const stack = await bootSchemaStack({
124+
jsonOutput: false,
125+
databaseUrl: `file:${file}`,
126+
deferSchemaDdl: true,
127+
readOnlyProbe: true,
128+
composeHostStack: true,
129+
composeServedPlatform: true,
130+
projectRoot: dir,
131+
});
132+
try {
133+
const engine = (stack.kernel as { getService: (name: string) => unknown }).getService('objectql');
134+
await assertOrganizationOwnershipMigrated(engine as never);
135+
} finally {
136+
await stack.shutdown();
137+
}
138+
}
139+
140+
let dir: string;
141+
let freshDb: string;
142+
let freshApply: { payload: any; exitCode: number };
143+
const savedEnv: Record<string, string | undefined> = {};
144+
const savedCwd = process.cwd();
145+
146+
/** A copy of the database the first apply created, edited raw by `edit`. */
147+
let seq = 0;
148+
function copyOfFresh(edit: (db: Database.Database) => void): string {
149+
seq += 1;
150+
const file = join(dir, `case-${seq}.db`);
151+
copyFileSync(freshDb, file);
152+
withDb(file, edit);
153+
return file;
154+
}
155+
156+
beforeAll(async () => {
157+
for (const key of [...OVERRIDING_ENV, 'NODE_ENV'] as const) savedEnv[key] = process.env[key];
158+
for (const key of OVERRIDING_ENV) delete process.env[key];
159+
dir = mkdtempSync(join(tmpdir(), 'os-apply-birth-'));
160+
mkdirSync(join(dir, 'dist'), { recursive: true });
161+
writeFileSync(
162+
join(dir, 'objectstack.config.ts'),
163+
"export default { manifest: { id: 'com.example.applybirth', name: 'birth', version: '0.0.0', type: 'app' } };\n",
164+
);
165+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
166+
process.env.NODE_ENV = 'production';
167+
process.env.OS_TELEMETRY_DB = '0';
168+
// (a)'s act: `os migrate apply` on a file that does not exist yet.
169+
freshDb = join(dir, 'fresh.db');
170+
process.chdir(dir);
171+
try {
172+
freshApply = await runJson(applyArgv(freshDb));
173+
} finally {
174+
process.chdir(savedCwd);
175+
}
176+
}, CASE_TIMEOUT_MS);
177+
178+
afterAll(() => {
179+
process.chdir(savedCwd);
180+
for (const [key, value] of Object.entries(savedEnv)) {
181+
if (value === undefined) delete process.env[key];
182+
else process.env[key] = value;
183+
}
184+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
185+
});
186+
187+
beforeEach(() => { process.chdir(dir); });
188+
afterEach(() => { process.chdir(savedCwd); });
189+
190+
describe('os migrate apply writes the organization-ownership birth marker on a database it created from empty', () => {
191+
it('(a) an empty file: one verified birth row at the current shape generation, and the guard admits it', async () => {
192+
expect(freshApply.exitCode, JSON.stringify(freshApply.payload).slice(0, 400)).toBe(0);
193+
expect(freshApply.payload.created.length, 'the apply created the tables').toBeGreaterThan(0);
194+
195+
const rows = markerRows(freshDb);
196+
expect(rows).toHaveLength(1);
197+
expect(rows[0].verified_at).not.toBeNull();
198+
expect(Number(rows[0].blocking)).toBe(0);
199+
expect(JSON.parse(String(rows[0].details)).birth).toMatchObject({
200+
shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION,
201+
});
202+
203+
await expect(guardVerdict(freshDb)).resolves.toBeUndefined();
204+
}, CASE_TIMEOUT_MS);
205+
206+
it.each([
207+
['an 18.0 pre-release shape (no marker, no retired column)', [] as string[]],
208+
['a 17.x shape (no marker, a retired organization column)', ['ALTER TABLE sys_platform_setting ADD COLUMN organization_id TEXT']],
209+
])('(b) a database the apply did not create, %s: no birth marker, and the guard still refuses it', async (_shape, extra) => {
210+
const file = copyOfFresh((db) => {
211+
db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID);
212+
db.exec(`DROP TABLE ${DROPPED_TABLE}`);
213+
for (const sql of extra) db.exec(sql);
214+
});
215+
216+
const { payload, exitCode } = await runJson(applyArgv(file));
217+
expect(exitCode, JSON.stringify(payload).slice(0, 400)).toBe(0);
218+
// The trap the birth half must not fall into: the flush's own work names
219+
// only tables it CREATED — a populated database that merely gains one reads
220+
// exactly like an empty one there.
221+
expect(payload.created).toEqual([expect.objectContaining({ table: DROPPED_TABLE, kind: 'create_table' })]);
222+
223+
expect(markerRows(file)).toEqual([]);
224+
const refusal = await guardVerdict(file).then(() => null, (error: unknown) => error);
225+
expect(isOrganizationOwnershipBootRefusal(refusal), String(refusal)).toBe(true);
226+
expect((refusal as Error).message).toContain(CEREMONY_PLAN);
227+
expect((refusal as { tables: string[] }).tables).toEqual(extra.length > 0 ? ['sys_platform_setting'] : []);
228+
}, CASE_TIMEOUT_MS);
229+
230+
it('(c, d) the apply run again over the database it created and marked, with a table to create: one row, unchanged', async () => {
231+
const file = copyOfFresh((db) => db.exec(`DROP TABLE ${DROPPED_TABLE}`));
232+
const before = markerRows(file);
233+
expect(before).toHaveLength(1);
234+
235+
const { payload, exitCode } = await runJson(applyArgv(file));
236+
expect(exitCode, JSON.stringify(payload).slice(0, 400)).toBe(0);
237+
expect(payload.created).toEqual([expect.objectContaining({ table: DROPPED_TABLE, kind: 'create_table' })]);
238+
239+
expect(markerRows(file)).toEqual(before);
240+
}, CASE_TIMEOUT_MS);
241+
});

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,10 @@ export default class MigrateApply extends Command {
629629
// reconciled. Drift was detected against the pre-flush database, and a
630630
// just-created table matches metadata by construction, so the two sets
631631
// never overlap.
632+
// [ADR-0131 D10] The flush also writes the organization-ownership birth
633+
// marker when it created the database from empty — the half this
634+
// deferred boot's birth plugin could not run — so the first `os serve`
635+
// admits it. A database that was not empty gets none.
632636
const created = await stack.flushSchemaDdl();
633637
const { applied, skipped } = await stack.applyMigrationEntries(drift, { allowDestructive });
634638

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D10] The birth half `SchemaStack.flushSchemaDdl` runs once a
5+
* deferred boot's DDL is performed (`recordBirthOfFlushedStore`), at the seam
6+
* the real-database pins cannot reach: a birth marker that cannot be written.
7+
*
8+
* The tables exist and the marker does not, so the first `os serve` would
9+
* refuse the database as one an earlier release created. The flush must hand
10+
* that failure to its caller — `os migrate apply` then exits 1 — naming what
11+
* was not written and the ceremony that certifies such a database, never
12+
* report success. The real driver, the real guard and the decision itself
13+
* are `apply.organization-ownership-birth.integration.test.ts`'s.
14+
*/
15+
16+
import { describe, expect, it, vi } from 'vitest';
17+
import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system';
18+
import { recordBirthOfFlushedStore } from './schema-migrate.js';
19+
20+
/** The ceremony's two steps, as the boot refusal prescribes them. */
21+
const CEREMONY_STEPS = [
22+
'os migrate organization-ownership --out PLAN',
23+
'os migrate organization-ownership --apply --plan-file PLAN --backup-taken',
24+
];
25+
26+
const engine = { getDriverForObject: () => ({}) };
27+
const kernelWith = (service: unknown) => ({ getService: (name: string) => (name === 'objectql' ? service : undefined) });
28+
29+
describe('recordBirthOfFlushedStore — the birth half after a deferred flush', () => {
30+
it('hands the runtime its engine and the logger, and resolves when the birth half does', async () => {
31+
const logger = { info: vi.fn() };
32+
const recordBirth = vi.fn(async () => null);
33+
await expect(recordBirthOfFlushedStore(kernelWith(engine), recordBirth, logger)).resolves.toBeUndefined();
34+
expect(recordBirth).toHaveBeenCalledWith(engine, logger);
35+
});
36+
37+
it('a birth marker that cannot be written fails the flush, naming the row, the cause and the ceremony', async () => {
38+
const cause = new Error('SQLITE_READONLY: attempt to write a readonly database');
39+
const recordBirth = vi.fn(async () => { throw cause; });
40+
const failure = await recordBirthOfFlushedStore(kernelWith(engine), recordBirth, undefined).then(
41+
() => null,
42+
(error: unknown) => error as Error,
43+
);
44+
expect(failure).toBeInstanceOf(Error);
45+
expect(failure!.cause).toBe(cause);
46+
expect(failure!.message).toContain(ORGANIZATION_OWNERSHIP_MIGRATION_ID);
47+
expect(failure!.message).toContain(cause.message);
48+
for (const step of CEREMONY_STEPS) expect(failure!.message).toContain(step);
49+
});
50+
51+
it('a kernel with no engine has no ledger to write: nothing is asked', async () => {
52+
const recordBirth = vi.fn(async () => null);
53+
await expect(recordBirthOfFlushedStore(kernelWith(undefined), recordBirth, undefined)).resolves.toBeUndefined();
54+
await expect(
55+
recordBirthOfFlushedStore({ getService: () => { throw new Error('not registered'); } }, recordBirth, undefined),
56+
).resolves.toBeUndefined();
57+
expect(recordBirth).not.toHaveBeenCalled();
58+
});
59+
});

0 commit comments

Comments
 (0)