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
67 changes: 67 additions & 0 deletions .changeset/driver-memory-field-level-uniqueness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
"@objectstack/driver-memory": minor
"@objectstack/types": patch
---

fix(driver-memory): enforce field-level `unique`, so a colliding write is refused instead of landing silently (#13197)

`InMemoryDriver` enforced **no uniqueness at all**. `create` was a
`table.push()` and `syncSchema` allocated an array, so a `unique: true` field
was declared-and-not-enforced — the ADR-0078 / Prime-Directive-#10 shape the
platform refuses everywhere else. A colliding write did not fail; it landed, and
a read returned both rows.

The motivating instance is the worst-shaped one. The engine's
`createWithAutonumberResync` re-seeds the counter and re-issues a record number
when the STORE rejects it as a duplicate, so on a store that rejected nothing
the whole branch was unreachable: an autonumber allocated out of process
duplicated an existing business identifier with **no error anywhere**. The
remedy's location was already ruled in-tree at that method — «uniqueness
enforcement in the driver, NOT a pre-issue existence probe here» — and this is
that remedy. Nothing in the new code knows what an autonumber is; the defect was
that the driver constrained nothing.

**The refusal** carries the ADR-0112 envelope the SQL family answers a conflict
with: `code: 'UNIQUE_VIOLATION'`, `status: 409`, no `[driver-memory]` prefix. So
a suite that swaps this driver for SQLite sees one envelope — the parity
`memory-filter-refusal-envelope.test.ts` already states for the filter family,
now held for the constraint family. It is checked before the row is written, so
a refused write leaves the table exactly as it found it, and `updateMany`
prepares and checks the whole batch before mutating any of it.

**The scoping is `driver-sql`'s, measured — not a simpler invention.** Read off
`uniqueIndexesFromFields` (ADR-0120 D1/D3) and reproduced arm for arm:
`unique: 'global'` is platform-wide; bare `true` and `'organization'` are
per-organization (bare `true` is the POSITIONAL spelling of `'organization'` at
FIELD level — reading it as `'global'` is the #4986 trap and would make two
organizations' identical values collide on a constraint neither can see); both
degrade to a single column when the object has no tenant column, and a `unique`
declaration on the tenant column itself stays single-column. NULL values stay
NULL-DISTINCT, exactly as under SQL `UNIQUE`. The D3 NULL-organization fold
needs no `'__global__'` token here — that sentinel is a SQL-expression artefact,
and a JavaScript key holds `null` directly.

**Not** widened into: object-level declared `indexes[]` (composite uniques),
primary keys, or row-level tenant isolation. This driver still refuses to boot
multi-tenant (#6915) and that guard is untouched.

`@objectstack/types` (`patch`): `isUniqueViolationError` now reads the
platform's own registered `UNIQUE_VIOLATION` code on the `code` channel. Not
cosmetic — a conflict that predicate does not recognise leaves the autonumber
resync unable to re-seed, so the counter stays warm and every following insert
collides too (#5495's PROBE3 storm), i.e. a silent duplicate traded for a
non-converging insert loop. It is a tautology rather than a widened heuristic
(the code already MEANS this condition), and no existing in-repo producer's
classification changes: `@objectstack/rest`'s own response body is the only
other site carrying that string, and it is downstream of the predicate.

**Grade.** `minor` for the driver, not `patch`: a write that previously
succeeded is now refused (`409`), which is an accept-set narrowing under the
repo's launch-window convention for breaking changes, and the package also gains
public exports (`UNIQUE_VIOLATION_CODE`, `uniqueConstraintsFromFields`,
`tenantFieldOf`, `uniqueKeyOf`, `assertNoUniqueViolation`,
`uniqueViolationError`). `patch` for `@objectstack/types`: no API added or
removed and no in-repo verdict changes — the limb exists to serve the new
producer. Fixtures that relied on duplicates landing on a declared-unique field
must stop declaring `unique`, or stop writing the duplicate; the repo's own
suites were measured and none did.
6 changes: 4 additions & 2 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,10 @@ pass `persistence: 'auto'` (or `'file'`) explicitly. See
For tests, prefer in-memory **SQLite** — `SqlDriver` with
`connection: { filename: ':memory:' }`, or `SqliteWasmDriver({ filename: ':memory:' })`
when you want no native build. Both give the SQL semantics production runs on;
mingo does not enforce primary keys, uniqueness, `NOT NULL` or column types, so a
green run against the memory driver is weaker evidence than it looks. The
the memory driver enforces **field-level `unique`** (with the same
per-organization scoping the SQL family applies) and nothing else — no primary
keys, no `NOT NULL`, no column types and no object-level composite `indexes[]`
— so a green run against it is still weaker evidence than it looks. The
framework's own dogfood gate boots on WASM SQLite at `:memory:` for this reason.

The memory driver remains fine where you want no setup at all and the assertions
Expand Down
14 changes: 14 additions & 0 deletions packages/drivers/driver-memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ export {
} from './memory-tenancy-guard.js';
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';

// [#13197] Field-level uniqueness — the refusal's wire 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.
export {
UNIQUE_VIOLATION_CODE,
UNIQUE_VIOLATION_STATUS,
assertNoUniqueViolation,
tenantFieldOf,
uniqueConstraintsFromFields,
uniqueKeyOf,
uniqueViolationError,
} from './memory-unique-constraint.js';
export type { MemoryUniqueConstraint, UniqueAwareSchema } from './memory-unique-constraint.js';

export default {
id: 'com.objectstack.driver.memory',
version: '1.0.0',
Expand Down
121 changes: 104 additions & 17 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ import {
indexTemporalFields,
type TemporalFieldKind,
} from './memory-temporal.js';
// [#13197] Field-level uniqueness — the constraint this driver enforced
// NOWHERE, and the reason an out-of-process duplicate autonumber used to land
// silently. The scoping semantics are `driver-sql`'s, measured; see the
// module docblock.
import {
assertNoUniqueViolation,
uniqueConstraintsFromFields,
type MemoryUniqueConstraint,
} from './memory-unique-constraint.js';

/**
* Persistence adapter interface.
Expand Down Expand Up @@ -176,16 +185,28 @@ interface MemoryTransaction {
* - Field projection and distinct values
* - Strict mode and initial data loading
*
* ## What this driver does NOT enforce
* ## What this driver enforces, and what it still does not
*
* Since #13197 it enforces **field-level `unique`**, with `driver-sql`'s
* ADR-0120 D1/D3 scoping (`memory-unique-constraint.ts` carries the measured
* arm-for-arm table): a colliding write is REFUSED — `code: 'UNIQUE_VIOLATION'`,
* `status: 409` — instead of landing silently. That closes the one gap whose
* failure mode was a wrong ANSWER rather than a missing check: an autonumber
* allocated out-of-process used to duplicate an existing business identifier
* with no error anywhere, because the engine's collision resync
* (`createWithAutonumberResync`) is triggered by the STORE rejecting the
* duplicate and this store rejected nothing.
*
* It stores no constraints of any kind. {@link create} is a `table.push()` and
* {@link syncSchema} only allocates an array and indexes temporal fields, so
* there is no primary key, no uniqueness, no `NOT NULL`, no foreign key and no
* column typing. `bulkCreate` will happily land two rows with the same `id`
* where a SQL driver raises a constraint violation, and a read returns both.
* Everything else is still unenforced. {@link syncSchema} allocates an array,
* indexes temporal fields and records those unique constraints — and nothing
* more — so there is no primary key, no `NOT NULL`, no foreign key and no
* column typing. `bulkCreate` will still happily land two rows with the same
* `id` (unless `id` itself declares `unique`) where a SQL driver raises a
* constraint violation, and a read returns both. Object-level declared
* `indexes[]` — composite uniques — are not enforced either.
*
* That makes it a WEAK oracle: code green against this driver can still be
* broken against the SQL engines production runs on. Prefer in-memory SQLite
* That still makes it a WEAK oracle: code green against this driver can still
* be broken against the SQL engines production runs on. Prefer in-memory SQLite
* for tests — `SqlDriver` with `connection: { filename: ':memory:' }`, or
* `SqliteWasmDriver({ filename: ':memory:' })` where no native build is wanted.
* This driver's remaining roles are the last-resort rung of the dev step-down
Expand Down Expand Up @@ -214,6 +235,15 @@ export class InMemoryDriver implements IDataDriver {
* for it: the driver does not guess types from values.
*/
private temporalFields: Map<string, Map<string, TemporalFieldKind>> = new Map();

/**
* [#13197] Declared field-level unique constraints per object, populated by
* {@link syncSchema} — the same shape and the same lifetime as
* {@link temporalFields} above, and for the same reason: an object absent
* from this map was never declared, so nothing is enforced for it. This
* driver does not infer a constraint from the data it happens to hold.
*/
private uniqueConstraints: Map<string, MemoryUniqueConstraint[]> = new Map();
private transactions: Map<string, MemoryTransaction> = new Map();
private persistenceAdapter: PersistenceAdapterInterface | null = null;

Expand Down Expand Up @@ -468,6 +498,12 @@ export class InMemoryDriver implements IDataDriver {
updated_at: data.updated_at || new Date().toISOString(),
});

// [#13197] Refuse a declared-unique collision instead of landing it. Checked
// on the STORED form, so a temporal value is compared in the one shape this
// driver stores (#4047), and BEFORE the push, so a refused write leaves the
// table exactly as it found it.
this.assertUnique(object, newRecord);

table.push(newRecord);
this.markDirty();
this.logger.debug('Record created', { object, id: newRecord.id, tableSize: table.length });
Expand Down Expand Up @@ -495,7 +531,11 @@ export class InMemoryDriver implements IDataDriver {
created_at: table[index].created_at, // Preserve created_at
updated_at: new Date().toISOString(),
});


// [#13197] The row being updated is excluded from its own check — an update
// that does not touch the unique field must not collide with itself.
this.assertUnique(object, updatedRecord, table[index].id);

table[index] = updatedRecord;
this.markDirty();
this.logger.debug('Record updated', { object, id });
Expand Down Expand Up @@ -584,17 +624,26 @@ export class InMemoryDriver implements IDataDriver {

const count = targetRecords.length;

// [#13197] Prepare and CHECK every row before mutating any of them: an
// `updateMany` that stamps the same unique value onto two rows collides
// by construction, and a half-applied batch is worse than a refusal. The
// pending rows are checked against each other too, which a per-row check
// against `table` alone would miss (nothing is written yet).
const pending: Array<{ index: number; row: Record<string, any> }> = [];
const targetIds = new Set(targetRecords.map((r) => r.id));
const settled = table.filter((r) => !targetIds.has(r.id));
for (const record of targetRecords) {
const index = table.findIndex(r => r.id === record.id);
if (index !== -1) {
const updated = this.toStoredRecord(object, {
...table[index],
...data,
updated_at: new Date().toISOString()
});
table[index] = updated;
}
if (index === -1) continue;
const updated = this.toStoredRecord(object, {
...table[index],
...data,
updated_at: new Date().toISOString()
});
this.assertUnique(object, updated, table[index].id, [...settled, ...pending.map((p) => p.row)]);
pending.push({ index, row: updated });
}
for (const { index, row } of pending) table[index] = row;

if (count > 0) this.markDirty();
this.logger.debug('UpdateMany completed', { object, count });
Expand Down Expand Up @@ -1439,6 +1488,15 @@ export class InMemoryDriver implements IDataDriver {
// (ADR-0053 D-B3) and, like it, is idempotent.
const kinds = indexTemporalFields(schema?.fields);
this.temporalFields.set(object, kinds);
// [#13197] Learn the object's field-level unique constraints in the same
// pass. Deliberately NOT retroactive: rows already in the table arrived
// from `initialData` or a persistence adapter, before any schema existed,
// and REFUSING them here would turn a declaration into a boot failure over
// data this driver did not write. From here on every write is checked, and
// 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).
this.uniqueConstraints.set(object, uniqueConstraintsFromFields(schema));
if (kinds.size > 0) {
const table = this.db[object];
for (let i = 0; i < table.length; i++) {
Expand All @@ -1452,6 +1510,10 @@ export class InMemoryDriver implements IDataDriver {
if (this.db[object]) {
const recordCount = this.db[object].length;
delete this.db[object];
// [#13197] The declaration dies with the table. A constraint left behind
// would be enforced over a table nobody declared — the inverse of the
// gap this closes, and just as invisible.
this.uniqueConstraints.delete(object);
this.logger.info('Dropped in-memory table', { object, recordCount });
}
}
Expand Down Expand Up @@ -1624,6 +1686,31 @@ export class InMemoryDriver implements IDataDriver {
return result;
}

/**
* [#13197] Refuse `candidate` if it violates one of `object`'s declared
* field-level unique constraints.
*
* The ONE seam every write path goes through, so create, update and
* update-many cannot disagree about what `unique` means — the same
* single-judgment-point discipline `assertFilterConditionShape` enforces for
* the filter faces. `rows` defaults to the live table; `updateMany` passes a
* projected set instead, because its pending rows are not in the table yet.
*
* An object never passed through {@link syncSchema} has no entry and is
* unconstrained: this driver does not guess a constraint from the data, for
* the same reason it does not guess types from values.
*/
private assertUnique(
object: string,
candidate: Record<string, any>,
exceptId?: unknown,
rows?: readonly Record<string, any>[],
): void {
const constraints = this.uniqueConstraints.get(object);
if (!constraints || constraints.length === 0) return;
assertNoUniqueViolation(object, rows ?? this.getTable(object), candidate, constraints, exceptId);
}

private getTable(name: string) {
if (!this.db[name]) {
this.db[name] = [];
Expand Down
Loading
Loading