Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/24956-boot-drift-line-names-ceremony.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/driver-sql': patch
---

fix(driver-sql): on a database the organization-ownership ceremony has not certified, the boot's drift line for an orphaned `organization_id` column names the ceremony instead of `os migrate apply --allow-destructive`

Clause-②: no (operator log line text only)
Metadata-protocol: none

A 17.x database booted on 18.x printed one `[schema-drift]` line per `organization_id` column ending `— "os migrate apply --allow-destructive" to drop it.`, during `os serve` and `os migrate apply --yes`. That command is refused on such a database: the drop is the ceremony's (ADR-0131 D10 item 5). The boot now asks the driver's own reader, `isTenantKeyCertified()`, once per table that has such a drop. When the answer is "not certified", the line keeps the finding and replaces that sentence with `— a drop the organization-ownership ceremony owns, and it has not verified on this database: run "os migrate organization-ownership", which drops it itself.` These are the words `os migrate plan` shows under the same entry.

A certified database prints the same line as before. So does every other column and every other drift kind. Nothing the boot applies or skips changes.
26 changes: 26 additions & 0 deletions packages/drivers/driver-sql/src/schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,32 @@ export function driftFindingWithoutRemedy(entry: Pick<ManagedDriftEntry, 'messag
: entry.message;
}

/**
* [ADR-0131 D10 item 5] The column whose drop the organization-ownership
* ceremony owns: on a database it has not certified, the ceremony drops it
* itself and `os migrate apply --allow-destructive` refuses the run.
*/
const CEREMONY_OWNED_DROP_COLUMN = 'organization_id';

/** Is `entry` the drop of a column the organization-ownership ceremony owns? */
export function isCeremonyOwnedColumnDrop(entry: Pick<ManagedDriftEntry, 'op'>): boolean {
return entry.op.type === 'drop_column' && entry.op.column === CEREMONY_OWNED_DROP_COLUMN;
}

/**
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for a ceremony-owned
* column drop ({@link isCeremonyOwnedColumnDrop}) on a database the driver
* reads as not certified: the finding without its `--allow-destructive`
* sentence, which that database refuses, and the ceremony in its place, in
* the words `os migrate plan` shows under the same entry.
*/
export function ceremonyOwnedDropFinding(entry: Pick<ManagedDriftEntry, 'message' | 'op'>): string {
return (
`${driftFindingWithoutRemedy(entry)} — a drop the organization-ownership ceremony owns, and it has not ` +
`verified on this database: run "os migrate organization-ownership", which drops it itself.`
);
}

/** Columns the driver creates unconditionally — never metadata fields. */
export const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { afterEach, describe, expect, it, vi } from 'vitest';
import { SqlDriver } from './sql-driver.js';

/**
* [ADR-0131 D10 item 5] The boot's `[schema-drift]` line for an orphaned
* `organization_id` column.
*
* On a database the organization-ownership ceremony has not certified, that
* drop is the ceremony's: `os migrate apply --allow-destructive` refuses the
* run, so a line prescribing it sends the operator to a refused command. The
* boot asks the driver's own reader ({@link SqlDriver.isTenantKeyCertified},
* the engine-handed resolver) and, on "not certified", names the ceremony in
* the place of that sentence. A certified database, every other column and
* every other drift kind print exactly the message the differ wrote.
*/

const TABLE = 'biz_unit';
const ORPHAN_ORG = `${TABLE}.organization_id: column exists in the database but not in metadata (orphaned)`;
const ALLOW_DESTRUCTIVE_DROP = ' — "os migrate apply --allow-destructive" to drop it.';

let driver: SqlDriver | undefined;

afterEach(async () => {
await driver?.disconnect();
driver = undefined;
});

/** A 17.x-shaped table: `organization_id` and `legacy_code` declared by nothing, `name` nullable under a declared NOT NULL. */
async function bootOn(resolver?: () => boolean): Promise<{ lines: string[]; asked: () => number }> {
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
const warn = vi.fn();
(driver as any).logger = { warn, info: vi.fn() };
let asked = 0;
if (resolver) {
driver.setTenantKeyCertifiedResolver(() => {
asked++;
return resolver();
});
}
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
t.string('id').primary();
t.string('name');
t.string('organization_id');
t.string('legacy_code');
});
await driver.initObjects([
{ name: TABLE, fields: { name: { type: 'string', required: true, storage: { notNull: true } } } },
]);
const lines = warn.mock.calls.map((c: any[]) => String(c[0])).filter((l) => l.startsWith('[schema-drift] '));
return { lines, asked: () => asked };
}

const lineFor = (lines: string[], prefix: string): string => {
const found = lines.filter((l) => l.startsWith(`[schema-drift] ${prefix}`));
expect(found, lines.join('\n')).toHaveLength(1);
return found[0]!;
};

describe('the boot drift line for an orphaned organization_id column', () => {
it('not certified: names the ceremony, never --allow-destructive', async () => {
const { lines, asked } = await bootOn(() => false);
const line = lineFor(lines, ORPHAN_ORG);
expect(line.startsWith(`[schema-drift] ${ORPHAN_ORG} — `)).toBe(true);
expect(line).toContain('"os migrate organization-ownership"');
expect(line).not.toContain('--allow-destructive');
expect(asked()).toBe(1);
});

it('no resolver handed over reads as not certified: the same line', async () => {
const { lines } = await bootOn();
const line = lineFor(lines, ORPHAN_ORG);
expect(line).toContain('"os migrate organization-ownership"');
expect(line).not.toContain('--allow-destructive');
});

it('certified: the line the differ wrote, unchanged', async () => {
const { lines } = await bootOn(() => true);
expect(lineFor(lines, ORPHAN_ORG)).toBe(`[schema-drift] ${ORPHAN_ORG}${ALLOW_DESTRUCTIVE_DROP}`);
});

it('control, not certified: another orphaned column keeps its --allow-destructive remedy', async () => {
const { lines } = await bootOn(() => false);
expect(lineFor(lines, `${TABLE}.legacy_code:`)).toBe(
`[schema-drift] ${TABLE}.legacy_code: column exists in the database but not in metadata (orphaned)${ALLOW_DESTRUCTIVE_DROP}`,
);
});

it('control, not certified: a tighten_not_null keeps its --allow-destructive remedy', async () => {
const { lines } = await bootOn(() => false);
expect(lineFor(lines, `${TABLE}.name:`)).toBe(
`[schema-drift] ${TABLE}.name: metadata declares \`storage.notNull\` but the column is nullable — existing nulls ` +
'must be backfilled. Run "os migrate apply --allow-destructive".',
);
});

it('a table with no organization_id drop never asks the question', async () => {
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
(driver as any).logger = { warn: vi.fn(), info: vi.fn() };
let asked = 0;
driver.setTenantKeyCertifiedResolver(() => {
asked++;
return false;
});
await (driver as any).knex.schema.createTable(TABLE, (t: any) => {
t.string('id').primary();
t.string('name');
t.string('legacy_code');
});
await driver.initObjects([{ name: TABLE, fields: { name: { type: 'string' } } }]);
expect(asked).toBe(0);
});
});
11 changes: 11 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ import {
GLOBAL_TENANT,
canonicalIndexKey,
carriesNullSafeTenantKeyPart,
ceremonyOwnedDropFinding,
isCeremonyOwnedColumnDrop,
hashShadowColumnFor,
isHashShadowCarrier,
parseHashShadowKeyParts,
Expand Down Expand Up @@ -14446,10 +14448,19 @@ export class SqlDriver implements IDataDriver {
}
}

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