Skip to content

fix(driver-sql): introspectPrimaryKeys returns the Postgres and MySQL key in declared key order - #11164

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11101-introspect-pk-key-order-pg-mysql
Aug 22, 2026
Merged

fix(driver-sql): introspectPrimaryKeys returns the Postgres and MySQL key in declared key order#11164
os-zhuang merged 1 commit into
mainfrom
claude/issue-11101-introspect-pk-key-order-pg-mysql

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11101

SqlDriver.introspectPrimaryKeys ordered its result on exactly one of its three dialect arms. #10997 repaired SQLite (completeness and key ordering, by sorting on the PRAGMA table_info ordinal); the Postgres and MySQL arms returned the composite key in unspecified row order.

primaryKeys is consumed as an addressing / upsert-conflict-target key — federated-object codegen, the persisted external_catalog under ADR-0015, schema-drift comparison against a declared key. For those consumers a key in the wrong order is a different key, and the same table introspected through different dialects disagreed. All three dialects now agree.

⭐ The card's founding condition — this SQL was executed, not reasoned about

#11101 concluded it needed "a seat with a reachable Postgres and MySQL", because neither arm was executable in an agent container. That was true of the container but not of the work. Two live servers were stood up in this session and every measurement below is from them:

versionmatches CI'sprovisioning
Postgres16.13postgres:16postgresql-16 was already installed in the container, simply not running — pg_ctlcluster 16 main start
MySQL8.0.46mysql:8.0apt-get install mysql-server-8.0 from the distro archive

Both were configured to CI's settings (timezone=Asia/Shanghai / default_time_zone='+08:00'), and the suite was run at TZ=America/New_York with OS_EXPECT_LIVE_DIALECT_MATRIX=1 — i.e. the exact env of the Temporal Conformance (live PG + MySQL) job, with a missing URL made fatal so no live cell could skip silently.

So the risk the card refused to take — landing a rewritten pg_index query that no real server had ever parsed — is not being taken. The prior "unverifiable in this container" conclusion should not be carried forward to the next driver-sql card.

The two rewrites

Postgresa.attnum = ANY(i.indkey) is a membership test. i.indkey is an int2vector holding the key's attnums in key order, but ANY() reads the vector as a set and discards the position; there was no ORDER BY. It now joins the ordinality of indkey:

FROM pg_index i
CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_attribute a ONa.attrelid=i.indrelidANDa.attnum=k.attnumWHEREi.indrelid= ?::regclass ANDi.indisprimaryORDER BYk.ord

Four spellings were tried against the live PG 16.13 before choosing — unnest … WITH ORDINALITY (with and without the explicit LATERAL), generate_subscripts, and array_position(i.indkey::smallint[], a.attnum). All four returned the declared order; the ordinality form is the one the card named. It was then re-checked against a single-column key, a keyless table, and a three-part key.

MySQLKEY_COLUMN_USAGE.ORDINAL_POSITIONis the key ordinal and was selected by neither the projection nor an order clause. It now carries ORDER BY ORDINAL_POSITION.

The fixture: a key declared OUT OF COLUMN SEQUENCE

A key that follows its columns proves nothing — column order is exactly what the unordered queries already returned. One DDL runs on all three dialects (varchar(64) rather than text, so MySQL will take the columns into a primary key):

createtableos11101_shipment_legs (
carrier_code varchar(64) not null,
shipment_id varchar(64) not null,
leg_seq integer,
primary key (shipment_id, carrier_code)
)

A second table declares primary key (b, c, a) over columns (c, a, b, d) — a genuine permutation, so an arm that merely reversed or sorted the rows cannot pass either.

What each dialect returned, before and after

Measured through the driver on the live servers. The "before" column is a real ablation run — the fix was reverted with git checkout origin/main -- sql-driver.ts (the revert confirmed on disk by grepping for both the removed and the injected text) and the suite re-run against the same servers:

dialectdeclared keybeforeafter
SQLite(shipment_id, carrier_code)shipment_id, carrier_codeshipment_id, carrier_code
Postgres 16.13(shipment_id, carrier_code)carrier_code, shipment_idshipment_id, carrier_code
MySQL 8.0.46(shipment_id, carrier_code)carrier_code, shipment_idshipment_id, carrier_code
Postgres 16.13(b, c, a)c, a, bb, c, a
MySQL 8.0.46(b, c, a)c, a, bb, c, a

Both live dialects returned exactly column order — the key reversed for the two-part fixture. SQLite was already correct (#10997), which is what makes the ablation a control rather than just a red.

⚠️The MySQL result contradicts the card's own expectation, and is worth recording.#11101 says "InnoDB tends to return ordinal order in practice, but nothing in the query requires it." On MySQL 8.0.46 it did not: the unordered query returned column order for an out-of-sequence key. This was a live defect on shipping code, not a theoretical one.

The ablation direction was predicted before running and matched exactly: 4 red (two ordered legs × two live dialects), with SQLite, the catalog-fact legs, and the per-column primaryKey flag legs all staying green.

⛔ The silent catch dictates the test shape (and is NOT changed here)

The whole method body is wrapped in catch { } and returns []. A query that is invalid on a live server does not fail loudly — it degrades to "no primary key at all", with no diagnostic. So every assertion in the new test is positive and ordered, on the exact array. A test asserting "does not throw", or checking membership/set equality, would be worthless: it stays green over total key loss. The helper checks length first so a degradation reads as the silent catch ate the query rather than as an uninterpretable diff.

Per this card's boundaries the catch itself is untouched — it is an error-contract change with its own blast radius. It is filed separately as #11161 with the evidence this work produced, including the finding that the repo already ruled on this exact question for introspectIndexes (#7332 gave it onFailure?: 'throw' | 'partial', defaulting to throw) and never applied the ruling to the three sibling methods. This PR does not pin the catch's behaviour in either direction.

Tests

packages/drivers/driver-sql/src/sql-driver-primary-key-order-dialects.test.ts — new. Declared through declareDialectCell, so the live cells are a named skip without the URLs and a hard failure under OS_EXPECT_LIVE_DIALECT_MATRIX=1; they execute for real in Temporal Conformance (live PG + MySQL), a required check. Agreement across dialects is by construction: every cell runs the same DDL and asserts against the same constant.

It also carries a non-vacuity guard — if a later edit ever flattens the fixture's key back into column sequence, the guard fails rather than quietly turning every assertion into a tautology the buggy query also passed — and per-dialect catalog pins for the facts each rewrite rests on (pg_index.indkey is in key order; KEY_COLUMN_USAGE.ORDINAL_POSITION is the key ordinal). The row order of the unordered query is deliberately not pinned: it is unspecified by both engines, and asserting the reversal these servers happen to produce would pin behaviour neither vendor promises.

Verification — all at 9e7e37509 (the final commit)

whatresult
pnpm --filter @objectstack/driver-sql test against live PG 16.13 + MySQL 8.0.46, TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1113 files / 2363 tests passed
the new file alone, verbose, on live servers14/14 passed — 4 sqlite, 4 live postgres, 4 live mysql, 2 catalog pins
ablation (fix reverted, same servers)4 failed / 10 passed — exactly the predicted legs
pnpm --filter @objectstack/driver-sql typechecktsc --noEmit, exit 0
pnpm lint (eslint . --no-inline-config, whole repo, not narrowed)exit 0
18 gates from node scripts/pm/dispatch-gates.mjsall exit 0

Gates run: check:changeset-gate-self-tests, check:driver-conformance (OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt), check:objectui-changeset, check:slot-lookup, check:test-source-alias (OK — 72 packages with tests scanned), check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-empty-changeset, check-plugin-teardown-shape, check-affected-docs, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract (OK — 383 pinned), check:where-matcher, check:nul-bytes (OK, scanned 6463 text files), and check:adr-anchors — the last added by hand because sql-driver.ts has an anchor file (ADR-0120) that no path derivation names.

Out of scope, filed separately

Three findings, none of them touched here:

Also untouched, per the card: everything else in sql-driver.ts (#11067 is queued against the same file), packages/spec, and content/docs/releases/.


Generated by Claude Code

…n declared key order (#11101)
`SqlDriver.introspectPrimaryKeys` ordered its result on exactly one of its three
dialect arms. #10997 repaired SQLite (completeness and key ordering, by sorting
on the `PRAGMA table_info` ordinal); the Postgres and MySQL arms returned the
composite key in unspecified row order.
- Postgres: `a.attnum = ANY(i.indkey)` is a MEMBERSHIP test. `i.indkey` is an
`int2vector` holding the key's attnums in key order, but `ANY()` reads it as a
set and discards the position, and the query carried no `ORDER BY`. It now
joins the ordinality of `indkey` (`unnest(i.indkey) WITH ORDINALITY`) and
orders by that ordinal.
- MySQL: `KEY_COLUMN_USAGE.ORDINAL_POSITION` IS the key ordinal and was selected
by neither the projection nor an order clause. It now carries
`ORDER BY ORDINAL_POSITION`.
Both arms were measured returning COLUMN order — the key reversed — on live
servers before the fix: PostgreSQL 16.13 and MySQL 8.0.46, over a table declared
`(carrier_code, shipment_id, leg_seq)` with `PRIMARY KEY (shipment_id,
carrier_code)`. Notably InnoDB did NOT return ordinal order, contradicting the
usual folklore.
`primaryKeys` is consumed as an addressing / upsert-conflict-target key
(federated-object codegen, the persisted `external_catalog` under ADR-0015,
schema-drift comparison), so a key in the wrong order is a DIFFERENT key. All
three dialects now agree on the same table.
The method's silent `catch { return [] }` is deliberately NOT changed here — it
is a separate error-contract decision with its own blast radius, filed as its own
finding. It does dictate the test shape: every assertion is positive and ordered,
because a query a server rejects degrades to "no primary key at all" and a
does-not-throw or set-equality test would stay green over total key loss.
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

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 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 — 9 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 c74aefe636bfa9ba68ed4a85b319db20d9cf2907packageMentionDocs.

Which tree this was computed on

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

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

@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    ✗ Build failed in 5.80s
    

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

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

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

历史信号:

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

分诊清单:

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

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

Merged via the queue into main with commit 927ccbbAug 22, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11101-introspect-pk-key-order-pg-mysql branch August 22, 2026 23:27
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-sql: the Postgres and MySQL introspectPrimaryKeys arms return the key in UNSPECIFIED row order — neither orders by key position

2 participants

@os-zhuang@claude