feat(driver-sql)!: NULL 安全的组织级唯一约束 + 声明索引 'organization' 作用域(ADR-0120 D3/D4,#5030) - #5212
Merged
Merged
Conversation
…rganization' scope (ADR-0120 D3/D4, #5030) Every organization-scoped unique index now materializes its organization key part as COALESCE(organization_id, '__global__') instead of the bare column: SQL UNIQUE is NULL-distinct, so the #3696 composite enforced nothing on NULL-organization rows — on a single-tenant stack, every row (#5030, measured). - uniqueIndexesFromFields / legacy replacements emit the NULL-safe key part; field-level unique: 'organization' accepted as the explicit synonym of true. - normalizeDeclaredIndex / expectedIndexes / syncDeclaredIndexes learn declared unique: 'organization' (prepend the org key part; degrade with no tenant column; a listed tenant column is made NULL-safe in place). 'global' / bare true stays VERBATIM — the #3696 contract, now the 'global' arm (spec token lands separately via #4986; driver deliberately merges first). - Drift both sides share one normalization: physical COALESCE(col, <literal>) attributes to col, compared literal-agnostically; the org key part is the sync's own vocabulary (isSyncReproducibleIndex + tenantField), scoped so the ADR-0048 overlay indexes keep their #4884 protection. - D4 ceremony: the bare-composite tightening is a recreate_index gated by a duplicate pre-flight probe — clean → safe (dev autoMigrate applies), dirty → BLOCKED with a row report, old index kept; apply re-probes so even --allow-destructive cannot drop a constraint whose replacement fails. - plugin-auth: '__global__' reserved as organization id/slug at the beforeCreateOrganization seam (ADR-0120 D3 guardrail). - Tests per ADR-0120 D6.6: contract header rewritten, #5030 probe graduated as a permanent regression, NULL-bucket semantics, declared 'organization' pins, pre-flight both states; 'exactly as authored' retained for 'global'. - ADR anchors for schema-drift.ts / sql-driver.ts (PD #13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 11:51
This was referenced Aug 4, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Aug 4, 2026
Resolves the scripts/adr-anchors.json conflict by keeping all five ADR-0120 anchor entries (spec/lint side + driver side from #5212), and clears the os-regen deferred merge of packages/spec/api-surface.json via spec build + gen:api-surface (isOrganizationUnique retained). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5030
合并次序(请勿提前合并):等 v17.0 cut 之后合入,且必须先于 #4986 的 spec/lint PR —— driver 侧先落地,保证 spec 侧接受
'organization'时驱动侧的兑现已经在位(ADR-0120 D7 的刻意次序)。背景
#5030 实测:SQL UNIQUE 是 NULL-distinct 的,#3696 的
(organization_id, field)复合唯一索引在 organization 为 NULL 的行上什么都不约束 —— 而单租户栈上内核无条件注入该列且从不填值,即每一行都是 NULL:字段级unique: true在单租户部署上是静默零约束。ADR-0120(Status: Accepted)裁定方向,本 PR 是 17.x 波第一块(driver 侧 D3+D4)。改动(
packages/plugins/driver-sql+ 守卫点plugin-auth)D3 — 组织键部 NULL 安全化
COALESCE(organization_id, '__global__'):NULL-org 行折叠成一个平台桶、桶内唯一;非 NULL 行不受影响。存储保持 NULL —— 哨兵只存在于索引键内,GLOBAL_TENANT常量收敛到schema-drift.ts单一定义点(与 autonumber 序列表同名同义,多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 的教训)。unique: 'organization'作为true的显式同义词被接受(D1);'global'、无租户列对象行为不变。unique: 'organization'在注册期(租户列可知的唯一位置)把 NULL 安全组织键部前置到列清单;无租户列时退化为列出的列(S11);作者自己列出租户列时原位改写其键部(S6 重拼写)。unique: true/'global'保持 verbatim —— 多租户缺陷:unique 物化为全局唯一索引、无视 tenancy,与按租户分裂的 autonumber 序列自相矛盾(跨租户必然撞号 + 存在性探测泄露) #3696 契约成为词汇表的'global'臂,九个引擎幂等键物理形状零变化。spec 尚未收词是刻意的合并次序,测试走 driver 直连initObjects。drift 两侧同一 normalize(#4884 纪律延伸)
COALESCE(col, <literal>)归因到 col(三方言拼写均有测试,含 MySQL_utf8mb4\'…\'修饰形),比较对字面量不敏感;isSyncReproducibleIndex把组织键部识别为 sync 自己的词汇,但按列收窄 —— ADR-0048 overlay 索引(COALESCE(package_id,''))保持 [schema-drift] Fresh DB boots "drifted": detector can't read COALESCE index columns, then tells the operator to --allow-destructive away a legitimate unique index #4884 保护不动。健康库(新建/已收敛)零假漂移,有既有测试与新增测试双重钉住。D4 — 收紧走仪式 + 重复数据预检
(organization_id, X)→ COALESCE 形是recreate_index(tightenNullSafeOnly)纯收紧,由重复数据预检裁决:探测干净 → 判safe(devautoMigrate: 'safe'/ 普通os migrate apply可应用,unique 索引迁移在启动时静默执行 DDL,os migrate plan 看不到 —— 运维无预检手段 #3728 机制);发现重复(旧空约束错误放进来的数据)→ op 阻塞,按组报告冲突行,旧索引原地保留。apply 期重探,--allow-destructive也无法在替代索引不可建时先删旧索引;创建失败还有裸复合索引回滚兜底(durability 级error日志)。CLI 面零改动 —— 复用既有 plan/apply 通道。error级说明未兑现范围,drift 持续报告待服务器升级后收紧。'__global__'保留守卫(ADR-0120 D3 guardrail)plugin-authbeforeCreateOrganization钩子拒绝 id/slug 等于'__global__'的组织创建,报错指向 ADR-0120 D3。测试(ADR-0120 D6.6)
sql-driver-unique-tenancy.test.ts头注契约表重写;driver-sql: 单租户栈上 organization_id 恒为 NULL,#3696 的 (tenant, col) 复合 UNIQUE 因 NULL-distinct 而完全不生效 —— 字段级 unique: true 静默零约束 #5030 探针升格为永久回归(org 列在、值 NULL、同值第二次被拒);NULL 桶唯一 + 两组织可同值(S8);字段级/声明'organization'用例;S11 退化;S6 重拼写;D4 预检两态(干净自动收紧 + 脏数据阻塞→去重解锁);保留leaves declared object-level indexes exactly as authored与'global'同义词钉(后者按 D6.6 注明随 protocol 18 退休)。sql-driver-overlay-index-drift.test.ts增补三方言归因、nullSafeColumns归集、differ 字面量不敏感等价与 bare ≠ COALESCE 判定的纯单元钉。scripts/adr-anchors.json增补schema-drift.ts/sql-driver.ts两条 ADR-0120 锚(PD [WIP] Add Chinese version of the documentation #13)。测试证据
边界(刻意不做)
'organization'token、unique/unscoped-declared-index规则、R10 重写 → driver-sql: 表级{ fields, unique: true }在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986true拒绝)→ ADR-0120 协议 18 项:D2 conversion(声明索引 unique: true → 'global')+ 裸 true 硬拒 + synonym pin 退役 —— 挂 18 列车,现在勿动工 #5082content/docs/releases/不触碰;changeset 已附(.changeset/null-safe-org-unique-driver.md)🤖 Generated with Claude Code
https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7
Generated by Claude Code