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
18 changes: 18 additions & 0 deletions .changeset/30562-sql-never-drop-table-with-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/driver-sql': patch
---

fix(driver-sql): schema sync never drops an existing table that holds rows; a `_id`-keyed table with rows is refused, naming the table and the column

Clause-②: yes (narrowing) — `SqlDriver.initObjects` / `syncSchema` refuse one more case that used to succeed: an existing table that has a `_id` column, no `id` column and at least one row. It used to be dropped and recreated empty; it is now refused with an `ExistingTableAdoptionRefusal` naming the table, the column and the row count, and nothing is changed.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored metadata changes: what moves is what schema sync does to a database table it finds, and the refused table is left exactly as it was, so there is nothing for a migration to rewrite. -->

**BREAKING** accept-set narrowing in schema sync (`@objectstack/driver-sql`): a table the sync would have rebuilt is refused while it holds rows.

- **Before.** Schema sync adopts an existing table by adding the columns the object declares and the table lacks, rows kept. A table keyed by `_id` with no `id` column (the storage key of the ObjectQL driver this one was ported from) was instead dropped and recreated as the object declares, whatever it held. Storing an object on a managed datasource over an existing database reached it: an object named after such a table deleted its rows, and the save answered success.
- **Now.** That rebuild runs only on an empty table, as before. A table that holds rows is refused before anything is written: no column is added, no index is built, no row is touched. The refusal is thrown from `initObjects` and reaches the caller of the sync. The metadata save, the boot sync and a datasource connect report a refused sync in the server log at `error`, followed by this message, as they do for any refused sync.
- **Who moves.** Only a deployment storing an object on a managed SQL datasource whose table of the same name is keyed by `_id` and holds rows. No platform table has that shape.

**The remedy.** To keep the rows and use the table, rename its `_id` column to `id`, then save the object again (or restart). To read the table as it is, bind the object through a datasource whose `schemaMode` is `external`. If the rows are not needed, empty the table and save again; the sync then rebuilds it.
59 changes: 59 additions & 0 deletions packages/drivers/driver-sql/src/existing-table-refusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Schema sync never drops a table that holds rows.
*
* `initObjects` adopts an existing table by adding the columns the object
* declares and it lacks — rows kept. One shape it cannot adopt that way: a
* table keyed by `_id` with no `id` column, the storage key of the ObjectQL
* driver this one was ported from. Every row this driver writes and reads is
* keyed by `id`, so for that shape the sync rebuilds the table: drop, then
* create it as the object declares. On an empty table that loses nothing and
* still happens.
*
* On a table that holds rows the rebuild deleted them, with no warning. The
* table did not have to be the platform's: a managed datasource over an
* existing database is reached by storing an object whose name matches one of
* its tables, and the save went on to answer success. So the rebuild is now
* gated on the row count, and a table holding rows is refused instead — before
* anything is written: no column added, no index built, no row touched. The
* refusal names the table and the column, and says what the operator can do.
*
* It carries no ADR-0112 `code`. No door answers this refusal on the wire: the
* metadata save door reports a refused schema sync in the server log at
* `error` and answers the save (`ensureObjectStorage`), the boot sync and a
* datasource connect do the same, and `os migrate apply` prints the message.
* A code a driver stamps carries a row in the error-code ledger; this refusal
* has no door to serve one through.
*/

/** Thrown by `SqlDriver.initObjects` (`sql-driver-rotation.ts`) — its one throw site. */
export class ExistingTableAdoptionRefusal extends Error {
constructor(
/** The existing table the sync was refused for. */
readonly table: string,
/** The column that makes its shape one the sync can only rebuild. */
readonly column: string,
/** How many rows it holds — never zero: an empty table is rebuilt. */
readonly rows: number,
) {
super(renderExistingTableAdoptionRefusal(table, column, rows));
this.name = 'ExistingTableAdoptionRefusal';
}
}

