diff --git a/.changeset/30562-sql-never-drop-table-with-rows.md b/.changeset/30562-sql-never-drop-table-with-rows.md new file mode 100644 index 00000000000..93dc236ef74 --- /dev/null +++ b/.changeset/30562-sql-never-drop-table-with-rows.md @@ -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 + + + +**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. diff --git a/packages/drivers/driver-sql/src/existing-table-refusal.ts b/packages/drivers/driver-sql/src/existing-table-refusal.ts new file mode 100644 index 00000000000..e07565a34bd --- /dev/null +++ b/packages/drivers/driver-sql/src/existing-table-refusal.ts @@ -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.` + ); +} diff --git a/packages/drivers/driver-sql/src/sql-driver-rotation.ts b/packages/drivers/driver-sql/src/sql-driver-rotation.ts index 27fcc5d28df..a5ea746e22d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-rotation.ts +++ b/packages/drivers/driver-sql/src/sql-driver-rotation.ts @@ -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, @@ -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`. @@ -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; } diff --git a/packages/drivers/driver-sql/src/sql-driver-schema.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema.test.ts index a60e5d9adbf..c61e807c46d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-schema.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-schema.test.ts @@ -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; @@ -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); +}