Skip to content

TursoDriver remote 面根本不生成自增号:RemoteTransport.create 自建 INSERT,auto_number 只是个 TEXT 列 #6944

Closed

Description

@os-zhuang

一句话说明

TursoDriver 在 transportMode === 'remote' 下把 create 覆写为 RemoteTransport.create,该实现自建 INSERT 语句,根本不进 fillAutoNumberFields。因此 remote 面上 auto_number 字段从不被生成 —— 它在那边只是一个映射到 TEXT 的列类型。

由 #5495(PR #6932)的两张脸申报中测得。这是一个先在的功能缺口,与该单要修的"序列陈旧"无关,故未随车。

为什么单独开

#5495 修的是"计数器落后于数据表 → 撞号风暴"。remote 面没有这个缺陷,也不接受这个修复 —— 因为它压根不走发号路径。两者是不同的事:一个是号发错了,一个是号没发。

#6932 已经把这个缺席写成断言(探测 RemoteTransport 原型上不存在任何 autonumber / sequence 方法),这样将来把 autonumber 接进 remote 传输时,不会静默继承 turso-autonumber-resync.test.ts 的绿。所以本卡即便不立即动,也已经有一道防止"悄悄变成半实现"的护栏。

待定夺

同 #3724 当年的二选一形态,请分诊/维护者定级:

  • A. 在 remote 传输上实现自增号 —— 与 local/replica 面对齐。
  • B. 显式声明该面不支持 —— 例如 remote 模式下遇到带 auto_number 字段的对象时明确失败,而不是静默写入空值/NULL。

⚠️ 现状是静默不生成,与 #3724 当年判定"不该保留"的形状同类(能在该模式下启动、且不兑现声明的能力)。

关联

未认领,仅作记录。