/**
* The refusal's prose: what was refused and why, what was left alone, and the
* three ways forward. Exported so the pins read the renderer the driver throws
* through, never a copy of its text.
*/
export function renderExistingTableAdoptionRefusal(table: string, column: string, rows: number): string {
return (
`Schema sync REFUSED for the existing table '${table}': it has a '${column}' column and no 'id' column, ` +
`and it holds ${rows} row${rows === 1 ? '' : 's'}. ObjectStack keys every row by 'id', so it could adopt ` +
`this table only by dropping and recreating it, which would delete those rows. Nothing was changed: no ` +
`column was added and no row was touched. To keep the rows and use the table, rename its '${column}' ` +
`column to 'id' and save the object again; to read the table as it is, bind the object through a ` +
`datasource whose schemaMode is 'external'; if the rows are not needed, empty the table and save again.`
);
}
13 changes: 12 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, isTenancyDisabled, parseAuto
import type { AutonumberToken } from '@objectstack/spec/data';
import { aggregandColumnClass } from '@objectstack/core';
import { StorageNameMapping, SystemObjectName } from '@objectstack/spec/system';
import { ExistingTableAdoptionRefusal } from './existing-table-refusal.js';
import { indexedKeyColumns, isUniqueScopeDeclared } from './schema-drift.js';
import {
AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes,
Expand Down Expand Up @@ -458,7 +459,7 @@ export class RotationFacet {
let exists = await this.knex.schema.hasTable(tableName);
// Recorded BEFORE the legacy-`_id` rebuild below can flip `exists`: a
// table that was here when we arrived stays "found" even if we then drop
// and recreate it. Re-syncing a table we made ourselves this boot — boot
// and recreate it (or refuse it). Re-syncing a table we made ourselves this boot — boot
// runs the sync more than once, and objects registered later sync on
// demand — is not evidence of anything preceding us, so it is ignored.
// See `getSchemaSyncStats`.
Expand All @@ -469,7 +470,17 @@ export class RotationFacet {
const columnInfo = await this.knex(tableName).columnInfo();
const existingColumns = Object.keys(columnInfo);

// A table keyed by `_id` with no `id` (the ObjectQL driver's storage
// key) is the one shape the additive path below cannot adopt, so it is
// rebuilt — but only while it is EMPTY. [#30562] Schema sync never
// drops a table that holds rows: on a managed datasource over an
// existing database that table is the customer's, and the drop deleted
// its rows behind a save that answered success. Refused here, before
// any DDL, so nothing about the table changes. See
// `existing-table-refusal.ts`.
if (existingColumns.includes('_id') && !existingColumns.includes('id')) {
const [{ rows }] = await this.knex(tableName).count({ rows: '*' });
if (Number(rows) > 0) throw new ExistingTableAdoptionRefusal(tableName, '_id', Number(rows));
await this.knex.schema.dropTable(tableName);
exists = false;
}
Expand Down
122 changes: 122 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';
import { ExistingTableAdoptionRefusal } from './existing-table-refusal.js';
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';

describe('SqlDriver Schema Sync (SQLite)', () => {
let driver: SqlDriver;
Expand Down Expand Up @@ -494,3 +496,123 @@ describe('SqlDriver Schema Sync (SQLite)', () => {
expect(existsFqn).toBe(false);
});
});

/**
* Schema sync never drops a table that holds rows.
*
* `initObjects` rebuilds an existing table keyed by `_id` with no `id` — the
* one shape it cannot adopt by adding columns. Measured through
* `PUT /api/v1/meta/object/NAME` on a managed SQLite datasource over an
* existing file: such a table holding 2 rows was dropped and recreated empty,
* and the save answered success. The rebuild now runs only on an empty table;
* a table holding rows is refused before anything is written.
*
* Driven through `syncSchema`, the call the engine makes per object. Across
* every dialect cell: SQLite always, Postgres and MySQL where the runner
* provisions them (the `Temporal Conformance (live PG + MySQL)` job runs this
* package against both).
*/
const ROW_GUARD_MATRIX = 'schema sync never drops a table holding rows';

/** A table this file owns; the schema it lands in is per-file on a live cell. */
const LEGACY_TABLE = 'os30562_legacy_items';

const LEGACY_OBJECT = {
name: LEGACY_TABLE,
fields: { title: { type: 'string' }, qty: { type: 'integer' } },
};

const LEGACY_ROWS = [
{ _id: 'a1', title: 'Bolt' },
{ _id: 'a2', title: 'Nut' },
];

function declareRowGuardPins(cell: DialectCell): void {
describe(`${ROW_GUARD_MATRIX} — ${cell.label}`, () => {
let driver: SqlDriver;
let knex: any;

/** The table as it stands: its column names, and every row in key order. */
const snapshot = async (key: string) => ({
columns: Object.keys(await knex(LEGACY_TABLE).columnInfo()).sort(),
rows: await knex(LEGACY_TABLE).select('*').orderBy(key),
});

beforeEach(async () => {
driver = new SqlDriver(cell.config());
knex = (driver as any).knex;
await knex.schema.dropTableIfExists(LEGACY_TABLE);
});

afterEach(async () => {
await knex.schema.dropTableIfExists(LEGACY_TABLE).catch(() => {});
await driver.disconnect();
});

it('refuses a `_id`-keyed table holding rows, naming the table and the column, and changes nothing', async () => {
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
t.string('_id').primary();
t.string('title');
});
await knex(LEGACY_TABLE).insert(LEGACY_ROWS);
const before = await snapshot('_id');
// Non-vacuous: the shape is the one the sync rebuilds, and it holds rows.
expect(before.columns).toEqual(['_id', 'title']);
expect(before.rows).toHaveLength(2);

const refusal = await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT).then(() => null, (e: unknown) => e);

expect(refusal).toBeInstanceOf(ExistingTableAdoptionRefusal);
expect(refusal).toMatchObject({
name: 'ExistingTableAdoptionRefusal',
table: LEGACY_TABLE,
column: '_id',
rows: 2,
});
const { message } = refusal as Error;
expect(message).toContain(`'${LEGACY_TABLE}'`);
expect(message).toContain(`'_id'`);

// Nothing was changed: the table is still there, with the same columns
// (no declared column added) and the same rows.
expect(await knex.schema.hasTable(LEGACY_TABLE)).toBe(true);
expect(await snapshot('_id')).toEqual(before);
});

it('CONTROL: rebuilds an empty `_id`-keyed table as the object declares, as before', async () => {
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
t.string('_id').primary();
t.string('title');
});

await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT);

const after = await snapshot('id');
expect(after.columns).toContain('id');
expect(after.columns).not.toContain('_id');
expect(after.columns).toEqual(expect.arrayContaining(['created_at', 'updated_at', 'title', 'qty']));
expect(after.rows).toEqual([]);
});

it('CONTROL: adopts an `id`-keyed table holding rows by adding the declared columns, rows kept', async () => {
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
t.string('id').primary();
t.string('title');
});
await knex(LEGACY_TABLE).insert(LEGACY_ROWS.map(({ _id, title }) => ({ id: _id, title })));

await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT);

const after = await snapshot('id');
expect(after.columns).toEqual(['id', 'qty', 'title']);
expect(after.rows.map(({ id, title }: any) => ({ id, title }))).toEqual([
{ id: 'a1', title: 'Bolt' },
{ id: 'a2', title: 'Nut' },
]);
});
});
}

for (const cell of DIALECT_CELLS) {
declareDialectCell(cell, ROW_GUARD_MATRIX, declareRowGuardPins);
}
Loading