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
68 changes: 68 additions & 0 deletions .changeset/memory-unique-sticky-tenancy-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
"@objectstack/driver-memory": minor
"@objectstack/driver-sql": patch
"@objectstack/objectql": patch
---

fix(driver-memory,driver-sql): an explicit `tenancy.enabled: false` opt-out is sticky, so a partial `syncSchema` re-registration no longer flips a platform-global object's UNIQUE partition (#16729)

## What was wrong

`InMemoryDriver.syncSchema` recomputed its uniqueness constraints from whatever
schema THAT call happened to carry. A second registration without a `tenancy`
block — the `{ name, fields }` shape — fell through to the implicit
`organization_id` heuristic, so a `unique` field moved from **one row per
install** (`scopeField: null`, which is what `tenancy.enabled: false` declares)
to **one row per organization**. A duplicate the declaration refuses then
landed. Measured at the driver door on `origin/main` `d61139f1ba`:

| sequence | second `key: 'K'`, different organization |
|:--|:--|
| register with `tenancy.enabled: false` | `REFUSED` — `UNIQUE_VIOLATION` / 409 |
| …then re-register with `{ name, fields }` | **`LANDED`** |

`SqlDriver` running the same sequence refuses in **both** cases: it has kept a
sticky `tenantOptOutByTable` since #3249. `driver-memory` had mirrored the inner
`computeTenantField` and not the wrapper that consults the record, so "mirrors
`computeTenantField` arm for arm" stayed literally true while the pair diverged.

It is silent in both directions — nothing logs the flip, and the refusal names
the field, never the partition. That is the declared-vs-enforced shape Prime
Directive #10 forbids, reached by a state change rather than by a missing check.

## What it does now

- **`@objectstack/driver-memory`** gains `computeAndRecordTenantField`, the
sticky resolver, and the `TenantOptOutRecord` type for the per-instance record
a driver owns. `InMemoryDriver` holds one and resolves through it, handing
BOTH declaration surfaces — field-level `unique` and declared `indexes[]` —
the same resolved column. `uniqueConstraintsFromFields` and
`uniqueConstraintsFromDeclaredIndexes` accept that column as an optional
second argument; called with one argument they answer exactly as before.
`tenantFieldOf` is unchanged and still a pure function of its argument.
- **`@objectstack/driver-sql`**: the shard leaf resolved its tenant column with
the BARE `computeTenantField`, so a `rotateShards` sweep carrying no `tenancy`
block gave a shard an organization key part the base table's index does not
have — one object, two partitions, decided by which physical table a row
landed in. It now resolves through the record, keyed by the base table.
- **`@objectstack/objectql`**: `LifecycleObjectLike` declares `tenancy`. The
Archiver hands that object straight to `cold.syncSchema`, and the published
type refused the key while the driver below read it — so an author writing a
fresh literal was pushed into producing exactly the partial re-registration
above. Same correction #16711 made where the shard leaf narrowed the key off
the object it was handed.

The record is deliberately narrow. Only the explicit OPT-OUT is sticky: a
declared `tenancy.tenantField` is not recorded, matching `SqlDriver`. An object
that never declared the opt-out never enters the record, so a genuinely
org-scoped object keeps its `organization_id` partition across a partial
re-registration — an implementation answering `null` more often would not be
stickier, it would be tenant isolation switched off. A carried `tenancy` block
stays authoritative in both directions and CLEARS a recorded opt-out.

