Repository navigation
refactor(driver-sql): move the upsert conflict-target path out of sql-driver.ts into sql-driver-upsert-conflict-target.ts (#25521 cut 14) - #26408
Conversation
…-driver.ts into sql-driver-upsert-conflict-target.ts (cut 14) Move-only. assertConflictTargetHonoured, assertConflictTargetBackedWithoutClause, introspectKeyIndexes, upsertTenantGuard and upsertConflictRefusal move their bodies into the new seam module behind UpsertConflictTargetHost; the class keeps every method as a stub with its docblock and signature. indexCoversConflictTarget, unbackedConflictTargetCause and refuseAmbiguousConflictTarget move with them; refuseUnbackedConflictTarget, refuseUpsertConflictOutsideWrittenOrganization and ConflictTargetVerdict gain export in place. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cyh2bm4otUm5Vnb5Zw2KDT
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 39ee0fae4e1e6aa77f901ada4d3123f5f8501a3d && git checkout 39ee0fae4e1e6aa77f901ada4d3123f5f8501a3d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c51aed4f9d9bc346274c03bc2ac1aaafbf2e8d7a 8adeb4ba47b2c8ab7a983edbd6139e242110666f && git checkout -B drift-repro c51aed4f9d9bc346274c03bc2ac1aaafbf2e8d7a && git merge --no-ff 8adeb4ba47b2c8ab7a983edbd6139e242110666f
node scripts/docs-audit/affected-docs.mjs --json c51aed4f9d9bc346274c03bc2ac1aaafbf2e8d7a
|
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Part of #25521
Clause-②: no
Metadata-protocol: none
Ruling-ref: 6270097840
Ruling-ref: 6348891160
维护者速读
sql-driver.ts里 upsert 的「冲突键预检」代码(判断表上是否真有能承接conflictKeys的唯一索引、MySQL 下是否会落到别的唯一键上,以及多组织写入的组织栅栏两个小助手)原样搬进新文件sql-driver-upsert-conflict-target.ts;类上的方法全部保留,只把方法体转交过去。src/sql-driver.ts(22,096 lines, +2,533 in 30 days) along itsSqlDriverseams, one move-only slice per PR #25521 拆分超大文件的第 14 刀;这块在剩余可搬区域里收益最大,sql-driver.ts减少 228 行。sql-driver-upsert-conflict-target.ts的头注释与 host 接口;sql-driver.ts中五个方法的转交桩;refuseUnbackedConflictTarget上方那段文档注释如今重新紧贴它自己的函数。pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-upsert-conflict-target-dialects.test.ts src/sql-driver-upsert-conflict-target-envelope.test.ts,预期全部通过。What changes
Cut 14 of the
sql-driver.tssplit (#24586 ruling A, recorded in6270097840). The claim is6431182919. The cut opens with a fresh seam table (below), taken onorigin/mainabb904605(cut 13's merge), and takes the area that pays best without breaking a series limit: the conflict-target path ofupsert. It moves unedited into a new seam module,packages/drivers/driver-sql/src/sql-driver-upsert-conflict-target.ts, through a new hostUpsertConflictTargetHost:assertConflictTargetHonoured, the MySQL pre-flight (drivers(sql): MySQL silently merges an upsert on a unique key the caller never named, and rewrites the merged row's primary key — measured, and it is a wrong write with no error #8621 unbacked, drivers(sql): on MySQL an upsert still merges on a unique key the caller never named — even when the named conflict target IS backed #8755 ambiguous, and drivers(sql): on MySQL an upsert with no conflictKeys — or one naming the primary key — still merges on a unique key the caller never named #8807's identity-at-risk verdict);assertConflictTargetBackedWithoutClause, its drivers(sql):upsert's empty-merge-set fallback is merge-ALL, which re-admits every insert-only column — currently unreachable, but it is the wrong shape for "nothing to merge" #8740 twin for a statement with no conflict clause;introspectKeyIndexes, the cached PRIMARY KEY / UNIQUE read both consult;upsertTenantGuardandupsertConflictRefusal, the [security] driver upsert: a tenant-scoped upsert keyed on a globally-unique business column can merge into, and re-parent, another tenant's row #21185 organization-fence helpersdriver-tursoalso calls on its own instance.The change is move-only, and the card stays open for later cuts. There is no
docs/adr/**edit, nopackages/spec/**edit, no anchor entry change, no test change, no gate edit and no new gate. Branchclaude/project-thread-h28rw8(the platform assigned it to this card thread, as the claim records). The move is one commit,84d0e45bd, onabb904605.Net line count (the seat's rule).
sql-driver.tsgoes from 20,022 lines to 19,794: -228.indexCoversConflictTarget(11),unbackedConflictTargetCause(11) andrefuseAmbiguousConflictTarget(103).Why -228 and not the -320 cut 13 priced. Two corrections, both measured on this base:
indexCoversConflictTarget's leading trivia holds two earlier comments that are not its own: the[#8445 → #8567]note onisUnbackedConflictTargetErrorand the[#8445]docblock ofrefuseUnbackedConflictTarget(80 lines; the fix(driver-sql): upsert with nothing left to merge is a plain INSERT, not merge-ALL #26015 insertion ofindexCoversConflictTargetandunbackedConflictTargetCausehad separated that docblock from its function). They describe code that stays, so they stay. With the two functions gone, the[#8445]docblock again sits directly onrefuseUnbackedConflictTarget.ConflictTargetVerdict(15 lines) is the declared return type of the class methodassertConflictTargetHonoured, whose signature stays in the class (and in the published.d.ts). It stays, exported in place.Serial with the file's in-flight PRs. I read the full file list of every open PR at start (23 PRs, every page of each). Before cut 13 merged only #26377 touched
sql-driver.ts. No other open PR touchessql-driver.ts,turso-driver.ts,sqlite-wasm-driver.ts,check-tenant-chokepoint.mjsor the driver-sql anchor entries. #26138 touchesdriver-sql/src/builtin-column-collision.tsonly; #26337 addsdriver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.tsonly.Seam table, fresh, on
abb904605sql-driver.tsis 20,022 lines. The 30-day column countsgit blamelines from commits on or after 2026-09-27T01:27:21Z, 30 days before the base commit. History reaches 2026-09-20.scripts/pm/git-history.mjs countproves 74 commits on the file in 30 days and 32 in the last 7. In all, 3,490 lines (17.4%) are from the last 30 days.SqlDriverspans lines 5273-19947 with 350 members: 243 instance methods, 13 accessors and 32 statics, all 32 private or protected.!andsatisfiesbefore it readsthis. Sections are the class's own banner comments.name…assertDialectEmitsconnect…disconnectfind…deletebulkCreate…countrawStatementFault…noteRuntimeIndexDdlbeginTransaction…rollbackTransactionaggregate…unresolvableAggregateColumnRefusaldistinct…unresolvableDistinctColumnRefusalfindWithWindowFunctions…findWithWindowFunctionsexplain…analyzeQuerysyncSchema…reclaimSpacerotationStateByTable…migrateMysqlTimeColumnssetDeferredDdl…flushDeferredSchemaDdldialectName…createNullSafeUniqueIndexlistPhysicalColumns…introspectSchemagetKnex…auditMissingTenanttableNameForBuilder…applyFalseConstantmapSortField…mapAggregateFuncbuildWindowFunction…buildWindowFunctionnowColumnDefault…applyDeclaredColumnDefaultsqliteFilename…isJsonFieldformatInput…formatOutputintrospectColumnOrder…introspectUniqueConstraintsPricing the areas
Eligibility is as cuts 11-13 defined it: an annotated return type or not public; no
getBuilder,this.FIELD.delete/insert/update(, private-static,super, bare-thisorthis-write site; notinsertOnlyUpsertColumns. 129 members are eligible on this base. Each candidate area is priced as its stubs minus its bodies, then net of the module-level declarations it shares with members that stay (they stay in place, exported; ⛔ no second copy of a helper), counting only a declaration's own docblock.assertConflictTargetHonoured,assertConflictTargetBackedWithoutClause,introspectKeyIndexes,upsertTenantGuard,upsertConflictRefusal)indexCoversConflictTarget,unbackedConflictTargetCause,refuseAmbiguousConflictTarget), +4 for the module importbackendStatementFault)backendStatementFaultErrorwith its docblock)ensureRotation,registerExternalObject,warnBuiltinColumnCollisions, …)migrateMysqlDatetimeColumnsandmigrateMysqlTimeColumns(-71) stay under the cut 12 durability-gate trap, leaving about -124.compileFilters-75,applyNormalizedComparison-60, …)filterArrayReachedDriverError(-44)applyFilterCondition, which an ADR-0139 pointer pins in place.Why this area
upsertasks of the table's physical keys before it compiles, and the fence it applies afterwards.upsertbuilds throughgetBuilder(2 sites) and stays, withassertMergeLandedInWrittenOrganization,assertMergeLandedOnSuppliedIdentityandmergeLandsInWrittenOrganization.insertOnlyUpsertColumnsstays (drivers(sql):upsert's empty-merge-set fallback is merge-ALL, which re-admits every insert-only column — currently unreachable, but it is the wrong shape for "nothing to merge" #8740).refuseUnbackedConflictTarget(also thrown byupsert's reactive catch),refuseUpsertConflictOutsideWrittenOrganization(also thrown byupsertandassertMergeLandedInWrittenOrganization) andConflictTargetVerdict(the class method's return type) are shared. Each gains a leadingexportand nothing else, and the module imports them back. The module reads the two functions only inside function bodies, so the import cycle is evaluation-safe.index.tsnames its exports one by one; the export lists below prove none of the three leaks.rotationWriteTarget,introspectIndexes,resolveTenantFieldandremoteColumnare read by members that stay too. The moved bodies reach them through the host.getBuilder, reads a static, has athis.FIELD.delete/insert/update(site, usessuperor a barethis, or writes tothis(introspectKeyIndexescalls.get/.seton thephysicalKeyIndexesmap it reads through a getter, as before). Every one has an annotated return type; three are protected and two private.check:tenant-chokepoint's D8 predicates, measured with its ownnullArmsOf: 4 at base and 4 at head, all insql-driver.ts, 0 in the new module.upsertTenantGuardreads the tenant field to build the fence descriptor and holds no predicate; disclosed because the new module is outside the gate'sSCAN_FILES.introspectKeyIndexeshas the onecatchin the moved code; it returnsnulland reaches no vocabulary callee.check:durability-log-levelreads 48 seams (7 propagating) at both ends.TursoDrivercallsupsertTenantGuardandupsertConflictRefusalon its own instance; both stubs keep their signatures and stayprotected.TursoDriverandSqliteWasmDriveroverride none of the five. Every member the bodies call is a late-bound closure on the instance.sql-driver.ts#…pointer or asql-driver.ts::…key. Prose mentions (live-dialect-matrix.isolation.test.ts,sql-driver-introspection.ts,sql-driver-upsert-conflict-target-envelope.test.ts, the turso parity tests, the driver-sql CHANGELOG) name the functions orsql-driver.tsas a file, and each is still true. No test reads these bodies' text by path, so no test changes.The shape (#23050 shape B, as cuts 1-13 apply it)
asyncmethods dropasync(each stub returns the moved function's promise; the.d.tsdoes not carryasync). Its body is now one host literal plusNAME.call(host, ...), marked[#25521 cut 14].upsertConflictRefusalreads no member, so its stub calls the function directly (the cut 11 shape).physicalTableByObject,physicalKeyIndexes). Methods go as closures that call the method on the driver at call time (introspectIndexes,introspectKeyIndexes,remoteColumn,resolveTenantField,rotationWriteTarget). So a subclass override, an instance spy, or a field replaced after construction is still the one the body reaches (probe below).UpsertConflictTargetHostlists exactly those seven members, each typed as the class member's indexed access. There is no cast, and no member becomes public.StandardErrorCode(@objectstack/spec/api, read by the movedrefuseAmbiguousConflictTarget),type DriverOptions(@objectstack/spec/data),type PhysicalIndex(./schema-drift.js), and from./sql-driver.jsthe three shared declarations plustype SqlDriver. Nosql-driver.tsimport lost its last reader.sql-driver.tsimport comment names cut 14 in 3 lines. The header cites noADR-NNNN Dn.ADR anchors
check:adr-anchorscounts 1877 decision-letter citations at baseabb904605(a clean detached worktree) and 1877 at head, over 91 anchored files at both ends. Both runs exit 0.ADR-0120 D1near this code is inupsertTenantGuard's docblock, which stays on the class method.sql-driver.tsentry's one paragraph (ADR-0053) describesformatOutputandpresentReadValue, neither of which moved. Noscripts/adr-anchors/**file changed.The published
.d.tsdoes not changeThis is measured under the series bar (ruling
6348891160, item 1).pnpm turbo run build --filter=@objectstack/driver-sql --forceat each end, against the same dependency tree. The base leg writes basesql-driver.tsinto this worktree and deletes the new module, under an EXIT/INT/TERM trap that restores HEAD. The restore is proved by the HEAD blob hashes (25041557,bcb95569), an emptygit diff HEADand 0 status entries.dist/index.d.tsanddist/index.d.mtsis the same at base and at head,0f33bf3febaac3c7aa96f68a4226d752766b8203ab7acade523b69f9aaa44d74(unchanged since cut 8).protectedfromassertConflictTargetHonouredin the headindex.d.ts. The result hashes to0f7f0de877d0becf5acec215a618fa3922518b60690f91898bec355659e1e21a, with a 1-line diff andcmpexit 1.requireof the head dist gives 59 names, the same set as its ESMimport(sha105757ce5ae); the ESM export statement of the base and headindex.mjslists the same 59 names (sha105757ce5aeboth). None of the three newly exported declarations, and nothing from the new module, is among them..call(hostsites inindex.mjsgo from 47 at base to 51 at head (four stubs; the fifth calls directly).Move-only proof
The proof compares TypeScript compiler AST leaf tokens (JSDoc nodes skipped), base
abb904605against head84d0e45bd. It is a scratch script and is not committed.thisupsertTenantGuardupsertConflictRefusalintrospectKeyIndexesassertConflictTargetHonouredassertConflictTargetBackedWithoutClauseasyncmatches the base method on all five functions.asyncon the three async methods.sql-driver.ts. The three shared ones differ from base by a leadingexportand nothing else.refuseUnbackedConflictTarget's leading trivia is now exactly the 80 lines of earlier comments that stayed fromindexCoversConflictTarget's trivia (checked byte for byte). Atsql-driver.ts's top level, 194 of 201 base statements are byte-identical. The rest are the three moved declarations, the three exports and the class; the one head-only statement besides those is the new import.PROOF OK, exit 0.scripts/ablation-replace.mjs, I changedintrospectKeyIndexes(tableName, { fresh: true })to{ fresh: false }in the module (1 hit, checked on disk). The proof then reportsassertConflictTargetHonouredwith its body tokens differing, printsPROOF FAILED, and exits 1. The restore was proved (blob equals HEAD, emptygit diff HEAD).Tests
Each end ran under one
os-verify-lockhold, after building that end's driver-sql dist. I compared the vitest JSON per test name and status, baseabb904605against head84d0e45bd. Every part's exit code was captured on its own (build, probe, three suites: all 0 at both ends).@objectstack/driver-sql@objectstack/driver-turso, against that end's built driver-sql dist@objectstack/driver-sqlite-wasmtsc --noEmit -p .in driver-sql exits 0.pnpm exec eslint --no-inline-config packages/drivers/driver-sqlexits 0.upsertTenantGuardon six rows (no options, emptytenantId, the tenant row, another organization's row, a row without the value, a non-tenant object) andupsertConflictRefusalon one, two, empty and absent keys, with instance spies on all five host methods showing the calls in order;introspectKeyIndexescold, cached,fresh, on a keyless table and a missing one, withintrospectIndexesreplaced on the instance by a throwing read (null), and with thephysicalKeyIndexesmap replaced on the instance after construction (the new map is the one read);assertConflictTargetHonouredon every verdict: ambiguous named and unnamed, identity at risk (idwith rivals) on two tables, honoured, unbacked named and unnamed, a composite unbacked target, a missing table, and a stale cache re-read before a refusal;assertConflictTargetBackedWithoutClausebacked, unbacked, onid, and on a missing table;rotationWriteTarget,resolveTenantField,remoteColumnandintrospectIndexes, each reached, andphysicalTableByObjectshadowed on the instance after construction (the refusal'scausenames the shadowed table);upsert()/find()calls end to end on SQLite: a seed and a merge, two unbacked refusals through the reactive catch (one tenant-scoped), and tenant-scoped composite upserts in two organizations, read back;code,status, message andcauseincluded. The other one is the bad-argument row ofupsertTenantGuard: it throws the sameTypeErrorwith the same message, and only the top stack frame's receiver name differs,_SqlDriver.upsertTenantGuardat base andObject.upsertTenantGuardat head. That is the host-literal receiver of shape B, the same at every earlier cut, and no test or consumer reads it.test:repo. Therepoprojects of types (11), core (83), objectql (5), spec (1279) and rest (280 passed, 18 skipped) pass locally after building rest's closure. Runtime's is NOT MEASURED locally; CI's Test Core runs all of them.origin/main(8adeb4ba47, 11 commits, nopackages/drivers/**file):tscexits 0, the two upsert conflict-target test files pass (22 passed, 3 skipped), andcheck:adr-anchorsstill reads 1877.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 53 commands for the 2 changed paths (merge baseabb904605). On84d0e45bd, 52 exit 0.check:dual-build-cjs-loadsexits 3,PREREQUISITE NOT MET(no full build here): NOT MEASURED locally, CI runs it. The teardown self-test's pinned commit was fetched first, and objectql was built forcheck:lean-entry-closure. After the merge oforigin/mainthe list gains one family,node scripts/ci/plan-test-core-shards.mjs --self-test, which exits 0 on8adeb4ba47.dispatch-gates --ran: 54 derived families accounted for, 53 run, 1 NOT-MEASURED (derived from the recorded exit 3), exit 0.abb904605) and at head, all exit 0:check:adr-anchorscheck:adr-symbol-anchorscheck:tenant-chokepointcheck:durability-log-levelcheck:driver-conformancecheck:keyed-text-boundscheck:object-def-param-keyscheck:system-context-censuscheck-tenant-audit-censuscheck:logger-receiver-detachcheck:nul-bytespasses. The pre-pushcheck:commit-card-trailerspassed on push.Acceptance notes
check:tenant-chokepoint's D8 rule does not scan the new module. The moved bodies hold 0 of the file's 4 tenant predicates (measured with the gate's ownnullArmsOf), and none builds a query. Noted, not filed: wideningSCAN_FILESwould be a gate change.[#8445]docblock ofrefuseUnbackedConflictTargethad been separated from it by the two helpers fix(driver-sql): upsert with nothing left to merge is a plain INSERT, not merge-ALL #26015 inserted; with them moved it again sits on its function. Its bytes are unchanged, and the published.d.tsdoes not carry it (the function is not exported fromindex.ts).backendStatementFaultwithbackendStatementFaultError) pays about -216; it readslogger, so it needs acheck:logger-receiver-detachandcheck:durability-log-levelscreen first.Human Minimum
packages/specchange.UpsertConflictTargetHost, follows the interface casing.eslint --no-inline-configpasses (repo-widepnpm lintis CI's run).main, one pull request.skip-changeset; both declaration lines above.packages/specnot touched.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01Cyh2bm4otUm5Vnb5Zw2KDT