Skip to content

Commit 6251f59

Browse files
feat(cli): os migrate plan lists the tenant columns still nullable, naming the voluntary ownership ceremony; the ceremony reports a NOT NULL shard truthfully (#25179)
Fixes #25134 Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves. Metadata-protocol: none Executes the maintainer's ruling 6328195425 (letter A, items 2 and 4) under the claim 6333411407. Dispatched by the epic seat (`epic:#15194`, `session_01Y5583bs2RDd53MH6kM1ca3`). Branch from `origin/main` `ab488d9b7d` (carries #25133's fix, #25155). The claim expected `Clause-②: no`. I re-declared it from the diff as `yes (widening)`: two machine-read outputs gain a field and an outcome value. Nothing is narrowed, and the level stays `patch` (`Metadata-protocol: none`). ## What changed **1. `os migrate plan`: one advisory section (ruling item 2).** New module `packages/cli/src/utils/nullable-tenant-columns.ts`, wired into `packages/cli/src/commands/migrate/plan.ts`. - **What it lists:** every tenant column the database still holds nullable, by table and column, with `os migrate organization-ownership` as the remedy. The section prints after the unmanaged-table section and before the drift verdict, so it shows on an "in sync" plan too. An 18.0.0-born store has no drift, so this matters. - **The population** is exactly the column the differ already exempts as the ceremony's (`ceremonyOwnedNullability`). It is read from the same source: each planned driver's tenant field per table (`tenantFieldByTable`), plus one catalog query per database for nullability. A rotation shard is read through its base. - An object with tenancy disabled has no tenant field, so `sys_sso_provider` is never listed. - A table no object declares (the retired `sys_notification_subscription`) is not listed either; the unmanaged-table section already reports it. - **Advisory only:** - It is not drift. It adds no entry to `changes`, nothing to `total`, and proposes no op. - It changes no exit code. - Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr: 'ADR-0131 D1', remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }`. - It is always present once a SQL driver is found, so `columns: []` (read, none) cannot be confused with not read. - `ceremonyOwnedNullability` is untouched (no driver file in the diff). **Residue: marked apart, the dispatch's open question.** I read 6323585273 §1 and marked residue, because that is both true and simple. Without the mark, a ceremonied store would be told to run the ceremony again for columns it never tightens. Each column carries `ceremony: 'tightens' | 'leaves'`, and a `leaves` column carries its reason. The reasons come from facts the plan already holds: - a rotation shard (`rotationBaseOf`): created before shards were created NOT NULL, and dropped when its period leaves the base's retention; - the driver's `driftApplyLimit('tighten_not_null')` (the Turso remote face); - the ceremony inventory: `notNullExempt`, or the `mirror-deletion` fate (`sys_email_template`). When no listed column is one the ceremony tightens, the footer says so and does not offer the ceremony. One case is deliberately not told apart: a table still holding a NULL row the ceremony cannot attribute. That would need a row count per table, and the ceremony's own plan already reports it. **2. The stale shard sentences (ruling item 4).** In `packages/cli/src/utils/organization-ownership-not-null.ts`, the docblock (`:27-31`) and the shard detail (`:46`) said each new shard is created nullable. That has not been true since 7b-0, and both are corrected. - A shard the driver created NOT NULL is now reported `already-not-null`: nothing to do, no note, counted in the step's new `alreadyNotNull` summary key. This comes from the new optional `NotNullTarget.isNotNull`, which `os migrate organization-ownership` wires to the same catalog read. - A shard created nullable before 7b-0 stays `unsupported`, and its note now says it leaves with the base's retention. **3. Docs.** - `content/docs/deployment/cli.mdx`: a new "Tenant columns still nullable (advisory)" subsection in the `os migrate plan` section, with the human output (as printed on the fixture) and the `--json` shape. - `content/docs/upgrading.mdx`: one paragraph in "Upgrading to 18.0". - Not touched: `content/docs/releases/**`, `docs/adr/**`, the shape generation, and any boot path. ## Measured on `fixture-18.0.0` (HEAD's `os migrate plan`, with the served environment's `OS_AUTH_SECRET`) - **Raw 18.0.0 store:** 65 tables carry a nullable `organization_id`. The advisory lists 63 of them. The 2 not listed are `sys_sso_provider` (opt-out) and `sys_notification_subscription` (retired, undeclared). - 61 are `tightens`, which matches the ceremony's measured `applied 61`. - 2 are `leaves`: `sys_activity__r20261020` (the pre-7b-0 shard) and `sys_email_template`. - **After the voluntary ceremony:** only that residue is listed, all `leaves`. - The ceremony's `not-null` step reports HEAD's own shard as `alreadyNotNull ≥ 1`, never `unsupported`. - **A fresh HEAD store:** `columns: []`. Its only nullable `organization_id` is `sys_sso_provider`. ## Pins | pin | where | |---|---| | the advisory on `fixture-18.0.0`: lists every nullable tenant column and only those; residue marked; no drift entry | `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`, the 18.0.0 leg | | after the ceremony: lists only the residue; the ceremony reports HEAD's shard `alreadyNotNull` | same leg | | a fresh store lists nothing | same leg; `utils/nullable-tenant-columns.test.ts`; `plan.nullable-tenant-columns.integration.test.ts` | | `--json` shape | `plan.nullable-tenant-columns.integration.test.ts`; `plan.apply-limit.test.ts` (key set) | | exit code unchanged (both faces) | `plan.nullable-tenant-columns.integration.test.ts` | | no drift op added (`changes`/`total` byte-equal to the fresh store's) | `plan.nullable-tenant-columns.integration.test.ts`; `utils/nullable-tenant-columns.test.ts` (the driver's own drift scan) | | the shard report | `utils/organization-ownership-not-null.test.ts` (real SQLite shard, both arms); `organization-ownership-apply.generation-1.test.ts` (step summary) | The harness helpers `runSchemaPlan` and `runOwnershipCeremony` gain an optional `env`. The new legs pass `SERVED_ENV` (an `OS_AUTH_SECRET`), as a production `os serve` of the showcase must have. The existing legs are unchanged. ## Proof (head `8ad60d9a`) - **Changed CLI files, locally:** 4 files / 30 tests pass. That includes the spawned `plan.nullable-tenant-columns.integration.test.ts`, which this diff adds to the integration tier. - **cli unit tier:** `vitest run --project unit` reported 321/322 files and 4754/4756 tests on the first run. The 2 red were `plan.apply-limit.test.ts` pinning the `--json` key set, which this PR deliberately widens. The lists were updated, and the file plus `plan.journal-mode-note.test.ts` re-ran at 19/19. The rest of the integration tier is declared to CI (`Test Core`). - **cli typecheck:** exit 0 (`tsc --noEmit` and `check:test-typecheck`). - **dogfood typecheck:** exit 0. The first run was aborted by the 4 GB heap; I re-ran it with `--max-old-space-size=6144`. - **eslint on the 10 changed `.ts` files:** exit 0. - **Upgrade harness, whole file** (`upgrade-18x-harness.dogfood.test.ts`, both the 18.0 and the 18.0.0 legs): 15/15. Lock VERDICT command-exit 0. - **Ablations.** Each mutation went through `scripts/ablation-replace.mjs`; the tool verified each landed on disk and was restored to blob == HEAD. - A1, the nullability filter removed: 5 red. - A2, residue marking removed: 3 red. - A3, the advisory counted into `total`: red at the `total` assertion. - A4, `process.exitCode = 1` when the section prints: red at the human exit assertion. - A5, the `--json` field dropped: red at the fresh-store field. - A6, the shard `isNotNull` check removed: red at the `already-not-null` pin. - My first attempts at A1, A4 and A5 were refused by the tool (anchor or replacement counts that could not move) and were re-run with moving anchors. The refused attempts measured nothing. - The CLI pins run from source, so no rebuild was needed. The harness pins were not ablated (they run `dist`). - **`node scripts/pm/dispatch-gates.mjs --commands`:** 100 families, each exit code captured before any pipe. `--ran` reconciliation: 100 accounted, 99 run, 1 NOT MEASURED. All 99 exit 0, after two re-runs: - `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone; it passed after a fetch of its pinned commit. - `check:skill-examples` first exited 3 because `client-react` was unbuilt; it passed after building it. - NOT MEASURED: `check:dual-build-cjs-loads` exits 3 without a whole-monorepo build. It is declared to CI. - After the last commit, I re-ran 10 families on head `8ad60d9a` (`check:type-check-coverage`, `check:type-check-debt`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:tier-file-adoption`, `check:durability-log-level`, both changeset gates, `check:nul-bytes`, `check:doc-authoring`). All exit 0. - **`check-governed-merges --branch origin/claude/issue-25134-migrate-plan-nullable-advisory`:** exit 0, NOT governed (0 of 15 paths), +926 / −33. - **R1:** - `pnpm dev:crm -- --fresh` reached ready, health 200. - A production `os serve` of `examples/app-crm` with `OS_AUTH_SECRET` on a scratch database reached ready, health 200. - `os migrate plan` on that fresh store: `nullableTenantColumns: { status: 'read', columns: [] }`, nothing printed, "in sync", exit 0. - **R4:** - A walled production `os serve` (`OS_TENANCY_POSTURE=group`, `OS_AUTH_MEMBERSHIP_POLICY=invite-only`, `OS_PLATFORM_OWNER_EMAIL`, a secret) of a scratch app declaring `@objectstack/organizations` reached ready with `Organizations` composed, health 200. - `os migrate plan` under the same environment: `columns: []`, exit 0. The only nullable `organization_id` is `sys_sso_provider`. ## Acceptance notes - **The advisory depends on the plan's own environment.** `os migrate plan` run without `OS_AUTH_SECRET` composes no auth family, as `os serve` does. The advisory then cannot see the auth family's tenant tables (`sys_member`, `sys_team`, `sys_user_permission_set`, …), and the unmanaged-table section lists them as declared by nothing. Measured on the fixture: 52 listed without a secret, 63 with one. This is the existing composition rule for the whole plan, not the advisory's. The docs already say to run a migration with the deployment's environment. - **`origin/main` moved 6 commits since the branch point** (`72a302a9`). None touches this diff's files or behaviour, and `merge-tree` is clean. Not merged, per the post-PR merge rule; CI tests the merge ref. - **Edits were first attempted on the session's primary checkout.** The worktree guard refused them, and all work was done in a linked worktree on the same branch. --- _Generated by [Claude Code](https://claude.ai/code/session_01RXxXFcq659RGJE6SsXAc3u)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6e8707 commit 6251f59

15 files changed

Lines changed: 926 additions & 33 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate plan` lists the tenant columns still nullable and names the voluntary organization-ownership ceremony; the ceremony reports a rotation shard already NOT NULL as such (#25134)
6+
7+
Clause-②: yes (widening) — `os migrate plan --json` gains the `nullableTenantColumns` field, and the ceremony's `not-null` step gains the `already-not-null` outcome and the `alreadyNotNull` summary count. No exit code, drift entry, op, refusal or accepted input moves.
8+
9+
Metadata-protocol: none
10+
11+
A database the published 18.0.0 created is certified at birth with its tenant columns (`organization_id`) nullable, and `os migrate organization-ownership`, which tightens them to `NOT NULL`, is voluntary on it. Nothing told the operator that.
12+
13+
- **`os migrate plan`: a new advisory section.** It lists every tenant column the database still holds nullable (table and column) and names `os migrate organization-ownership` as the remedy. Each column says whether the ceremony tightens it or leaves it, and why. The ceremony leaves a rotation shard created before shards were created `NOT NULL`, `sys_email_template` (its rows are mirrors the ceremony deletes, and the table retires), a table its inventory exempts, and every table on a database whose transport cannot rebuild a table (the Turso remote face). When the ceremony would tighten none of the listed columns, the section says so and does not offer it. A fresh install lists nothing. A column named `organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a tenant column and is not listed.
14+
- **Advisory only.** It is not drift: it proposes no change, adds nothing to `total` or `changes`, and the exit code does not change. Under `--json` it is the separate field `nullableTenantColumns`: `{ status: 'read', adr, remedy, columns: [{ table, column, database?, ceremony: 'tightens' | 'leaves', reason? }] }`, or `{ status: 'unreadable', adr, detail }` when the plan could not read it.
15+
- **The ceremony's `not-null` step.** A rotation shard that the driver created `NOT NULL` used to be reported as `unsupported`, with a note saying each new shard is created nullable. That has not been true since the driver began creating shards `NOT NULL`. Such a shard is now `already-not-null` (counted in `alreadyNotNull`) and gets no note. A shard rotated in before then stays `unsupported`, and its note says it is dropped when its period leaves the base table's retention.
16+
17+
**What to do.** Nothing is required: the server starts either way. To tighten the columns the plan lists, run `os migrate organization-ownership --out PLAN`, review it, then run `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,37 @@ without `NODE_ENV=development` describes a production `os serve`, which keeps th
986986
objects in the primary unless `OS_TELEMETRY_DB` names a file. A `plan` against a
987987
sibling that does not exist yet lists its tables to create and leaves no file behind.
988988
989+
#### Tenant columns still nullable (advisory)
990+
991+
`os migrate plan` lists every tenant column the database still holds nullable, in a
992+
section of its own, and names `os migrate organization-ownership` as the remedy. A
993+
database the published 18.0.0 created is certified at birth with its tenant columns
994+
nullable, and the [organization-ownership ceremony](#os-migrate-organization-ownership)
995+
that tightens them is voluntary on it: the server starts either way. A database the
996+
ceremony upgraded keeps only the few the ceremony leaves, and each of those is listed
997+
with the reason it stays nullable:
998+
999+
```text
1000+
ℹ 2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift, and nothing in this plan or in "os migrate apply" changes it.
1001+
• sys_activity__r20261020.organization_id — the ceremony leaves it nullable: a rotation shard of sys_activity, created before shards were created NOT NULL: the ceremony rebuilds no shard, and it is dropped when its period leaves sys_activity's retention
1002+
• sys_email_template.organization_id — the ceremony leaves it nullable: its rows are mirrors the ceremony deletes, and the table retires (ADR-0131 D10 fate 2, D13), so the ceremony does not tighten it
1003+
None of them is one the organization-ownership ceremony tightens; each line says why it stays nullable.
1004+
```
1005+
1006+
When the ceremony would tighten any listed column, the section's last line names its
1007+
two commands instead. On a Turso remote database every listed column is one the
1008+
ceremony leaves, because that transport cannot rebuild a table. A fresh install creates
1009+
every tenant column `NOT NULL` and the section prints nothing. A column named
1010+
`organization_id` on an object with tenancy disabled (`sys_sso_provider`) is not a
1011+
tenant column and is not listed.
1012+
1013+
The section is advisory only. It is not drift: it proposes no change, adds nothing to
1014+
the change count, and does not change the exit code. Under `--json` it is the separate
1015+
`nullableTenantColumns` field: `{ status: "read", adr, remedy, columns }`, where each
1016+
column is `{ table, column, database?, ceremony: "tightens" | "leaves", reason? }`. It is
1017+
`{ status: "unreadable", detail }` when the plan could not read it; `columns: []` means
1018+
it read and found none.
1019+
9891020
#### Nothing is applied before you confirm
9901021
9911022
Both commands boot your app to read its metadata. That boot writes no row and no

‎content/docs/upgrading.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ ceremony, under any tenancy posture. A v18 server serves a database it created
354354
itself or one the ceremony certified, and refuses any other at boot, naming the
355355
command.
356356

357+
**A database the published 18.0.0 created** carries a birth marker and is certified
358+
with its tenant columns (`organization_id`) still nullable. `os migrate plan` lists
359+
them, and the ceremony, which such a database does not require, tightens them to
360+
`NOT NULL` when you run it.
361+
357362
**The sequence:**
358363

359364
1. Before the first 18.0 boot, run `os migrate security-catalog-overlays`. It

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,14 @@ export default class OrganizationOwnershipCeremony extends Command {
865865
const { StorageNameMapping } = await import('@objectstack/spec/system');
866866
const { rotationBaseOf } = await import('../../utils/unmanaged-tables.js');
867867
const { applyNotNullThroughDrift } = await import('../../utils/organization-ownership-not-null.js');
868+
const { readColumnNullability } = await import('../../utils/nullable-tenant-columns.js');
869+
const { normalizeRows } = await import('@objectstack/metadata-protocol');
870+
const driverFor = (database: string | undefined): unknown =>
871+
database === undefined
872+
? liveStack.driver
873+
: database === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase
874+
? liveStack.drivers[1]
875+
: undefined;
868876
const declared = new Set(
869877
(liveStack.allObjects() as Array<{ name?: unknown } | null>)
870878
.map((o) => o?.name)
@@ -875,12 +883,13 @@ export default class OrganizationOwnershipCeremony extends Command {
875883
{
876884
isDeclared: (table) => declared.has(rotationBaseOf(table) ?? table),
877885
shardBaseOf: (table) => rotationBaseOf(table) ?? null,
878-
driverFor: (database) =>
879-
(database === undefined
880-
? liveStack.driver
881-
: database === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase
882-
? liveStack.drivers[1]
883-
: undefined) as never,
886+
// [ADR-0131 D1] A shard the driver created NOT NULL is reported
887+
// so, never as one the ceremony could not tighten.
888+
isNotNull: async (table, column, database) => {
889+
const read = await readColumnNullability(driverFor(database), [column], normalizeRows);
890+
return 'detail' in read ? null : read.tables.get(table)?.get(column) ?? null;
891+
},
892+
driverFor: (database) => driverFor(database) as never,
884893
},
885894
columns,
886895
);

‎packages/cli/src/commands/migrate/plan.apply-limit.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
* 2. The footer offers `--allow-destructive` only for a destructive entry the
2525
* database can apply, and names how many listed entries it cannot.
2626
* 3. CONTROL: a driver that declares nothing reads exactly as before.
27-
* 4. The `--json` document is the report, unchanged: the same keys, and each
28-
* entry's message as the differ wrote it.
27+
* 4. The `--json` document is the report, unchanged: the same keys (the
28+
* ADR-0131 D1 `nullableTenantColumns` advisory among them, which these
29+
* limits do not move), and each entry's message as the differ wrote it.
2930
*
3031
* `bootSchemaStack` is stubbed, so this runs without a database; the same
3132
* cases run against a real `sqld` in `apply.remote-face.integration.test.ts`.
@@ -245,7 +246,7 @@ describe('a driver that declares what its apply door cannot perform', () => {
245246
const { payload, exitCode } = await run(['--json']);
246247
expect(exitCode).toBe(0);
247248
expect(Object.keys(payload).sort()).toEqual(
248-
['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'schemaSync', 'total', 'unmanagedTables'],
249+
['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'nullableTenantColumns', 'pending', 'schemaSync', 'total', 'unmanagedTables'],
249250
);
250251
expect(payload.changes.map((c: Entry) => c.message)).toEqual([DROP.message, TIGHTEN.message, RELAX.message]);
251252
}, CASE_TIMEOUT_MS);
@@ -430,7 +431,7 @@ describe('#24925 — a database the organization-ownership ceremony has not veri
430431
const { payload, exitCode } = await run(['--json']);
431432
expect(exitCode).toBe(0);
432433
expect(Object.keys(payload).sort()).toEqual(
433-
['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'pending', 'total', 'unmanagedTables'],
434+
['changes', 'database', 'databaseSource', 'duration', 'managedTables', 'nullableTenantColumns', 'pending', 'total', 'unmanagedTables'],
434435
);
435436
expect(payload.changes.map((c: Entry) => c.message)).toEqual([INDEX.message, ORG_JOB.message, LEGACY.message]);
436437
expect(getService).not.toHaveBeenCalled();
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterAll } from 'vitest';
4+
import { spawnSync } from 'node:child_process';
5+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6+
import { tmpdir } from 'node:os';
7+
import { dirname, join, resolve } from 'node:path';
8+
import { fileURLToPath } from 'node:url';
9+
import Database from 'better-sqlite3';
10+
11+
/**
12+
* [ADR-0131 D1; ruling record 6328195425, letter A, item 2] `os migrate plan`
13+
* lists the tenant columns a database still holds nullable, in an advisory
14+
* section of its own that names `os migrate organization-ownership`.
15+
*
16+
* A database the published 18.0.0 created is certified with its tenant columns
17+
* nullable; this one is brought to that shape by rebuilding a table a fresh
18+
* boot created with its `organization_id` nullable — the 18.0.0 DDL — keeping
19+
* its rows and indexes.
20+
*
21+
* What is pinned, through the public door (`serve`'s provision-and-exit, then
22+
* the command, from the source entry):
23+
*
24+
* - a fresh store lists nothing (`columns: []`, read — not unreadable);
25+
* - the 18.0.0-shaped store lists the nullable tenant column as one the
26+
* ceremony tightens, and a rotation shard created nullable as one it leaves;
27+
* - ⭐ it is advisory: the plan's `changes` and `total` are the fresh store's,
28+
* byte for byte — no drift entry and no op is added — and the exit code is
29+
* 0 in both faces;
30+
* - the human face prints the section and the ceremony's two commands.
31+
*/
32+
33+
const HERE = dirname(fileURLToPath(import.meta.url));
34+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
35+
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
36+
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
37+
const CASE_TIMEOUT_MS = 300_000;
38+
39+
const APP_OBJECT = 'os25134_deal';
40+
const SHARD = `${APP_OBJECT}__r20261019`;
41+
42+
const CONFIG = [
43+
'export default {',
44+
" manifest: { id: 'com.example.os25134advisory', name: 'advisory', version: '0.0.0', type: 'app' },",
45+
" requires: ['auth'],",
46+
` objects: [{ name: '${APP_OBJECT}', fields: { title: { type: 'text' } } }],`,
47+
'};',
48+
'',
49+
].join('\n');
50+
51+
const dirs: string[] = [];
52+
afterAll(() => {
53+
for (const d of dirs) {
54+
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
55+
}
56+
});
57+
58+
interface Fixture {
59+
dir: string;
60+
db: string;
61+
}
62+
63+
function fixture(): Fixture {
64+
const dir = mkdtempSync(join(tmpdir(), 'os-25134-advisory-'));
65+
dirs.push(dir);
66+
mkdirSync(join(dir, 'dist'), { recursive: true });
67+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG);
68+
return { dir, db: join(dir, 'boot.db') };
69+
}
70+
71+
/** The child's environment: the fixture's database, one file, nothing inherited that retargets it. */
72+
function childEnv(f: Fixture, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
73+
const env: NodeJS.ProcessEnv = { ...process.env };
74+
for (const key of [
75+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
76+
'OS_TELEMETRY_DB', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'NO_COLOR',
77+
]) {
78+
delete env[key];
79+
}
80+
return {
81+
...env,
82+
OS_DATABASE_URL: `file:${f.db}`,
83+
OS_ARTIFACT_PATH: join(f.dir, 'dist', 'objectstack.json'),
84+
OS_CLOUD_URL: 'off',
85+
OS_TELEMETRY_DISABLED: '1',
86+
OS_TELEMETRY_DB: '0',
87+
OS_AUTH_SECRET: 'os25134-advisory-secret-at-least-32-characters',
88+
OS_LOG_LEVEL: 'warn',
89+
NO_COLOR: '1',
90+
...extra,
91+
};
92+
}
93+
94+
/** `os serve`'s own provision-and-exit: a fresh store, the full composition schema-synced into it. */
95+
function serveBoot(f: Fixture): void {
96+
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
97+
cwd: f.dir, env: childEnv(f, { OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000,
98+
});
99+
expect(boot.status, `the serving boot did not provision the database:\n${(boot.stderr ?? '').slice(-6000)}`).toBe(0);
100+
}
101+
102+
function plan(f: Fixture, json: boolean): { exit: number | null; payload: any; text: string } {
103+
const run = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', ...(json ? ['--json'] : [])], {
104+
cwd: f.dir, env: childEnv(f), encoding: 'utf8', timeout: 240_000,
105+
});
106+
let payload: any;
107+
if (json) {
108+
try { payload = JSON.parse(run.stdout); } catch { payload = { unparsed: `${run.stdout}\n${run.stderr}`.slice(-800) }; }
109+
}
110+
return { exit: run.status, payload, text: `${run.stdout}\n${run.stderr}` };
111+
}
112+
113+
/**
114+
* Rebuild `table` with its `organization_id` nullable — the shape the 18.0.0
115+
* driver created — keeping its rows and its indexes.
116+
*/
117+
function relaxTenantColumn(file: string, table: string): void {
118+
const db = new Database(file);
119+
try {
120+
const { sql } = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { sql: string };
121+
const relaxed = sql.replace(/([`"]organization_id[`"][^,]*?)\s+not null/i, '$1');
122+
if (relaxed === sql) throw new Error(`${table}.organization_id is not NOT NULL in ${sql}`);
123+
const indexes = (db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL").all(table) as Array<{ sql: string }>)
124+
.map((i) => i.sql);
125+
db.exec('PRAGMA foreign_keys = OFF');
126+
db.transaction(() => {
127+
db.exec(`ALTER TABLE "${table}" RENAME TO "${table}__pre"`);
128+
db.exec(relaxed);
129+
db.exec(`INSERT INTO "${table}" SELECT * FROM "${table}__pre"`);
130+
db.exec(`DROP TABLE "${table}__pre"`);
131+
for (const index of indexes) db.exec(index);
132+
})();
133+
} finally {
134+
db.close();
135+
}
136+
}
137+
138+
function notNull(file: string, table: string): boolean {
139+
const db = new Database(file, { readonly: true });
140+
try {
141+
const col = (db.prepare(`SELECT "notnull" AS nn FROM pragma_table_info(?) WHERE name = 'organization_id'`).get(table)) as { nn: number } | undefined;
142+
if (!col) throw new Error(`${table} carries no organization_id`);
143+
return col.nn === 1;
144+
} finally {
145+
db.close();
146+
}
147+
}
148+
149+
describe('os migrate plan lists the nullable tenant columns as an advisory (ADR-0131 D1)', () => {
150+
it('a fresh store lists nothing; an 18.0.0-shaped one lists them, and neither the drift nor the exit code moves', () => {
151+
const f = fixture();
152+
serveBoot(f);
153+
expect(notNull(f.db, APP_OBJECT), 'a table this release creates carries its tenant column NOT NULL').toBe(true);
154+
155+
const fresh = plan(f, true);
156+
expect(fresh.exit, JSON.stringify(fresh.payload).slice(0, 800)).toBe(0);
157+
expect(fresh.payload.nullableTenantColumns).toEqual({
158+
status: 'read', adr: 'ADR-0131 D1', remedy: 'os migrate organization-ownership', columns: [],
159+
});
160+
161+
// The 18.0.0 shape: the app's tenant column nullable, and a shard rotated in nullable.
162+
relaxTenantColumn(f.db, APP_OBJECT);
163+
expect(notNull(f.db, APP_OBJECT)).toBe(false);
164+
const db = new Database(f.db);
165+
try { db.exec(`CREATE TABLE "${SHARD}" ("id" varchar(255) PRIMARY KEY, "organization_id" varchar(255))`); } finally { db.close(); }
166+
167+
const shaped = plan(f, true);
168+
expect(shaped.exit, JSON.stringify(shaped.payload).slice(0, 800)).toBe(0);
169+
expect(shaped.payload.nullableTenantColumns).toEqual({
170+
status: 'read',
171+
adr: 'ADR-0131 D1',
172+
remedy: 'os migrate organization-ownership',
173+
columns: [
174+
{ table: APP_OBJECT, column: 'organization_id', ceremony: 'tightens' },
175+
{ table: SHARD, column: 'organization_id', ceremony: 'leaves', reason: expect.stringMatching(/^a rotation shard of os25134_deal/) },
176+
],
177+
});
178+
// ⭐ Advisory, not drift: the plan's changes are the fresh store's, and no
179+
// entry names a tenant column.
180+
expect(shaped.payload.changes).toEqual(fresh.payload.changes);
181+
expect(shaped.payload.total).toBe(fresh.payload.total);
182+
expect(shaped.payload.changes.filter((c: any) => c.column === 'organization_id')).toEqual([]);
183+
184+
const human = plan(f, false);
185+
expect(human.exit, human.text.slice(-2000)).toBe(0);
186+
expect(human.text).toContain('2 tenant column(s) are still nullable (ADR-0131 D1). This is advisory: it is not drift');
187+
expect(human.text).toContain(`• ${APP_OBJECT}.organization_id\n`);
188+
expect(human.text).toContain(`• ${SHARD}.organization_id — the ceremony leaves it nullable: a rotation shard of ${APP_OBJECT}`);
189+
expect(human.text).toContain('tightens 1 of them: run "os migrate organization-ownership --out PLAN"');
190+
}, CASE_TIMEOUT_MS);
191+
});

0 commit comments

Comments
 (0)