Skip to content

Commit 8d7f906

Browse files
fix(driver-sql): schema sync never drops an existing table that holds rows (#30632)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgG3GBsgZPwY1z2JaG3ucvGu)_ domain:server#2 · os-bill · session_01Utp6CYE7swB6ksdveFrzwH Fixes #30562 Clause-②: yes (narrowing) — `SqlDriver.initObjects` / `syncSchema` refuse one case that used to succeed: an existing table with a `_id` column, no `id` column and at least one row. It used to be dropped and recreated empty; it is now refused (`ExistingTableAdoptionRefusal`, naming the table, the column and the row count) and left exactly as it was. Metadata-protocol: none Skills-docs: none — `git grep -n -i -E "drop(s|ped)? and recreat|legacy _id|_id column|existing table|ExistingTableAdoption" -- 'skills/**'` has no hit (exit 1); the `schemaMode` table in `skills/objectstack-data/rules/datasources.md` stays true. ## 维护者速读 - 改了什么:在托管 SQL 数据源上保存对象时,如果同名的已有表用 `_id` 当主键、没有 `id` 列,而且表里有数据,结构同步不再删表重建,改为拒绝:表、列、行数原样不动,拒绝信息点名表名和 `_id` 列,并给出三条出路。空表照旧重建,有 `id` 的表照旧补列收编。 - 为什么改:实测这条路会把客户已有表的数据全部删掉(2 行变 0 行),保存还答成功;新建数据源对话框在 PR #30561 之前默认就是托管,所以这是默认路径上的静默丢数据(p1)。 - 风险与回滚:只动 driver-sql 的一个分支加一个新的拒绝类;平台自己没有任何 `_id` 键的表,不影响升级。注意:保存接口仍答 200,拒绝只写进服务器日志(error 级,#22985 的既有做法),数据不再丢失;让保存把拒绝报给调用方另立卡。回滚即撤本 PR。 - 先看:`packages/drivers/driver-sql/src/sql-driver-rotation.ts` 中 `initObjects` 的 `_id` 分支(先数行,有行就抛);`existing-table-refusal.ts`(拒绝类与文案);`sql-driver-schema.test.ts` 末尾新增的 describe。 - 两分钟验证:`pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-schema.test.ts`,预期全绿,`schema sync never drops a table holding rows — sqlite` 下 3 条通过(PG/MySQL 无服务器时显示未跑)。 - 需要你做的:无,席位落地。 ## What changes **The rule: schema sync never drops a table that holds rows.** - **`initObjects` (`packages/drivers/driver-sql/src/sql-driver-rotation.ts`).** Before the legacy-`_id` rebuild (an existing table with `_id` and no `id`: drop, then create as the object declares), the table's rows are counted. One row or more: the sync throws `ExistingTableAdoptionRefusal` before any DDL, so no column is added, no index is built and no row is touched. Zero rows: the empty table is rebuilt exactly as before. A table with `id` is adopted as before (missing declared columns added, rows kept). The table is still recorded as found-existing for `getSchemaSyncStats`, since it was there when the sync arrived. - **`ExistingTableAdoptionRefusal` (new, `packages/drivers/driver-sql/src/existing-table-refusal.ts`).** An `Error` with `name`, `table`, `column` and `rows`. Its message names the table, the column and the row count, says nothing was changed, and gives the three ways forward: rename `_id` to `id` and save again; bind the object through an `external` datasource to read the table as it is; or empty the table and save again. English, like its neighbours. Not exported from `index.ts`. - **No ADR-0112 `code`.** Measured below: the metadata save door answers `200` for any refused schema sync and logs it at `error` (`ensureObjectStorage`, the deliberate #22985 design), as do the boot sync and a datasource connect. No door serves a code for a schema-sync refusal, and a code stamped by `driver-sql` needs a ledger row in `packages/spec` (out of this card's file surface). The pins assert the class, its named fields and that the message names the table and the column. **The other drop sites in driver-sql, read and left alone.** None drops a table holding rows that the platform did not create: - `sql-driver-schema-sync.ts` `rebuildSqliteTablePatched`: `dropTableIfExists('__os_mig_TABLE')` is its own scratch table; `dropTable(table)` runs inside the same transaction after `INSERT … SELECT` copied every kept column into the scratch table, which is then renamed back (copy → swap, rows kept). - `ensureRotation`: the base table is merged into the current shard (`INSERT … SELECT *`) before it is dropped; aged-out shards (`TABLE__rYYYYMMDD`) are dropped by the object's own declared `lifecycle.storage` retention. - `sql-driver-sequence-semantics.ts`: `_objectstack_sequences` (the platform's counter table) is copied into `__rebuild` before the swap; the scratch table is dropped on failure. - `SqlDriver.dropTable` is the explicit drop a caller asks for (object delete with `dropStorage`), not schema sync. **Where the `_id` branch came from.** It arrived in `cdd1bd15c7` (2026-03-22), the commit that brought `driver-sql` over from the ObjectQL driver, unchanged; that driver keyed rows by `_id`. Nothing in this repository creates a `_id`-keyed SQL table today (`git grep` for a quoted `_id` across `packages/drivers/*/src` non-test source: the branch itself is the only hit outside `driver-mongodb`), so refusing a populated one breaks no shipped upgrade path; the title carries no `!`. ## Why #30562: storing an object on a managed SQL datasource over an existing database deleted the rows of a same-named table keyed by `_id`, and the save answered success. The New datasource dialog defaulted to managed until PR #30561, so this was the default path. ## Tests New top-level describe in `sql-driver-schema.test.ts` (the package's shared schema-sync harness), declared across the dialect matrix (`DIALECT_CELLS` / `declareDialectCell`): SQLite always; Postgres and MySQL where the runner provisions them. Driven through `syncSchema`, the call the engine makes per object. - `refuses a _id-keyed table holding rows, naming the table and the column, and changes nothing` — 2 rows; the refusal is an `ExistingTableAdoptionRefusal` with `table`, `column: '_id'`, `rows: 2`, its message names both; the table's column set and every row are byte-for-byte the snapshot taken before. - `CONTROL: rebuilds an empty _id-keyed table as the object declares, as before`. - `CONTROL: adopts an id-keyed table holding rows by adding the declared columns, rows kept`. ## Acceptance notes - **Data no longer lost; the save still answers 200, and the refusal goes only to the server log.** Measured live (below): after this change `PUT /api/v1/meta/object/legacy_items` answers `200 {"success":true,…}`, the definition is stored and bound to the managed datasource, the table and its 2 rows are untouched, and the server logs the #22985 `error` line followed by this refusal. Reporting a refused schema sync to the caller of the save is the opposite of #22985's deliberate "the publish is NOT failed", in `metadata-protocol`, outside this card; the seat files it as its own card. - **After the refusal the object is served on the unchanged table, as for any refused sync.** Measured live: `GET /api/v1/data/legacy_items` answers `200` with the 2 rows and `id: null` (the table has no `id`); `POST` is refused `400 INVALID_FIELD` (`__search` has no column) and writes nothing. That is the documented state after any refused sync (#22985's line says so), not new here. - **`os migrate plan` does not foresee this refusal.** Its additive preview (`sql-driver-schema-sync.ts`, the `add_columns` branch) lists the declared columns a `_id`-keyed table lacks, while `apply` refuses it when it holds rows (and, before and after this change, rebuilds it when empty). Noted from reading, not measured through the CLI; carrier: seat. - **MySQL not run locally.** The pins ran on SQLite and on a local PostgreSQL 16 (all green); the MySQL cell runs in CI's `Temporal Conformance (live PG + MySQL)` job, which carries the full battery on `main` (push and schedule), not on this PR. ## Verification Code commit `8157270e`; head `9a71d244` merges `origin/main` at `1c5cee67` (7 commits, none in `driver-sql`). Measured first at base `bdbaf80c`. <details><summary>Live: PUT /api/v1/meta/object/NAME on a managed SQLite datasource over an existing file (examples/app-showcase, objectstack dev --fresh)</summary> Fixture: a SQLite file with `parts (part_no, part_name, qty)`, 2 rows, and `legacy_items (_id, title)`, 2 rows. `POST /api/v1/datasources` registers it as a runtime datasource with `schemaMode: 'managed'` (`201`); then `PUT /api/v1/meta/object/parts` and `PUT /api/v1/meta/object/legacy_items`, each with `datasource` set to it and one declared field `title`. | | `parts` (has no `_id`) | `legacy_items` (`_id`, no `id`) | |:--|:--|:--| | base `bdbaf80c` | `200` success · 3 to 13 columns · 2 rows kept | `200` success · **dropped and recreated**: 11 columns, `_id` gone, **0 rows** | | this PR | `200` success · 3 to 13 columns · 2 rows kept (unchanged) | `200` success · **table unchanged**: 2 columns, **2 rows** | After this PR the server log carries, for `legacy_items`, the existing #22985 line (`[Protocol] object 'legacy_items' was published but its storage was NOT created or altered — its schema sync was refused. The publish answered success …`) followed by this refusal: `Schema sync REFUSED for the existing table 'legacy_items': it has a '_id' column and no 'id' column, and it holds 2 rows. …`. `GET /api/v1/meta/object/legacy_items` answers `200` with `datasource` set to the managed datasource (the definition is stored and bound). `GET /api/v1/data/legacy_items` answers `200` with the 2 rows (`id: null`); `POST /api/v1/data/legacy_items` answers `400 INVALID_FIELD` and the table still holds 2 rows. </details> <details><summary>Tests</summary> - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2` with a local PostgreSQL 16 (`OS_TEST_POSTGRES_URL`, server zone Asia/Shanghai, `TZ=America/New_York`): Test Files 247 passed, 3 skipped (250); Tests 4885 passed, 110 skipped (4995); coverage reporter: sqlite RAN, live postgres RAN, live mysql NOT RUN. Lock VERDICT command-exit 0. On `8157270e`. - `src/sql-driver-schema.test.ts` alone, same PostgreSQL: 26 passed, 1 skipped (the MySQL cell). - `pnpm --filter @objectstack/driver-sql typecheck` (`tsc --noEmit`): exit 0; `--listFiles` includes both changed source files and the test file. - Drivers that inherit `SqlDriver`: `@objectstack/driver-sqlite-wasm` 40 files, 750 tests passed; `@objectstack/driver-turso` 97 files, 2514 passed, 34 skipped. `@objectstack/service-datasource` (drives managed datasources): 72 files, 1195 tests passed. - eslint (`--no-inline-config`) on the three changed source and test files: exit 0. </details> <details><summary>Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)</summary> | # | Mutation in `initObjects` | Result (SQLite + PostgreSQL cells) | |:--|:--|:--| | A1 | delete the row guard (`if (Number(rows) > 0) throw …`) | the refusal pin red in both cells: `expected null to be an instance of ExistingTableAdoptionRefusal` (2 failed, 24 passed) | | A2 | guard threshold `> 0` to `>= 0` (an empty table refused too) | the empty-table rebuild control red in both cells (2 failed, 24 passed) | Each mutation landed (anchor 1 to 0, blob changed); each restore left the blob equal to HEAD and `git diff HEAD` empty. </details> <details><summary>Gates and dogfood, on head 9a71d24</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 59 derived families (change set: the 4 paths of this PR vs merge base `1c5cee67`). All 59 run, exit code captured per line before any pipe: 59 exit 0. `--ran`: 59 derived, 59 run, 0 NOT-MEASURED (a derived zero). - `pnpm --filter @objectstack/spec check:generated` after the merge: all 14 generated artifacts up to date. - Full dogfood under the verify lock, after a full `turbo run build` of the merged tree (118 tasks successful): `pnpm --filter @objectstack/dogfood test` VERDICT command-exit 0, Test Files 420 passed, 3 skipped (423), Tests 3496 passed, 36 skipped (3532). - `pnpm lint` over the whole repository was not run locally (a repository-wide scan, CI's `Lint & Repo Gates`); eslint ran on the changed files. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYE7swB6ksdveFrzwH)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b77c160 commit 8d7f906

4 files changed

Lines changed: 211 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
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
6+
7+
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.
8+
Metadata-protocol: none
9+
10+
<!-- 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. -->
11+
12+
**BREAKING** accept-set narrowing in schema sync (`@objectstack/driver-sql`): a table the sync would have rebuilt is refused while it holds rows.
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
18+
**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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Schema sync never drops a table that holds rows.
5+
*
6+
* `initObjects` adopts an existing table by adding the columns the object
7+
* declares and it lacks — rows kept. One shape it cannot adopt that way: a
8+
* table keyed by `_id` with no `id` column, the storage key of the ObjectQL
9+
* driver this one was ported from. Every row this driver writes and reads is
10+
* keyed by `id`, so for that shape the sync rebuilds the table: drop, then
11+
* create it as the object declares. On an empty table that loses nothing and
12+
* still happens.
13+
*
14+
* On a table that holds rows the rebuild deleted them, with no warning. The
15+
* table did not have to be the platform's: a managed datasource over an
16+
* existing database is reached by storing an object whose name matches one of
17+
* its tables, and the save went on to answer success. So the rebuild is now
18+
* gated on the row count, and a table holding rows is refused instead — before
19+
* anything is written: no column added, no index built, no row touched. The
20+
* refusal names the table and the column, and says what the operator can do.
21+
*
22+
* It carries no ADR-0112 `code`. No door answers this refusal on the wire: the
23+
* metadata save door reports a refused schema sync in the server log at
24+
* `error` and answers the save (`ensureObjectStorage`), the boot sync and a
25+
* datasource connect do the same, and `os migrate apply` prints the message.
26+
* A code a driver stamps carries a row in the error-code ledger; this refusal
27+
* has no door to serve one through.
28+
*/
29+
30+
/** Thrown by `SqlDriver.initObjects` (`sql-driver-rotation.ts`) — its one throw site. */
31+
export class ExistingTableAdoptionRefusal extends Error {
32+
constructor(
33+
/** The existing table the sync was refused for. */
34+
readonly table: string,
35+
/** The column that makes its shape one the sync can only rebuild. */
36+
readonly column: string,
37+
/** How many rows it holds — never zero: an empty table is rebuilt. */
38+
readonly rows: number,
39+
) {
40+
super(renderExistingTableAdoptionRefusal(table, column, rows));
41+
this.name = 'ExistingTableAdoptionRefusal';
42+
}
43+
}
44+
45+
/**
46+
* The refusal's prose: what was refused and why, what was left alone, and the
47+
* three ways forward. Exported so the pins read the renderer the driver throws
48+
* through, never a copy of its text.
49+
*/
50+
export function renderExistingTableAdoptionRefusal(table: string, column: string, rows: number): string {
51+
return (
52+
`Schema sync REFUSED for the existing table '${table}': it has a '${column}' column and no 'id' column, ` +
53+
`and it holds ${rows} row${rows === 1 ? '' : 's'}. ObjectStack keys every row by 'id', so it could adopt ` +
54+
`this table only by dropping and recreating it, which would delete those rows. Nothing was changed: no ` +
55+
`column was added and no row was touched. To keep the rows and use the table, rename its '${column}' ` +
56+
`column to 'id' and save the object again; to read the table as it is, bind the object through a ` +
57+
`datasource whose schemaMode is 'external'; if the rows are not needed, empty the table and save again.`
58+
);
59+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, isTenancyDisabled, parseAuto
3636
import type { AutonumberToken } from '@objectstack/spec/data';
3737
import { aggregandColumnClass } from '@objectstack/core';
3838
import { StorageNameMapping, SystemObjectName } from '@objectstack/spec/system';
39+
import { ExistingTableAdoptionRefusal } from './existing-table-refusal.js';
3940
import { indexedKeyColumns, isUniqueScopeDeclared } from './schema-drift.js';
4041
import {
4142
AUDIT_TIMESTAMP_COLUMNS, MYSQL_DATETIME_PRECISION, NUMERIC_SCALAR_TYPES, declaredValueShapes,
@@ -458,7 +459,7 @@ export class RotationFacet {
458459
let exists = await this.knex.schema.hasTable(tableName);
459460
// Recorded BEFORE the legacy-`_id` rebuild below can flip `exists`: a
460461
// table that was here when we arrived stays "found" even if we then drop
461-
// and recreate it. Re-syncing a table we made ourselves this boot — boot
462+
// and recreate it (or refuse it). Re-syncing a table we made ourselves this boot — boot
462463
// runs the sync more than once, and objects registered later sync on
463464
// demand — is not evidence of anything preceding us, so it is ignored.
464465
// See `getSchemaSyncStats`.
@@ -469,7 +470,17 @@ export class RotationFacet {
469470
const columnInfo = await this.knex(tableName).columnInfo();
470471
const existingColumns = Object.keys(columnInfo);
471472

473+
// A table keyed by `_id` with no `id` (the ObjectQL driver's storage
474+
// key) is the one shape the additive path below cannot adopt, so it is
475+
// rebuilt — but only while it is EMPTY. [#30562] Schema sync never
476+
// drops a table that holds rows: on a managed datasource over an
477+
// existing database that table is the customer's, and the drop deleted
478+
// its rows behind a save that answered success. Refused here, before
479+
// any DDL, so nothing about the table changes. See
480+
// `existing-table-refusal.ts`.
472481
if (existingColumns.includes('_id') && !existingColumns.includes('id')) {
482+
const [{ rows }] = await this.knex(tableName).count({ rows: '*' });
483+
if (Number(rows) > 0) throw new ExistingTableAdoptionRefusal(tableName, '_id', Number(rows));
473484
await this.knex.schema.dropTable(tableName);
474485
exists = false;
475486
}

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

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

68
describe('SqlDriver Schema Sync (SQLite)', () => {
79
let driver: SqlDriver;
@@ -494,3 +496,123 @@ describe('SqlDriver Schema Sync (SQLite)', () => {
494496
expect(existsFqn).toBe(false);
495497
});
496498
});
499+
500+
/**
501+
* Schema sync never drops a table that holds rows.
502+
*
503+
* `initObjects` rebuilds an existing table keyed by `_id` with no `id` — the
504+
* one shape it cannot adopt by adding columns. Measured through
505+
* `PUT /api/v1/meta/object/NAME` on a managed SQLite datasource over an
506+
* existing file: such a table holding 2 rows was dropped and recreated empty,
507+
* and the save answered success. The rebuild now runs only on an empty table;
508+
* a table holding rows is refused before anything is written.
509+
*
510+
* Driven through `syncSchema`, the call the engine makes per object. Across
511+
* every dialect cell: SQLite always, Postgres and MySQL where the runner
512+
* provisions them (the `Temporal Conformance (live PG + MySQL)` job runs this
513+
* package against both).
514+
*/
515+
const ROW_GUARD_MATRIX = 'schema sync never drops a table holding rows';
516+
517+
/** A table this file owns; the schema it lands in is per-file on a live cell. */
518+
const LEGACY_TABLE = 'os30562_legacy_items';
519+
520+
const LEGACY_OBJECT = {
521+
name: LEGACY_TABLE,
522+
fields: { title: { type: 'string' }, qty: { type: 'integer' } },
523+
};
524+
525+
const LEGACY_ROWS = [
526+
{ _id: 'a1', title: 'Bolt' },
527+
{ _id: 'a2', title: 'Nut' },
528+
];
529+
530+
function declareRowGuardPins(cell: DialectCell): void {
531+
describe(`${ROW_GUARD_MATRIX} — ${cell.label}`, () => {
532+
let driver: SqlDriver;
533+
let knex: any;
534+
535+
/** The table as it stands: its column names, and every row in key order. */
536+
const snapshot = async (key: string) => ({
537+
columns: Object.keys(await knex(LEGACY_TABLE).columnInfo()).sort(),
538+
rows: await knex(LEGACY_TABLE).select('*').orderBy(key),
539+
});
540+
541+
beforeEach(async () => {
542+
driver = new SqlDriver(cell.config());
543+
knex = (driver as any).knex;
544+
await knex.schema.dropTableIfExists(LEGACY_TABLE);
545+
});
546+
547+
afterEach(async () => {
548+
await knex.schema.dropTableIfExists(LEGACY_TABLE).catch(() => {});
549+
await driver.disconnect();
550+
});
551+
552+
it('refuses a `_id`-keyed table holding rows, naming the table and the column, and changes nothing', async () => {
553+
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
554+
t.string('_id').primary();
555+
t.string('title');
556+
});
557+
await knex(LEGACY_TABLE).insert(LEGACY_ROWS);
558+
const before = await snapshot('_id');
559+
// Non-vacuous: the shape is the one the sync rebuilds, and it holds rows.
560+
expect(before.columns).toEqual(['_id', 'title']);
561+
expect(before.rows).toHaveLength(2);
562+
563+
const refusal = await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT).then(() => null, (e: unknown) => e);
564+
565+
expect(refusal).toBeInstanceOf(ExistingTableAdoptionRefusal);
566+
expect(refusal).toMatchObject({
567+
name: 'ExistingTableAdoptionRefusal',
568+
table: LEGACY_TABLE,
569+
column: '_id',
570+
rows: 2,
571+
});
572+
const { message } = refusal as Error;
573+
expect(message).toContain(`'${LEGACY_TABLE}'`);
574+
expect(message).toContain(`'_id'`);
575+
576+
// Nothing was changed: the table is still there, with the same columns
577+
// (no declared column added) and the same rows.
578+
expect(await knex.schema.hasTable(LEGACY_TABLE)).toBe(true);
579+
expect(await snapshot('_id')).toEqual(before);
580+
});
581+
582+
it('CONTROL: rebuilds an empty `_id`-keyed table as the object declares, as before', async () => {
583+
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
584+
t.string('_id').primary();
585+
t.string('title');
586+
});
587+
588+
await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT);
589+
590+
const after = await snapshot('id');
591+
expect(after.columns).toContain('id');
592+
expect(after.columns).not.toContain('_id');
593+
expect(after.columns).toEqual(expect.arrayContaining(['created_at', 'updated_at', 'title', 'qty']));
594+
expect(after.rows).toEqual([]);
595+
});
596+
597+
it('CONTROL: adopts an `id`-keyed table holding rows by adding the declared columns, rows kept', async () => {
598+
await knex.schema.createTable(LEGACY_TABLE, (t: any) => {
599+
t.string('id').primary();
600+
t.string('title');
601+
});
602+
await knex(LEGACY_TABLE).insert(LEGACY_ROWS.map(({ _id, title }) => ({ id: _id, title })));
603+
604+
await driver.syncSchema(LEGACY_TABLE, LEGACY_OBJECT);
605+
606+
const after = await snapshot('id');
607+
expect(after.columns).toEqual(['id', 'qty', 'title']);
608+
expect(after.rows.map(({ id, title }: any) => ({ id, title }))).toEqual([
609+
{ id: 'a1', title: 'Bolt' },
610+
{ id: 'a2', title: 'Nut' },
611+
]);
612+
});
613+
});
614+
}
615+
616+
for (const cell of DIALECT_CELLS) {
617+
declareDialectCell(cell, ROW_GUARD_MATRIX, declareRowGuardPins);
618+
}

0 commit comments

Comments
 (0)