`@objectstack/driver-memory` is `minor` for the two new public-entry exports.
The behaviour repairs themselves are `patch`: each restores an implementation to
the `tenancy.enabled: false` contract (`isTenancyDisabled`, ADR-0066) it was
already declaring, rather than replacing one legal published answer with
another. The `objectql` entry is a published type WIDENING — a key the interface
refused is now accepted, and nothing that compiled before stops compiling.
10 changes: 10 additions & 0 deletions packages/drivers/driver-memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,19 @@ export type { TenancyAwareSchema } from './memory-tenancy-guard.js';
// identity and the scoping helpers, exported so a consumer can assert the
// envelope (`code` AND `status`, never merely "it threw") without
// string-matching the message.
//
// [#16729] `computeAndRecordTenantField` is published BESIDE `tenantFieldOf`,
// not kept private, because publishing only the inner half is what let this
// package diverge in the first place: `tenantFieldOf` mirrors
// `SqlDriver.computeTenantField`, the stickiness lives in the wrapper AROUND
// that function on the SQL side, and a reader who found only the inner half
// exported mirrored only the inner half. The next driver reproducing this pair
// sees both halves or repeats the same omission.
export {
UNIQUE_VIOLATION_CODE,
UNIQUE_VIOLATION_STATUS,
assertNoUniqueViolation,
computeAndRecordTenantField,
declaredIndexViolationError,
isDeclaredIndexConstraint,
tenantFieldOf,
Expand All @@ -48,6 +57,7 @@ export type {
MemoryDeclaredIndexConstraint,
MemoryUniqueConstraint,
MemoryUniqueEnforcement,
TenantOptOutRecord,
UniqueAwareSchema,
} from './memory-unique-constraint.js';

Expand Down
35 changes: 33 additions & 2 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
// module docblock.
import {
assertNoUniqueViolation,
computeAndRecordTenantField,
uniqueConstraintsFromDeclaredIndexes,
uniqueConstraintsFromFields,
type MemoryUniqueEnforcement,
Expand Down Expand Up @@ -404,6 +405,27 @@ export class InMemoryDriver implements IDataDriver {
* the data it happens to hold.
*/
private uniqueConstraints: Map<string, MemoryUniqueEnforcement[]> = new Map();

/**
* [#16729] Objects whose schema EXPLICITLY declared `tenancy.enabled: false`,
* this driver's counterpart of `SqlDriver.tenantOptOutByTable` and the record
* {@link computeAndRecordTenantField} maintains.
*
* Sticky across re-registrations on purpose: a later `syncSchema` that omits
* the `tenancy` block must NOT resurrect org-scoping of the uniqueness key
* via the implicit `organization_id` heuristic. Without it a platform-global
* object's UNIQUE partition silently moved from one row per install to one
* row per organization, and the duplicate its declaration refuses LANDED.
*
* Unlike {@link uniqueConstraints} it is deliberately NOT cleared by
* `dropTable`. That map is cleared because a constraint outliving its table
* would be ENFORCED over a table nobody declared; this record enforces
* nothing on its own — it only decides which partition the NEXT declaration
* resolves to, and the last authoritative word on this object was still
* "platform-global". Dropping a table is not a schema declaring itself
* tenant-scoped, and only such a declaration clears the record.
*/
private tenantOptOutByObject: Set<string> = new Set();
private transactions: Map<string, MemoryTransaction> = new Map();
private persistenceAdapter: PersistenceAdapterInterface | null = null;

Expand Down Expand Up @@ -1978,9 +2000,18 @@ export class InMemoryDriver implements IDataDriver {
// an already-duplicated pair is reported by the first write that touches
// it — the same posture `driver-sql` takes when a unique index cannot be
// built over dirty data (it announces, it does not delete rows).
// [#16729] Resolve the tenant column through the STICKY record rather than
// from this call's schema alone. `syncSchema` is idempotent and is called
// again with whatever schema the caller happens to hold; a call carrying no
// `tenancy` block would otherwise fall through to the implicit
// `organization_id` heuristic and re-scope an object that declared itself
// platform-global. Both surfaces are handed the SAME resolved column, so
// the field-level and declared-index keys of one object cannot disagree
// about which partition it lives in.
const tenantField = computeAndRecordTenantField(this.tenantOptOutByObject, object, schema);
this.uniqueConstraints.set(object, [
...uniqueConstraintsFromFields(schema),
...uniqueConstraintsFromDeclaredIndexes(schema),
...uniqueConstraintsFromFields(schema, tenantField),
...uniqueConstraintsFromDeclaredIndexes(schema, tenantField),
]);
if (kinds.size > 0) {
const table = this.db[object];
Expand Down
98 changes: 96 additions & 2 deletions packages/drivers/driver-memory/src/memory-unique-constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,20 +300,105 @@ export function tenantFieldOf(schema: UniqueAwareSchema | null | undefined): str
return null;
}

/**
* [#16729] One driver's record of the objects whose schema EXPLICITLY declared
* `tenancy.enabled === false` — this package's counterpart of `SqlDriver`'s
* `tenantOptOutByTable`.
*
* Owned by the DRIVER INSTANCE, never by this module. Two `InMemoryDriver`s in
* one process are two independent stores (this driver's whole shape is
* per-instance state), so a module-level record would let one store's
* declaration decide another store's uniqueness partition — a fresh
* cross-instance channel introduced by the fix for a cross-registration one.
* `SqlDriver` holds its own record per instance for the same reason.
*/
export type TenantOptOutRecord = Set<string>;

/**
* [#16729] {@link tenantFieldOf} + maintenance of the sticky explicit-opt-out
* record. Mirrors `SqlDriver.computeAndRecordTenantField` arm for arm, and it
* is the arm this package was missing.
*
* ## Why the mirror needed a SECOND function, not a change to the first
*
* `driver-memory` already reproduced `SqlDriver.computeTenantField` faithfully
* as {@link tenantFieldOf}. The stickiness, though, does not live in that
* function on the SQL side either: it lives in the WRAPPER around it. So the
* inner half was mirrored and the outer half was not, and "this mirrors
* `computeTenantField` arm for arm" stayed literally true while the pair as a
* whole diverged. {@link tenantFieldOf} is therefore UNCHANGED and still
* answers from the passed schema alone — a pure function of its argument is
* what its own pins assert, and they remain correct.
*
* ## What the record buys
*
* A schema that carries a `tenancy` declaration is authoritative: it sets or
* clears the record and is computed normally. A schema WITHOUT one — a partial
* re-registration — preserves a previously declared opt-out instead of letting
* the implicit `organization_id` heuristic re-scope a platform-global object.
* Without it, a second `syncSchema` carrying only `{ name, fields }` silently
* moves a `unique` field from ONE row per install (`scopeField: null`, which is
* what `tenancy.enabled: false` declares) to one row per organization: a
* duplicate the declaration refuses then LANDS, and nothing announces the
* change — the declared-vs-enforced divergence Prime Directive #10 forbids,
* reached by a state change rather than by a missing check.
*
* ## Only the OPT-OUT is sticky, deliberately
*
* A declared `tenancy.tenantField` is NOT recorded, so a partial
* re-registration of a custom-tenant-column object still falls back to
* `organization_id`. That is not an oversight: it is what `SqlDriver` does, and
* this module's contract is to answer as `driver-sql` answers. Recording more
* here would be a second, easier answer to "what does `unique` mean" — the
* one-contract-two-numbers defect this module exists to close.
*
* A genuinely tenant-scoped object (no `tenancy` block, an `organization_id`
* column) never enters the record, so it keeps its `organization_id` partition
* across a partial re-registration exactly as before. An implementation that
* answered `null` more often than this one would not be stickier, it would be
* tenant isolation switched off.
*/
export function computeAndRecordTenantField(
record: TenantOptOutRecord,
key: string,
schema: UniqueAwareSchema | null | undefined,
): string | null {
// A carried `tenancy` block is AUTHORITATIVE in both directions: it records a
// fresh opt-out, and it CLEARS a stale one. `!= null` is the SQL side's test
// — the declaration's PRESENCE is what makes it authoritative, so a
// `tenancy: {}` that declares no opt-out clears the record too.
if (schema?.tenancy != null) {
if (isTenancyDisabled(schema)) record.add(key);
else record.delete(key);
return tenantFieldOf(schema);
}
if (record.has(key)) return null;
return tenantFieldOf(schema);
}

/**
* The constraints an object's field-level `unique` declarations ask for.
*
* The single place a `unique` declaration becomes a constraint in this package,
* so the create, update and update-many paths cannot disagree about what one
* means — the same reason `uniqueIndexesFromFields` is the single place on the
* SQL side.
*
* [#16729] `tenantField` is the RESOLVED tenant column. It defaults to
* {@link tenantFieldOf} of this very schema, so the published one-argument call
* answers exactly as before; a caller holding a {@link TenantOptOutRecord}
* passes {@link computeAndRecordTenantField}'s answer instead, and a partial
* re-registration then cannot re-scope an object that declared itself
* platform-global. `driver-sql` threads the resolved column into
* `syncDeclaredIndexes` rather than letting that leaf recompute it, for this
* same reason.
*/
export function uniqueConstraintsFromFields(
schema: UniqueAwareSchema | null | undefined,
tenantField: string | null = tenantFieldOf(schema),
): MemoryUniqueConstraint[] {
const fields = schema?.fields;
if (!fields) return [];
const tenantField = tenantFieldOf(schema);
const out: MemoryUniqueConstraint[] = [];
for (const [name, field] of Object.entries(fields)) {
const unique = (field as { unique?: unknown } | null | undefined)?.unique;
Expand Down Expand Up @@ -344,13 +429,22 @@ export function uniqueConstraintsFromFields(
* `driver-memory` must not depend on `driver-sql`, so the arms are reproduced
* and pinned here (`memory-declared-index-unique.test.ts`), the way
* {@link tenantFieldOf} reproduces `SqlDriver.computeTenantField`.
*
* [#16729] `tenantField` is the RESOLVED tenant column. It defaults to
* {@link tenantFieldOf} of this very schema, so the published one-argument call
* answers exactly as before; a caller holding a {@link TenantOptOutRecord}
* passes {@link computeAndRecordTenantField}'s answer instead, and a partial
* re-registration then cannot re-scope an object that declared itself
* platform-global. `driver-sql` threads the resolved column into
* `syncDeclaredIndexes` rather than letting that leaf recompute it, for this
* same reason.
*/
export function uniqueConstraintsFromDeclaredIndexes(
schema: UniqueAwareSchema | null | undefined,
tenantField: string | null = tenantFieldOf(schema),
): MemoryDeclaredIndexConstraint[] {
const declared = schema?.indexes;
if (!Array.isArray(declared)) return [];
const tenantField = tenantFieldOf(schema);
const out: MemoryDeclaredIndexConstraint[] = [];
for (const idx of declared) {
// The same filter the SQL side applies, and nothing more: a non-string or
Expand Down
Loading
Loading