Skip to content

Commit cf1bd18

Browse files
committed
docs(spec): 补 changeset 与 unique 租户作用域文档 (#3696)
CI: `Check Changeset` (no changeset added) + `TypeScript Type Check` (content/docs/references out of date with packages/spec). - Changeset as `minor`: breaking changes ship minor during the launch window (all ~70 packages version in lockstep, so a `major` would promote the whole stack). - Regenerate content/docs/references/data/{field,object}.mdx — generated from the Zod schemas, so the `unique` type widening has to land there. - protocol/objectql/schema.mdx is HAND-WRITTEN and documented the old contract ("Enforce uniqueness at database level"). Adds a "Uniqueness and tenancy" section: why `true` is per-tenant, when to reach for `'global'`, and that declared `indexes[]` take their columns verbatim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uu13EPsmLey7drXSMjuNq
1 parent 1e4d73a commit cf1bd18

4 files changed

Lines changed: 145 additions & 7 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/driver-mongodb": minor
3+
"@objectstack/driver-sql": minor
4+
"@objectstack/spec": minor
5+
---
6+
7+
fix(driver-sql)!: `unique` materializes per tenant, ending its contradiction with the per-tenant autonumber sequence (#3696)
8+
9+
`unique: true` became a **single-column global index that ignored `tenancy`
10+
entirely**, while the autonumber sequence table is keyed by
11+
`(object, tenant_id, field, scope)` and hands every tenant its own counter
12+
starting at 1. Two subsystems of the same platform contradicted each other:
13+
tenant B's `PROD-00001` was rejected by an index it could not see — **no user
14+
did anything wrong**, the platform's left hand refused what its right hand
15+
issued.
16+
17+
The rejection also doubled as a **cross-tenant existence oracle**: a UNIQUE
18+
violation told tenant B that some *other* tenant held the value, enumerable by
19+
probing emails / codes / names.
20+
21+
**The contract now:**
22+
23+
| Declaration | Materializes as |
24+
|---|---|
25+
| `unique: true` + tenant column | composite `(tenantField, field)` — unique **within** the tenant |
26+
| `unique: true`, no tenant column | single-column — single-tenant DDL is byte-identical to before |
27+
| `unique: 'global'` | single-column, always platform-wide |
28+
29+
The tenant column comes first in the composite, so the index also serves the
30+
`WHERE tenant = ?` prefix scans every tenant-scoped read issues.
31+
32+
**Declared `indexes[]` are deliberately unchanged.** They are materialized over
33+
exactly the columns listed — no tenant column is injected. The author already
34+
spells them out, per-tenant ones have always been written explicitly
35+
(`fields: ['organization_id', 'code']`), and many are legitimately platform-wide
36+
(a DNS hostname, a reserved slug, an external provider id). `'global'` is
37+
accepted there as a synonym of `true` so one vocabulary covers both spellings.
38+
39+
**Migration is automatic and cannot fail.** Legacy indexes
40+
(`<table>_<col>_unique` from knex, `uniq_<table>_<col>` from the drift-rebuild
41+
path) are retired inline at schema-sync time. The old global constraint is
42+
strictly stronger than the new per-tenant one, so existing rows satisfy the
43+
replacement by construction — no dedup, no cleanup, no data touched. It
44+
converges at sync rather than waiting for a deliberate `os migrate` run because
45+
a deployment that never ran migrate would otherwise stay broken.
46+
47+
**Upgrading — audit your `unique: true` fields.** On a tenant-scoped object the
48+
constraint is now per tenant. Anything that must stay platform-wide has to say
49+
so:
50+
51+
```ts
52+
hostname: Field.text({ unique: 'global' }) // no two tenants may claim it
53+
```
54+
55+
Note the reach: `applySystemFields` injects `organization_id` into every
56+
registered object unless it opts out, and the driver falls back to that column
57+
when no `tenancy.tenantField` is declared — so most objects are tenant-scoped.
58+
Typical candidates for `'global'`: DNS hostnames, reserved slugs, external
59+
provider ids (Stripe customer/subscription), device identities.
60+
61+
Postgres materializes `col.unique()` as a table CONSTRAINT rather than a bare
62+
index, so the retirement tries `DROP CONSTRAINT` before `DROP INDEX` —
63+
`DROP INDEX` alone would have made the migration a no-op on exactly the
64+
deployments that matter most.
65+
66+
`@objectstack/driver-mongodb` accepts the new declaration but keeps single-field
67+
indexes: it implements no row-level tenancy at all (no tenant predicate on read,
68+
no tenant stamp on write), so a `(tenant, field)` index would advertise an
69+
isolation it does not deliver. Tracked separately.

‎content/docs/protocol/objectql/schema.mdx‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ fields:
278278
| `type` | `string` | All | **Required.** Field type (see [Types](/docs/protocol/objectql/types)). |
279279
| `label` | `string` | All | **Required.** Display label in UI. |
280280
| `required` | `boolean` | All | Validation: Field must have a value. |
281-
| `unique` | `boolean` | All | Enforce uniqueness at database level. |
281+
| `unique` | `boolean \| 'global'` | All | Enforce uniqueness at database level. `true` = unique **within the tenant**; `'global'` = unique platform-wide. See [Uniqueness and tenancy](#uniqueness-and-tenancy). |
282282
| `searchable` | `boolean` | All | Is searchable. |
283283
| `defaultValue` | `any` | All | Default value when creating new records. |
284284
| `description` | `string` | All | Tooltip/Help text. |
@@ -566,6 +566,54 @@ Supported index `type` values: `btree` (default), `hash`, `gin`, `gist`, `fullte
566566
- Fields that change frequently
567567
- Large text fields (use full-text search instead)
568568

569+
### Uniqueness and tenancy
570+
571+
On a tenant-scoped object, **field-level `unique: true` means unique *within* the
572+
tenant**, not platform-wide. The driver materializes it as a composite index
573+
`(tenantField, field)`:
574+
575+
```yaml
576+
fields:
577+
code:
578+
type: autonumber
579+
autonumberFormat: 'PROD-{00000}'
580+
unique: true # each tenant may hold its own PROD-00001
581+
```
582+
583+
This matches every other tenant-aware part of the platform: reads are filtered by
584+
the tenant predicate, writes stamp the tenant column, and the auto-number sequence
585+
gives each tenant a counter starting at 1. A platform-wide index would contradict
586+
the sequence outright — the second tenant's `PROD-00001` would be rejected by an
587+
index it cannot see, and the rejection itself would reveal that *some other tenant*
588+
holds the value.
589+
590+
For the identifiers that genuinely are platform-wide — a DNS hostname, a reserved
591+
slug, an external provider id, a device identity — say so explicitly:
592+
593+
```yaml
594+
fields:
595+
hostname:
596+
type: text
597+
unique: global # no two tenants may claim the same hostname
598+
```
599+
600+
On an object with no tenant column (`tenancy: { enabled: false }`, or simply no
601+
tenant field) both spellings behave identically. **Single-tenant deployments are
602+
unaffected** — the tenant column is constant, so the composite index degenerates
603+
to the single-column one.
604+
605+
**Declared `indexes` are different:** they are materialized over exactly the
606+
columns you list, with no tenant column injected. Write the tenant column yourself
607+
when you want a per-tenant index:
608+
609+
```yaml
610+
indexes:
611+
- fields: [organization_id, code] # unique per tenant
612+
unique: true
613+
- fields: [hostname] # unique platform-wide
614+
unique: true
615+
```
616+
569617
## Lifecycle Hooks
570618

571619
Record-triggered logic is **not** an object-schema field — `triggers` (and `hooks`,

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Field Type Enum
1414
## TypeScript Usage
1515

1616
```typescript
17-
import { Address, ComputedFieldCache, CurrencyConfig, CurrencyValue, DataQualityRules, Field, FieldType, LocationCoordinates, SelectOption } from '@objectstack/spec/data';
18-
import type { Address, ComputedFieldCache, CurrencyConfig, CurrencyValue, DataQualityRules, Field, FieldType, LocationCoordinates, SelectOption } from '@objectstack/spec/data';
17+
import { Address, ComputedFieldCache, CurrencyConfig, CurrencyValue, DataQualityRules, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data';
18+
import type { Address, ComputedFieldCache, CurrencyConfig, CurrencyValue, DataQualityRules, Field, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data';
1919

2020
// Validate data
2121
const result = Address.parse(data);
@@ -105,7 +105,7 @@ const result = Address.parse(data);
105105
| **required** | `boolean` | optional | Is required |
106106
| **searchable** | `boolean` | optional | Is searchable |
107107
| **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. |
108-
| **unique** | `boolean` | optional | Is unique constraint |
108+
| **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 |
109109
| **defaultValue** | `any` | optional | Default value |
110110
| **maxLength** | `number` | optional | Max character length |
111111
| **minLength** | `number` | optional | Min character length |
@@ -245,3 +245,24 @@ const result = Address.parse(data);
245245

246246
---
247247

248+
## UniqueScope
249+
250+
### Union Options
251+
252+
This schema accepts one of the following structures:
253+
254+
#### Option 1
255+
256+
Type: `boolean`
257+
258+
---
259+
260+
#### Option 2
261+
262+
Type: `'global'`
263+
264+
---
265+
266+
267+
---
268+

‎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` | ✅ | Whether the index enforces uniqueness |
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` |
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; … }[]` | optional | Database performance indexes |
125+
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | 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; … }[]` | 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'; … }[]` | 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)