fix(driver-sql)!: unique 按租户物化,不再与分裂的 autonumber 序列冲突 (#3696) - #3717
Merged
Conversation
`unique: true` used to become a single-column global index that ignored
`tenancy` entirely, while the autonumber sequence table is keyed by
`(object, tenant_id, field, scope)` and hands every tenant its own counter
starting at 1. Two subsystems of the same platform contradicted each other:
tenant B's `PROD-00001` was rejected by an index it could not see, with no
user error involved. The rejection doubled as a cross-tenant existence
oracle — a UNIQUE violation told tenant B that some OTHER tenant held the
value, enumerable by probing emails/codes/names.
Contract:
- `unique: true` + tenant column -> composite `(tenantField, field)`,
unique WITHIN the tenant. Tenant column first, so the index also serves
the `WHERE tenant = ?` prefix scans every tenant-scoped read issues.
- `unique: true`, no tenant column -> single-column. Single-tenant
deployments get byte-identical DDL to before.
- `unique: 'global'` -> single-column, always. Platform-wide
uniqueness is the special case and now has to say so (a DNS hostname, a
reserved slug, an external provider id, a device identity).
Field-level `unique` had three DDL paths that each inlined their own
single-column SQL — createTable/alterTable via `createColumn`, and the
SQLite drift rebuild. That is how a tenant-scoped field could silently
regain a global index after any drift reconcile. All three now converge on
one tenancy-aware path (`uniqueIndexesFromFields` + `syncTableIndexes`);
`createColumn` owns type/nullability/defaults only, since a column builder
cannot express a composite at all.
Declared object-level `indexes[]` are deliberately NOT rewritten: the author
already names the columns, tenant-scoped ones have always been written
explicitly (`fields: ['organization_id', 'code']`), and many are legitimately
platform-wide. Auto-injecting a tenant column there would have broken global
slug reservation, hostname allocation and external provider ids. `'global'`
is accepted there as a synonym of `true` for one vocabulary across both
spellings.
Legacy indexes (`<table>_<col>_unique` from knex, `uniq_<table>_<col>` from
the rebuild path) are retired inline at sync time. This is a pure relaxation
— the old global constraint is strictly stronger than the per-tenant one, so
existing data satisfies the replacement by construction: no dedup, no
cleanup, no possible failure. It converges at sync rather than waiting for a
deliberate `os migrate` run because a deployment that never ran migrate would
otherwise stay broken, and the failure mode is a rejected insert. Postgres
materializes `col.unique()` as a CONSTRAINT rather than a bare index, so the
drop tries `DROP CONSTRAINT` before `DROP INDEX` — `DROP INDEX` alone would
have made this a no-op on exactly the deployments that matter.
driver-mongodb accepts the new type but keeps single-field indexes: it
implements no row-level tenancy at all (no tenant predicate on read, no
tenant stamp on write), so a `(tenant, field)` index would advertise an
isolation it does not deliver. Tracked separately.
BREAKING CHANGE: on a tenant-scoped object, field-level `unique: true` is now
unique per tenant instead of platform-wide. Fields that need platform-wide
uniqueness must declare `unique: 'global'`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uu13EPsmLey7drXSMjuNq
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
CI: `Check Changeset` (no changeset added) + `TypeScript Type Check`
(content/docs/references out of date with packages/spec).
- Changeset as `minor`: breaking changes ship minor during the launch
window (all ~70 packages version in lockstep, so a `major` would
promote the whole stack).
- Regenerate content/docs/references/data/{field,object}.mdx — generated
from the Zod schemas, so the `unique` type widening has to land there.
- protocol/objectql/schema.mdx is HAND-WRITTEN and documented the old
contract ("Enforce uniqueness at database level"). Adds a
"Uniqueness and tenancy" section: why `true` is per-tenant, when to
reach for `'global'`, and that declared `indexes[]` take their columns
verbatim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uu13EPsmLey7drXSMjuNq
The previous run's docs-sync failure short-circuited the job, so every later step — including this check — was skipped and never reported. Ran the whole skipped tail locally: api-surface, skill-refs, react-blocks, skill-examples, i18n bundles, example-app typecheck and the downstream consumer contract. Only this snapshot needed updating. 0 breaking (nothing removed or narrowed), 4 added: UniqueScope, UniqueScopeSchema, isGlobalUnique, isUniqueDeclared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uu13EPsmLey7drXSMjuNq
os-zhuang
marked this pull request as ready for review
July 27, 2026 16:11
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.
Closes #3696
问题
unique: true被物化成不带租户列的全局唯一索引,而 autonumber 序列表按(object, tenant_id, field, scope)分裂、每个租户各自从 1 起发号。平台的两个子系统互相打架:租户 B 的PROD-00001撞进一条它根本看不见的索引,用户没做错任何事。而且这条冲突本身就是跨租户存在性探测 oracle —— UNIQUE 冲突等于告诉租户 B「某个别的租户已有这个值」,拿邮箱/编码/名称批量试探即可枚举。
契约
unique: true+ 租户列(tenantField, field)—— 租户内唯一unique: true,无租户列unique: 'global'租户列排在前面,这样索引同时服务于每个租户作用域读都会发的
WHERE tenant = ?前缀扫描。三条 DDL 路径收敛成一条
字段级
unique原先有三处各自内联单列 SQL ——createColumn(createTable / alterTable 两个分支)和 SQLite 漂移重建。这正是为什么一次漂移 reconcile 之后,租户作用域字段会悄悄拿回全局索引。现在全部走uniqueIndexesFromFields+syncTableIndexes;createColumn只管类型/可空/默认值 —— 列构造器本来就表达不了复合索引。声明式
indexes[]刻意不改写issue 建议三条路径全部自动升为复合唯一,这一条没有采纳,因为会破坏真正需要全局唯一的索引:
声明式索引的列清单由作者显式写死,租户作用域的一直是显式表达的(
sys_team: ['name','organization_id']、sys_member: ['organization_id','user_id']、sys_business_unit: ['code','organization_id'])。字段级unique不一样 —— 那里没有任何语法能表达复合,所以默认值必须承担租户作用域。'global'在声明式索引上作为true的同义词被接受,只为两种写法共用一套词汇。存量迁移:纯放松,落在 sync 时
遗留索引(knex 的
<table>_<col>_unique、重建路径的uniq_<table>_<col>)在 sync 时就地退休。旧的全局约束严格强于新的租户内约束,存量数据天然满足替换约束 —— 无需查重、无需清洗、不可能失败。选择在 sync 时收敛而非等
os migrate:没跑过 migrate 的部署会一直坏着,而故障形态是插入被拒。col.unique()物化成 CONSTRAINT 而非裸索引,所以 drop 先试DROP CONSTRAINT再试DROP INDEX—— 只发DROP INDEX会让这次迁移在最要紧的那批部署上变成空操作。driver-mongodb
只接受新类型,索引形态不变。该 driver 完全没有行级租户隔离(读不加租户谓词、写不打租户戳),造
(tenant, field)复合索引会假装有隔离 —— 比单列索引更糟,因为它看起来像修好了。租户缺失本身另行跟踪。测试
sql-driver-unique-tenancy.test.ts(15 例)覆盖 issue 的三条验证场景,外加物理索引形状、遗留索引迁移、漂移重建后不回退、幂等性。已验证这些测试真的会咬:对未修复的 driver 跑,8 例失败、7 例通过 —— 通过的正是断言行为不变的那些(单租户、
unique: 'global'、声明式索引逐字保留、tenancy.enabled:false、租户列自身的 unique、幂等)。全量:
pnpm build71/71,pnpm test132/132。租户作用域对象上,字段级
unique: true从全平台唯一变为租户内唯一。注意放大器:
objectql/registry.ts的applySystemFields给每个注册对象无条件注入organization_id(除非显式退出),而computeTenantField有隐式organization_id兜底 —— 所以绝大多数对象都是租户对象。objectstack仓库内字段级unique: true只有 2 处,且真正约束走的是声明式复合索引,无影响。但objectstack-ai/cloud有 4 处会被降级,升级 spec 前必须标注为unique: 'global':sys_slug_reservationslugsys_environmenthostnamesys_cloud_connectionruntime_idsys_cloud_connectionenvironment_id这四个都没有
tenancy.enabled: false(managedBy分别是config/platform/platform),隐式organization_id生效,全部会被降级。Generated by Claude Code