Skip to content

fix(driver-sql): introspectSchema() emits the spec introspection contract — primaryKey, dialect, introspectedAt (#10676, #10998) - #11124

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-10676-driver-emits-spec-introspection-shape
Aug 22, 2026
Merged

fix(driver-sql): introspectSchema() emits the spec introspection contract — primaryKey, dialect, introspectedAt (#10676, #10998)#11124
os-zhuang merged 3 commits into
mainfrom
claude/issue-10676-driver-emits-spec-introspection-shape

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Fixes#10676
Fixes#10998

Folded family: both cards are one clause of one ruling — the driver emits the spec shape — landing in one method. One commit per member, so neither half can be silently under-delivered.

Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 = 驱动侧对齐 spec 契约): packages/spec is the one contract and the driver aligns to it; the packages/objectql/src/util.ts local declaration converges on the spec import rather than keeping a second contract.

packages/spec itself is not edited. This is conformance to a contract it already declares.

The defect, measured before the change

Live in-memory SQLite, the driver's own bytes for a primary key (id) table on origin/main @ b302aabc7:

Object.keys(schema) ["tables"]
id column {"name":"id","type":"varchar","nullable":true,"defaultValue":null,
"isPrimary":true,"isUnique":true,"maxLength":"255"}
generateObjectDraft(...).source no "// Remote primary key:" line at all

Three declarations described one idea in two vocabularies — packages/spec/src/contracts/schema-diff-service.ts, packages/drivers/driver-sql/src/sql-driver.ts and packages/objectql/src/util.ts. Each side compiled against its own, and the value crossed between them with no compiler in the middle.

What changed

primaryKey on columns (#10676). The driver emits the spec spelling and no longer emits isPrimary — one spelling, so no consumer can key off the wrong one again. IntrospectedColumn is now derived from the spec import in both driver-sql and objectql, so a key added to the contract fails their tsc until the producer emits it.

dialect and the required introspectedAt (#10998).IntrospectedTable / IntrospectedSchema derive from the spec import too, which is what makes introspectedAt enforced rather than remembered. dialect is this.dialectName (sqlite / postgres / mysql / unknown) — deliberately not the raw Knex client and not the spec's SQLDialectSchema enum. The only in-tree consumer is suggestFieldTypeForSqlType(col.type, schema.dialect as SqlDialect), whose vocabulary in type-compat.ts spells PostgreSQL postgres; emitting the enum's postgresql would have put the key in Object.keys() while leaving every per-dialect alias exactly as unreachable as before — the omission wearing a fix's clothes. introspectedAt is stamped before the reads begin, so it never claims to cover a moment later than the first table actually read.

Two divergences are kept, explicitly and visibly (spelled as Omit from the spec type rather than left to drift): defaultValue stays unknown at the SQL layer because Knex reports null, and indexes is omitted rather than emitted empty, because indexes: [] tells a schema differ a table HAS no indexes when it merely was not asked. Both are filed, not guessed: #11122.

Acceptance, answered separately

cardcriterionresult at 86ccc458c
#10676generateObjectDraft off a realintrospectSchema() carries primaryKey✅ the draft source carries // Remote primary key: id; composite keys name every member in declared key order
#10998Object.keys() of a live result contains dialectandintrospectedAt["tables","dialect","introspectedAt"], dialect: "sqlite", introspectedAt a real ISO instant

⚠️Where the key surfaces changed under this card's feet, and the criterion is read accordingly. PR #11073 landed the same ruling's item 8: fields.FIELD.primaryKey is not authorable, so generateObjectDraft / renderObjectSource no longer render it and the introspected key survives as a comment in the generated source. So "the draft carries the key" is asserted on draft.source, and the new pin also asserts the definition stays free of the unauthorable key — an implementation that re-emitted it onto the field would satisfy the first half while re-opening a defect the platform's own validator refuses.

The pin the ruling requires

external-object-draft-real-introspection.test.ts drives generateObjectDraft off a real SqlDriver.introspectSchema() against a live database — the case the original suite could not be, because every other draft fixture hand-writes its remote schema in the spec spelling and is therefore blind to the seam. Reverse-verified: against the pre-fix driver dist it fails on exactly the two assertions the card is about (expected undefined to be '// Remote primary key: id'), and its negative case — a table declaring no key — passes in both directions.

The driver-side pin sql-driver-introspection-spec-contract.test.ts asserts the emitted bytes: primaryKey true on the key column and false on the others, 'isPrimary' in col === false, and the two schema keys with their values.

Cross-lane touch, and its exact boundary

packages/services/service-datasource/src/__tests__/external-introspection-seam.test.ts was built by the services seat as a deliberate landmine — it asserts the producer's spelling, so it reddens on purpose when the driver aligns. It did, exactly once (1 failed / 561 passed). Changed there, and nowhere else in that package:

  • the test's title;
  • its two producer assertions, flipped to the new direction (id.primaryKey === true, id.isPrimary === undefined);
  • the file docblock's tense, plus a note that the union read's isPrimary arm now has no in-tree producer.

Not touched: primaryKeyReader and its three-signal union read (col.primaryKey / col.isPrimary / table.primaryKeys). That belt is the services lane's, on its own one-release clock. Its docblock is now stale in two ways — it still names SqlDriver as the isPrimary producer, and its #10997 note still says the composite-key truncation is unrepaired upstream — recorded for that lane as #11123 rather than edited here.

Not in this family

#11000 remains open and untouched — a different key (fields.FIELD.primaryKey in a rendered definition) in a different place; aligning the driver does not make it authorable. #11009 (compare-and-set where, same file) and #11101 (PG/MySQL key ordering plus the silent catch … => []) are out of scope. #10997 is already merged.

Verification

All at 86ccc458c, all green, each read from the command's own verdict line:

  • pnpm --filter @objectstack/driver-sql exec vitest run1795 passed | 72 skipped
  • pnpm --filter @objectstack/objectql exec vitest run4034 passed
  • pnpm --filter @objectstack/service-datasource exec vitest run563 passed
  • typecheck for all three packages — clean
  • pnpm lint (repo-wide eslint . --no-inline-config) — clean, 127s, no narrowing needed
  • gate families re-derived with node scripts/pm/dispatch-gates.mjs against the real change set, all green: check:driver-conformance · check:durability-log-level · check:slot-lookup · check:test-source-alias · check:type-source-resolution · check-ci-filter-parity · check-engine-split-ratio (97.5%, no shallow-clone refusal) · check-plugin-teardown-shape · docs-audit/check-affected-docs · check:nul-bytes
  • convention-triggered (new test files): check:query-options-erasure · check:type-check-coverage · check:type-check-debt --re-measure (33 ledger entries, none above its recorded number) · check:engine-double-contract · check:where-matcher
  • changeset families: check-adr-0087-registration (disposition runtime-interface-only, mechanically verified) · check-changeset-no-major · check-empty-changeset · check:changeset-gate-self-tests · check:objectui-changeset

Dialects: only SQLite is executed here — Postgres and MySQL are unreachable from this container (no listener, no DSN). Both repaired values are built at a single dialect-independent site in introspectSchema, downstream of the per-dialect helpers, so the shape cannot vary by dialect. No per-dialect claim about primaryKeyscontent is made. Measured and worth stating plainly: on the SQLite arm, supplying the dialect leaves the suggested field types byte-identical for all 10 columns of a wide fixture — its alias map overlaps the base map — so the type-mapping payoff of dialect is on Postgres/MySQL and is not claimed here.

Generated by Claude Code

…rimary` (#10676)
`SqlDriver.introspectSchema` declared its own `IntrospectedColumn` and spelled
primary-key membership `isPrimary`, while everything downstream of `plugin.ts`
is typed against `packages/spec/src/contracts/schema-diff-service.ts`, which
spells it `primaryKey`. The value crossed between the two contracts untyped, so
`ExternalDatasourceService.generateObjectDraft` — which reads `col.primaryKey`
— saw `undefined` on every real driver result and every federated object
drafted from a remote table silently lost its remote primary key.
Maintainer ruling, 2026-08-22 (live session, 「同意所有」 item 9 =
驱动侧对齐 spec 契约): `packages/spec` is the one contract and the driver
aligns to it.
- `driver-sql` and `objectql/src/util.ts` now DERIVE `IntrospectedColumn` from
the spec import instead of re-declaring it, so a key added to the contract
fails their `tsc` until the producer emits it. Two divergences are kept
explicitly (`defaultValue` stays `unknown` — Knex reports `null`; `isUnique`
/ `maxLength` are SQL extras the spec does not declare).
- The driver emits `primaryKey` and no longer emits `isPrimary`: one spelling,
so no consumer can key off the wrong one again.
- New pin `external-object-draft-real-introspection.test.ts` drives
`generateObjectDraft` off a REAL `introspectSchema()` result, which is what
the ruling requires and what the hand-written fixtures could never do. It
fails on the pre-fix driver (measured: the `// Remote primary key:` line is
absent) and asserts the key does not return as the unauthorable
`fields.<f>.primaryKey`.
- `external-introspection-seam.test.ts` asserted the producer's OLD spelling as
a deliberate landmine; its producer-spelling case is flipped to the new
direction. The service's three-signal union read is untouched.
Fixes#10676
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
#10998)
`introspectSchema()` returned `{ tables }` and nothing else, while
`packages/spec/src/contracts/schema-diff-service.ts` declares `dialect` and a
REQUIRED `introspectedAt`. Measured live on in-memory SQLite before this
change: `Object.keys()` was `["tables"]`. Two silent consequences — type
mapping ran with `dialect: undefined` on the whole federation path, so every
per-dialect alias in `suggestFieldTypeForSqlType` was unreachable there, and
`refreshCatalog` persisted `dialect: undefined` into the `external_catalog`
record Studio's schema browser and the boot gate read back.
Same ruling as #10676 (2026-08-22, 「同意所有」 item 9): the driver aligns to
the one contract.
- `IntrospectedTable` / `IntrospectedSchema` now derive from the spec import in
both `driver-sql` and `objectql/src/util.ts`, so `introspectedAt` being
required is enforced by `tsc` rather than by remembering. `indexes` is
`Omit`ted rather than emitted empty: this driver does not introspect indexes,
and `[]` would claim a table has none — filed separately, not guessed.
- `dialect` is `this.dialectName` (`sqlite` / `postgres` / `mysql` /
`unknown`), NOT the raw Knex client and NOT the spec's `SQLDialectSchema`
enum: the only in-tree consumer keys `DIALECT_ALIASES` on the `SqlDialect`
vocabulary of `type-compat.ts`, which spells PostgreSQL `postgres`. Emitting
`postgresql` would satisfy `Object.keys()` while leaving the aliases just as
unreachable.
- `introspectedAt` is stamped before the reads begin, so it never claims to
cover a moment later than the first table actually read.
Measured after: `Object.keys()` is `["tables","dialect","introspectedAt"]`;
the persisted catalog records `dialect: 'sqlite'`. On the SQLite arm the
suggested field types are byte-identical before and after (its alias map
overlaps the base map); the per-dialect payoff is on Postgres/MySQL, which are
unreachable from this container and are therefore not claimed.
Fixes#10998
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/objectql, touching 8 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 422f997d50817d3a1992ad2321b63d516f65fbcf.

2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 18 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 422f997d50817d3a1992ad2321b63d516f65fbcfpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5609c9c58a93bb7a6a57b1d60102fdec107d2fec — the merge of head 86ccc458cc50b6b5705cfe8a68b884eed803a6d2 into base 422f997d50817d3a1992ad2321b63d516f65fbcf, 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 5609c9c58a93bb7a6a57b1d60102fdec107d2fec && git checkout 5609c9c58a93bb7a6a57b1d60102fdec107d2fec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 422f997d50817d3a1992ad2321b63d516f65fbcf 86ccc458cc50b6b5705cfe8a68b884eed803a6d2 && git checkout -B drift-repro 422f997d50817d3a1992ad2321b63d516f65fbcf && git merge --no-ff 86ccc458cc50b6b5705cfe8a68b884eed803a6d2
node scripts/docs-audit/affected-docs.mjs --json 422f997d50817d3a1992ad2321b63d516f65fbcf

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 422f997d50817d3a1992ad2321b63d516f65fbcf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32593706263 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.12s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 92 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude