Skip to content

fix(driver-sql): schema sync never drops an existing table that holds rows - #30632

Queued
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-cs7yq4
Queued

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/project-thread-cs7yq4

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

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 fix(app-shell): a new datasource starts on External and says what each schema mode does; a driver box's default is a placeholder, not a value #30561 之前默认就是托管,所以这是默认路径上的静默丢数据(p1)。
  • 风险与回滚:只动 driver-sql 的一个分支加一个新的拒绝类;平台自己没有任何 _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-_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 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 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

Verification

Code commit 8157270e; head 9a71d244 merges origin/main at 1c5cee67 (7 commits, none in driver-sql). Measured first at base bdbaf80c.

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, 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.

Tests
  • 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.
Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)
# 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.

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 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.

Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Contract-review-claim: session_01Utp6Cbvcmv8YWFhFJwzjKL · head 9a71d24 · 2026-11-05T19:18Z

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 63de248a1cb2a8f2b14a7047f351eda9ed852fba → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9ef552d4ab9ed6ea8b0da3c625d53856fb88c37b — the merge of head 9a71d2443e0d42b61a1836d84787db1fad72184c into base 63de248a1cb2a8f2b14a7047f351eda9ed852fba, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9a71d2443e0d42b61a1836d84787db1fad72184c
Local-runs: none
Scope: .changeset/30562-sql-never-drop-table-with-rows.md, packages/drivers/driver-sql/src/existing-table-refusal.ts, packages/drivers/driver-sql/src/sql-driver-rotation.ts, packages/drivers/driver-sql/src/sql-driver-schema.test.ts + packages/drivers/driver-sql/src/sql-driver-schema-sync.ts (previewDeferredSchemaWork / ensureRotation / rebuildSqliteTablePatched), packages/drivers/driver-sql/src/sql-driver-deferred-ddl.ts (flushDeferredSchemaDdl), packages/drivers/driver-sql/src/sql-driver-schema-sync-section.ts (syncSchema), packages/drivers/driver-sql/src/index.ts, packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/drivers/driver-turso/src/remote-transport.ts, packages/spec/src/contracts/data-driver.ts, packages/spec/src/data/datasource.zod.ts, skills/objectstack-data/rules/datasources.md, packages/metadata-protocol/src/protocol-registry-hydration.ts (ensureObjectStorage), packages/objectql/src/plugin.ts (boot sync), packages/objectql/src/engine-compatibility-api.ts (syncObjectSchema), packages/services/service-datasource/src/datasource-connection-service.ts, packages/cli/src/utils/schema-migrate.ts, docs/adr/0112-error-code-vocabulary-and-ledger.md

净 diff(main...9a71d244)共 4 个文件 +211/−1,与 PR 文件表一致;head 是 8157270e 加一次对 origin/main(1c5cee67)的合并,合并带入的 11 个 main 提交无一触及 driver-sql。本记录只读:diff、卡片 #30562 全部评论、head 上的 check-runs,以及 origin/main 活树中被引用的代码;未 checkout、未构建、未跑测试。

① Derived judgments

