Skip to content

Commit 1e4d73a

Browse files
committed
fix(driver-sql)!: unique 按租户物化,不再与分裂的 autonumber 序列冲突 (#3696)
`unique: true` used to become 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, with no user error involved. The rejection 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. Contract: - `unique: true` + tenant column -> composite `(tenantField, field)`, unique WITHIN the tenant. Tenant column first, so the index also serves the `WHERE tenant = ?` prefix scans every tenant-scoped read issues. - `unique: true`, no tenant column -> single-column. Single-tenant deployments get byte-identical DDL to before. - `unique: 'global'` -> single-column, always. Platform-wide uniqueness is the special case and now has to say so (a DNS hostname, a reserved slug, an external provider id, a device identity). Field-level `unique` had three DDL paths that each inlined their own single-column SQL — createTable/alterTable via `createColumn`, and the SQLite drift rebuild. That is how a tenant-scoped field could silently regain a global index after any drift reconcile. All three now converge on one tenancy-aware path (`uniqueIndexesFromFields` + `syncTableIndexes`); `createColumn` owns type/nullability/defaults only, since a column builder cannot express a composite at all. Declared object-level `indexes[]` are deliberately NOT rewritten: the author already names the columns, tenant-scoped ones have always been written explicitly (`fields: ['organization_id', 'code']`), and many are legitimately platform-wide. Auto-injecting a tenant column there would have broken global slug reservation, hostname allocation and external provider ids. `'global'` is accepted there as a synonym of `true` for one vocabulary across both spellings. Legacy indexes (`<table>_<col>_unique` from knex, `uniq_<table>_<col>` from the rebuild path) are retired inline at sync time. This is a pure relaxation — the old global constraint is strictly stronger than the per-tenant one, so existing data satisfies the replacement by construction: no dedup, no cleanup, no possible failure. It converges at sync rather than waiting for a deliberate `os migrate` run because a deployment that never ran migrate would otherwise stay broken, and the failure mode is a rejected insert. Postgres materializes `col.unique()` as a CONSTRAINT rather than a bare index, so the drop tries `DROP CONSTRAINT` before `DROP INDEX` — `DROP INDEX` alone would have made this a no-op on exactly the deployments that matter. driver-mongodb accepts the new type 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. BREAKING CHANGE: on a tenant-scoped object, field-level `unique: true` is now unique per tenant instead of platform-wide. Fields that need platform-wide uniqueness must declare `unique: 'global'`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uu13EPsmLey7drXSMjuNq
1 parent 6169615 commit 1e4d73a

7 files changed

Lines changed: 756 additions & 26 deletions

File tree

‎packages/plugins/driver-mongodb/src/mongodb-schema.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ import type { Db, CreateIndexesOptions, IndexSpecification } from 'mongodb';
1414
*/
1515
interface FieldDef {
1616
type?: string;
17-
unique?: boolean;
17+
/**
18+
* `true` | `'global'` — see `UniqueScopeSchema` in @objectstack/spec.
19+
*
20+
* Both spellings materialize the SAME single-field unique index here, and
21+
* that is currently correct for this driver: unlike the SQL driver it
22+
* implements NO row-level tenancy at all (no tenant predicate on read, no
23+
* tenant stamp on write), so there is no tenant column to compose with. A
24+
* `(tenant, field)` index would advertise an isolation this driver does not
25+
* deliver — worse than the single-field index, because it would read as
26+
* fixed. The tenancy gap itself is tracked separately; when it lands, this
27+
* must adopt the same scoping rule as `SqlDriver.uniqueIndexesFromFields`.
28+
*/
29+
unique?: boolean | 'global';
1830
indexed?: boolean;
1931
required?: boolean;
2032
reference_to?: string;

0 commit comments

Comments
 (0)