Repository navigation
fix(objectql,driver-turso): the one-definition-of-multi-valued invariant reaches packages/objectql and driver-turso - #18668
Conversation
…ant reaches these two packages Maintainer ruling 2026-09-13 (decision batch #128 item 5, option 1'): there is ONE definition of "is this field multi-valued", `@objectstack/spec`'s `isMultiValueField`, and storage follows it. driver-sql was aligned by #17469 and `os generate migration` by #18199; these four sites read `field.multiple` raw, which answers `true` on types the predicate calls single-valued and `false` on the inherently-multi option types that carry no flag. - objectql: one local seam, `declaredMultiValued`, for the option-derived insert default, the referential-integrity dependents probe and the cascade-delete multi-value verdict; record-validator's select/radio gate is now the negation of its own multi-value branch's predicate. - driver-turso: `RemoteTransport.mapFieldTypeToSQL`'s short-circuit asks the predicate, so this driver's two transports stop declaring one field's column in two different storage classes. Comments that named the losing authority are rewritten rather than left standing. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…lti-valued-invariant-objectql-turso
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 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 384548d56a0bfb6ee1125e54980250a54d92ab5f && git checkout 384548d56a0bfb6ee1125e54980250a54d92ab5f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 14d25441138aee0a52621f9369d564afa28df9d4 66de450f648e770f4b263394f28dc0b135e7eed4 && git checkout -B drift-repro 14d25441138aee0a52621f9369d564afa28df9d4 && git merge --no-ff 66de450f648e770f4b263394f28dc0b135e7eed4
node scripts/docs-audit/affected-docs.mjs --json 14d25441138aee0a52621f9369d564afa28df9d4
|
…silently dropping them (objectstack-ai#18717) Fixes objectstack-ai#18616 Clause-②: yes The Turso **remote** face now refuses transactions with `NOT_IMPLEMENTED` / `501` instead of accepting them and doing nothing with them. Local and embedded-replica modes are byte-for-byte unchanged in behaviour. ## What was wrong `@objectstack/spec`'s `driver.zod.ts` states the delivery mechanism verbatim: > A transaction handle to be passed to subsequent operations via `options.transaction`. On the remote transport nothing could receive it. ⭐ Every reading below was **re-derived by content on this branch's base** `f8eaf670454a`, which contains `ef67b47afb` (PR objectstack-ai#18668) — that PR edited `remote-transport.ts` after the card was written, so the card's line numbers no longer locate anything: | reading | instrument | result | |:--|:--|:--| | `RemoteTransport` members naming a transaction | signature scan of `remote-transport.ts` | **3** — `beginTransaction()`, `commit(t)`, `rollback(t)` | | `RemoteTransport` **data** methods accepting `options` or a transaction | same file, same instrument | **0** | | ⭐ firing control — data methods present at all in that file | same file | **13** (`find`, `findOne`, `aggregate`, `create`, `update`, `upsert`, `delete`, `count`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) ⇒ the zero is a reading, not a dead grep | | `this.isRemote` guards in `turso-driver.ts` | `git grep -c` | **26** — matches the card |⚠️ The card's reading said **9** data methods; re-derived by content it is **13**. The difference is in the instrument, not in the finding — the zero above and its firing control both stand, and the wider control makes the zero stronger, not weaker. So a write issued between `beginTransaction()` and `rollback()` executed on the plain connection, was **already durable**, and the rollback resolved having undone nothing. Every step reported success. The local-mode pin in `turso-driver.test.ts` ("should support transactions with rollback") already asserted the correct shape; the remote face was the unpinned one. ## The line triage drew, and this PR's radius ⛔ The deliverable is **stop the silence**, not implement remote transactions. Triage's ruling is quoted rather than paraphrased, because the boundary is the deliverable: > **止损(本卡)**:remote 模式遇到 `options.transaction` 或 `beginTransaction()` 时**大声拒绝 / 声明不支持**,让调用者立刻知道自己没有事务语义。 > **实现远程事务**:那是 **objectstack-ai#18116 已完成的那次「measure, do not implement」量出来的半径**,是**另一件事**、另一个量级。⛔ 不要把它折进本卡。 Measured radius of the half NOT taken, reported rather than spent: implementing remote transactions means giving `RemoteTransport`'s 13 data methods an `options` parameter, threading a libsql `Transaction` through each statement builder, and deciding what `connect()`'s knex-free remote arm does about `SqlDriver`'s `openTransactions` accounting. That is a different order of change and it is not in this PR. ## The judgement the dispatch left to me: which door, and why not only one The dispatch named three candidates and asked which callers can reach a remote data method with a handle in hand. Measured, there are **two independent producers**, so the answer is **both doors**: 1. **`driver.beginTransaction()`** — every in-repo producer of a handle is one of these: `engine.transaction()`, `ScopedContext.beginTransaction()`, the sandbox trio. Closing this door closes all of them, and it closes them *before* the callback writes anything. 2. **`ExecutionContext.transaction`**, threaded by the caller. `buildDriverOptions` in `packages/objectql/src/engine.ts` reads it **first** — its own comment: *"Explicit wins; ambient is the safety net"* — and `ExecutionContext.transaction` is a declared member of the envelope in `packages/spec/src/kernel/execution-context.zod.ts`. Such a handle never passes through (1). The same-origin gate does not stop it either: `transactionCoversDriverFor` attributes a handle only when it **is** the ambient store's handle, and for anything else it declines to judge and returns `true` — its own recorded limit. ⇒ **Door (1) alone is the false floor the dispatch warned about**, and this is measured, not argued — see the second ablation below, where keeping only the `beginTransaction()` refusal leaves all 17 `options.transaction` cases green while silently dropping the handle. `commit()` / `rollback()` refuse on the remote arm too. With `beginTransaction()` refusing, this driver issues no remote handle at all, so the only way to reach them is with a handle from somewhere else — and `rollback()` accepting one is the precise silence the card is named for: it resolves, reports a successful undo, and undoes nothing. Both engine faces call `commit`/`rollback` only with a handle their own `beginTransaction()` returned, which now throws first, so no in-repo caller can be stranded with an unrolled-back transaction by this refusal. ## Where the refusal lives, and why not one layer down On `TursoDriver`, not inside `RemoteTransport` — the same layering argument the sibling `auto_number` refusal already records in this file. The transport's data methods have no `options` parameter at all, so the handle is already gone by the time a statement is built. This class is the last layer that still holds one. ## Why `NOT_IMPLEMENTED` / 501, and why no `packages/spec` edit The request is spelled correctly and the spec declares these members, so the gap is the backend's — the same two-class taxonomy this package already applies to remote `auto_number`, aggregate functions and date buckets. ⭐ **No new error code is needed and `packages/spec` is untouched**: `NOT_IMPLEMENTED` is a `StandardErrorCode` member, and the error-code ledger registers *extension* codes only, so nothing has to be added to it. The precedent is eleven hundred lines up in the same file. ## What this deliberately does not do - It does not implement transactions on the remote transport. - It does not remove `RemoteTransport`'s own `beginTransaction` / `commit` / `rollback`, which stay reachable through `getRemoteTransport()` and are pinned by an existing declared-types suite. - It does not touch `driver-sql`, the engine, or the spec. ## Verification All commands run from this branch; exit codes captured before any pipe. - **`pnpm --filter @objectstack/driver-turso test`** — `Test Files 54 passed (54)`, `Tests 1278 passed (1278)`. The pre-existing local-mode transaction pins are inside that green. - **`pnpm --filter @objectstack/driver-turso typecheck`** — exit 0. - **`pnpm --filter '@objectstack/driver-turso^...' build`** — `VERDICT command-exit 0` (dependency closure). - **Full package build** — `pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*` — `Tasks: 72 successful, 72 total`. - **Gate families** — derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (⛔ not from a hand-written diff): **60 families, 60 run, 0 NOT MEASURED, 0 UNRUN**, reconciled back through `--ran` with an exit code recorded per family. One was a real red and is fixed in this PR: `check:doc-authoring` refused the tracker id inside the refusal's runtime string (maintainer ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」) — the id moved to the docblock. Three more were `PREREQUISITE NOT MET` (exit 3) until the full build above, then green: `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`. - **`pnpm lint`** — the repo-wide `eslint . --no-inline-config` union, exit 0, run at final commit `97009bc42e`. ⭐ This is the union, not a narrowed run, so no narrowing argument is owed. ### Reverse verification — two ablations, directions predicted before either was run Both ran from the **committed** fix, mutated on disk with the mutation proved by grep counts and a changed blob hash, and restored under a `trap` that verifies the restored blob equals the `HEAD` blob and that `git diff HEAD` is empty. Subject blob at `HEAD`: `4b2b21a0da4e`. **① Both doors removed** (the options guard emptied, the trio restored to its pre-fix delegation). Predicted: the two REMOTE blocks go red, the silence controls stay green. Measured **`21 failed | 5 passed (26)`** — 4 trio cases plus 17 `options.transaction` cases red, the 5 controls green. The failures reprint the defect rather than merely reporting an absence: `expected the remote face to refuse, but it resolved with ...`, carrying the row that landed. **② Only the `options.transaction` door removed**, the `beginTransaction` / `commit` / `rollback` refusals kept — i.e. exactly the shape of a fix that closed candidate (1) alone. Predicted: the 17 `options` cases red, the 4 trio cases and the 5 controls green. Measured **`17 failed | 9 passed (26)`**. ⭐ That is the false floor, measured: a PR that had stopped at (1) would have shown a green trio block and shipped the silent drop. Restore verified on both legs: `RESTORE OK — on-disk blob 4b2b21a... == HEAD blob 4b2b21a..., and git diff HEAD is empty`. ### Changeset, measured rather than assumed `skip-changeset` is wrong here and it was **measured**, not taken from the dispatch. `@objectstack/driver-turso` publishes and its `files[]` is `dist`, `README.md`, `CHANGELOG.md`. After building the package, grepping those paths for a symbol unique to this change gives `dist/index.js:1`, `dist/index.mjs:1`; the positive control (a pre-existing shipped refusal sentence) hits `dist/index.js:2`, `dist/index.mjs:2`, `CHANGELOG.md:2`; the negative control (a test-only helper name) hits **0** anywhere in `files[]`. ⇒ it publishes ⇒ a changeset is owed, and it is graded `minor` to satisfy the clause-② level axis while staying inside the launch-window ban on `major`. ## Acceptance notes Observations found while measuring, ⛔ not filed and ⛔ not fixed here. - **`engineCanRollBack` reads method PRESENCE and there is no way for a driver to say "present, but not on this connection mode".** `packages/core/src/utils/migration-journal.ts` gates on `typeof defaultDriver.beginTransaction === 'function'`, which is `true` for a remote `TursoDriver` because the method is inherited from `SqlDriver` at class-definition time while the transport mode is a runtime config. The spec's own retirement note for the `transactions` capability bit says transaction use "is gated on METHOD PRESENCE", so this is the declared design, not a bug in that helper. After this PR the consequence is loud rather than silent (`batchData`'s atomic gate now gets a refusal instead of a half-applied batch), so nothing is left silently wrong — but the underlying "presence cannot express a per-mode capability" shape is exactly the semantics half that objectstack-ai#18063 and objectstack-ai#18116 are already about, and it belongs to that decision, not to a new card. **承接者:** the objectstack-ai#18063 ruling. - **`RemoteTransport.beginTransaction` / `commit` / `rollback` are now unreachable through `TursoDriver`.** They stay reachable via `getRemoteTransport()` and are pinned by `turso-driver-doors-declared-types.test.ts`, so they are not dead code; removing them is part of the implement-or-retire decision above, not of this refusal. **承接者:** the same ruling. Neither is a class (a) reproducible defect, a class (b) declared-contract violation, or a class (c) metadata-authoring trap, so neither is filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18408
Clause-②: no
The
domain:enginehalf of #17469's one-definition ruling, executed:packages/objectqlandpackages/drivers/driver-tursonow ask@objectstack/spec'sisMultiValueFieldthe question they used to answer from a rawfield.multiple. This NARROWS to a declared contract (the predicateFieldSchemaalready enforces at the authoring entrance) and widens nothing — henceClause-②: no.The population, and triage's
11reconciledThe card said the counts are triage's and told a taker to re-derive them. Both halves of that are now answered.
What I counted (my population): every read of the field-level
multipleflag in NON-TEST source of the two declared packages, located by content —.multiple, bracket access, destructuring, and a full word sweep ofmultipleover bothsrctrees. 5 sites in 3 files.Triage's table (
5681445124) reproduces exactly — as SITES, not files. Its column header says "files"; the numbers are matching LINES. At triage's own ref827cacbf4b, with triage's own declared predicate set (isMultiValueField/field.multiple/.multiple ??), counting matching lines over the WHOLE package (tests andCHANGELOG.mdincluded):827cacbf4bpackages/clipackages/objectqlpackages/runtimepackages/drivers/driver-tursoAll four cells match on the line reading and none matches on the file reading, so the gap was a population difference exactly as the claim suspected: sites, tests included, labelled "files". ⛔ Nothing in triage's work is wrong; the label is.
Two consequences worth stating rather than burying:
packages/objectqlis dominated by sites that ALREADY ask the spec predicate —record-validator.tsimports it asspecIsMultiValueFieldand wraps it — plus twoCHANGELOG.mdlines. Only one of those 11 is a raw read (engine.ts:4741, today:4759).fdef.multiplerather thanfield.multiple:engine.ts:13168andengine.ts:13602. A taker who worked from the grep alone would have shipped half the card.⭐ Every site below was located by CONTENT. The card's
engine.ts:4741is at:4759today, as the claim already measured.The per-site premise — the discriminating question, asked five times
If
isMultiValueFieldand the raw flag ever disagreed, would this site's behaviour be WRONG, or merely worded differently?objectql/src/engine.tsresolveOptionDefaultmultiselect/checkboxes/tagswith no flag; any non-capable type WITH the flagobjectql/src/engine.tsreferenceProbeFilter$containsvs bare equality against the FK columnmaster_detail+ flagobjectql/src/engine.tscascademultiValuedset_nullwrite shape, the required-FK escalation, the probe narrowingmaster_detail+ flagobjectql/src/validation/record-validator.ts:824select/radiodriver-turso/src/remote-transport.tsmapFieldTypeToSQLnumber/integer/boolean+ flagSite 1 is a live defect in both directions: a
multiselectwith an option markeddefault: truewas defaulted to a bare scalar, whichvalidateRecord's own multi-value branch then refuses asinvalid_type_arrayon the very insert the default was resolved for. Measured — that case throwsVALIDATION_ERRORbefore this PR.Site 5 is the same defect #17469 repaired on
driver-sql, here inside ONE driver:TursoDriveris dual-transport, its local half extends the ALIGNEDSqlDriver, and its remote half spelled its own types. So{ type: 'number', multiple: true }was afloatcolumn on one transport and aTEXTcolumn on the other, chosen by which URL the deployment holds.if ((t === 'select' || t === 'radio') && def.multiple !== true)is dominated by its own type gate:selectandradioare both inMULTI_CAPABLE_TYPESand in neither multi-option set, so on that domainisMultiValueField(def)reduces BY CONSTRUCTION tomultiple === true. The two authorities cannot disagree there today, and the alignment therefore changes no behaviour on any input — it is not one of the divergences above.It is aligned anyway, and the reason is the ruling's own: the line is the exact complement of the
isMultiValueField(def)branch twelve lines below it, and the equality holds only whilepackages/speckeepsselect/radioin the set they are in now. Spelling one dispatch's two halves from two authorities that happen to agree is the drift the #17469 ruling exists to end. ⛔ If the seat reads this as out of scope, it is a two-line revert and nothing else in the PR depends on it.Exclusions, with a reason each
Within the population defined above the exclusion list is empty, and the card is right that an empty list needs its own evidence. It is empty because the population is defined by the READ, not by a grep for
multiple— every candidate the wider grep surfaces was examined and is listed here as excluded:objectql/src/validation/rule-validator.ts:2037—CHOICE_FIELD_TYPES(select,multiselect,radio,checkboxes). A hand-listed type set that aMULTI_OPTION_TYPESgrep matches. It asks "does this field draw its value(s) fromoptions?" —selectandradioare on it,lookup/file/userare not — and it never reads the flag. Different question: the driver-sqlisJsonFieldand specisMultiValueFielddisagree formaster_detail/tree/text+multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 exclusion class, verbatim.engine.ts({ multiple?: unknown }). Declarations of what a function reads, not reads. (Two gainedtype?: unknownso the seam can be asked.)FieldSchemacitations in the cascade/set_nullprose (engine.ts×2). They nameFieldSchema'smultipleandrequireddoc blocks as the authority for the RESIDUAL REPRESENTATION — that an emptied set is written[]and nevernull([Decision] Multi-value lookup after set_null member removal: does the emptied array read back as[]ornull? #9447). That is stillFieldSchema's question and is untouched. Only the comments that named the flag as the authority for MULTI-VALUEDNESS were rewritten.record-validator.ts's localisMultiValueFieldwrapper andnormalizeMultiValueFields. Already delegating to the spec predicate — the seam the suggested route asks for already exists in that file.turso-json-column-type-asymmetry.test.ts:215's "themultiple: trueroute" note. Its fixture is{ type: 'lookup', multiple: true }, which IS multi-valued by the predicate, so the sentence stays true and the Converging driver-turso's two transports onto one physical column type for Field.json — and the measured reason VALUE_ROUNDTRIP cannot adjudicate it #12738 pin is unaffected.Precedent check against #18199's own exclusion: nothing here CONSTRUCTS a descriptor the predicate is asked of one frame later (
action-execution.ts:1279's shape), and nothing here interpolates the flag into a message (#17469's three).The comment half
Copied from the
domain:clihalf: the three in-tree comments that declared the losing authority were rewritten rather than left standing —resolveOptionDefault's "multiple: trueassembles an ARRAY … (FieldSchema.multiple)",referenceProbeFilter's "A field declaringmultiple: truestores an ARRAY … (FieldSchema.multiple)", andcascadeDeleteRelations' "On amultiple: truefield the FK is a SET".objectqlgets one local seam,declaredMultiValued, for its three sites;driver-tursohas exactly one site and calls the predicate directly rather than minting a second name for it.Evidence
Reverse verification (mutation = the three source files restored to their pre-fix content, tests left as written; restore =
git checkout HEAD -- path, proven byte-identical bygit hash-objectagainst the HEAD blob):The five red assertions are the discriminating ones. The three that stay GREEN under the mutation are declared as such in the test bodies: two cascade guard legs (
$containsis a substring test, so it still matches a scalar column, and the engine's own narrowing pass repairs the over-match) and the turso "ask the predicate directly" leg. Stating which halves cannot fail is the point of listing them.Tests, on the final head:
pnpm --filter @objectstack/objectql test— 298 files / 4995 passedpnpm --filter @objectstack/driver-turso test— 53 files / 1252 passedpnpm --filter ... typecheck(both) — Done, 0 errorspnpm --filter @objectstack/driver-sql testagainst a local PG 16 (initdb+pg_ctl, port 54318,timezone=Asia/Shanghai,TZ=America/New_York) — 186 passed | 3 skipped (189 files), 3315 passed | 85 skipped. Positive control that the live cells really ran:sql-driver-datetime-postgres-timezone.test.tsis 5 passed withOS_TEST_POSTGRES_URLset and 5 skipped without it, same tree. The 3 skipped files are the MySQL-gated cells — ⛔ nomysql-serverwas installed,OS_EXPECT_LIVE_DIALECT_MATRIXwas deliberately left unset so those skip instead of becoming a named red that says nothing about this diff, and the MySQL half is left to CI.New pins:
turso-remote-multi-value-column-type.test.ts(the declared column type per predicate verdict, plus a cross-transport storage-class leg), two cases inengine-select-option-default.test.ts, three inengine-cascade-delete-multivalue-probe.test.ts.Gates
Derived from the real changed paths and reconciled on the FINAL head
66de450f64— one count, one reconciliation:All 63 measured families exit 0. Exit codes were captured to disk before anything was read.
check:cross-package-test-inputsfailed at my first head with [finding]check:cross-package-test-inputsexits 0 or 1 on the same commit depending on whether adist/exists — and lint.yml runs it with no build ahead of it, so CI only ever sees the blind leg #18621 —@objectstack/cli descends a directory tree from packages/spec/dist/, triggered by the local spec build and nothing to do with this diff. It is GREEN after mergingorigin/main, which brought fix(gates): judge a cross-package walk root against the index, not the working tree #18641 ("judge a cross-package walk root against the index, not the working tree"). Named, not chased, and no twin filed.check:engine-split-ratio --days 90exited 2 on the shallow clone — a refusal, not a ratio. Deepened with the remedy the script prints (git fetch --shallow-since=2026-06-12 origin) and re-run: exit 0, ratio 97.8% over 317 engine-core commits.pnpm lint(the repo-wide scan CI owns) was run in full rather than narrowed; result in the report.Acceptance notes — noted, not filed
RemoteTransport.mapFieldTypeToSQL'scase 'formula': return ''is unreachable from all three of its callers, each of which skipstype === 'formula'before calling. Dead branch, not a defect, and untouched here. Carrier: whoever next edits that mapper.field.type || 'string'in the mapper while all three callers resolve the identical expression before calling it — two spellings of one default, one frame apart. This PR uses the mapper's own copy (one resolution now feeds both the predicate and the switch) and does not unify the two. Carrier: the same mapper edit as above.Generated by Claude Code