Repository navigation
Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495
Description
Activity
Triage (maintainer-directed batch, 2026-08-07): routed
domain:engine-core— the in-memory autonumber counters + seed-from-max atpackages/objectql/src/engine.ts:2096-2158lack per-tenant MAX sync and collision re-check.bug+target:v17retained. Anchored by code read; triage seat may veto.
Generated by Claude Code
认领(执行席 PM,engine-core 车道,第 11 轮)
- 会话:
session_019Q7oc7ASjh8yxyS3Yz78We - 分支:
claude/issue-5495-autonumber-max-sync - 工作树:
../objectstack-issue-5495 - 文件面:
packages/objectql/src/engine.ts仅自增号区域(:2096-2158 一带,按内容定位)+ 测试 + changeset;⛔ 不触 update 剥离区(update 路径的 stripReadonlyFields 删除的是 hook 已覆写的当前值而非调用方提交的值 —— 调用方回传 readonly 键会连坐抹掉 beforeUpdate hook 的写入,可落出 status=published 而 published_at=null 的行(17.0.0-rc.2) #5591 待发)、事务区(ScopedContext.transaction 不 join ambient 事务 —— 沙箱 hook 体里的 ctx.api.transaction() 会在事务中再开一个事务(违反 ADR-0067 D2) #6168 待发)、其余区域
与 #6114(seedAutonumber outage 语义)同区域接续,其 catch 判别语义不动、在其上构建。
Generated by Claude Code
- 会话:
前提复核结果:落点 mis-attributed,裁向 (a) 被实测证伪 —— 停下报告,未开 PR(执行席开发,会话
session_019Q7oc7ASjh8yxyS3Yz78We,分支未推送)单据是线索不是规格,四条前提在
origin/main(70f132c15)逐条实读 + 实测。两条成立,一条对本落点不成立,一条成立但恰好推翻裁向。前提逐条
前提 结论 证据 P4 driver 声明 supports.autonumber的路径提前返回、不经此处✅ 成立 engine.ts:2064if (driverOwnsAutonumber) return;;sql-driver.ts:1621autonumber: true;TursoDriver extends SqlDriver(turso-driver.ts:173)、SqliteWasmDriver同P2 现播种/计数逻辑不带 tenant 维度 ✅ 成立 engine.ts:2095const counterKey = `${object}.${name}.${probe.scope}`;—— 只有 scope,无 tenantP3 #6114 的 outage 语义 ✅ 成立且未触碰 engine.ts:2150-2164的类型判别 catch 原样;本次无任何 diffP1 唯一索引按 org 分区(#3696 形态) ⚠️ 对 driver-sql 成立,对本落点服务的驱动不成立见下 P1 为什么对 engine.ts:2096-2158 不成立
由 P4,本区域只服务不声明
supports.autonumber的驱动。仓内实现IDataDriver的一共三个,除driver-sql外只剩两个,而它们都没有(organization_id, field)分区索引:driver-memory—— 自己的文档字面写着不落任何约束(memory-driver.ts:110:"there is no primary key, no uniqueness, no NOT NULL, no foreign key and no column typing")。没有唯一索引可分区,也永远抛不出 UNIQUE 冲突。driver-mongodb——mongodb-schema.ts:82-86建的是单字段全局唯一索引idx_{field}_unique;同文件 :20-33 明确写明这对该驱动「currently correct」,因为 driver-mongodb 完全没有行级租户隔离:读不加谓词、写不打戳,多租户下跨租户可读写 #3724 让它拒绝以多租户姿态启动(mongodb-tenancy-guard.ts:"never reads DriverOptions.tenantId")。
即:
(organization_id, field)这个分区形态,恰好只存在于唯一一个走不到这段代码的驱动上。裁向 (a) 的实测:方向先写死,再运行
预测写在
predictions.md后才执行。方向不是普通红向,是「改完反而制造重复」。肢 A —— 临时把 tenant 加进
counterKey,分别对两种驱动跑:驱动 find行为预测 实测 相符 按 tenantId 分域(SqlDriver 的行为,但它走不到这里) 修好:orgA=CASE-00010、orgB=CASE-00039 PROBE1 orgA=CASE-00010 orgB=CASE-00039 orgB2=CASE-00040,findCalls=2✅ 忽略 tenantId(memory 与 mongodb 的实际行为) 两个 org 各自建计数器却都从同一个全局 max(38)播种,双双发出 CASE-00039→ 在 mongodb 单字段全局唯一索引上是重复PROBE4 orgA=CASE-00039 orgB=CASE-00039 duplicate=true✅ 所以按裁向 (a) 在本区域分区,会造出今天不存在的撞号,而它「修好」的那个方向服务的是一个到不了这里的驱动。裁向 (a) 的条件句前件("若唯一索引是 (organization_id, field)")为假,按「前提破则停下报告、禁静默改道」执行。
但单据描述的两个缺陷在本区域确实成立(实测)
前提证伪的是落点与裁向,不是缺陷本身:
PROBE1(未打补丁的 main)orgA=CASE-00010 orgB=CASE-00011 orgB2=CASE-00012 findCalls=1orgA(max 9)拿到 10 是对的;orgB 库里已有
CASE-00038,却拿到 CASE-00011 —— 播种只跑了一次,orgB 从头到尾没有自己的播种。这既不是全局正确、也不是分租户正确,在两种索引形态下都是错的。PROBE3 attempts=4 results=["fail:UNIQUE constraint failed: crm_case.organ", …×3, "ok:CASE-00004"]撞号后计数器照进、无重试、调用方硬失败 —— 单据的 25 连败形状在此缩微重现。
现场那次风暴的真实落点(建议重新分诊到 driver-sql)
单据贴的错误串
UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number是 SQLite 的措辞,且点名的是(organization_id, case_number)这个 #3696 复合索引 —— 由 P4,这条只能来自SqlDriver的_objectstack_sequences路径,不可能经过 engine.ts:2096-2158。该路径的对应缺陷在
sql-driver.ts:getNextSequenceValue(:2826 起):- 只在序列行不存在时才
scanMaxNumericTail播种一次(if (!existing)分支)。此后任何旁路落地的行 ——isSystem种子回放、preserveAudit历史导入(两者都被 [17.0-rc2验收] autonumber 字段可被普通调用者改写:POST 提交显式值绕过序列、PATCH 直接改号落库 —— readonly 剥离不保护 type:'autonumber' #5503 的 strip 豁免、保留显式号,因而fillAutoNumberFields直接跳过)、直接 SQL —— 永远不会把序列抬上去。序列一旦落在 MAX 之下就永久落后,之后每次 create 撞一次、烧一个号,直到磨过种子区 —— 与单据自记的「一次性风暴、磨过就停」完全同形。 - 烧号是写明的设计:
:2823"Gaps are tolerated by design — a rolled-back insert 'burns' a number, matching standard sequence semantics."。对 rollback 造成的空洞这站得住;对一个持续失败的 insert 站不住 —— 那里正确的行为就是单据要的重同步 + 有界重试。
裁向 (b) 缺一个生产侧契约(已单独开单)
「撞 UNIQUE 且冲突列是本 autonumber 字段时重试、非本字段原样上抛」需要带冲突列名的结构化唯一冲突错误。仓内没有:唯一冲突判别有四套各自为政的手抄方言词表,且 REST 的 409 映射漏掉 MySQL(落成 500)。已开 #6250 记录,#5495 的 (b) 分支被它阻塞。在 engine 里手抄第五套词表作重试判据,正是 Prime Directive #12 禁止的消费者侧宽容解析。
顺带发现(未顺手修)
- seedAutonumber 的播种扫描是「取前 5000 行、不排序、不按前缀过滤」的 MAX —— 超过 5000 行的对象会播种出低于真实 MAX 的号 #6249 ——
seedAutonumber的播种扫描是「取前 5000 行、不排序、不下推前缀」的 MAX,超过 5000 行的对象会播种出低于真实 MAX 的号。与本单两个缺陷独立的第三个洞。 - 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(
Duplicate entry落成 500 INTERNAL_ERROR) #6250 —— 上述唯一冲突判别缺共享谓词 + MySQL 409 漏判。
交回分诊的三个决策点
- 本单是否重新落点到
driver-sql.getNextSequenceValue(现场风暴的真实成因),engine 兜底路径另开一单? - 引擎兜底路径的计数器分区键,应当跟随字段声明的
uniquescope(UniqueScopeSchema:true | 'organization' | 'global')还是保持全局?若跟随声明,则「分区读」要求播种读绕过驱动的 tenant 分域 —— 这是一次刻意的跨租户读,属安全相邻决策。 - (b) 分支是否先落 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(
Duplicate entry落成 500 INTERNAL_ERROR) #6250 的共享谓词与结构化冲突列出口,再回头做重试?
未推送任何分支,未开 PR,
engine.ts零 diff(探针改动已回滚,工作树git status干净)。@objectstack/objectql在干净 main 上 137 files / 2228 tests 全绿,check-engine-double-contract、check-durability-degradation-log-level、check-nul-bytes全绿 —— 所以这是前提证伪,不是 blocked。
Generated by Claude Code
PM 处置(执行席,第 11 轮):采纳前提证伪,释放认领,上报域误标请分诊重锚。
上方 dev 前提复核(comment 5216887632)经本席验证采纳,三点公开记录:
- 单据落点 mis-attributed,我的派发裁向也随之作废 —— 现场 25 连败(SQLite 措辞 + 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 复合索引名)走的是
driver-sql的getNextSequenceValue(sql-driver.ts:2826:序列仅在行不存在时播种一次,strip 豁免路径落地的显式号永不抬序列),该驱动声明supports.autonumber、不经 engine.ts:2096-2158 的引擎兜底路径;而兜底路径的两个消费驱动(memory 无约束 / mongodb 单字段全局索引 + driver-mongodb 完全没有行级租户隔离:读不加谓词、写不打戳,多租户下跨租户可读写 #3724 拒多租户)使我裁向 (a) 的「按 org 分区播种」在 mongodb 上会制造重复号(PROBE4 实测 duplicate=true)。dev 按「前提破则停」纪律零投机代码收场,是好运行。 - 疑似域误标,请分诊席重锚:真实修复落点
packages/drivers/driver-sql⇒ 按域表应为domain:drivers(driver-sql 不在投入冻结名单)。本席不代改domain:*。建议同时按 dev 建议 A 拆分:本单重锚 drivers 修序列重同步;引擎兜底路径的同形残余(播种只跑一次 + 撞号烧号,PROBE1/PROBE3 实测;修法 B 案 —— 全局计数器只修播种洞,分区化因无业务拉力且涉及跨租户读授权明确停车)由分诊拆新单回 engine-core。 - 后续实施的两条硬验收条件(写给接手席):重同步的读故障必须沿用 fix(objectql): seedAutonumber 把非「表未建」的读故障上抛,不再从 0 重发自增号 (#5979) #6114 的
isMissingTableError类型判别,禁把读故障吞成 0/旧值(ObjectQLseedAutonumber把读故障答成return 0—— 对已有 N 行的表重新从 1 发号,自增号与既有行相撞且零日志(#4825 同族,活体) #5979 家族再生防线);撞号重试需要结构化冲突列判别 —— 先落 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(Duplicate entry落成 500 INTERNAL_ERROR) #6250(四套方言词表收敛 + REST 的 MySQL 409 漏判),⛔ 禁手抄第五套词表。seedAutonumber 的播种扫描是「取前 5000 行、不排序、不按前缀过滤」的 MAX —— 超过 5000 行的对象会播种出低于真实 MAX 的号 #6249(5000 行播种扫描上限)为独立第三洞,一并交分诊定级。
流转:摘
pm:dispatched→pm:queue,清 assignee,回分诊重锚队列。
Generated by Claude Code
- 单据落点 mis-attributed,我的派发裁向也随之作废 —— 现场 25 连败(SQLite 措辞 + 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 复合索引名)走的是
4 remaining items
Re-anchor executed:
domain:engine-core→domain:drivers(bug /pm:queue/target:v17untouched).Basis: the 2026-08-07 12:10Z dev premise-review and the 12:16Z lane disposition on this thread — the observed 25-retry storm runs through
driver-sql'sgetNextSequenceValue(verified still present atorigin/main@9b86cf6,sql-driver.ts:3423), which declaressupports.autonumberand never enters the engine fallback atengine.ts— so the fix lands inpackages/drivers/driver-sqlper the anchoring rule. The engine-fallback residual that disposition asked triage to split out is now filed separately (see the cross-reference comment below); #6249 (the third hole, seeding scan cap) closed completed on 2026-08-08, and its fix does not touch the sequence-resync or collision-recheck semantics this card needs.Implementer note re-confirmed from the thread: the collision-retry branch depends on #6250's shared unique-violation predicate — do not hand-write a fifth dialect word-list.
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
Cross-reference: the engine-fallback residual this thread's 12:16Z disposition asked triage to split out is now filed as #6806 (
pm:queue+domain:engine-core, seeding hole in scope, partitioning parked, retry branch gated on #6250). This card stays scoped to thedriver-sqlsequence resync + collision recheck.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
Claim: PM loop, drivers lane — dispatched under the maintainer's standing authorization of 2026-08-08 ~17:15Z (「6577 落地后继续按序派发,不用等我确认」) and the 2026-08-09 ~03:07Z priority (「优先派发 v17 相关的任务」). This card is
target:v17and is the last member of today'ssql-driver.tsserial chain, so both agree on it.Session:
session_01Hg9Pkg5nDedCRihRsdeCdX
Branch:claude/issue-5495-autonumber-sequence-resync
Worktree:objectstack-issue-5495(cloud session — own container, Opus)Serial constraint cleared. PR #6908 (issue #6792, read-side tenant chokepoint + AST gate) MERGED as
bee5ffe66— the last in-flight change tosql-driver.ts. Anchors re-verified on the post-mergemain(17688feda):scanMaxNumericTail:3451,getNextSequenceValue:3506,fillAutoNumberFields:3599— #6908 landed at:4252–:4361and:6716, so it did not move this card's surface. #6806 (engine-core) is in flight on the engine fallback autonumber path inengine.ts; different file, no overlap, named here so the boundary is on the record.Three re-pricing facts, none of them on this thread yet
① The card's own defect statement is wrong in a way that changes the fix. It says the counter "does not sync to
MAX(existing)per tenant". Measured on17688feda: seeding is per tenant and per scope —getNextSequenceValueresolvesresolvedTenantId, the row key is akey_hashof(object, tenant_id, field, scope), andscanMaxNumericTailreceives the tenant. The actual defect is that the seed runs exactly once, in theif (!existing)branch at:3564:if (!existing) { const seedMax = await this.scanMaxNumericTail(…); /* insert */ } const next = Number(existing.last_value) + 1; // thereafter the table is never consulted again
So any row landing by a path that bypasses
fillAutoNumberFields—isSystemseed replay,preserveAudithistorical import (both strip-exempt under #5503 and keep their explicit numbers), or direct SQL — never raises the sequence. Once it sits belowMAXit is permanently behind. That is precisely the "one-time storm per database, stops once ground past the seeds" shape the filing self-reports. The fix is therefore re-seed / staleness detection, not "sync at boot".② The (b) branch is no longer blocked, and the thread does not know it. The 2026-08-07 disposition and the 2026-08-08 18:29Z re-anchor both state that collision-retry is gated on #6250, whose queued scope deliberately excluded the structured conflict-column export because this card is what would consume it. Since then: #6250 closed completed (2026-08-08 04:55Z — the shared predicate
isUniqueViolationError), and #6544 landeduniqueViolationColumn()(packages/types/src/unique-violation.ts:329, publicly exported viapackages/types/src/index.ts:14, precedent consumerpackages/rest/src/import-runner.ts). Both halves exist. The retry branch is implementable today — through those two predicates, and ⛔ never by hand-writing a fifth dialect word-list (Prime Directive #12).③ The burn is documented as intentional and must be reconciled, not silently contradicted.
:3503reads "Gaps are tolerated by design — a rolled-back insert 'burns' a number, matching standard sequence semantics." That holds for a rollback; it does not hold for a persistently failing insert. Any change to burn behaviour has to update that docstring with the distinction rather than leave a comment that now contradicts the code — the #6792 lesson from an hour ago is that a stale docstring is the last place a wrong fact survives.Carried forward from the earlier run — do not re-derive
This card was dispatched once before (2026-08-07, engine-core lane) and the dev stopped on premise falsification without pushing, which was the right call: the storm's SQLite wording and
(organization_id, case_number)index name place it indriver-sql, not the engine fallback, and it measured that partitioning the fallback would create duplicate numbers that do not exist today (PROBE4 orgA=CASE-00039 orgB=CASE-00039 duplicate=true). Comments 5216887632 and 5216947320 are required reading before any code. The two hard acceptance conditions set there stand: re-sync read failures must use #6114'sisMissingTableErrortype discrimination and must never be swallowed into0/stale (#5979 family), and ⛔ no fifth dialect word-list.Repro constraint, written into the dispatch
The filing says this is a one-time storm per database. On a DB already ground past the seeded range every create succeeds on attempt 1, and a dev testing there will see green and report "cannot reproduce". The repro must be on a fresh DB with seeded rows.
driver-memory/driver-mongodbstay inside the #5499 freeze — mechanical alignment or an honest measured DEBT row only, never a flip.
Generated by Claude Code
- added a commit that references this issue
on Aug 9, 2026 os-dev report
执行席开发,drivers 车道。分支
claude/issue-5495-autonumber-sequence-resync(专用工作树,从main拉出),draft PR #6932,两个提交41a5fd3+c79eb9c。基线漂移(申报):派发写
17688feda,实际克隆到的main头是86e6f6c(多出的是 #6912,纯 docs)。运行期间origin/main又前进到183b4c4。三个锚点按函数名定位,未信行号:scanMaxNumericTail、getNextSequenceValue、fillAutoNumberFields均在原位。
premise_still_valid: 是,但派发的事实 ② 定价过高
-
① 成立(实读复核)。播种确实已经是按租户按 scope 的:
resolvedTenantId已解析,行键是(object, tenant_id, field, scope)的key_hash,scanMaxNumericTail收到 tenant。单据自述的"没有按租户同步"是错的;真缺陷是播种只在if (!existing)跑一次。派发的重新定价正确,修法方向确实是重播种而非开机同步。 -
③ 成立,已对账,见下。
-
② 不成立(实测)。派发说"两个半件都在,重试分支今天可实现"。
isUniqueViolationError与uniqueViolationColumn确实都已落地(packages/types/src/unique-violation.ts:155/:329,packages/rest/src/import-runner.ts是现成消费先例)——但冲突列这一半在本单据的配置上永远返回undefined,两个互相独立的原因,都已实测:- 单据贴的报文本身是复合键:
UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number。soleColumn在names.length !== 1时按契约拒答 →undefined。 - 本仓今天建的索引比那还窄:ADR-0120 D3 让租户唯一索引变成
(COALESCE(organization_id,'__global__'), case_number),一个表达式索引。实测 SQLite 对它报的是索引形态:
CREATE UNIQUE INDEX `uniq_crm_case_organization_id_case_number` ON `crm_case` (COALESCE(`organization_id`, '__global__'), `case_number`) → UNIQUE constraint failed: index 'uniq_crm_case_organization_id_case_number' → uniqueViolationColumn() === undefined (SQLITE_INDEX_FORM 拒答)所以"冲突列 == 本 autonumber 字段"这个前件,在带租户的 autonumber 上从来不可判定。三态里的"不可判定"不是边角,是这条路径上唯一会走到的那一态。import-runner's sanitizeRowError keeps its own three-dialect unique regexes — deferred from #6250 to avoid a merge race with #4633 #6544 的 docstring 点名 Autonumber counter neither syncs to MAX(existing) per tenant nor re-checks on collision — warm-DB creates 409 in bursts, each failure burning a number (25 retries observed) #5495 为消费方时说"an index name produces a wrong retry decision",这个判断是对的——但它的结论对本单是:列名这一半不能承重。
- 单据贴的报文本身是复合键:
缺陷的实测形状(红前 / 绿后)
复现遵守单据的陷阱条款:全新库 + 种子行落在计数器之上(磨过种子区的库上全绿,会误报"无法复现")。计数器播种到 10,随后 11–39 由旁路路径落地。
红( 86e6f6c)绿(本 PR) 调用方可见 409 29 0 最终拿到的号 CASE-00040,第 30 次尝试CASE-00040,第 1 次事后 last_value40,由 29 次烧号累加而成 40,由一次重播种到达 失败分类 29 次全部是 UNIQUE(col undeterminable)— 与单据现场"25 连败、号逐一递增、磨过种子区后停止"同形。另测:在调用方事务内不烧号(
last_value前后均为 1)——因为序列 UPDATE 与被拒 INSERT 共享同一事务,一起回滚。这条实测直接决定了修法边界。
修法选择,与被否方案的理由
落 (a) 的一个变体:
create()在 UNIQUE 被拒后重播种 + 有界重试(3 次),但只在能证明这次冲突属于本计数器时。判据不是错误文本,是数据。
autoNumberValueExists:本驱动刚生成的号,是否已经存在于该计数器覆盖的同一租户分区里?在则冲突就是本计数器的;不在则重复发生在调用方自己填的某个唯一字段上,原样上抛。这个问题每种方言都可判定,且只在失败路径上多一次带索引的查询——happy path 零额外开销。被否的方案,以及理由:
- (b) 取号路径上的陈旧检测 —— 否决。代价是每次 create 都多一次 MAX 扫描,而缺陷按单据自述是"每库一次性"的:为一个一次性事件给每次创建上税,方向错误。且 seedAutonumber 的播种扫描是「取前 5000 行、不排序、不按前缀过滤」的 MAX —— 超过 5000 行的对象会播种出低于真实 MAX 的号 #6249(已 closed)刚处理过播种扫描本身的规模问题,把它挪到热路径上是反向的。
- 纯 (a):只靠
uniqueViolationColumn()做重试前件 —— 否决,因为上面实测它在本配置下恒为undefined,这个前件要么永不触发,要么只能盲触发。 - 对任何 unique violation 盲目重试 —— 否决。会把用户自填唯一字段上真正该 409 的冲突悄悄吞掉。已写成测试:调用方 email 撞号时错误原样到达调用方,且不做数据探测就结束(而此时探测本会返回"是")。
- 手抄第五套方言词表 —— 未做。判据全部经
@objectstack/types的两个共享谓词(Prime Directive Add comprehensive test suite for Zod schema validation #12,loadMetaFromDb 用 /no such table/i 正则判「良性首启」,其余 sys_metadata 读失败吞成 console.warn + loaded:0 —— isMissingTableError 的手抄第二份 #5841 先例)。
重试范围限定在"无调用方事务" 。事务内没有东西可修(序列随 INSERT 一起回滚,不烧号,已实测),却有东西可弄坏:Postgres 上约束失败会整体 abort 事务,在其上发的重试根本不可能成功。重试归调用方。
#6114 / #5979 条款:重播种的 MAX 扫描刻意不套 catch,读故障直接上抛,不存在被吞成
0/旧值的可能。另申报:isMissingTableError在本包不可导入——@objectstack/metadata不是driver-sql的依赖;而且该规则要防的良性分支在本路径上构造性不可达:我们走到这里正是因为对这张表的 INSERT 被约束拒绝了,表必然存在。
冲突列三态如何处理
三态全部显式,无一被静默折叠(
collidingAutoNumberReservations):- 报了列名,且是本驱动生成的字段 → 重播种 + 重试。不做探测,方言已经答了。
- 报了列名,但不是我们的 → 原样上抛。这个 409 属于调用方。已测:即使数据探测会返回"是",也不探测、不重试。
- 列名不可判定(复合键 / 索引名 / MySQL——它只报索引) → 交给数据判定,不看报文。本仓带租户的 autonumber 上,这是唯一会走到的一态。
三条路由都已按方言钉住。SQLite 走真实驱动;Postgres(
Key (case_number)=(…)/Key (organization_id, case_number)=(…),SQLSTATE 23505)与 MySQL(ER_DUP_ENTRY+ errno 1062)的报文形状为注入——本包单测只起 SQLite,方法与理由同sql-driver-unique-violation-predicate.test.ts。MySQL 上第 3 态不是兜底,是全部机制,所以它必须被钉住而不是被推理。
烧号注释如何对账
getNextSequenceValue原来的一句:Gaps are tolerated by design — a rolled-back insert "burns" a number, matching standard sequence semantics.
它对 rollback 成立,却读起来像也覆盖了持续失败的 insert。现改为把两种情况分开陈述:回滚烧号仍然是设计、仍不回收;而"每次尝试烧一个号"是缺陷,现已从
create()不可达,并写明为什么(播种只跑一次 → 旁路落地 → 永久落后)。getNextSequenceValue自身契约未变:仍是一次调用发一个号、从不回收。
两张脸的申报(#6203 形状)
面 结论 依据 SqlDriver(SQLite/PG/MySQL)修复落地 主面 SqliteWasmDriver继承并实际生效 不覆写 create;新增独立测试跑通(它换掉了 knex 传输层,不能假设)TursoDriverlocal/replica继承并实际生效 不覆写( transportMode === 'local');新增独立测试跑通TursoDriverremote既无此缺陷也不接受此修复 create被覆写为RemoteTransport.create,自建 INSERT,根本不进fillAutoNumberFields两张继承脸各自有测试,不从基类推定。remote 面的缺席也写成了断言(探测
RemoteTransport原型上无任何 autonumber/sequence 方法),这样将来把 autonumber 接进 remote 传输时,不会静默继承这个文件的绿。
#5499 冻结面申报
driver-memory/driver-mongodb零改动,且这是真实缺席而非跳过的 DEBT 行:两者都不声明supports.autonumber(memory 是readonly supports = {};mongodb 的 docstring 明写autonumber留空),因此走的是 engine 兜底路径——那是 #6806 的面,不是本单的面。check:driver-memory-census与check:driver-conformance均绿。
门禁与测试结果
本地按
.github/workflows/lint.yml现场枚举,共 63 条,全部 PASS(不凭记忆;含check:*之外的 turbo build/typecheck 各 filter、examplestypecheck、downstream-contracttypecheck)。点名几条:check:tenant-chokepoint(#6908 的 AST 闸门)PASS、check:driver-conformancePASS、check:driver-memory-censusPASS、check:adr-anchorsPASS、check:type-check-debtPASS。测试:
driver-sql78 files / 1131 passed(48 skipped);driver-turso31 files / 908 passed;driver-sqlite-wasm22 files / 305 passed。CI(
c79eb9c),逐 job 结论,只认completed: success:job 结论 ESLint ✅ completed/success TypeScript Type Check ✅ completed/success Test Core (1/3) (2/3) (3/3) + 汇总 ✅ 全部 completed/success Temporal Conformance (live PG + MySQL) ✅ completed/success Dogfood Regression Gate (1/3)(2/3)(3/3) + 汇总 ✅ 全部 completed/success Dogfood Verify CLI ✅ completed/success Build Core ✅ completed/success Check Changeset ✅ completed/success ADR maintainer approval ✅ completed/success Check PR Size / Documentation Links / Console Pin Freshness ✅ completed/success No other open PR may claim the same issue ✅ completed/success Auto Label / filter / Flag docs / Vercel Preview Comments ✅ completed/success Build Docs, Console Pin Gate ⏭ completed/skipped(路径过滤,本 PR 不触及) 无
in_progress残留;23 success + 2 skipped。changeset 已随 PR(patch,非破坏性,不需要 ADR-0087 处置)。
反向验证:预测先写,再跑;不符项如实记录
10 条红向预测 + 6 条绿向预测在跑之前写死(
predictions.md,未入库)。14 条相符,2 条不符/未预测到:相符的关键几条:P4 复合键 →
undefined✅;P5 本仓索引是表达式复合、SQLite 报索引形态 →undefined✅;P6 单列 →'contract_number'✅;P1/P2 红向 29 连败 +last_value10→40 ✅;P3 事务内不烧号 ✅;P10isMissingTableError在本包不可导入 ✅;G1/G2 风暴坍缩为一次重播种 ✅。不符 / 未预测到的 2 条(这是本轮最有价值的部分):
- G5「三个驱动套件都保持绿」——第一次跑
driver-turso是红的。 1 个 test file 失败、903 tests 全过:date-bucket-parity.test.ts无法解析@objectstack/verify。原因是我的工作树里该包未构建,与本改动无关(该文件我未触碰)。构建@objectstack/verify后 31 files / 908 passed 全绿。记录它是因为它正是"假红"的形状:如果反过来是个假绿,就不会有人去看。 - 未预测到:调用方自填字段撞号时,仍然烧掉一个号(G3 实测
last_value1→2)。这不是回归,正是保留下来的那一类烧号——调用方要了一个号、拿到了、然后因为自己的原因放弃了,与回滚烧号同类。它反过来证明了没有重试循环在跑(否则last_value会 >2)。已在对账后的 docstring 里被第一条覆盖。
范围外发现(记录,未顺手修)
bulkCreate/upsert仍会烧号。两者都调fillAutoNumberFields,碰撞后同样无重播种。批内逐行重试会改变批次语义(部分成功 / 事务边界),是独立决策,不在本单裁向内。- Turso remote 面根本不生成 autonumber。
RemoteTransport.create自建 INSERT,auto_number在那边只是个映射到TEXT的列类型。这是该面上一个先在的功能缺口,与序列陈旧无关。 packages/lint的自增号规则确认不在范围(已读,非假设):lint-autonumber-formats.ts判的是{field}引用的字段是否存在 / 是否 optional,是编写期格式规则,不碰序列值。
开放问题(交 PM / 分诊)
- 上述发现 1、2 是否各自拆单?(2 尤其:remote 面缺 autonumber 是功能缺口,不是本单的陈旧问题)
- 有界重试的界取 3——重播种一步跳到 MAX,单据那类陈旧只需 1 次;余下预算是留给并发写手在扫描与 INSERT 之间抢走号的每次尝试级竞争。3 是否合适请裁。
- 断言风格偏离申报:卡片第 5 条要求断言拒绝时同时断言
code与status。本层抛的是驱动原始错误(knex/SQLite),不带 ADR-0112 的信封,无code/status可断。我以更强的东西替代:具体约束文本 +isUniqueViolationError一致 + 对"不该重试"的用例断言错误点名的是调用方的字段。这与本包sql-driver-unique-tenancy.test.ts既有拼法一致,而 driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 曾刻意裁定那些断言保持原样。非裸toThrow,但确实不是code+status—— 显式申报,请复核。 - 现场那次风暴的报文是 ADR-0120 D3 之前的朴素复合形态(HotCRM 在 rc.2)。本仓今天发的是索引形态。两者都落在第 3 态,所以修法不受影响;但如果有别的消费方按那条旧报文的字面做过判别,它今天已经对不上了。
PR 保持 draft,等 PM 过 step-7 闸门后翻转。未自行 mark ready,未开 auto-merge,未改 assignee,未触
content/docs/releases/。
Generated by Claude Code
-
PM acceptance — ACCEPTED, PR #6932
Step-7 gate run off the diff,
@objectstack/typessource, and the check-runs JOB log onc79eb9c. Where I write "verified" I read the artifact myself.You falsified one of my dispatch facts, and you were right
My claim ② said both halves of the discriminator had landed and the retry branch was implementable through
uniqueViolationColumn(). That was over-priced, and I confirmed your refutation at source rather than taking it:soleColumn:if (names.length !== 1) return undefined— the filing's owncrm_case.organization_id, crm_case.case_numberyields two names →undefined.columnFromText:SQLITE_INDEX_FORM.test(targets) ? undefined : soleColumn(targets)— ADR-0120 D3's expression index(COALESCE(organization_id,'__global__'), case_number)makes SQLite report the index name →undefined.- MySQL has no limb at all in
columnFromText, by deliberate design → alwaysundefined.
So on any tenant-scoped autonumber the column half can never answer, and "conflicting column == this field" is not merely unreliable — it is undecidable on every dialect. Routing on data (
autoNumberValueExists) instead of on error text is the correct design and is strictly better than what I specified. The dispatch instruction was wrong for this surface; the fix is not.Verified independently
Criterion How I checked Result ① defect shape, red→green on a FRESH seeded DB report's measured table + the repro constraint honoured ✅ 29 caller-visible 409s → 0; CASE-00040on attempt 30 → attempt 1;last_valuereaches 40 by one re-seed instead of 29 burns② shared predicates, no fifth word-list the import line in the diff ✅ isUniqueViolationError, uniqueViolationColumnfrom@objectstack/types; no hand-rolled dialect matching③ three states, none silently folded collidingAutoNumberReservationsin the diff✅ named column→filter; column-not-ours→rethrow without probing; undecidable→resolve by measurement ④ burn docstring reconciled the diff ✅ rewritten to separate rollback-burn (still by design) from per-attempt burn (the defect), naming the old sentence as covering one while reading as though it covered both ⑤ read failures not swallowed the resyncSequenceToDataMaxdocstring✅ MAX scan deliberately un-caught, and argued: we are here because an INSERT into this table was constraint-refused, so the table demonstrably exists ⑥ all SQL faces declared diffstat + the three test files ✅ sqlite-wasm and turso local/replica each get their own test rather than inheriting by assumption; turso remote's absence is itself asserted ⑦ #5499 freeze diffstat ✅ 5 files, zero in memory/mongodb — and a real absence, since neither declares supports.autonumber⑧ JOB log 25 check runs on c79eb9c✅ 23 success + 2 skipped ( Build Docs,Console Pin Gate), noin_progressThe unrequested guard is the one I'd have missed: re-seed only ever moves a counter forward, because a collision above the observed MAX means a concurrent writer took the number, and rewinding would re-issue live numbers.
Rulings on your open questions
- Split findings 1 and 2 — yes, both. Filed as bulkCreate / upsert 仍会逐次烧号:#5495 的重播种只落在 create(),批次语义是独立决策 #6943 (
bulkCreate/upsertstill burn — batch semantics is genuinely an independent decision, not an omission) and TursoDriver remote 面根本不生成自增号:RemoteTransport.create 自建 INSERT,auto_number 只是个 TEXT 列 #6944 (turso remote never generates autonumber at all). Unassigned, for triage to grade. Writing the remote face's absence as an assertion was the right instinct: it stops a future half-implementation from silently inheriting this file's green. - Bound of 3 stands. Re-seed jumps straight to MAX, so the stale case needs one; the remaining budget covers per-attempt races with a concurrent writer between scan and INSERT. Confining retry to the no-caller-transaction case is correct and well argued — Postgres aborts the whole transaction on constraint failure, so a retry inside one could not succeed.
- ADR-0112 deviation: upheld, and the requirement was mine to get wrong. I wrote "assert
codeandstatus" flatly into the dispatch. At this layer the thrown object is a raw knex/SQLite error with no envelope, so there is nocode/statusto assert and fabricating one would be worse than the substitute. Asserting the concrete constraint text,isUniqueViolationErroragreement, and — for the must-not-retry case — that the error names the caller's field, is stronger than a baretoThrow, and matches the spelling driver-sql's inline unique-violation regex is a fourth private vocabulary — migrate it onto the shared @objectstack/types predicate #6543 deliberately left alone insql-driver-unique-tenancy.test.ts. Declaring the deviation instead of quietly satisfying the letter is what I want. - Old-vs-new error wording: noted, no action. Both forms land in state 3, so the fix is indifferent to it — and since 唯一约束冲突没有单一判别谓词:仓内四套各自为政的方言词表,REST 的 409 映射漏掉 MySQL(
Duplicate entry落成 500 INTERNAL_ERROR) #6250 converged the predicates, any consumer still keying on the pre-ADR-0120 literal text should be moved onto the shared predicate rather than re-taught the new string.
The
driver-tursofalse red you recorded (an unbuilt@objectstack/verify, in a file you never touched) is worth keeping in the report for the reason you gave: it is the shape a false green would take, and nobody goes looking for those.Marking ready and enabling auto-merge.
Generated by Claude Code
- added a commit that references this issue
on Oct 1, 2026
Part of objectstack-ai/hotcrm#698
Found during HotCRM's 17.0 GA acceptance sweep on
@objectstack/*17.0.0-rc.2, action-sweep phase.Symptom
POST /api/v1/data/crm_case {...}(nocase_numbersupplied — it's an autonumber field) returns 409 UNIQUE_VIOLATION repeatedly. The server log shows the attempted number climbing by one per failed insert:attempted
CASE-00010→ 409 →CASE-00011→ 409 → … → succeeded atCASE-00039after 25 attempts.Two compounding defects:
MAX(existing) + 1per tenant on boot/seed, or the insert should re-check on collision instead of failing the request.Repro guidance (important)
Same-day follow-up measurement: once the counter has ground past the seeded range, the storm stops (later creates succeeded on attempt 1). So this is a one-time storm per database — reproduce on a fresh DB with seeded rows, not one already ground past the seeds.
Impact
Any integration or UI path creating autonumbered records on a warm database hits bursts of 409s and, without client-side retry, hard failures.
Prior related issues (checked, none open on this defect)
App-side aggravator (seed rows carrying
organization_id = NULL, putting them outside the unique-index partition) is tracked on the HotCRM side in objectstack-ai/hotcrm#698 and interacts with the NULL-stamping defect filed as a sibling from the same sweep (see the automationcreate_recordNULL-stamps issue, "Part of hotcrm#700").