Skip to content

Commit 1e6ab15

Browse files
os-zhuangclaude
andauthored
feat(spec,lint): unique 范围显式词汇 'organization' + D5a/D5b lint 规则(ADR-0120 17.x 波,#4986) (#5208)
* feat(spec,lint): unique 范围显式词汇 'organization' + D5a/D5b lint 规则 (#4986, ADR-0120 17.x 波) - UniqueScopeSchema → boolean | 'global' | 'organization'(D1,17.x 纯增量); 'tenant'/'org' 拒绝且报错指名 'organization'(§Terminology,strictTenancyError 先例) - IndexSchema.unique 注释与 describe() 按 D1/D6 重写:verbatim 收窄到 'global'; 'organization' = 注册期并入 NULL-safe 组织键部(COALESCE 物化随 #5030); 裸 true 弃用(17.x 警告 → 协议 18 拒绝,#5082) - UniqueScopeSchema 文档块单租户假豁免替换为 D3 真相(NULL 桶 + COALESCE); Resolved #2 非规范性引导入注释(示例/脚手架输出 'organization',裸 true 永久合法) - 新导出 isOrganizationUnique;isUniqueDeclared 计入 'organization'(ADR-0078) - lint 新规则 unique/unscoped-declared-index(D5a,警告,只判拼写不推断租户性); R10 unique/double-declaration 四象限重写(D5b),fix 改说 'organization' 词汇 - 测试:spec unique-scope 契约测试扩充;cli data-model-rules 测试按四象限重写 - 再生成:references/data/{field,object}.mdx、api-surface.json;adr-anchors 三条; changeset 两包(spec minor / lint minor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 * chore(spec): regen api-surface after origin/main merge (merge-driver deferred artifact) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 * fix(lint): unique/unscoped-declared-index 独立注册进 AUTHORING_RULES,不再搭车 R10 导出面即承诺(#4409 wiring 测试):导出的规则必须入注册表或入账。搭车在 lintUniqueDeclarations 里跑对注册表不可见,且 validate/build 会经两条路径 各报一次。改为:R11 自己的 AUTHORING_RULES 条目(validate/build,advisory), lintDataModel 直接调用它服务 os lint —— 三命令全覆盖,每条 finding 恰好一报。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c794f78 commit 1e6ab15

13 files changed

Lines changed: 646 additions & 130 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b)
6+
7+
- **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared
8+
index with bare `unique: true` — the spelling whose scope is unstated, the
9+
#4986 trap. Fires on the spelling alone (no tenancy/posture inference —
10+
`organization_id` is kernel-injected at registration, so an authoring-time
11+
guess would be wrong half the time; see #4698). The fix names both words:
12+
`'global'` (installation-wide — exactly today's behavior) or
13+
`'organization'` (one holder per organization). Protocol 18 rejects the
14+
spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` +
15+
`UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES
16+
entry (validate/build) and called by `lintDataModel` for `os lint`, so all
17+
three commands report it — each finding exactly once.
18+
- **R10 `unique/double-declaration` rewritten as the four-quadrant scope
19+
matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'`
20+
(or bare `true`, its deprecated spelling) on the same single column =
21+
CONTRADICTION (the installation-wide index wins physically; the
22+
per-organization intent is silently dead) — and the mirror, field `'global'`
23+
× declared `'organization'`, likewise; same scope on both sides = REDUNDANCY
24+
(the same index declared twice). The old field-`'global'` exemption is gone
25+
(now reported as redundancy), and the fix text replaces the hand-written
26+
`fields: ['organization_id', …]` advice with the `'organization'` spelling —
27+
the hand-written composite is not NULL-safe (#5030).
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `unique` scope vocabulary gains `'organization'` — scope is said, not positional (#4986, ADR-0120 D1/D6)
6+
7+
`UniqueScopeSchema` (field-level `unique` and `IndexSchema.unique`) widens from
8+
`boolean | 'global'` to `boolean | 'global' | 'organization'`. Purely additive
9+
in 17.x — no existing spelling changes meaning:
10+
11+
- **Field-level** `'organization'` is the explicit synonym of `true`
12+
(per-organization uniqueness, identical materialization through the driver
13+
predicates: `isUniqueDeclared` counts it, `isGlobalUnique` does not). Bare
14+
`true` stays valid indefinitely; official examples and scaffolding emit
15+
`'organization'` in new code (non-normative, ADR-0120 Resolved #2).
16+
- **Declared-index** contract is now stated per word (ADR-0120 D1, amending
17+
#3696): `'global'` = today's verbatim behavior — materialized over exactly
18+
`fields`, no organization column injected; `'organization'` = the driver
19+
prepends the NULL-safe organization key part
20+
(`COALESCE(organization_id, '__global__')`, ADR-0120 D3) at registration —
21+
materialization lands with #5030's driver PR, which this change must follow;
22+
bare `true` = the deprecated positional spelling of `'global'` — warned in
23+
17.x by lint `unique/unscoped-declared-index`, rejected at protocol 18
24+
(#5082).
25+
- **Rejected words carry the fix**: `'tenant'` and `'org'` are not accepted and
26+
are not aliases — the parse error names `'organization'` (ADR-0120
27+
§Terminology).
28+
- New export `isOrganizationUnique` — detects the explicit `'organization'`
29+
spelling, single source of truth for the declared-index distinction across
30+
SQL/Mongo index sync.
31+
- The `UniqueScopeSchema` doc block's false single-tenant exemption ("the
32+
tenant column is constant, so the composite index degenerates to the
33+
single-column one" — falsified by #5030: the constant is NULL and SQL UNIQUE
34+
is NULL-distinct) is replaced with the D3 truth (NULL bucket + COALESCE).

‎content/docs/references/data/field.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const result = AddressSchema.parse(data);
8080
| **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
8181
| **searchable** | `boolean` | optional | Is searchable |
8282
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
83-
| **unique** | `boolean \| 'global'` | optional | Unique constraint. true = unique within the tenant (composite with the tenant column on tenant-scoped objects); 'global' = unique platform-wide across all tenants |
83+
| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' |
8484
| **defaultValue** | `any` | optional | Default value |
8585
| **maxLength** | `number` | optional | Max character length |
8686
| **minLength** | `number` | optional | Min character length |
@@ -245,6 +245,12 @@ Type: `'global'`
245245

246246
---
247247

248+
#### Option 3
249+
250+
Type: `'organization'`
251+
252+
---
253+
248254

249255
---
250256

‎content/docs/references/data/object.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const result = ApiMethod.parse(data);
6868
| **name** | `string` | optional | Index name (auto-generated if not provided) |
6969
| **fields** | `string[]` | ✅ | Fields included in the index |
7070
| **type** | `Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>` | ✅ | Index algorithm type |
71-
| **unique** | `boolean \| 'global'` | ✅ | Whether the index enforces uniqueness. Materialized over exactly `fields` — no tenant column is injected; list the tenant column explicitly for a per-tenant index. 'global' is a synonym of true, for symmetry with field-level `unique` |
71+
| **unique** | `boolean \| 'global' \| 'organization'` | ✅ | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' |
7272
| **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) |
7373

7474

@@ -122,7 +122,7 @@ const result = ApiMethod.parse(data);
122122
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
123123
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
124124
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. |
125-
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Database performance indexes |
125+
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Database performance indexes |
126126
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
127127
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
128128
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
@@ -196,7 +196,7 @@ const result = ApiMethod.parse(data);
196196
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
197197
| **description** | `string` | optional | Override description for the extended object |
198198
| **validations** | `any[]` | optional | Additional validation rules to merge into the target object |
199-
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Additional indexes to merge into the target object |
199+
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Additional indexes to merge into the target object |
200200
| **priority** | `integer` | optional | Merge priority (higher = applied later) |
201201

202202

0 commit comments

Comments
 (0)