逐条列出 diff 蕴含的接受集 / 公开面变化,并判对错:

  1. SqlDriver.initObjects / syncSchema 的接受集收窄:已有表同时满足「有 _id 列、无 id 列、行数 ≥ 1」时,在任何 DDL 之前抛 ExistingTableAdoptionRefusal(sql-driver-rotation.ts 的 _id 分支先 count(*) 再判 > 0)。原行为是 dropTable 后按对象声明重建。对:抛出点在 columnInfo() 之后、dropTable 之前,表、列、索引、行均未触碰;syncSchema 只是 initObjects([{ ...def, name: object }])(sql-driver-schema-sync-section.ts:52),所以两条门同一收窄。
  2. 空表重建不变:行数为 0 时仍 dropTable + 重建。对,与 PR 声明一致,且 CONTROL 用例钉住。
  3. 有 id 的表照旧增列收编:_id 分支条件 includes('_id') && !includes('id') 未改,同时含 _id 与 id 的表走 alter 路径。对。
  4. getSchemaSyncStats 不变:tablesFoundExisting 的记录在 _id 分支之前(活树 465–466 行),被拒绝的表仍计为 existing,与 data-driver.ts 契约「a table that existed and was rebuilt counts as existing」的保守口径一致。对。
  5. 包公开面不变:新模块 existing-table-refusal.ts 导出 ExistingTableAdoptionRefusal 与 renderExistingTableAdoptionRefusal,但 index.ts 未再导出;@objectstack/driver-sql 的 api-surface 不动。对(见 ④ 对「不出口、无 code」的判断)。
  6. 错误形状:Error 子类,name / table / column / rows 四个字段,无 ADR-0112 code、无 status。读活树三处调用方:ensureObjectStorage(metadata-protocol,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 设计:catch → console.error,保存仍答 200)、引擎启动同步(plugin.ts 的 reportSyncFailure:逐对象 catch → logger.error,不中断其他对象)、数据源连接(datasource-connection-service.ts 892–907 行:逐对象 catch → error,连接不失败)——三条路均在到达任何 HTTP 门之前吞掉并记日志,所以「无门可出 code」的判断对;若日后让保存门上报拒绝,届时再登记 code(另立卡,见 ③)。
  7. 继承该驱动的实现:driver-sqlite-wasm、driver-turso 的本地/replica 模式走 super.initObjects / super.syncSchema,同收窄;turso remote 模式的 RemoteTransport 自有 DDL 路径,读其源码无 _id 丢表分支(仅显式 dropTable),故无「规则在 remote 面缺口」的问题。对。
  8. Metadata-protocol: none:元数据作者可写的键、值、类型均未变;收窄的是同步面对的数据库状态,不是作者的接受集。对,且与 Check Changeset 的 level axis 一致(「a driver posture narrowing is none」)。
  9. Skills-docs: none:skills/objectstack-data/rules/datasources.md 的 schemaMode 表只说 managed = 「DDL + migrations allowed」,从未宣称会丢弃已有数据,仍为真。对。
  10. 仓库内无 _id 键的 SQL 表制造者:git grep "'_id'" 于 packages/drivers/**/src 非测试源码,除 mongodb 外唯一命中即该分支本身。PR 声明「不破坏任何已发布的升级路径、标题不带 !」对。

② Semver level

  • changeset .changeset/30562-sql-never-drop-table-with-rows.md:'@objectstack/driver-sql': patch,正文带 Clause-②: yes (narrowing)、Metadata-protocol: none、**BREAKING** 横幅、ADR-0087 标记 not-required (no-migration-prescription) 及三条出路(改名 _id→id / 改走 external / 清空后再存)。按 AGENTS.md Post-Task Checklist §3:「a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition」——级别 patch 正确;移动的只有 driver-sql 一个已发布包,与 diff 发布的一致。Check Changeset(含 check-adr-0087-registration)在 head 上 success。
  • Clause-②: 行:PR 正文与 changeset 同为 yes (narrowing),拼写在 clause2-line.mjs 闭集内,(narrowing) 臂正确触发 narrowing 面与 BREAKING 读法。should-fix(措辞精度,不阻塞):yes 答的是「是否放宽接受集或扩大公开面」,而本 diff 没有任何放宽——新类未从 index.ts 出口、无新接受值——准确读法是 no (narrowing)。两种读法在本仓的门与分面上结果相同(均 narrowing 面、均 patch),故不计为 must-fix;下次编辑 PR 正文时顺手改正即可。
  • ADR-0087 no-migration-prescription:未增删改任何 schema 键,也未改存储元数据;被拒绝的表原样保留,确无可迁移之物。对。

③ Boundary flags

