Skip to content

fix(driver-sql)!: unique 按租户物化,不再与分裂的 autonumber 序列冲突 (#3696) - #3717

Merged
os-zhuang merged 3 commits into
mainfrom
claude/multi-tenant-unique-autonumber-gi5zuu
Jul 27, 2026
Merged

fix(driver-sql)!: unique 按租户物化,不再与分裂的 autonumber 序列冲突 (#3696)#3717
os-zhuang merged 3 commits into
mainfrom
claude/multi-tenant-unique-autonumber-gi5zuu

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3696

问题

unique: true 被物化成不带租户列的全局唯一索引,而 autonumber 序列表按 (object, tenant_id, field, scope) 分裂、每个租户各自从 1 起发号。平台的两个子系统互相打架:租户 B 的 PROD-00001 撞进一条它根本看不见的索引,用户没做错任何事

而且这条冲突本身就是跨租户存在性探测 oracle —— UNIQUE 冲突等于告诉租户 B「某个别的租户已有这个值」,拿邮箱/编码/名称批量试探即可枚举。

契约

声明 物化
unique: true + 租户列 复合 (tenantField, field) —— 租户内唯一
unique: true,无租户列 单列 —— 单租户部署 DDL 逐字节不变
unique: 'global' 单列,恒为全平台唯一

租户列排在前面,这样索引同时服务于每个租户作用域读都会发的 WHERE tenant = ? 前缀扫描。

三条 DDL 路径收敛成一条

字段级 unique 原先有三处各自内联单列 SQL —— createColumn(createTable / alterTable 两个分支)和 SQLite 漂移重建。这正是为什么一次漂移 reconcile 之后,租户作用域字段会悄悄拿回全局索引。现在全部走 uniqueIndexesFromFields + syncTableIndexescreateColumn 只管类型/可空/默认值 —— 列构造器本来就表达不了复合索引。

声明式 indexes[] 刻意不改写

issue 建议三条路径全部自动升为复合唯一,这一条没有采纳,因为会破坏真正需要全局唯一的索引:

sys_slug_reservation  { fields: ['slug'], unique: true }      ← 全局 slug 抢注
sys_environment       { fields: ['hostname'], unique: true }   ← DNS 主机名
sys_billing_customer  { fields: ['stripe_customer_id'] }       ← 外部 provider id

声明式索引的列清单由作者显式写死,租户作用域的一直是显式表达的(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 的部署会一直坏着,而故障形态是插入被拒。

⚠️ Postgres 把 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 build 71/71,pnpm test 132/132。

⚠️ BREAKING CHANGE + cloud 侧必须配套

租户作用域对象上,字段级 unique: true 从全平台唯一变为租户内唯一

注意放大器:objectql/registry.tsapplySystemFields每个注册对象无条件注入 organization_id(除非显式退出),而 computeTenantField 有隐式 organization_id 兜底 —— 所以绝大多数对象都是租户对象。

objectstack 仓库内字段级 unique: true 只有 2 处,且真正约束走的是声明式复合索引,无影响。但 objectstack-ai/cloud 有 4 处会被降级,升级 spec 前必须标注为 unique: 'global'

对象 字段 不改的后果
sys_slug_reservation slug 全局 slug 抢注机制失效
sys_environment hostname 两个租户可抢同一主机名(路由/安全)
sys_cloud_connection runtime_id 设备身份不再全局唯一
sys_cloud_connection environment_id 1:1 绑定被削弱

这四个都没有 tenancy.enabled: falsemanagedBy 分别是 config / platform / platform),隐式 organization_id 生效,全部会被降级。


Generated by Claude Code

`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
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 3:59pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-mongodb, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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
@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Jul 27, 2026
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
os-zhuang marked this pull request as ready for review July 27, 2026 16:11
@os-zhuang
os-zhuang merged commit b90086a into main Jul 27, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/multi-tenant-unique-autonumber-gi5zuu branch July 27, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants