Skip to content

fix(account): scope account-name uniqueness per organization (#625) - #646

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-625-account-name-tenant-scope
Aug 2, 2026
Merged

fix(account): scope account-name uniqueness per organization (#625)#646
os-zhuang merged 1 commit into
mainfrom
claude/issue-625-account-name-tenant-scope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #625

问题

crm_account 把"客户名称唯一"写成了表级声明索引 indexes: [{ fields: ['name'], unique: true }]driver-sqlnormalizeDeclaredIndex() 对声明索引的列原样保留、不做租户加权;只有字段级 unique: true 才会走 uniqueIndexesFromFields() 拿到 (organization_id, ...) 复合索引(framework#3696)。于是物理约束是 UNIQUE (name)第二个组织创建名为 "Acme Corp" 的客户会被数据库直接拒绝。名称同时是种子数据的 external-id / upsert key(src/data/sales.seed.ts externalId: 'name'),所以这个问题在第一个多租户安装、在任何人录入数据之前就会踩到。

crm_contact.email / crm_lead.email / crm_product.sku 早就换成了字段级写法并在注释里记录了这个坑,crm_account 是最后一个还留着平台级写法的核心对象。

改动

  • src/objects/account.object.ts:唯一性声明从表级 indexes[] 挪到字段 name 上(unique: true),并删除旧的 { fields: ['name'], unique: true }——不是两个都留。理由写进了注释:同时声明会让平台级索引胜出、把按租户的复合索引挤成不可达(framework#3991 unique/double-declaration),那样这个修复会"看起来生效、实际什么也没做"。底部 validations 注释里指向"上面那条 name 索引"的旧说明也一并更正。
  • 不需要另加 { fields: ['name'] } 普通索引:复合索引以 organization_id 打头,WHERE organization_id = ? AND name = ? 正好是前缀匹配,searchableFields 和种子 upsert 的读路径都被它覆盖。
  • content/docs/sales/accounts{,.zh-Hans,.zh-Hant}.mdx:这三份文档里那条规则原本写的是"名称在整个系统中必须唯一(不区分大小写)"。两半都是错的——范围现在是按组织,且没有任何地方做大小写归一Normalize account matching in lead conversion (split out of #598 scope 2) #626 的分析已确认:flow 模板没有 LOWER/TRIM,公式字段没有物理列,也没有 hook 在做归一)。既然改的就是这句话,就把它一次写准,而不是留一个运行时并不兑现的承诺。

验证方式:真数据库,不是只断言元数据

新增 test/account-name-tenant-scope.test.ts(9 个用例,全绿)。关键点是声明不等于约束——本 bug 恰恰是"元数据说的"和"驱动生成的 DDL"之间的落差,所以只断言元数据形状是抓不住它的。测试分三层:

  1. 元数据形状name.unique === true,且 indexes[] 里没有任何 unique 条目(这一条是给未来的编辑者的:写回表级形式时,红的是带原因的断言,而不是一句裸 SQL 错误)。
  2. 驱动推导的索引集:用 @objectstack/objectqlapplySystemFields(..., { multiTenant: true }) 造出运行时真正交给驱动的 schema(含注入的 organization_id),再喂给 @objectstack/driver-sqlexpectedIndexes()——正是 issue 里的复现路径。
  3. 真 SQLiteSqliteWasmDriver(':memory:') + driver.initObjects([...])(boot 时的同一个调用),然后真的写入。

验收 1、2 是实测出来的:

INDEXES: uniq_crm_account_organization_id_name
         CREATE UNIQUE INDEX `uniq_crm_account_organization_id_name`
           on `crm_account` (`organization_id`, `name`)
org_a "Acme Corp" ok
org_b "Acme Corp" ok
org_a "Globex" 第二次 → UNIQUE constraint failed: crm_account.organization_id, crm_account.name

account.object.ts stash 掉再跑同一份测试,9 条里红 7 条,报错正是这个缺陷本身的签名(UNIQUE constraint failed: crm_account.name,且失败发生在 org_b 那次插入上)——测试不是空转的。

顺带一个观察:pnpm lint 在旧写法下不报 unique/double-declaration。那条 framework lint 只抓"两个都声明"的情形,而 crm_account 只有表级那一个,所以工具链此前对这个缺陷是完全沉默的。

升级路径(issue 验收第 3 条)

写在 changeset 里,正文会随发布进 CHANGELOG.md

  • 新建:uniq_crm_account_organization_id_name (organization_id, name) —— create_indexcategory: 'safe',boot 时的 reconciler 会自动建。
  • 需要删除:旧的 uniq_crm_account_name —— drop_indexcategory: 'destructive',reconciler 会跳过,只有 os migrate apply --allow-destructive 才清得掉(docs/MAINTENANCE.md §3.1)。

留着旧索引不是无害的:它严格紧于新索引,只要还在就继续执行平台级唯一约束,这个修复静默失效。今天 HotCRM 只有全新安装——空库上按新元数据建表,uniq_crm_account_name 压根不会被创建,所以这一步现在无人需要执行;但将来一旦出现既存部署,升级必须跑一次 --allow-destructive,否则修了等于没修。这句话在 changeset 里是加粗写死的。

#626(客户名归一化)的影响:更好走

验证

pnpm validate   ✓ Validation passed (841ms) — 15 Objects 314 Fields
pnpm typecheck  ✓ tsc --noEmit,无输出
pnpm lint       1 warning(0 个与本改动相关:crm_campaign_member field-group,main 上既有)
pnpm hygiene    ✓ source hygiene clean
pnpm build      ✓ Build complete — dist/objectstack.json (1140.5 KB)
pnpm test       ✓ Test Files 44 passed (44) · Tests 1013 passed | 1 skipped (1014)

范围仅限 crm_account 的唯一性声明及其用户文档;未新增/删除字段,未改 i18n bundle(没有新增可翻译标签),未触碰 content/docs/releases/

🤖 Generated with Claude Code

https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf


Generated by Claude Code

`crm_account` spelled name uniqueness as a table-level declared index,
`indexes: [{ fields: ['name'], unique: true }]`. `driver-sql` keeps a declared
index's columns verbatim — only FIELD-level `unique: true` gets the tenant
composite `(organization_id, ...)` (framework#3696) — so the physical constraint
was `UNIQUE (name)` and the SECOND organization to create an account called
"Acme Corp" was refused by the database. Account name is also the seed data's
external-id / upsert key, so this bit on the very first multi-tenant install.

Moves the declaration onto the field, matching `crm_contact.email` and
`crm_product.sku`, and REMOVES the table-level entry rather than keeping both:
declaring both leaves the platform-wide index enforcing the old behaviour and
the per-tenant composite unreachable (framework#3991
`unique/double-declaration`), i.e. the fix would look applied and do nothing.
`crm_lead` and `crm_product` already carry that comment; `crm_account` now does
too.

Tests drive the REAL metadata through a REAL SQLite database — a declaration is
not a constraint until the driver turns it into DDL, and the defect was exactly
a mismatch between the two. `initObjects` now materializes
`uniq_crm_account_organization_id_name (organization_id, name)` and no
`uniq_crm_account_name`; org_a and org_b each insert "Acme Corp" successfully,
and a second "Globex" in org_a is still rejected with `UNIQUE constraint failed:
crm_account.organization_id, crm_account.name`. Reverting the object file turns
7 of the 9 assertions red with the defect's own signature.

The three `content/docs/sales/accounts*.mdx` rule lines said the name must be
unique "across the system (case-insensitive)". Both halves were wrong: the
scope is now per organization, and nothing normalizes case — `Acme Corp` and
`ACME  Corp` are two accounts today (#626 is the follow-up).

Fixes #625

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
@vercel

vercel Bot commented Aug 2, 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)
hotcrm Ignored Ignored Aug 2, 2026 6:47pm

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces labels Aug 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 18:50
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 8b26082 Aug 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

crm_account.name uniqueness is platform-wide, not per-tenant — two orgs cannot both have an "Acme Corp"

2 participants