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
69 changes: 69 additions & 0 deletions .changeset/unique-tenant-scoped-materialization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-sql": minor
"@objectstack/spec": minor
---

fix(driver-sql)!: `unique` materializes per tenant, ending its contradiction with the per-tenant autonumber sequence (#3696)

`unique: true` became a **single-column global index that ignored `tenancy`
entirely**, while the autonumber sequence table is keyed by
`(object, tenant_id, field, scope)` and hands every tenant its own counter
starting at 1. Two subsystems of the same platform contradicted each other:
tenant B's `PROD-00001` was rejected by an index it could not see — **no user
did anything wrong**, the platform's left hand refused what its right hand
issued.

The rejection also doubled as a **cross-tenant existence oracle**: a UNIQUE
violation told tenant B that some *other* tenant held the value, enumerable by
probing emails / codes / names.

**The contract now:**

| Declaration | Materializes as |
|---|---|
| `unique: true` + tenant column | composite `(tenantField, field)` — unique **within** the tenant |
| `unique: true`, no tenant column | single-column — single-tenant DDL is byte-identical to before |
| `unique: 'global'` | single-column, always platform-wide |

The tenant column comes first in the composite, so the index also serves the
`WHERE tenant = ?` prefix scans every tenant-scoped read issues.

**Declared `indexes[]` are deliberately unchanged.** They are materialized over
exactly the columns listed — no tenant column is injected. The author already
spells them out, per-tenant ones have always been written explicitly
(`fields: ['organization_id', 'code']`), and many are legitimately platform-wide
(a DNS hostname, a reserved slug, an external provider id). `'global'` is
accepted there as a synonym of `true` so one vocabulary covers both spellings.

**Migration is automatic and cannot fail.** Legacy indexes
(`<table>_<col>_unique` from knex, `uniq_<table>_<col>` from the drift-rebuild
path) are retired inline at schema-sync time. The old global constraint is
strictly stronger than the new per-tenant one, so existing rows satisfy the
replacement by construction — no dedup, no cleanup, no data touched. It
converges at sync rather than waiting for a deliberate `os migrate` run because
a deployment that never ran migrate would otherwise stay broken.

**Upgrading — audit your `unique: true` fields.** On a tenant-scoped object the
constraint is now per tenant. Anything that must stay platform-wide has to say
so:

```ts
hostname: Field.text({ unique: 'global' }) // no two tenants may claim it
```

Note the reach: `applySystemFields` injects `organization_id` into every
registered object unless it opts out, and the driver falls back to that column
when no `tenancy.tenantField` is declared — so most objects are tenant-scoped.
Typical candidates for `'global'`: DNS hostnames, reserved slugs, external
provider ids (Stripe customer/subscription), device identities.

Postgres materializes `col.unique()` as a table CONSTRAINT rather than a bare
index, so the retirement tries `DROP CONSTRAINT` before `DROP INDEX` —
`DROP INDEX` alone would have made the migration a no-op on exactly the
deployments that matter most.

`@objectstack/driver-mongodb` accepts the new declaration but keeps single-field
indexes: it implements no row-level tenancy at all (no tenant predicate on read,
no tenant stamp on write), so a `(tenant, field)` index would advertise an
isolation it does not deliver. Tracked separately.
50 changes: 49 additions & 1 deletion content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ fields:
| `type` | `string` | All | **Required.** Field type (see [Types](/docs/protocol/objectql/types)). |
| `label` | `string` | All | **Required.** Display label in UI. |
| `required` | `boolean` | All | Validation: Field must have a value. |
| `unique` | `boolean` | All | Enforce uniqueness at database level. |
| `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). |
| `searchable` | `boolean` | All | Is searchable. |
| `defaultValue` | `any` | All | Default value when creating new records. |
| `description` | `string` | All | Tooltip/Help text. |
Expand Down Expand Up @@ -566,6 +566,54 @@ Supported index `type` values: `btree` (default), `hash`, `gin`, `gist`, `fullte
- Fields that change frequently
- Large text fields (use full-text search instead)

### Uniqueness and tenancy

On a tenant-scoped object, **field-level `unique: true` means unique *within* the
tenant**, not platform-wide. The driver materializes it as a composite index
`(tenantField, field)`:

```yaml
fields:
code:
type: autonumber
autonumberFormat: 'PROD-{00000}'
unique: true # each tenant may hold its own PROD-00001
```

This matches every other tenant-aware part of the platform: reads are filtered by
the tenant predicate, writes stamp the tenant column, and the auto-number sequence
gives each tenant a counter starting at 1. A platform-wide index would contradict
the sequence outright — the second tenant's `PROD-00001` would be rejected by an
index it cannot see, and the rejection itself would reveal that *some other tenant*
holds the value.

For the identifiers that genuinely are platform-wide — a DNS hostname, a reserved
slug, an external provider id, a device identity — say so explicitly:

```yaml
fields:
hostname:
type: text
unique: global # no two tenants may claim the same hostname
```

On an object with no tenant column (`tenancy: { enabled: false }`, or simply no
tenant field) both spellings behave identically. **Single-tenant deployments are
unaffected** — the tenant column is constant, so the composite index degenerates
to the single-column one.

**Declared `indexes` are different:** they are materialized over exactly the
columns you list, with no tenant column injected. Write the tenant column yourself
when you want a per-tenant index:

```yaml
indexes:
- fields: [organization_id, code] # unique per tenant
unique: true
- fields: [hostname] # unique platform-wide
unique: true
```

## Lifecycle Hooks

Record-triggered logic is **not** an object-schema field — `triggers` (and `hooks`,
Expand Down
27 changes: 24 additions & 3 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Field Type Enum
## TypeScript Usage

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

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

---

## UniqueScope

### Union Options

This schema accepts one of the following structures:

#### Option 1

Type: `boolean`

---

#### Option 2

Type: `'global'`

---


---

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` | ✅ | Whether the index enforces uniqueness |
| **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` |
| **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; … }[]` | optional | Database performance indexes |
| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | 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; … }[]` | optional | Additional indexes 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 |
| **priority** | `integer` | optional | Merge priority (higher = applied later) |


Expand Down
14 changes: 13 additions & 1 deletion packages/plugins/driver-mongodb/src/mongodb-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ import type { Db, CreateIndexesOptions, IndexSpecification } from 'mongodb';
*/
interface FieldDef {
type?: string;
unique?: boolean;
/**
* `true` | `'global'` — see `UniqueScopeSchema` in @objectstack/spec.
*
* Both spellings materialize the SAME single-field unique index here, and
* that is currently correct for this driver: unlike the SQL driver it
* implements NO row-level tenancy at all (no tenant predicate on read, no
* tenant stamp on write), so there is no tenant column to compose with. A
* `(tenant, field)` index would advertise an isolation this driver does not
* deliver — worse than the single-field index, because it would read as
* fixed. The tenancy gap itself is tracked separately; when it lands, this
* must adopt the same scoping rule as `SqlDriver.uniqueIndexesFromFields`.
*/
unique?: boolean | 'global';
indexed?: boolean;
required?: boolean;
reference_to?: string;
Expand Down
Loading
Loading