Repository navigation
crm_account.name uniqueness is platform-wide, not per-tenant — two orgs cannot both have an "Acme Corp" #625
Description
Found while investigating #598 (account dedupe in lead_conversion). Out of scope there; filing separately.
What
src/objects/account.object.ts spells account-name uniqueness as a declared index:
indexes: [
{ fields: ['name'], unique: true },
...
]normalizeDeclaredIndex() in @objectstack/driver-sql keeps a declared index's columns verbatim — it does not tenant-scope them. Only field-level unique: true gets the (organization_id, ...) composite treatment (uniqueIndexesFromFields(), framework#3696).
So the physical index this produces is:
uniq_crm_account_name UNIQUE (name) <-- platform-wide
whereas crm_contact.email, which declares the same intent as a field-level unique: true, produces:
uniq_crm_contact_organization_id_email UNIQUE (organization_id, email) <-- per-tenant
Reproduced against the shipped driver (17.0.0-rc.1):
import { expectedIndexes } from '@objectstack/driver-sql';
expectedIndexes({
table: 'crm_account',
fields: { name: { type: 'text' } },
tenantField: 'organization_id',
declaredIndexes: [{ fields: ['name'], unique: true }],
physicalColumns: new Set(['id', 'organization_id', 'name']),
});
// => [{ name: 'uniq_crm_account_name', columns: ['name'], unique: true }]Why it matters
The second organization to create an account called "Acme Corp" is rejected by the database. Account name is also the seed data's external-id / upsert key, so this bites the very first multi-tenant install.
This is exactly the failure mode src/objects/lead.object.ts already documents and deliberately avoids:
Declaring the single-column index too made the platform-wide constraint win and left the per-tenant one unreachable (framework#3991) — two organizations must be able to work the same lead address independently.
crm_lead and crm_product learned that lesson; crm_account still carries the platform-wide spelling.
Suggested fix
Move the uniqueness declaration onto the field, matching crm_contact.email / crm_lead.email / crm_product.sku:
name: Field.text({ label: 'Account Name', required: true, unique: true, ... }),
// and drop `{ fields: ['name'], unique: true }` from indexes[]Note this is a schema change on existing data, and it is not free:
- The new
uniq_crm_account_organization_id_nameindex is acreate_indexdrift entry (category: 'safe'). - The old
uniq_crm_account_namebecomes an orphan →drop_index,category: 'destructive', which the boot-time reconciler skips and onlyos migrate apply --allow-destructiveclears (docs/MAINTENANCE.md§3.1).
Leaving the old index in place is not harmless here: it is strictly tighter than the new one, so it keeps enforcing the platform-wide rule and the fix silently does nothing. Whoever picks this up should decide the migration story before writing code — same class of question as #598 scope 1.
Acceptance
- Two organizations can each create an account named "Acme Corp".
- Within one organization, a duplicate account name is still rejected.
- The upgrade path for existing deployments is written down (which index is created, which is dropped, and by what command).
Activity
os-zhuang commentedon Aug 2, 2026
认领:PM 循环第 8 轮
会话:
session_019SS7C5SXpniKeCApxgARyf分支:
claude/issue-625-account-name-tenant-scopeWorktree:
hotcrm-issue-625issue 里"应该先定迁移方案"那一问已经有答案了,不要再问一遍。 维护者在 #598 的裁决中确认:HotCRM 目前只有全新安装,没有"库已建好且不能重建"的部署。在空库上按新写法建表,
uniq_crm_account_name这个平台级索引压根不会存在,所以孤儿索引与--allow-destructive的问题在今天不成立。但 issue 指出的那一点必须写进 changeset:遗留的旧索引不是无害的——它严格紧于新索引,只要还在就继续执行平台级唯一约束,使这个修复静默失效。所以若将来出现既存部署,升级必须执行一次
os migrate apply --allow-destructive清掉旧索引,否则修了等于没修。这句话请写清楚。修法按 issue 的建议:唯一性声明从表级
indexes[]挪到字段级unique: true,与crm_contact.email/crm_lead.email/crm_product.sku对齐。注意crm_lead.object.ts里已有的那段注释——它记录了"同时声明单列索引会让平台级约束胜出、把按租户的那条挤成不可达"(framework#3991),所以旧的表级声明必须删掉,不能两个都留。验收第 1、2 条请实测:两个组织各建一个 "Acme Corp" 应当都成功,同一组织内重名仍被拒。#626(客户名归一化)
Blocked-by本件,本轮不做,但请在 PR 里说明本次改动是否让 #626 的路更好走或更难走。同批:#631(四个语言包)、#643(
src/flows/),与你的account.object.ts互斥。Generated by Claude Code
os-zhuang commentedon Aug 2, 2026
PM 复核:通过,已入队合并 — #646
核验依据(以 GitHub 为准):9 项检查全绿;6 个文件全在范围内(
account.object.ts、三份文档各改一句、新测试、changeset)。验证层次正确,这一点是本件的关键。 这个缺陷本身就是"元数据声明的"与"驱动生成的 DDL"之间的落差,所以只断言元数据形状是结构上抓不住它的。测试跑到了真 SQLite:
SqliteWasmDriver(':memory:')+driver.initObjects(...)(boot 时的同一个调用)+ 真实写入,量出的 DDL 是UNIQUE (organization_id, name),两个组织各建一个 "Acme Corp" 都成功,同组织内重名被拒。反向验证 stash 掉对象文件后 9 条红 7 条,且失败签名正是缺陷本身(UNIQUE constraint failed: crm_account.name,发生在 org_b 那次插入上)。旧的表级声明删掉了而不是两个都留,这是必须的:同时声明会让平台级索引胜出、把按租户的复合索引挤成不可达(framework#3991),那样修复会"看起来生效、实际什么也没做"。理由已写进注释,与
crm_lead/crm_product的既有记录一致。顺带那条观察比修复本身更值得警惕:
pnpm lint对旧写法不报任何问题——平台的unique/double-declarationlint 只在"两种形式都声明"时才触发,而crm_account只有表级那一个,于是os validate、os lint、typecheck、测试,整条工具链对"两个组织不能有同名客户"完全沉默。这和 #642(decision 节点带着引擎不读的config.condition,validate 与 lint 都放行)是同一类:工具链只检查它认识的错误形状。我会把这条合并上报到平台。对 #626 影响的分析也按我的要求给了,而且结论是更好走:
(organization_id, name_normalized)的冲突面比平台级小,只有同组织内重名才挡建索引,所以那次 merge pass 的工作量反而更少;文档那句"不区分大小写"被改成实话之后,#626 落地时是加强承诺而不是先去修一个旧谎言。派生的 #648(联系人文档说邮箱"在同一客户内唯一"并举了董事同时在两家公司的例子,而实际是按组织全局唯一、且
contact_integrity在入库前就拒绝跨客户重复——文档描述的用例正是产品拒绝的行为)已标 P2。那份文档描述的"一个人能否同时属于两家公司"是产品问题,不是文档笔误,修文档时需要先确认产品意图,我在排期时会带上这一点。Generated by Claude Code
fix(account): scope account-name uniqueness per organization (objects…
fix(conversion): match accounts on a normalized company name (objects…
docs(contacts): state the email rule as per-organization, not per-acc…