You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On an object with no tenant column (tenancy.enabled: false, or simply no tenant field) both spellings materialize identically — single-column unique. Single-tenant deployments are therefore unaffected: the tenant column is constant, so the composite index degenerates to the single-column one.
sql-driver-unique-tenancy.test.ts 的头注也写着 unique: true, no tenant column → single-column (single-tenant: unchanged);对应的用例(materializes a SINGLE-column unique when the object has no tenant column)用的是一个手写、完全没有 organization_id 字段的 schema —— 它绕开了 applySystemFields,因此测的不是单租户栈上对象的真实形状。真实形状(有列、值为 NULL)没有任何用例覆盖。
未认领。 从 #4986 的调研中实测发现,与该 issue 的决策面相邻但独立,单独归档。
现象(已实测复现,非推断)
在
packages/plugins/driver-sql里跑一个探针:对象带organization_id列(就是applySystemFields在单租户栈上注入的那一根),email字段声明unique: true,然后连写两行、都不带organization_id:第二行创建成功。字段级
unique: true在这条路径上一条约束都没有兑现。成因链(四步,每一步单独看都正确)
packages/objectql/src/registry.ts的applySystemFields:wantTenant = sf?.tenant !== false && !tenancyDisabled—— 与multiTenant开关无关。注释自己写明了这次解耦的意图:「organization_id列无条件供给……多租户开关现在只决定这列是否建索引」。所以单租户栈上这列存在。SqlDriver.computeTenantField:只要fields里有organization_id且没显式关租户,就返回'organization_id'—— 单租户栈上非 null。uniqueIndexesFromFields(schema-drift.ts):scoped = !isGlobalUnique && tenantField != null && tenantField !== name→ 成立,物化成UNIQUE (organization_id, email)。organization_id每行都是NULL。而 SQL 的 UNIQUE 是 NULL-distinct 的 —— 这一点sql-driver.ts的syncDeclaredIndexes文档块自己就写明了:「NULL-distinct semantics are the default across SQLite/Postgres/MySQL」。于是(NULL, 'dup@example.com')写多少行都不冲突。与规范陈述的冲突(declared ≠ enforced)
packages/spec/src/data/field.zod.ts的UniqueScopeSchema文档块声称:「租户列是常量,所以复合索引退化成单列索引」这句推理在常量取值为 NULL 时不成立:它退化成的不是单列索引,而是没有索引约束。而第 1 步已经保证了单租户栈上这列存在(不是「no tenant field」),所以这段豁免说明覆盖不到真实形状。
sql-driver-unique-tenancy.test.ts的头注也写着unique: true, no tenant column → single-column (single-tenant: unchanged);对应的用例(materializes a SINGLE-column unique when the object has no tenant column)用的是一个手写、完全没有organization_id字段的 schema —— 它绕开了applySystemFields,因此测的不是单租户栈上对象的真实形状。真实形状(有列、值为 NULL)没有任何用例覆盖。影响面
unique: true都是空约束。email、code、业务唯一键,全部可以重复落库。os migrate plan也看不出问题(索引确实按声明建出来了,只是不约束)。复现脚本
探针文件已在调研中写过又删除(未提交),内容就是上面第 1 段描述的三十行 vitest:
SqlDriver+better-sqlite3:memory:,initObjects一个带organization_id和email: { type: 'text', unique: true }的对象,连create两次同 email、不传 organization_id。可能方向(未定,交维护者裁)
organization_id落一个非 NULL 的 sentinel(驱动里已经有GLOBAL_TENANT = '__global__'这个常量,但目前只用于 autonumber 序列表,sql-driver.ts:1831反而显式把它转回null再写列)。改动面涉及 RLS 读谓词与既有数据迁移,不小。computeTenantField在多租户未启用时返回null,让单租户栈退回单列 UNIQUE —— 与规范文档块的原意一致,但会让多租户栈上的 platform-global NULL 行仍然漏。NULLS NOT DISTINCT(PG 15+),SQLite/MySQL 无对应语法,会造成方言语义分裂。COALESCE(organization_id, '')——schema-drift.ts已经有一整套COALESCE索引列的识别与归因逻辑([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 作业过),基础设施是现成的。我倾向 D 或 A:两者都让约束在所有方言上真正生效,且 D 的 drift 侧识别代码已经存在。但这条链跨 registry / driver / spec 文档三处,应由维护者定方向后再动手。
关联
(tenant, col)复合 UNIQUE 的来源){ fields, unique: true }在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986(相邻决策:表级声明索引是否也补租户列 —— 若照补,本缺陷会从字段级扩散到sys_notification.dedup_key、http_delivery (source, dedup_key)、sys_presence.session_id、sys_job.name等一批 engine-owned 幂等键上)COALESCE索引列识别,方向 D 的现成基础设施)