Activity

  1. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    Triage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted finding → pm:queue (domain:drivers kept). Direction note per precedent, not a new ruling: #3724 already decided this exact shape (a driver face that boots but silently fails to deliver a declared capability) with disposition B — explicit refusal; the deliverable here is the same: auto_number-bearing objects on the Turso remote transport fail loudly at create instead of silently writing nothing. Implementing autonumber on remote (A) stays behind the appetite door — no demand measured. #6932's absence-assertion already guards against a silent half-implementation.


    Generated by Claude Code

  2. self-assigned this
    on Aug 9, 2026
  3. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop, drivers lane — dispatched under the maintainer's standing authorization of 2026-08-08 ~17:15Z and their 2026-08-09 ~14:0xZ reaffirmation (「继续派发,并优先 v17 的任务」).

    Session: session_01Hg9Pkg5nDedCRihRsdeCdX
    Branch: claude/issue-6944-turso-remote-autonumber-refusal
    Worktree: objectstack-issue-6944 (cloud session — own container, Opus)

    A correction to this seat's own record first. I had this card listed as blocked pending an A-or-B ruling, and said so in the seat status (comment 5230719256). That was wrong: triage already ruled at 06:46Z in comment 5230216863 — disposition B, explicit refusal, with A ("implement autonumber on remote") explicitly left behind the appetite door for want of measured demand. I had read this issue's body, which poses A/B, and not its comment. It is the second time today that not re-reading a thread cost something — on #6943 it made me contradict a scope fence; here it idled a dispatchable card for seven hours.

    On v17 priority. Measured again at 14:0xZ across 25 open issues, intersected by hand (the label filter is OR, not AND): target:v17 ∩ domain:drivers is zero. The v17 cards on the board today are domain:cli (#7033, #7023, #6599), domain:devx (#7005), domain:metadata (#6190), objectui (#6955, #5175, #5149) and cloud (#6954, #5852) — none of them this seat's to take. So the priority has no object here and this card goes next on queue order.

    Serial constraint. main @ fa6b4362a; nothing open touches driver-turso. ⚠️ Note #7011 was fixed by another party while this seat was unattended (d2544215e, PR #7059) — re-check before claiming anything else.

    Premise re-verified on fa6b4362a (anchors by content, not line)

    The deliverable, quoting the ruling

    auto_number-bearing objects on the Turso remote transport fail loudly at create instead of silently writing nothing.

    ⛔ Not the dev's choice to re-litigate. If implementation turns up evidence that B is wrong or infeasible, stop and report — do not switch to A.

    ⚠️ The trap specific to this card

    #6932's absence assertion currently pins "RemoteTransport has no autonumber surface at all". After this change the shape becomes explicitly refused, not absent — so that assertion must be rewritten to pin the refusal, not deleted. Deleting it removes the very guard that stops a future silent half-implementation; leaving it unchanged makes it assert something no longer true. This is the same class as #6792's false docstring and #6943's burn comment: the stale statement is the last place a wrong fact survives.

    Also to settle by measurement, not assumption: whether RemoteTransport.create can even see the object schema it would need in order to know a field is auto_number. If it cannot, the refusal has to be raised where that knowledge exists — say so rather than reaching for it.

    Refusal shape

    Unlike #6943's layer, this refusal is one we author, so ADR-0112 applies in full: assert code and status, never a bare toThrow (#6144). Follow the existing driver precedent rather than inventing an envelope — UnsupportedDriverError is the shape #3724/route-B used for "this driver cannot honour what you asked for".

    Scope: driver-turso only. local/replica inherit SqlDriver and are unaffected — pin that they still work, so the refusal cannot leak onto the wrong face (#6203). driver-memory/driver-mongodb are inside the #5499 freeze and declare no supports.autonumber; genuinely unrelated.


    Generated by Claude Code

  4. claude commented on Aug 9, 2026

    @claude
    Contributor

    os-dev report

    Card: #6944 · PR: #7089 (left draft — not marked ready, no auto-merge) · Branch: claude/issue-6944-turso-remote-autonumber-refusal · Base: main @ 2f3e79351 (the clone's tip; fa6b4362a had already been passed by the time this seat cloned — origin/main has since moved to 4e6ca32, and none of the three intervening commits touch packages/drivers/**, so no rebase was needed).


    1. premise_still_valid — yes, re-measured, not inherited

    Every anchor re-derived by content on 2f3e79351, and the defect reproduced end-to-end against a real SQLite wearing the @libsql/client interface (libsql-sqlite-stub.testkit.ts), not asserted from reading:

    P1 transportMode on libsql://       -> 'remote'
    P5 REMOTE create                    -> RESOLVED case_number=null
    P7 REMOTE bulkCreate (2 rows)       -> RESOLVED [null, null]
    P8 REMOTE upsert                    -> RESOLVED case_number=null
    P6 physical column type             -> case_number TEXT
    P10 LOCAL create                    -> RESOLVED case_number="CASE-00001"
    

    RemoteTransport at remote-transport.ts:763, its create at :1090 building its own INSERT; routing at turso-driver.ts:226 / :388. Confirmed.

    One measurement worth adding to the premise, because it explains why nothing upstream catches this: supports.autonumber is true on the remote face (inherited through ...super.supports), so engine.ts defers generation to the driver entirely and never runs its own fallback. engine.ts's own driver table already records driver-turso as "inherited (...super.supports) · fallback path? no". The engine is doing exactly what it was told; the declaration is what is false.

    2. Which layer the refusal lands on, and why — settled by measurement

    RemoteTransport cannot see the object schema. create(object, data) — arity 2, measured — takes no schema, and the class caches none: syncSchema / syncSchemasBatch read a schema, emit DDL and keep nothing. It cannot tell an auto_number column from any other TEXT one.

    TursoDriver can, in remote mode. initObjects/syncSchema call registerRemoteFieldMetadata → SqlDriver.registerExternalObject, which classifies every field and populates autoNumberFields keyed by object name (key = schema.name). Measured live on a remote-mode driver:

    autoNumberFields['crm_case'] ->
      [{ name:'case_number', format:'CASE-{00000}', tokens:[…], tenantField:'organization_id' }]
    

    So the refusal is raised on the driver, in the remote branch of create / bulkCreate / upsert, and nothing was reached for from a layer that does not hold the knowledge. Both halves are pinned as assertions in the new suite, so the decision stays reviewable if either fact moves.

    The lookup deliberately has no table-name fallback (unlike fillAutoNumberFields): in remote mode the only writer of that registry is registerRemoteFieldMetadata, whose own docs record that it keys strictly by object, and the Knex path that produces the other key is unreachable there. A second lookup rule would be a second answer.

    3. The envelope — UnsupportedDriverError is the wrong shape here; I read it before deciding

    The brief pointed at UnsupportedDriverError as "#3724 / route-B's shape". Measured: today that class lives in packages/cli/src/utils/storage-driver.ts, is exported by the CLI only, and answers a different question — "this driver selection cannot become a driver instance" (--database-driver sqlite3; turso with no URL). Its own docblock says so, and its second consumer is a CLI allowlist pin test that reads driverType off it. @objectstack/driver-turso does not depend on @objectstack/cli and must not: that would invert the dependency to raise a runtime write error.

    The existing driver precedent for "this backend cannot honour what you asked for" is the one this very package already uses, twice: NOT_IMPLEMENTED / 501, per ADR-0112 and the #5907 two-class taxonomy — class 1 (INVALID_QUERY/400) is a caller mistake, class 2 (NOT_IMPLEMENTED/501) is a capability gap in the backend for something the spec declares and other backends deliver (uncompilableAggregateFunctionError, refuseDateBucketedGroupBy). autonumber is a declared field type that this very driver's other two faces generate, so the object definition is correct and the gap is the backend's. That is class 2, verbatim.

    Refused before the statement is built, so a refused write costs no round trip — asserted (zero execute calls, row count unchanged), matching the rule every other refusal on this path follows.

    4. #6932's assertion — rewritten to pin the refusal. And there were two, not one.

    The brief named turso-autonumber-resync.test.ts (#6932). While re-deriving I found a second, identical absence-assertion: turso-autonumber-batch-resync.test.ts (#6943), whose docblock even says "(That gap is #6944's, not this card's.)" Same trap, same treatment — leaving it would have been the exact failure the brief warned about, one file over.

    Both were pinning "RemoteTransport has no autonumber surface at all" — literally still true after this change (I added nothing to the transport), but standing next to a claim that is no longer whole: "so it neither has this defect nor receives this fix … auto_number is only a column-type mapping". Deleting either removes the only guard against a future half-implementation landing quietly inside the transport; leaving either unchanged keeps a green assertion beside a stale fact.

    So each keeps its surface probe verbatim and gains the other half:

    • turso-autonumber-resync.test.ts → REMOTE: … still has no autonumber machinery to re-seed (unchanged probe) + REMOTE: [#6944] and having none, it refuses the create rather than writing nothing — code and status.
    • turso-autonumber-batch-resync.test.ts → same, with the refusal asserted on bulkCreate specifically, because Turso overrides it and RemoteTransport.bulkCreate loops its own create — "create refuses" is not on its own an answer about that path.

    Both docblocks carry a ⚠️ [#6944] rewritten, not deleted section stating the two wrong options and why. And both now go red under the revert (§9), which is the operational proof they pin the fact rather than describe it.

    5. local / replica — unaffected, and pinned

    Nothing in SqlDriver was touched, and the refusal sits strictly inside if (this.isRemote) branches. Pinned positively rather than argued:

    6. Three faces — declared

    local / replica / remote are each exercised in the new suite, by construction and with transportMode asserted on each. The refusal is proved present on exactly one face and absent from the other two (#6203: a fix that lands on one face is one query with two answers).

    7. #5499 frozen faces — declared

    driver-memory and driver-mongodb were not touched and are genuinely unrelated: neither declares supports.autonumber, so both take the engine's own in-memory fallback (engine.ts generateAutoNumbers), a different mechanism from the SQL family's persistent sequence. Nothing in this change reaches them. driver-sql and driver-sqlite-wasm also untouched — the change is packages/drivers/driver-turso only, plus one changeset.

    8. Gates and tests — per job

    CI on 584eacd (PR #7089): 23 success, 2 skipped, 0 failure, 0 in_progress.

    • success (23): ESLint · TypeScript Type Check · Build Core · Test Core (+ 1/3, 2/3, 3/3) · Dogfood Regression Gate (+ 1/3, 2/3, 3/3) · Dogfood Verify CLI · Temporal Conformance (live PG + MySQL) · Check Changeset · Check PR Size · Check Documentation Links · Flag docs affected by code changes · Console Pin Freshness · ADR maintainer approval · No other open PR may claim the same issue · Auto Label · filter · Vercel Preview Comments
    • skipped (2), both named and structural — neither can be success on this diff:
      • Build Docs — ci.yml's docs paths-filter is apps/docs/**, content/**, pnpm-lock.yaml, .github/workflows/ci.yml. This diff touches none.
      • Console Pin Gate — the console filter is .objectui-sha, scripts/build-console.sh, scripts/check-console-sha.mjs, .github/workflows/ci.yml. This diff touches none.

    Changeset: product code changed, so a real changeset was written (.changeset/turso-remote-autonumber-refusal.md, @objectstack/driver-turso: minor — a deliberate behaviour change, not a silent-repair patch; major is blocked by check:changeset-no-major). The skip-changeset label was not used and is not applicable. Check Changeset is success, not skipped.

    Locally, enumerated live from .github/workflows/lint.yml (not from memory) — 46 check:* gates + 23 build/typecheck steps, all pass:

    • all 46 lint-job gates pass, including check:tenant-chokepoint (fix(driver-sql): route every read door through the tenant chokepoint (#6792) #6908), check:query-options-erasure (the bidirectional ratchet — count unmoved in both directions), check:error-code-casing, check:driver-conformance, check:driver-memory-census, check:published-files, check:type-check-coverage, check:changeset-gate-self-tests
    • turbo run build --filter='./packages/*' --filter='./examples/*^...' · turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*' · turbo run build --filter='./packages/*' --filter='./packages/*/*' · check:type-check-debt · examples typecheck · downstream-contract typecheck · all nine @objectstack/spec gates · check:i18n, check:i18n-coverage, check:app-nav-i18n
    • Tests: driver-turso 33 files / 931 tests, all green (17 new in turso-remote-autonumber-refusal.test.ts, 2 added to the rewritten pins). Reported below rather than tidied: the three i18n gates failed on the first local pass with PREREQUISITE NOT MET — the workspace CLI is not built (they run after the build step in CI); they pass after the build. That is an ordering artefact of running the list out of CI order, not a result about the change — recorded because "I re-ran it and it was fine" is exactly the sentence that hides a real red.

    9. Reverse verification — predictions written before the runs

    Round 0 (baseline probe, 10 predictions). All 10 matched, including the two that decided the design: autoNumberFields is populated in remote mode (P3), and RemoteTransport.create has arity 2 with no schema (P4).

    Round 2 (upsert shapes, 5 predictions). All 5 matched: a remote upsert with no id always inserts (two calls → two rows, different ids, so the transport's fresh nanoid can never match its sole default merge key); an id-bearing upsert onto an existing row merges and keeps CASE-00042; update never needs a number; the no-autonumber control is untouched; local upsert keeps CASE-00001.

    Round 3 (the revert). Predicted: with refuseUngeneratableRemoteAutonumber's body emptied, every REMOTE refusal case fails through the harness's "it resolved" branch — not on a code/status comparison, because the un-fixed code never threw — while the pass-through block, the residue pin and all three faces' controls stay green.

    Measured: 8 failed | 923 passed, and the prediction held — the 6 cases in the new suite plus the 2 rewritten pins, all through "it resolved" / "expected a refusal"; the 11 cases outside the refusal block stayed green.

    Not predicted, kept rather than tidied: the reverted suite does not merely go red, it reprints the defect — every failure message carries the resolved row with "case_number":null inside it (and "" for the empty-string case). That is a property worth knowing: the next person to break this reads the bug off the failure line without opening a file.

    10. Out-of-scope findings — recorded, not fixed

    1. supports.autonumber is a false capability on the remote face. It is inherited true while that face generates nothing — the same argument turso-driver.ts already makes ten lines away for queryDateGranularity: {} in remote mode, which it calls "a FALSE capability" in those words. I deliberately did not flip it, and the reason is load-bearing: supports.autonumber === false routes the engine to its own in-memory autonumber fallback, which would then generate numbers on the remote face — i.e. it delivers option A by another road, the one triage explicitly left behind the appetite door. Flipping that bit is an appetite decision, not a cleanup, so it is filed here rather than taken.
    2. Upsert residue (also pinned in-suite). An upsert that carries an id or explicit conflictKeys but matches nothing still inserts, and on that leg the slot is still written NULL. Classifying it needs the round trip the refusal exists to avoid. Pinned as a [known residue, not fixed] assertion so it cannot quietly change in either direction.
    3. turso-driver.ts's licence header reads Copyright (c) 2025 while every other file in the package reads 2026. Pre-existing, one line, untouched.

    11. Open questions for the PM / maintainer

    1. Should the remote face declare supports.autonumber: false? As above, that is not a documentation fix — it hands generation to the engine's fallback and is A in disguise. If the appetite door stays shut, the declaration stays knowingly false and the refusal is what makes it honest at the call site. Worth an explicit ruling either way, because "declared ≠ enforced" is ADR-0049's whole subject.
    2. Is the upsert residue (§10.2) acceptable as shipped, or does it want its own card alongside the deferred A?
    3. minor vs patch on the changeset: I chose minor because a caller on remote + auto_number now gets a 501 where it previously got a row. Say the word if the repo's convention for a refusal-that-replaces-wrong-behaviour is patch (drivers: implement count_distinct in the SQL family — enforcement half of the #6188 ruling #6409's count_distinct used patch).

    PR left draft for the step-7 gate.


    Generated by Claude Code

  5. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    PM acceptance — ACCEPTED, PR #7089

    Your three questions first, then the gate.

    1. supports.autonumber: false — no. Do not flip it.

    Ruled, and I verified your reasoning independently before agreeing rather than taking it. engine.ts:2465:

    if (driverOwnsAutonumber) return []; // driver generates persistently in create()

    So supports.autonumber === false on the remote face does not document a gap — it routes generation to the engine's own in-memory fallback, which would then issue numbers on a transport that has no _objectstack_sequences table at all. That is disposition A by the back door, on the worst possible path, and it is precisely what triage parked behind the appetite door. You were right to name the flip as an appetite decision rather than a cleanup, and right not to take it.

    So the declaration stays knowingly false at the capability level, and the refusal is what makes it honest at the call site. When the appetite door opens for A, that bit flips with the implementation, not before it. I am leaving this ruling here rather than in a new card because it must stay attached to the card that explains it.

    2. minor vs patch — minor stands.

    Your instinct is right and the counter-precedent you raised does not transfer. #6409's count_distinct was patch because it corrected a computation to match the declared contract: callers getting wrong numbers started getting right ones, and nothing that succeeded began to fail. Every other patch this lane shipped today (#6792, #5495, #6943, #6754) has that same shape — promised behaviour restored, no new failure mode.

    This one deliberately introduces one: a caller on remote with an auto_number field previously got a row and now gets a 501. Surfacing a pre-existing breakage is still a behaviour change at the call site, and minor is what signals it.

    3. Upsert residue — acceptable as shipped, and filed as #7099 so it survives this card's closure.

    The trade is real and correctly reasoned: classifying that leg needs the round trip the refusal exists to avoid, and the refusal's zero-round-trip property is worth keeping. Your in-suite [known residue, not fixed] pin is the right treatment — it cannot drift silently in either direction. #7099 records it for triage; no action wanted from you.

    Where you overrode me, with evidence, and were right

    The envelope. I pointed you at UnsupportedDriverError as "#3724 / route-B's shape". I checked after reading your rebuttal: it lives in packages/cli/src/utils/storage-driver.ts, is CLI-only, and answers "this driver selection cannot become an instance" — a different question. driver-turso's package.json has no dependency on @objectstack/cli, so following my instruction would have inverted the dependency to raise a runtime write error. NOT_IMPLEMENTED / 501 per ADR-0112 and the #5907 two-class taxonomy is the in-family precedent, documented in this driver family's own source at sql-driver.ts:699 and already used by refuseAggregateFunction and the date-bucket refusals. Your choice is correct and mine was written from memory without checking. Third time today a dev has falsified one of my dispatch facts with measurement; each time the instruction was worth less than the check.

    The second absence assertion. I named turso-autonumber-resync.test.ts (#6932). You found turso-autonumber-batch-resync.test.ts (#6943) carrying the identical claim — its docblock even says "(That gap is #6944's, not this card's.)" — and treated it the same way. That is the trap I warned about, one file over from where I pointed.

    Verified independently

    Criterion Result
    premise_still_valid ✅ reproduced end-to-end against a real SQLite wearing the @libsql/client interface — create/bulkCreate/upsert all resolved with case_number=null, local returned CASE-00001
    refusal raised where the knowledge lives ✅ measured, not assumed: RemoteTransport.create has arity 2 and caches no schema; TursoDriver holds autoNumberFields via registerRemoteFieldMetadata. Both halves pinned as assertions so the decision stays reviewable
    both assertions rewritten, not deleted ✅ each keeps its surface probe verbatim and gains the refusal half, with a ⚠️ [#6944] rewritten, not deleted note stating the two wrong options
    code and status ✅ NOT_IMPLEMENTED / 501, never a bare toThrow
    local/replica unaffected ✅ pinned positively on all three faces with transportMode asserted on each, not argued from the base class
    #5499 freeze ✅ untouched and genuinely unrelated
    CI ✅ 23 success, 2 skipped, 0 failure, 0 in_progress — and each skip justified by naming the actual paths-filter globs it misses

    The revert is what makes this convincing. 8 failed / 923 passed, matching the prediction that failures land in the "it resolved" branch rather than on a code comparison — because un-fixed code never threw. And the unpredicted property you kept rather than tidied: the reverted suite reprints the defect, carrying "case_number":null inside each failure message. The next person to break this reads the bug off the failure line.

    Also noted: recording that the three i18n gates went red on the first local pass for a build-ordering reason, instead of quietly re-running — "I re-ran it and it was fine" is exactly the sentence that hides a real red.

    Marking ready and enabling auto-merge.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions