fix(account): scope account-name uniqueness per organization (#625) - #646
Merged
Merged
Conversation
`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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-zhuang
marked this pull request as ready for review
August 2, 2026 18:50
This was referenced Aug 2, 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 #625
问题
crm_account把"客户名称唯一"写成了表级声明索引indexes: [{ fields: ['name'], unique: true }]。driver-sql的normalizeDeclaredIndex()对声明索引的列原样保留、不做租户加权;只有字段级unique: true才会走uniqueIndexesFromFields()拿到(organization_id, ...)复合索引(framework#3696)。于是物理约束是UNIQUE (name),第二个组织创建名为 "Acme Corp" 的客户会被数据库直接拒绝。名称同时是种子数据的 external-id / upsert key(src/data/sales.seed.tsexternalId: '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#3991unique/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"之间的落差,所以只断言元数据形状是抓不住它的。测试分三层:name.unique === true,且indexes[]里没有任何 unique 条目(这一条是给未来的编辑者的:写回表级形式时,红的是带原因的断言,而不是一句裸 SQL 错误)。@objectstack/objectql的applySystemFields(..., { multiTenant: true })造出运行时真正交给驱动的 schema(含注入的organization_id),再喂给@objectstack/driver-sql的expectedIndexes()——正是 issue 里的复现路径。SqliteWasmDriver(':memory:')+driver.initObjects([...])(boot 时的同一个调用),然后真的写入。验收 1、2 是实测出来的:
把
account.object.tsstash 掉再跑同一份测试,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_index,category: 'safe',boot 时的 reconciler 会自动建。uniq_crm_account_name——drop_index,category: 'destructive',reconciler 会跳过,只有os migrate apply --allow-destructive才清得掉(docs/MAINTENANCE.md§3.1)。留着旧索引不是无害的:它严格紧于新索引,只要还在就继续执行平台级唯一约束,这个修复静默失效。今天 HotCRM 只有全新安装——空库上按新元数据建表,
uniq_crm_account_name压根不会被创建,所以这一步现在无人需要执行;但将来一旦出现既存部署,升级必须跑一次--allow-destructive,否则修了等于没修。这句话在 changeset 里是加粗写死的。对 #626(客户名归一化)的影响:更好走
name_normalized是否自带唯一索引"。本次改动把"唯一性怎么写"这个问题在本对象上已经答完了:字段级unique: true,按租户。Normalize account matching in lead conversion (split out of #598 scope 2) #626 若要给归一列加唯一约束,照抄同一行写法即可,不用再重新论证一遍租户范围。UNIQUE索引会因已有重复而失败。(organization_id, name_normalized)的冲突面比平台级(name_normalized)小——只有同一组织内的重名才会挡住建索引,跨组织的同名不再是障碍。也就是说合并(merge pass)的工作量比在旧模型下更小。create_index应当在旧uniq_crm_account_name被清掉之后再谈——但那对今天的全新安装不成立,两者都不会存在。验证
范围仅限
crm_account的唯一性声明及其用户文档;未新增/删除字段,未改 i18n bundle(没有新增可翻译标签),未触碰content/docs/releases/。🤖 Generated with Claude Code
https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
Generated by Claude Code