Uh oh!
There was an error while loading. Please reload this page.
fix(service-datasource): read the introspected primary key at the isPrimary/primaryKey seam - #11001
Conversation
…rimary/primaryKey seam Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 0 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 9db6252595eb1b8f194615d9234d6ed3296f4c56 && git checkout 9db6252595eb1b8f194615d9234d6ed3296f4c56
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a 5cdd4e41fe6e2759f35831f320aaca5843ceaa76 && git checkout -B drift-repro 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a && git merge --no-ff 5cdd4e41fe6e2759f35831f320aaca5843ceaa76
node scripts/docs-audit/affected-docs.mjs --json 7ab286e44aa6626e91e87f9d7d4a7ce881fec34a |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32551066936 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Part of #10676. The seam read is repaired and pinned; the object-draft half of that card is
not discharged here, because measurement turned it into a contract question rather than a
missing read. What remains, and why, is in "Not fixed here" below.
The defect
ExternalDatasourceServiceis typed against thepackages/specIntrospectedColumn(
contracts/schema-diff-service.ts), which spells the flagprimaryKey. Butplugin.tshands itthe driver's
introspectSchema()result unmodified, andSqlDriverimplements the otherIntrospectedColumn— the one inpackages/objectql/src/util.ts— which spells itisPrimaryandadditionally fills
table.primaryKeys.So the service reads a key no in-tree driver ever sets.
Measured on current
origin/main(368e7a06f), against a live in-memory SQLite database declaredprimary key (id)— the driver's own output, not a description of it:{ "name": "id", "type": "varchar", "isPrimary": true, ... } // primaryKey: undefined { "name": "customers", "primaryKeys": ["id"], ... }refreshCatalogfed thatundefinedintoExternalCatalogSchema, whoseprimaryKeykey isz.boolean().default(false). The default absorbed it silently, so the persistedexternal_catalogrecorded every column of every remote table as not being part of the remote key — including the
ones that are. Studio's schema browser and the boot gate read that record.
The repair
One module-local helper,
primaryKeyReader(table), reading the union of the three signals(
col.primaryKey,col.isPrimary,table.primaryKeys) rather than picking one, wired intorefreshCatalog.Union rather than a precedence chain, deliberately: no in-tree producer uses a
falseto negate akey another signal asserts — the falses are just "not a key", written by producers that fill exactly
one of the three. A precedence chain would drop a real key whenever the winning signal is the one
its producer left blank, which is the defect being repaired. Where the per-column flag and the
table-level list disagree, the union takes both; under-reporting a federated table's key costs the
caller its addressing key, and no in-tree consumer treats a column's PK-ness as an exclusive claim.
The extra spellings are read structurally off the value. Nothing in
packages/specorpackages/objectql/src/util.tsis touched — reconciling those two declarations is spec-owned and isnot this PR.
Note that no in-tree driver produces a disagreement today:
SqlDriverderivesisPrimaryfromprimaryKeys, so the two always agree — including where both are wrong together. See "compositekeys" below.
Clause ②
The response and record shapes do not change. A field that should always have carried the
introspected value starts carrying it:
external_catalogcolumns already declareprimaryKey, andalready emit it on every column — only ever as
false. This PR makes thetrues true.primaryKey. On thecatalog path, none does — the key is non-optional in
ExternalCatalogSchemaand is always present.On the object-draft path one does, which is exactly why that half is not shipped here; see below.
Composite keys
The union handles a disagreement, but on SQLite the two signals agree and are both short: a
composite key is truncated upstream in the driver, before this seam ever sees it.
introspectPrimaryKeysfiltersPRAGMA table_infoonrow.pk === 1, while SQLite numberscomposite members
1, 2, 3…. Measured ont.primary(['order_id','line_no']): PRAGMA reportsline_nowithpk: 2, and the driver returnsprimaryKeys: ["order_id"]. Filed as #10997;not repaired here (different package, different lane).
Not fixed here — and why it is a decision, not an omission
generateObjectDraftstill drops the key. Restoring it there is blocked on a contract question thatthis lane cannot answer, so it is escalated rather than guessed:
A field-level
primaryKey— i.e.fields.FIELDNAME.primaryKey, the exact key this generatorrenders — is not an authorable spec field key. Measured, with a positive control in the same
tsc program:
error TS2353: ... 'primaryKey' does not exist in type ...against theServiceObjectannotation the generator itself writes; the identical file with the key removedproduced no diagnostic.
ObjectSchema.safeParse→unrecognized_keysat["fields","id"]; identical definition withoutthe key →
success: true.That strict schema is a consumer keying on the absence of
primaryKey, so shipping the draft halfunder the Clause-② reading above would have been wrong. And there is no other authorable home for it:
ObjectExternalBindingSchemais astrictObjectwith no key for a remote primary key either.Filed as #11000 with the three candidate repairs (emit it anyway / reuse
externalId/add an authorable spelling) and the measurements behind each. Reported as
needs_decisionon#10676. Note #11000 is reachable today without this PR: the
opts.primaryKeypath already emitsthat key and the existing suite pins it.
Adjacent, untouched: #10712 (the same draft also fails
os buildon the missing namespaceprefix and absent
sharingModel) and #10998 (introspectSchemaalso omits the contract'sdialectand requiredintrospectedAt).The pin
The card's own diagnosis of why the suite was blind:
A pin that hand-wrote the fixed spelling would reproduce that blindness exactly. So
external-introspection-seam.test.tsdrives the service off a realSqlDriver.introspectSchema()against a live in-memory SQLite database, and asserts the producer's spelling directly — if the
driver ever starts emitting the spec spelling, that case reddens and the union read gets re-derived
rather than quietly relaxed.
Both directions are pinned: a table with a declared key marks that column and no other; a table
without one still yields a usable catalog entry and promotes nothing, least of all the first
column. Three further cases cover the seam read under a staged disagreement — hand-built on purpose,
and labelled as such in the file, because no live database can currently produce one.
Verification
5cdd4e41f, clean tree.Ablation — signature predicted in writing before mutating: reduce
primaryKeyReaderto thepre-fix read (
col.primaryKey === trueonly) ⇒ exactly 3 failures, all in the new file(
refreshCatalog carries the introspected key…, and both union cases), allexpected false to be true; the file's other 3 cases and all 22 other files stay green; totals 522/525.Observed, on the final bytes: 3 failed | 522 passed (525),
1 failed | 22 passed (23)files,the three named cases, all
AssertionError: expected false to be true. Prediction matched exactly.Restore proved byte-identical:
5707f16a98f62541e164a1dfe3ef09e28c3936c8before, mutated282cb7dfbacf759655198eaf7f6f0fbe22e52b2d,5707f16a98f62541e164a1dfe3ef09e28c3936c8after —restore leg re-run green at 525/525.
No rebuild sits between edit and run, argued from the files: the pin imports the mutated subject
by the relative specifier
../external-datasource-service.js, which vitest resolves tosrc/external-datasource-service.ts;packages/services/service-datasource/dist/does not exist inthis worktree at all; and the package's only vitest alias is anchored on
@objectstack/corealone,which cannot capture the subject. The driver does resolve through its
dist/— built, anddeliberately not mutated, so no leg depends on driver bytes.
pnpm --filter @objectstack/service-datasource testTest Files 23 passed (23)·Tests 525 passed (525)pnpm --filter @objectstack/service-datasource typecheckerror TS(and--listFilesconfirms the new test file is inside that program)check:changeset-gate-self-tests✓ check-empty-changeset --self-test: 118 assertions …check:objectui-changeset✓ objectui-range --self-test: all checks passedcheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) … none newcheck:test-source-aliascheck-test-source-alias OK — 72 packages with tests scannedcheck:type-source-resolutioncheck-type-source-resolution OK — 76 packages … scannedcheck:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none newcheck:type-check-coveragecheck-type-check-coverage: OK — 64/77 workspace packages type-checkedcheck:engine-double-contractcheck-engine-double-contract: OK — 376 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher✓ where-matcher conformance holds: 275 matcher(s) … none newcheck:nul-bytescheck-nul-bytes: OK (scanned 6340 text file(s) … no raw ASCII control bytes)check-adr-0087-registration.mjs✓ … this PR adds no declared-breaking changesetcheck-changeset-no-major.mjs✓ This diff introduces no major bump.check-ci-filter-parity.mjsOK: all 82 declared cross-package glob(s) … are coveredcheck-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diffcheck-plugin-teardown-shape.mjs✓ check:plugin-teardown-shape: 57 Plugin implementation(s) …check-affected-docs.mjsGate union derived on the final commit with a clean tree via
node scripts/pm/dispatch-gates.mjs,no path arguments (exit 0; 11 path-matched families + 5 convention-triggered). Every exit code above
was captured before any pipe.
Declared narrowings, so the gaps are on the record rather than implied:
check:type-check-debt --re-measurewas not run — it requires the whole workspace buildclosure, which is the full-farm build this container's shared verify lock is meant to keep out of
a per-card lane. Partial mitigation, not a substitute: the package's own
typecheckis green andtsc --listFilesconfirms the new test file is inside that program, which is the tsc run thatgate re-measures for this package. CI runs the gate.
SqlDriverwas executed (better-sqlite3, in-memory). Postgres andMySQL were read, not run — no server was reachable. The card claims the defect hits "every SQL
driver"; that claim is not inherited. What is measured is stronger than a per-dialect sweep for
this particular seam: the
isPrimaryassignment is a single dialect-independent site inintrospectSchema(if (primaryKeys.includes(col.name)) col.isPrimary = true), downstream of theper-dialect
introspectPrimaryKeys, so the spelling collision cannot vary by dialect. Only thecomposite-key truncation (driver-sql: SQLite introspection reports only the FIRST column of a composite primary key (
pk === 1vs SQLite's 1,2,3… numbering) #10997) is dialect-specific, and only SQLite's arm was measured for it.packages/create-objectstack/bin/create-objectstack.js, whichpnpm installmode-flipped (100644 → 100755) in this worktree. Not my edit, deliberately leftunstaged and out of the commit. It only widened the derived gate set (
packages/**families),never narrowed it.
#10676 remains open: the object-draft routing decision is still outstanding, so no closing
keyword is used anywhere in this body.
Generated by Claude Code