Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/unique-scope-lint-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/lint": minor
---

feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b)

- **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared
index with bare `unique: true` — the spelling whose scope is unstated, the
#4986 trap. Fires on the spelling alone (no tenancy/posture inference —
`organization_id` is kernel-injected at registration, so an authoring-time
guess would be wrong half the time; see #4698). The fix names both words:
`'global'` (installation-wide — exactly today's behavior) or
`'organization'` (one holder per organization). Protocol 18 rejects the
spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` +
`UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES
entry (validate/build) and called by `lintDataModel` for `os lint`, so all
three commands report it — each finding exactly once.
- **R10 `unique/double-declaration` rewritten as the four-quadrant scope
matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'`
(or bare `true`, its deprecated spelling) on the same single column =
CONTRADICTION (the installation-wide index wins physically; the
per-organization intent is silently dead) — and the mirror, field `'global'`
× declared `'organization'`, likewise; same scope on both sides = REDUNDANCY
(the same index declared twice). The old field-`'global'` exemption is gone
(now reported as redundancy), and the fix text replaces the hand-written
`fields: ['organization_id', …]` advice with the `'organization'` spelling —
the hand-written composite is not NULL-safe (#5030).
34 changes: 34 additions & 0 deletions .changeset/unique-scope-organization-vocabulary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": minor
---

feat(spec): `unique` scope vocabulary gains `'organization'` — scope is said, not positional (#4986, ADR-0120 D1/D6)

`UniqueScopeSchema` (field-level `unique` and `IndexSchema.unique`) widens from
`boolean | 'global'` to `boolean | 'global' | 'organization'`. Purely additive
in 17.x — no existing spelling changes meaning:

- **Field-level** `'organization'` is the explicit synonym of `true`
(per-organization uniqueness, identical materialization through the driver
predicates: `isUniqueDeclared` counts it, `isGlobalUnique` does not). Bare
`true` stays valid indefinitely; official examples and scaffolding emit
`'organization'` in new code (non-normative, ADR-0120 Resolved #2).
- **Declared-index** contract is now stated per word (ADR-0120 D1, amending
#3696): `'global'` = today's verbatim behavior — materialized over exactly
`fields`, no organization column injected; `'organization'` = the driver
prepends the NULL-safe organization key part
(`COALESCE(organization_id, '__global__')`, ADR-0120 D3) at registration —
materialization lands with #5030's driver PR, which this change must follow;
bare `true` = the deprecated positional spelling of `'global'` — warned in
17.x by lint `unique/unscoped-declared-index`, rejected at protocol 18
(#5082).
- **Rejected words carry the fix**: `'tenant'` and `'org'` are not accepted and
are not aliases — the parse error names `'organization'` (ADR-0120
§Terminology).
- New export `isOrganizationUnique` — detects the explicit `'organization'`
spelling, single source of truth for the declared-index distinction across
SQL/Mongo index sync.
- The `UniqueScopeSchema` doc block's false single-tenant exemption ("the
tenant column is constant, so the composite index degenerates to the
single-column one" — falsified by #5030: the constant is NULL and SQL UNIQUE
is NULL-distinct) is replaced with the D3 truth (NULL bucket + COALESCE).
8 changes: 7 additions & 1 deletion content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const result = AddressSchema.parse(data);
| **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. |
| **searchable** | `boolean` | optional | Is searchable |
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
| **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 |
| **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' |
| **defaultValue** | `any` | optional | Default value |
| **maxLength** | `number` | optional | Max character length |
| **minLength** | `number` | optional | Min character length |
Expand Down Expand Up @@ -245,6 +245,12 @@ Type: `'global'`

---

#### Option 3

Type: `'organization'`

---


---

6 changes: 3 additions & 3 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const result = ApiMethod.parse(data);
| **name** | `string` | optional | Index name (auto-generated if not provided) |
| **fields** | `string[]` | ✅ | Fields included in the index |
| **type** | `Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>` | ✅ | Index algorithm type |
| **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` |
| **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' |
| **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) |


Expand Down Expand Up @@ -122,7 +122,7 @@ const result = ApiMethod.parse(data);
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
| **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }` | optional | Remote table binding for federated (external) objects. |
| **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. |
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Database performance indexes |
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Database performance indexes |
| **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications |
| **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
Expand Down Expand Up @@ -196,7 +196,7 @@ const result = ApiMethod.parse(data);
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
| **description** | `string` | optional | Override description for the extended object |
| **validations** | `any[]` | optional | Additional validation rules to merge into the target object |
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Additional indexes to merge into the target object |
| **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 |
| **priority** | `integer` | optional | Merge priority (higher = applied later) |


Expand Down
Loading
Loading