Skip to content

Commit 3ff7833

Browse files
committed
fix(driver-sql): the boot drift line for an organization_id drop names the ceremony on an uncertified database
On a database the driver's reader (isTenantKeyCertified) answers not certified, the boot's [schema-drift] line for an orphaned organization_id column no longer prescribes 'os migrate apply --allow-destructive', which the drop gate refuses there; it names 'os migrate organization-ownership'. A certified database, every other column and every other drift kind print the differ's message unchanged. Claude-Session: https://claude.ai/code/session_01NQBsTsRUMvji8sjVyHJkvY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82cfaf7 commit 3ff7833

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

‎packages/drivers/driver-sql/src/schema-drift.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,32 @@ export function driftFindingWithoutRemedy(entry: Pick<ManagedDriftEntry, 'messag
441441
: entry.message;
442442
}
443443

444+
/**
445+
* [ADR-0131 D10 item 5] The column whose drop the organization-ownership
446+
* ceremony owns: on a database it has not certified, the ceremony drops it
447+
* itself and `os migrate apply --allow-destructive` refuses the run.
448+
*/
449+
const CEREMONY_OWNED_DROP_COLUMN = 'organization_id';
450+
451+
/** Is `entry` the drop of a column the organization-ownership ceremony owns? */
452+
export function isCeremonyOwnedColumnDrop(entry: Pick<ManagedDriftEntry, 'op'>): boolean {
453+
return entry.op.type === 'drop_column' && entry.op.column === CEREMONY_OWNED_DROP_COLUMN;
454+
}
455+
456+
/**
457+
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for a ceremony-owned
458+
* column drop ({@link isCeremonyOwnedColumnDrop}) on a database the driver
459+
* reads as not certified: the finding without its `--allow-destructive`
460+
* sentence, which that database refuses, and the ceremony in its place, in
461+
* the words `os migrate plan` shows under the same entry.
462+
*/
463+
export function ceremonyOwnedDropFinding(entry: Pick<ManagedDriftEntry, 'message' | 'op'>): string {
464+
return (
465+
`${driftFindingWithoutRemedy(entry)} — a drop the organization-ownership ceremony owns, and it has not ` +
466+
`verified on this database: run "os migrate organization-ownership", which drops it itself.`
467+
);
468+
}
469+
444470
/** Columns the driver creates unconditionally — never metadata fields. */
445471
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
446472

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import { SqlDriver } from './sql-driver.js';
5+
6+
/**
7+
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for an orphaned
8+
* `organization_id` column.
9+
*
10+
* On a database the organization-ownership ceremony has not certified, that
11+
* drop is the ceremony's: `os migrate apply --allow-destructive` refuses the
12+
* run, so a line prescribing it sends the operator to a refused command. The
13+
* boot asks the driver's own reader ({@link SqlDriver.isTenantKeyCertified},
14+
* the engine-handed resolver) and, on "not certified", names the ceremony in
15+
* the place of that sentence. A certified database, every other column and
16+
* every other drift kind print exactly the message the differ wrote.
17+
*/
18+
19+
const TABLE = 'biz_unit';
20+
const ORPHAN_ORG = `${TABLE}.organization_id: column exists in the database but not in metadata (orphaned)`;
21+
const ALLOW_DESTRUCTIVE_DROP = ' — "os migrate apply --allow-destructive" to drop it.';
22+
23+
let driver: SqlDriver | undefined;
24+
25+
afterEach(async () => {
26+
await driver?.disconnect();
27+
driver = undefined;
28+
});
29+
30+
/** A 17.x-shaped table: `organization_id` and `legacy_code` declared by nothing, `name` nullable under a declared NOT NULL. */
31+
async function bootOn(resolver?: () => boolean): Promise<{ lines: string[]; asked: () => number }> {
32+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
33+
const warn = vi.fn();
34+
(driver as any).logger = { warn, info: vi.fn() };
35+
let asked = 0;
36+
if (resolver) {
37+
driver.setTenantKeyCertifiedResolver(() => {
38+
asked++;
39+
return resolver();
40+
});
41+
}
42+
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
43+
t.string('id').primary();
44+
t.string('name');
45+
t.string('organization_id');
46+
t.string('legacy_code');
47+
});
48+
await driver.initObjects([
49+
{ name: TABLE, fields: { name: { type: 'string', required: true, storage: { notNull: true } } } },
50+
]);
51+
const lines = warn.mock.calls.map((c: any[]) => String(c[0])).filter((l) => l.startsWith('[schema-drift] '));
52+
return { lines, asked: () => asked };
53+
}
54+
55+
const lineFor = (lines: string[], prefix: string): string => {
56+
const found = lines.filter((l) => l.startsWith(`[schema-drift] ${prefix}`));
57+
expect(found, lines.join('\n')).toHaveLength(1);
58+
return found[0]!;
59+
};
60+
61+
describe('the boot drift line for an orphaned organization_id column', () => {
62+
it('not certified: names the ceremony, never --allow-destructive', async () => {
63+
const { lines, asked } = await bootOn(() => false);
64+
const line = lineFor(lines, ORPHAN_ORG);
65+
expect(line.startsWith(`[schema-drift] ${ORPHAN_ORG} — `)).toBe(true);
66+
expect(line).toContain('"os migrate organization-ownership"');
67+
expect(line).not.toContain('--allow-destructive');
68+
expect(asked()).toBe(1);
69+
});
70+
71+
it('no resolver handed over reads as not certified: the same line', async () => {
72+
const { lines } = await bootOn();
73+
const line = lineFor(lines, ORPHAN_ORG);
74+
expect(line).toContain('"os migrate organization-ownership"');
75+
expect(line).not.toContain('--allow-destructive');
76+
});
77+
78+
it('certified: the line the differ wrote, unchanged', async () => {
79+
const { lines } = await bootOn(() => true);
80+
expect(lineFor(lines, ORPHAN_ORG)).toBe(`[schema-drift] ${ORPHAN_ORG}${ALLOW_DESTRUCTIVE_DROP}`);
81+
});
82+
83+
it('control, not certified: another orphaned column keeps its --allow-destructive remedy', async () => {
84+
const { lines } = await bootOn(() => false);
85+
expect(lineFor(lines, `${TABLE}.legacy_code:`)).toBe(
86+
`[schema-drift] ${TABLE}.legacy_code: column exists in the database but not in metadata (orphaned)${ALLOW_DESTRUCTIVE_DROP}`,
87+
);
88+
});
89+
90+
it('control, not certified: a tighten_not_null keeps its --allow-destructive remedy', async () => {
91+
const { lines } = await bootOn(() => false);
92+
expect(lineFor(lines, `${TABLE}.name:`)).toBe(
93+
`[schema-drift] ${TABLE}.name: metadata declares \`storage.notNull\` but the column is nullable — existing nulls ` +
94+
'must be backfilled. Run "os migrate apply --allow-destructive".',
95+
);
96+
});
97+
98+
it('a table with no organization_id drop never asks the question', async () => {
99+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
100+
(driver as any).logger = { warn: vi.fn(), info: vi.fn() };
101+
let asked = 0;
102+
driver.setTenantKeyCertifiedResolver(() => {
103+
asked++;
104+
return false;
105+
});
106+
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
107+
t.string('id').primary();
108+
t.string('name');
109+
t.string('legacy_code');
110+
});
111+
await driver.initObjects([{ name: TABLE, fields: { name: { type: 'string' } } }]);
112+
expect(asked).toBe(0);
113+
});
114+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ import {
121121
GLOBAL_TENANT,
122122
canonicalIndexKey,
123123
carriesNullSafeTenantKeyPart,
124+
ceremonyOwnedDropFinding,
125+
isCeremonyOwnedColumnDrop,
124126
hashShadowColumnFor,
125127
isHashShadowCarrier,
126128
parseHashShadowKeyParts,
@@ -14446,10 +14448,19 @@ export class SqlDriver implements IDataDriver {
1444614448
}
1444714449
}
1444814450

14451+
// [ADR-0131 D10 item 5] An `organization_id` drop is the ownership
14452+
// ceremony's until the database is certified: `--allow-destructive` is
14453+
// refused there, so the line names the ceremony instead. Asked once per
14454+
// table, and only when such a drop is about to be warned.
14455+
let certified: boolean | undefined;
1444914456
for (const d of drift) {
1445014457
const k = driftKey(d);
1445114458
if (this.driftWarned.has(k)) continue;
1445214459
this.driftWarned.add(k);
14460+
if (isCeremonyOwnedColumnDrop(d) && !(certified ??= await this.isTenantKeyCertified())) {
14461+
this.logger.warn(`[schema-drift] ${ceremonyOwnedDropFinding(d)}`);
14462+
continue;
14463+
}
1445314464
this.logger.warn(`[schema-drift] ${d.message}`);
1445414465
}
1445514466
}

0 commit comments

Comments
 (0)