dev report open_questions: [];5 条 deviations、2 条 out_of_scope、PR 正文 4 条 Acceptance notes,逐条回答:

  1. 新文件 existing-table-refusal.ts 不在点名文件表内 → 仍在派单允许的 packages/drivers/driver-sql/src/** 内,且 sql-driver-rotation.ts 已是四千行级拆分体的一个 facet,单独成文件是本包既有做法(dialect-emission-refusal.ts、organizationless-counter-refusal.ts)。接受。
  2. 无 ADR-0112 code → 见 ①-6:三条调用路径都在门前吞错记日志,没有门会把它放上线。接受;并指出若后续卡让保存门上报拒绝,彼时需同时出口该类并在 packages/spec 登记 code。
  3. 仓库级 pnpm lint 未本地跑 → head 上 Lint · ESLint success(见下)。已由 CI 回答。
  4. MySQL cell 未本地跑;Temporal Conformance (live PG + MySQL) 在 PR 上 skipped → 读代码:knex(table).count({ rows: '*' }) 三方言均把别名加引号(MySQL 8 的保留字 ROWS 以反引号包裹无碍),Number(rows) 吸收 PG 的字符串计数;columnInfo()、hasTable 与原分支同源。残留风险低,记为未测量:MySQL cell 由 main 的 push/schedule 批次首次测量。
  5. 跳过 MCP 写 assignee/reviewers → 派单禁写,接受。
  6. out_of_scope b-1:PUT /api/v1/meta/object/NAME 对被拒绝的 schema sync 仍答 200,拒绝只进服务器日志 → 卡片「Expected」写的是「the save is refused with a message that names the table and the column」。活树 ensureObjectStorage 的 docblock 与 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 明文「the publish is deliberately NOT failed」,改它既违反既有裁决又越出本卡文件面;dev 已如实在正文与 report 中标出,carrier 为 seat 另立卡。已升级,接受;本卡的核心(不丢数据、拒绝信息点名表与列)已在同步层兑现。
  7. out_of_scope b-2:os migrate plan 对 _id 表仍预告 add_columns,而 apply 在有行时拒绝 → 读 previewDeferredSchemaWork(sql-driver-schema-sync.ts 1988–1991 行)确认:预览只看列差,不看 _id 形状,此前预告 add_columns 而实际 drop+recreate,错配本就存在,本 PR 把 apply 一侧从「静默丢数据」变为「拒绝」,错配变小而未消失。已升级(carrier: seat),接受;见 ④ 的 should-fix。
  8. Acceptance note「after the refusal the object is served on the unchanged table, id: null」→ 这是任何被拒绝同步后的既有状态(registerObjectMetadata 在 DDL 前已登记),非本 PR 新增。接受。

④ Design judgment

读 main 上的已发布规范判断这次收窄本身:

  • 规范从未宣告过「丢表重建」这项能力。 packages/spec/src/contracts/data-driver.ts:394–397 对 syncSchema 的契约是「Idempotent: creates tables if missing, adds columns, updates indexes」——纯加法;datasource.zod.ts 与 datasources.md 对 managed 的全部承诺是「ObjectStack owns the schema; DDL + migrations allowed」。_id 分支是 cdd1bd15c7 从 ObjectQL 驱动原样搬来的实现细节,没有任何规范、ADR 或 skill 把「遇 _id 表即丢弃其行」当作消费者可依赖的行为。因此这次收窄不是在消费者处拿走一项宣告的能力,而是让规范自己的加法契约在它唯一被违反的分支上成真;它也不是「宣告了却没交付」的实现缺口——交付的恰是规范写明的东西。
  • 尝试打破设计(均读活树,未运行):
    • 抛出时机:count 在 dropTable 之前、columnInfo() 之后,tablesFoundExisting 已记录,registerObjectMetadata 已完成——与其他被拒绝同步的后态一致,无半写状态。
    • 数据源连接(managed 运行时数据源)、引擎启动、保存门三处均逐对象 catch,一张表被拒不牵连同数据源其他对象。
    • 空表 count=0 → drop 之间的 TOCTOU 是原分支既有窗口,本 PR 未扩大。
    • yes (narrowing) 之外的声明面、index.ts 出口、api-surface 均未动,Governed Surface Queue Guard success。
    • 测试:三条 pin 经 syncSchema 驱动(引擎的真实门),拒绝用例先断言前态非空再断言后态逐字节相等(快照含列集与全部行),CONTROL 两条覆盖空表重建与 id 表收编;通过 declareDialectCell 声明于三个 cell,未供给的 cell 以具名 SKIP 可见。SQLite cell 由 Test Core 覆盖。
  • should-fix(不阻塞,随 ③-7 的 seat 卡一并处理):os migrate apply 走 flushDeferredSchemaDdl → initObjects(pending) 一次传入全部延迟对象(sql-driver-deferred-ddl.ts:65–89),且在调用前已 deferredSchemaObjects.clear();一张有行的 _id 表在循环中途抛出,会让同一数据源排在其后的对象本轮得不到 DDL,performed 清单也随异常丢失,而 plan 事先未预告这项拒绝。改前的同一场景是静默丢行后继续,所以这是从「错得安静」变为「停得响亮」,方向正确;但完整形态应是 plan 预告一种 refused 工作项、apply 逐表报告而非中途中止。不构成 FAIL。
  • 无 must-fix。

check-runs on 9a71d244(定稿时全部 completed,无一 failure):success — Governed Surface Queue Guard、Check Changeset、Check PR Size、PR-body guards、Lint · ESLint、Lint · repo & tooling gates、Lint · PM dispatch-gates、Lint · CI, release & engine gates、Lint · docs & code guards、Lint · scoped ×3、Type Check · workspace / source gates / consumer gates / debt ledger、Build Core、Test Core(8/8 分片)、Test UI、Live E2E UI、Dogfood Regression Gate、Plan Test Core shards、Auto Label、Flag docs affected、filter;skipped(路径过滤或 opt-in,非本 PR 所欠)— Temporal Conformance (live PG + MySQL)、Type Check UI、Lint UI、Build & E2E UI、Build UI Deps、Build Docs、UI Published Artifacts、Dogfood Verify CLI、Packed-tarball smoke、18.x upgrade harness、Schedule gate。

Implemented-by: claude/project-thread-cs7yq4
Reviewed-by: session_01Utp6Cbvcmv8YWFhFJwzjKL-contract-review-30632

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL

维护者速读

席位核对 — ACCEPT · domain:server#2 · head 9a71d2443e0d42b61a1836d84787db1fad72184c

  • 形态: draft, author objectstack-fleet[bot] (relay pr_create), no assignee, no review request; 4 files, +211 −1; check-governed-merges --pr 30632: NOT governed (0 of 4), 212 lines ≤ 3000. Fixes #30562.
  • Review face: narrowing face (Clause-②: yes (narrowing), no schema or governed path) ⇒ CONTRACT_REVIEW_TIER over every path of the diff; record 6667636753 (PASS, no must-fix, independent of the drafter). No remainder for TIER_DEFAULT.
  • Seat rulings held: never drop a table that holds rows; new file existing-table-refusal.ts accepted (package precedent: dialect-emission-refusal.ts); refusal carries no ADR-0112 code (no door serves it: save, boot sync and datasource connect each catch per object and log at error); class not exported from index.ts, api-surface unchanged.
  • Seat read: the count runs after columnInfo() and before dropTable; Number(rows) absorbs Postgres' string count; tablesFoundExisting is recorded before the branch, so stats are unchanged. syncSchema is initObjects on one object, so both doors narrow alike; driver-sqlite-wasm / turso local inherit it, turso remote has no _id drop branch.
  • Deviations (declared): new file; no code; repo-wide pnpm lint left to CI (Lint · ESLint success); MySQL cell not run locally (Temporal Conformance skipped on PRs, first measured on the main push batch: stated in the PR body); MCP assign step skipped per the order. All accepted.
  • Dev readings (9a71d244): derived gates 59/59 exit 0, 0 NOT MEASURED; full dogfood under the verify lock VERDICT 0 (3496 passed, 36 skipped); ablations A1/A2 red on sqlite and pg, restored. os-dev-report 6667000624.
  • Follow-ups: Object save answers success when the schema sync behind it is refused: the caller never learns the table was not created or adopted #30635 (p2: the save answers success while the sync is refused); os migrate plan previews "add columns" for a _id-keyed table that apply then refuses (rows) or rebuilds (empty) #30636 (p3: os migrate plan previews add_columns for this shape; the record also notes apply stops mid-batch on the refusal, carried there).
  • 门禁 check-runs: 48 on the head, none failed; Temporal Conformance skipped on PRs by design.
  • 落地条款: Fixes #30562. Ordinary queue landing by this seat: pr_ready + automerge_enable on this head.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 21:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 21:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Managed SQL datasource: storing an object over an existing table that has _id and no id drops and recreates the table, and its rows are lost

1 participant