Repository navigation
fix(driver-sql): schema sync never drops an existing table that holds rows - #30632
objectstack-fleet[bot] wants to merge 2 commits into
Conversation
… rows initObjects rebuilt an existing table keyed by `_id` with no `id` column (drop, then create as the object declares) whatever it held. On a managed datasource over an existing database that deleted a customer table's rows behind a save that answered success. The rebuild now runs only on an empty table. A table that holds rows is refused with an ExistingTableAdoptionRefusal naming the table, the column and the row count, before any DDL, so nothing about the table changes. Pinned across the dialect matrix (SQLite always; Postgres and MySQL where the runner provisions them): the refusal with rows, the empty-table rebuild and the id-keyed adoption as controls. Claude-Session: https://claude.ai/code/session_01Utp6CYE7swB6ksdveFrzwH Co-authored-by: Claude <noreply@anthropic.com>
…-cs7yq4 Claude-Session: https://claude.ai/code/session_01Utp6CYE7swB6ksdveFrzwH Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract-review-claim: session_01Utp6Cbvcmv8YWFhFJwzjKL · head 9a71d24 · 2026-11-05T19:18Z |
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9ef552d4ab9ed6ea8b0da3c625d53856fb88c37b && git checkout 9ef552d4ab9ed6ea8b0da3c625d53856fb88c37b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 63de248a1cb2a8f2b14a7047f351eda9ed852fba 9a71d2443e0d42b61a1836d84787db1fad72184c && git checkout -B drift-repro 63de248a1cb2a8f2b14a7047f351eda9ed852fba && git merge --no-ff 9a71d2443e0d42b61a1836d84787db1fad72184c
node scripts/docs-audit/affected-docs.mjs --json 63de248a1cb2a8f2b14a7047f351eda9ed852fba |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL Contract reviewServed-tier: 净 diff( ① Derived judgments逐条列出 diff 蕴含的接受集 / 公开面变化,并判对错:
② Semver level
③ Boundary flagsdev report
④ Design judgment读
check-runs on Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL 维护者速读
席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CYE7swB6ksdveFrzwH
Fixes #30562
Clause-②: yes (narrowing) —
SqlDriver.initObjects/syncSchemarefuse one case that used to succeed: an existing table with a_idcolumn, noidcolumn 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); theschemaModetable inskills/objectstack-data/rules/datasources.mdstays true.维护者速读
_id当主键、没有id列,而且表里有数据,结构同步不再删表重建,改为拒绝:表、列、行数原样不动,拒绝信息点名表名和_id列,并给出三条出路。空表照旧重建,有id的表照旧补列收编。_id键的表,不影响升级。注意:保存接口仍答 200,拒绝只写进服务器日志(error 级,durability log level: schema-sync DDL refusals in four paths log below error, and check:durability-log-level cannot see them (no syncObjectSchema entry; sync.call reads as callee "call") #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-_idrebuild (an existing table with_idand noid: drop, then create as the object declares), the table's rows are counted. One row or more: the sync throwsExistingTableAdoptionRefusalbefore 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 withidis adopted as before (missing declared columns added, rows kept). The table is still recorded as found-existing forgetSchemaSyncStats, since it was there when the sync arrived.ExistingTableAdoptionRefusal(new,packages/drivers/driver-sql/src/existing-table-refusal.ts). AnErrorwithname,table,columnandrows. Its message names the table, the column and the row count, says nothing was changed, and gives the three ways forward: rename_idtoidand save again; bind the object through anexternaldatasource to read the table as it is; or empty the table and save again. English, like its neighbours. Not exported fromindex.ts.code. Measured below: the metadata save door answers200for any refused schema sync and logs it aterror(ensureObjectStorage, the deliberate durability log level: schema-sync DDL refusals in four paths log below error, and check:durability-log-level cannot see them (no syncObjectSchema entry; sync.call reads as callee "call") #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 bydriver-sqlneeds a ledger row inpackages/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.tsrebuildSqliteTablePatched:dropTableIfExists('__os_mig_TABLE')is its own scratch table;dropTable(table)runs inside the same transaction afterINSERT … SELECTcopied 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 declaredlifecycle.storageretention.sql-driver-sequence-semantics.ts:_objectstack_sequences(the platform's counter table) is copied into__rebuildbefore the swap; the scratch table is dropped on failure.SqlDriver.dropTableis the explicit drop a caller asks for (object delete withdropStorage), not schema sync.Where the
_idbranch came from. It arrived incdd1bd15c7(2026-03-22), the commit that broughtdriver-sqlover from the ObjectQL driver, unchanged; that driver keyed rows by_id. Nothing in this repository creates a_id-keyed SQL table today (git grepfor a quoted_idacrosspackages/drivers/*/srcnon-test source: the branch itself is the only hit outsidedriver-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 throughsyncSchema, 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 anExistingTableAdoptionRefusalwithtable,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
PUT /api/v1/meta/object/legacy_itemsanswers200 {"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 durability log level: schema-sync DDL refusals in four paths log below error, and check:durability-log-level cannot see them (no syncObjectSchema entry; sync.call reads as callee "call") #22985errorline followed by this refusal. Reporting a refused schema sync to the caller of the save is the opposite of durability log level: schema-sync DDL refusals in four paths log below error, and check:durability-log-level cannot see them (no syncObjectSchema entry; sync.call reads as callee "call") #22985's deliberate "the publish is NOT failed", inmetadata-protocol, outside this card; the seat files it as its own card.GET /api/v1/data/legacy_itemsanswers200with the 2 rows andid: null(the table has noid);POSTis refused400 INVALID_FIELD(__searchhas no column) and writes nothing. That is the documented state after any refused sync (durability log level: schema-sync DDL refusals in four paths log below error, and check:durability-log-level cannot see them (no syncObjectSchema entry; sync.call reads as callee "call") #22985's line says so), not new here.os migrate plandoes not foresee this refusal. Its additive preview (sql-driver-schema-sync.ts, theadd_columnsbranch) lists the declared columns a_id-keyed table lacks, whileapplyrefuses 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.Temporal Conformance (live PG + MySQL)job, which carries the full battery onmain(push and schedule), not on this PR.Verification
Code commit
8157270e; head9a71d244mergesorigin/mainat1c5cee67(7 commits, none indriver-sql). Measured first at basebdbaf80c.Live: PUT /api/v1/meta/object/NAME on a managed SQLite datasource over an existing file (examples/app-showcase, objectstack dev --fresh)
Fixture: a SQLite file with
parts (part_no, part_name, qty), 2 rows, andlegacy_items (_id, title), 2 rows.POST /api/v1/datasourcesregisters it as a runtime datasource withschemaMode: 'managed'(201); thenPUT /api/v1/meta/object/partsandPUT /api/v1/meta/object/legacy_items, each withdatasourceset to it and one declared fieldtitle.parts(has no_id)legacy_items(_id, noid)bdbaf80c200success · 3 to 13 columns · 2 rows kept200success · dropped and recreated: 11 columns,_idgone, 0 rows200success · 3 to 13 columns · 2 rows kept (unchanged)200success · table unchanged: 2 columns, 2 rowsAfter 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_itemsanswers200withdatasourceset to the managed datasource (the definition is stored and bound).GET /api/v1/data/legacy_itemsanswers200with the 2 rows (id: null);POST /api/v1/data/legacy_itemsanswers400 INVALID_FIELDand the table still holds 2 rows.Tests
pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2with 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. On8157270e.src/sql-driver-schema.test.tsalone, same PostgreSQL: 26 passed, 1 skipped (the MySQL cell).pnpm --filter @objectstack/driver-sql typecheck(tsc --noEmit): exit 0;--listFilesincludes both changed source files and the test file.SqlDriver:@objectstack/driver-sqlite-wasm40 files, 750 tests passed;@objectstack/driver-turso97 files, 2514 passed, 34 skipped.@objectstack/service-datasource(drives managed datasources): 72 files, 1195 tests passed.--no-inline-config) on the three changed source and test files: exit 0.Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)
initObjectsif (Number(rows) > 0) throw …)expected null to be an instance of ExistingTableAdoptionRefusal(2 failed, 24 passed)> 0to>= 0(an empty table refused too)Each mutation landed (anchor 1 to 0, blob changed); each restore left the blob equal to HEAD and
git diff HEADempty.Gates and dogfood, on head 9a71d24
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 59 derived families (change set: the 4 paths of this PR vs merge base1c5cee67). 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:generatedafter the merge: all 14 generated artifacts up to date.turbo run buildof the merged tree (118 tasks successful):pnpm --filter @objectstack/dogfood testVERDICT command-exit 0, Test Files 420 passed, 3 skipped (423), Tests 3496 passed, 36 skipped (3532).pnpm lintover the whole repository was not run locally (a repository-wide scan, CI'sLint & Repo Gates); eslint ran on the changed files.Generated by Claude Code