From fd5151caa66aa2bca728b3982e280a9dd90542b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 03:36:45 +0000 Subject: [PATCH] fix(driver-memory): enforce object-level declared `indexes[]` uniqueness (#13239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `driver-sql` materializes uniqueness from two declaration surfaces. #13197 closed the field-level one here; object-level declared `indexes[]` entries carrying `unique` were still declared-and-not-enforced, so a composite unique was a real constraint on the SQL family and nothing at all in memory — the colliding write landed and a read returned both rows. - `normalizeDeclaredIndex`'s arms are reproduced (not imported — this package must not depend on `driver-sql`), including the #4986 trap: on a DECLARED index bare `unique: true` is the positional spelling of `'global'`, the opposite of the field surface, so the scope test is the strict `unique === 'organization'`. - Both surfaces share one key model, so there is exactly one NULL rule: a NULL in any listed key column exempts the row, while a NULL organization folds onto one bucket. Measured against SQLite over both DDL shapes `syncDeclaredIndexes` emits, not assumed. - The refusal is the same ADR-0112 envelope, stamped in one place for both surfaces. It names the key COLUMNS and no index name, so `uniqueViolationColumn` answers `undefined` — what `driver-sql` answers for a composite, and the safe answer under #6544. - The `memory-unique-constraint.ts` docblock sentence that listed this surface under "Deliberately out of scope" is removed, not left standing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry --- ...driver-memory-declared-index-uniqueness.md | 70 +++ packages/drivers/driver-memory/src/index.ts | 19 +- .../src/memory-declared-index-unique.test.ts | 576 ++++++++++++++++++ .../driver-memory/src/memory-driver.ts | 49 +- .../src/memory-unique-constraint.ts | 346 ++++++++++- 5 files changed, 1013 insertions(+), 47 deletions(-) create mode 100644 .changeset/driver-memory-declared-index-uniqueness.md create mode 100644 packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts diff --git a/.changeset/driver-memory-declared-index-uniqueness.md b/.changeset/driver-memory-declared-index-uniqueness.md new file mode 100644 index 0000000000..719aec1153 --- /dev/null +++ b/.changeset/driver-memory-declared-index-uniqueness.md @@ -0,0 +1,70 @@ +--- +"@objectstack/driver-memory": minor +--- + +fix(driver-memory): enforce object-level declared `indexes[]` uniqueness, so a colliding composite write is refused instead of landing silently (#13239) + +`driver-sql` materializes uniqueness from **two** declaration surfaces — +field-level `unique` (`uniqueIndexesFromFields`) and object-level `indexes[]` +entries carrying `unique` (`normalizeDeclaredIndex`). #13197 closed the first +one here. The second was still **declared and not enforced**: an object +declaring + +```json +{ "indexes": [{ "fields": ["account_id", "code"], "unique": "organization" }] } +``` + +got a real composite UNIQUE on the SQL family and **nothing at all** in memory — +the colliding write landed and a read returned both rows. That is the same +ADR-0078 / Prime-Directive-#10 shape, one surface over. + +**⚠️ Bare `true` means the OPPOSITE on the two surfaces, and this reproduces the +disagreement rather than smoothing it.** At field level `unique: true` is the +positional spelling of `'organization'`; on a declared index it is the +positional spelling of `'global'` — the listed columns VERBATIM, no organization +key part. That is the #4986 trap, it is deliberate (the #8323 maintainer ruling +of 2026-08-13 rejected routing the declared-index branch through the field-level +predicate, because it would silently reinterpret every deployed declared +`unique: true` as organization-scoped), and it is staged for retirement at +protocol 18 by #5082. So the scope test on this surface is the strict +`unique === 'organization'`, exactly as `normalizeDeclaredIndex` does it, and +`memory-declared-index-unique.test.ts` holds both readings side by side on one +object so a future edit cannot move one without moving the other. + +`normalizeDeclaredIndex`'s arms are reproduced — not imported: `driver-memory` +must not depend on `driver-sql`, the same reason `computeTenantField` was +reproduced for #13197. + +- `unique: true` / `'global'` → the listed columns verbatim. +- `unique: 'organization'` with a tenant column → the organization key part is + prepended (and is NOT prepended twice when the author already listed it — its + own key part goes NULL-safe instead, order preserved). +- `unique: 'organization'` with no tenant column → degrades to the listed + columns alone. +- `unique` absent / `false`, or an entry with no usable `fields` → not a + constraint. + +**NULL handling was measured against SQLite, not assumed.** A NULL in any listed +key column exempts the row (SQL `UNIQUE` is NULL-distinct), while a NULL +ORGANIZATION folds onto one bucket, because ADR-0120 D3 materializes that key +part as `COALESCE(organization_id, '__global__')` — an expression that is never +NULL. Both halves hold here through one key model shared with the field surface, +so there is exactly one NULL rule in the package. + +**The refusal** is the field surface's envelope — `code: 'UNIQUE_VIOLATION'`, +`status: 409`, no `[driver-memory]` prefix — stamped in one place for both +surfaces. It names the key COLUMNS and carries no index name, so +`uniqueViolationColumn` answers `undefined`: the same answer `driver-sql` gives +for a composite, and the safe one under the #6544 ruling that an identifier +mistaken for a column is worse than no answer. + +**Why `minor` rather than `patch`:** this refuses writes that previously +succeeded, and the blast radius was measured rather than assumed. 57 in-repo +production/metadata declaration sites carry a `unique` `indexes[]` entry — +`sys_user`, `sys_session`, `sys_setting`, `sys_metadata`, `sys_member`, +`sys_team_member` and most of the identity surface among them — so any stack +served by `InMemoryDriver` newly enforces constraints the SQL family already +enforced. Every one of those refusals is a write SQL would have refused too, and +existing rows are never retroactively refused (a declaration arriving over +`initialData` is recorded, not applied backwards), but a dev or demo stack that +relied on the store accepting a duplicate will now see a 409. diff --git a/packages/drivers/driver-memory/src/index.ts b/packages/drivers/driver-memory/src/index.ts index 4bd7644000..644bae66da 100644 --- a/packages/drivers/driver-memory/src/index.ts +++ b/packages/drivers/driver-memory/src/index.ts @@ -22,19 +22,30 @@ 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. +// [#13197, #13239] Uniqueness on BOTH declaration surfaces — field-level +// `unique` and object-level declared `indexes[]` — with 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, + declaredIndexViolationError, + isDeclaredIndexConstraint, tenantFieldOf, + uniqueConstraintsFromDeclaredIndexes, uniqueConstraintsFromFields, uniqueKeyOf, uniqueViolationError, } from './memory-unique-constraint.js'; -export type { MemoryUniqueConstraint, UniqueAwareSchema } from './memory-unique-constraint.js'; +export type { + DeclaredIndexInput, + MemoryDeclaredIndexConstraint, + MemoryUniqueConstraint, + MemoryUniqueEnforcement, + UniqueAwareSchema, +} from './memory-unique-constraint.js'; export default { id: 'com.objectstack.driver.memory', diff --git a/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts b/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts new file mode 100644 index 0000000000..1b48bd0726 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts @@ -0,0 +1,576 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13239] `driver-memory` enforces OBJECT-LEVEL declared `indexes[]` carrying + * `unique` — a colliding write is REFUSED, not landed. + * + * #13197 closed the FIELD surface. This is the other declaration surface + * `driver-sql` materializes uniqueness from, and it was + * declared-and-not-enforced here in exactly the same ADR-0078 / + * Prime-Directive-#10 shape: an object declaring + * `indexes: [{ fields: ['account_id', 'code'], unique: 'organization' }]` got a + * real composite UNIQUE on the SQL family and NOTHING at all in memory — the + * colliding write landed and a read returned both rows. + * + * ## ⚠️ This is NOT a smaller copy of #13197 — bare `true` inverts + * + * The two surfaces share a vocabulary and disagree about its POSITIONAL member: + * + * - FIELD level, bare `unique: true` = `'organization'` (per organization). + * - DECLARED INDEX, bare `unique: true` = `'global'` (the listed columns + * VERBATIM, no organization key part). + * + * That is the #4986 trap, it is deliberate (maintainer ruling 2026-08-13 on + * #8323, staged for retirement at protocol 18 by #5082), and `driver-sql` pins + * it in `sql-driver-declared-index-organization-respelling.test.ts`. So the + * scope judgment here is read off `normalizeDeclaredIndex`, NOT off + * `uniqueIndexesFromFields` — `driver-memory` must not depend on `driver-sql`, + * so the arms are reproduced and pinned here, and `the two surfaces disagree` + * below holds both readings side by side on ONE object. + * + * ## Two things every refusal test in this package must do (#13197's rule) + * + * 1. Assert the ENVELOPE — `code` AND `status` — never merely "it threw" + * (#6144). + * 2. Assert the store is UNCHANGED. "Refused" and "refused after writing the + * row" are different facts. + * + * ## The NULL rule is SQL's, MEASURED (not assumed) + * + * Run against SQLite while writing this file, over the two DDL shapes + * `syncDeclaredIndexes` actually emits: + * + * ``` + * UNIQUE (account_id, code) -- bare true / 'global' + * ('acme', NULL, 'X') then ('acme', NULL, 'X') -> BOTH ACCEPTED (NULL-DISTINCT) + * ('acme', 'A2', NULL) then ('acme', 'A2', NULL) -> BOTH ACCEPTED (NULL-DISTINCT) + * + * UNIQUE (COALESCE(organization_id,'__global__'), account_id, code) -- 'organization' + * (NULL, 'A1', 'X') then (NULL, 'A1', 'X') -> second REFUSED (the org part FOLDS) + * (NULL, 'A2', NULL) then (NULL, 'A2', NULL) -> BOTH ACCEPTED (NULL-DISTINCT wins) + * ``` + * + * So the composite rule is the field-level rule with a wider key, and NOT a new + * invention: a NULL in any LISTED key column exempts the row, while a NULL + * ORGANIZATION folds onto one bucket because its key part is an expression that + * is never NULL. Both halves are pinned below. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types'; +import { InMemoryDriver } from './memory-driver.js'; +import { + UNIQUE_VIOLATION_CODE, + UNIQUE_VIOLATION_STATUS, + uniqueConstraintsFromDeclaredIndexes, +} from './memory-unique-constraint.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +/** Run `fn`, requiring it to reject; hand back the rejection for inspection. */ +async function refusalOf(fn: () => Promise): Promise { + try { + await fn(); + } catch (e) { + return e as WireBearingError; + } + throw new Error('expected the driver to refuse this write, but it resolved'); +} + +/** The envelope assertion — `code` AND `status`, never just "it threw" (#6144). */ +function expectUniqueViolationEnvelope(err: WireBearingError, ...mentions: string[]) { + expect(err.code).toBe(UNIQUE_VIOLATION_CODE); + expect(err.status).toBe(UNIQUE_VIOLATION_STATUS); + expect(err.code).toBe('UNIQUE_VIOLATION'); + expect(err.status).toBe(409); + for (const m of mentions) expect(err.message).toContain(m); + // The wire identity is the SQL family's; a driver name in the sentence breaks + // the parity `memory-filter-refusal-envelope.test.ts` holds for the filters. + expect(err.message).not.toContain('[driver-memory]'); +} + +/** An object with a tenant column and one declared composite index. */ +const ledger = (unique: unknown) => ({ + name: 'ledger', + fields: { + id: { type: 'text' }, + organization_id: { type: 'text' }, + account_id: { type: 'text' }, + code: { type: 'text' }, + }, + indexes: [{ fields: ['account_id', 'code'], unique }], +}); + +/* ====================================================================== * + * 1. The defect this card closes + * ==================================================================== */ + +describe('[#13239] a declared composite unique is enforced — the colliding write is refused, not landed', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + }); + + it('the second row on a taken (account_id, code) pair is refused, and nothing is written', async () => { + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + + const err = await refusalOf(() => + driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'X' }), + ); + + expectUniqueViolationEnvelope(err, 'account_id', 'code', 'organization_id'); + // The half that makes it a fix rather than a louder bug: ONE row, not two. + const rows = await driver.find('ledger', { fields: ['id'] }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('1'); + }); + + it('the refusal carries the same envelope the SQL family answers a conflict with', async () => { + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + const err = await refusalOf(() => + driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'X' }), + ); + expect(isUniqueViolationError(err)).toBe(true); + }); + + it('a DIFFERENT pair still lands — the constraint is not a blanket refusal', async () => { + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + await driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'Y' }); + await driver.create('ledger', { id: '3', organization_id: 'acme', account_id: 'A2', code: 'X' }); + expect(await driver.count('ledger')).toBe(3); + }); +}); + +/* ====================================================================== * + * 2. The #4986 trap — bare `true` means the OPPOSITE of the field surface + * ==================================================================== */ + +describe('[#13239] the two `unique` surfaces disagree about bare `true`, and this driver reproduces the disagreement', () => { + it("a DECLARED index's bare `true` is `'global'` — the listed columns VERBATIM, no organization key part", async () => { + expect(uniqueConstraintsFromDeclaredIndexes(ledger(true))).toEqual([ + { columns: ['account_id', 'code'], nullSafeColumns: [] }, + ]); + + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger(true)); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + + // A DIFFERENT organization collides: that is what "global" means, and it is + // what #8323 measured in the field before the platform's own objects were + // respelled. + const err = await refusalOf(() => + driver.create('ledger', { id: '2', organization_id: 'globex', account_id: 'A1', code: 'X' }), + ); + expectUniqueViolationEnvelope(err, 'account_id', 'code'); + expect(err.message).not.toContain('within the same'); + expect(await driver.count('ledger')).toBe(1); + }); + + it("`unique: 'global'` is the same materialization — bare `true` is its positional spelling", () => { + expect(uniqueConstraintsFromDeclaredIndexes(ledger(true))).toEqual( + uniqueConstraintsFromDeclaredIndexes(ledger('global')), + ); + }); + + it("only the explicit `'organization'` prepends the NULL-safe organization key part", async () => { + expect(uniqueConstraintsFromDeclaredIndexes(ledger('organization'))).toEqual([ + { columns: ['organization_id', 'account_id', 'code'], nullSafeColumns: ['organization_id'] }, + ]); + + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + // The same pair in ANOTHER organization now lands — the cross-organization + // existence oracle #8323 removed. + await driver.create('ledger', { id: '2', organization_id: 'globex', account_id: 'A1', code: 'X' }); + expect(await driver.count('ledger')).toBe(2); + }); + + it('FIELD-level bare `true` and DECLARED bare `true` disagree ON ONE OBJECT — the divergence itself', async () => { + // Same two characters, opposite meaning, one level up. An author who reads + // the field-level rule and writes the table-level declaration gets the + // global index silently; a driver that reads the field-level rule here + // would silently make it per-organization instead. Both halves live on this + // one schema so a future edit cannot move one without moving the other. + const both = { + name: 'thing', + fields: { + id: { type: 'text' }, + organization_id: { type: 'text' }, + email: { type: 'text', unique: true }, + code: { type: 'text' }, + }, + indexes: [{ fields: ['code'], unique: true }], + }; + const driver = new InMemoryDriver(); + await driver.syncSchema('thing', both); + await driver.create('thing', { id: '1', organization_id: 'acme', email: 'a@b.com', code: 'C1' }); + + // FIELD `unique: true` = per organization -> another organization may hold + // the same email. + await driver.create('thing', { id: '2', organization_id: 'globex', email: 'a@b.com', code: 'C2' }); + expect(await driver.count('thing')).toBe(2); + + // DECLARED `unique: true` = global -> another organization may NOT hold the + // same code. + const err = await refusalOf(() => + driver.create('thing', { id: '3', organization_id: 'globex', code: 'C1' }), + ); + expectUniqueViolationEnvelope(err, 'code'); + expect(err.message).not.toContain('within the same'); + expect(await driver.count('thing')).toBe(2); + }); +}); + +/* ====================================================================== * + * 3. `normalizeDeclaredIndex`'s remaining arms, reproduced and pinned + * ==================================================================== */ + +describe("[#13239] the arms are `normalizeDeclaredIndex`'s, reproduced without importing driver-sql", () => { + it('a listed column that IS the tenant column is not prepended again — its own key part becomes NULL-safe', () => { + // The hand-written S6 spelling, opted in. Order is preserved: the author's + // column stays where they put it. + const schema = { + name: 'ledger', + fields: { id: {}, organization_id: {}, code: {} }, + indexes: [{ fields: ['code', 'organization_id'], unique: 'organization' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['code', 'organization_id'], nullSafeColumns: ['organization_id'] }, + ]); + }); + + it("with NO tenant column, `'organization'` degrades to the listed columns alone", () => { + const schema = { + name: 'doc', + fields: { id: {}, a: {}, b: {} }, + indexes: [{ fields: ['a', 'b'], unique: 'organization' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['a', 'b'], nullSafeColumns: [] }, + ]); + }); + + it('an object that opts OUT of tenancy has no tenant column to prepend', () => { + const schema = { + name: 'doc', + fields: { id: {}, organization_id: {}, a: {} }, + tenancy: { enabled: false }, + indexes: [{ fields: ['a'], unique: 'organization' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['a'], nullSafeColumns: [] }, + ]); + }); + + it('a declared `tenancy.tenantField` is what gets prepended, when it exists on the object', () => { + const schema = { + name: 'doc', + fields: { id: {}, org: {}, organization_id: {}, a: {} }, + tenancy: { tenantField: 'org' }, + indexes: [{ fields: ['a'], unique: 'organization' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['org', 'a'], nullSafeColumns: ['org'] }, + ]); + }); + + it('`unique: false` / absent declares a plain index — not a constraint', () => { + expect(uniqueConstraintsFromDeclaredIndexes(ledger(false))).toEqual([]); + expect(uniqueConstraintsFromDeclaredIndexes(ledger(undefined))).toEqual([]); + expect( + uniqueConstraintsFromDeclaredIndexes({ + fields: { a: {} }, + indexes: [{ fields: ['a'] }], + }), + ).toEqual([]); + }); + + it('an entry with no usable `fields` is unusable — `normalizeDeclaredIndex` answers null there', () => { + const unusable = { + name: 'x', + fields: { a: {} }, + indexes: [ + { fields: [], unique: true }, + { unique: true }, + { fields: ['', ' '], unique: true }, + ], + }; + // `' '` is a non-empty string and survives the SQL-side filter too — the + // filter is `typeof f === 'string' && f.length > 0`, nothing more. + expect(uniqueConstraintsFromDeclaredIndexes(unusable)).toEqual([ + { columns: [' '], nullSafeColumns: [] }, + ]); + }); + + it('non-string entries are filtered out of `fields`, exactly as on the SQL side', () => { + const schema = { + name: 'x', + fields: { a: {}, b: {} }, + indexes: [{ fields: ['a', 42, null, 'b'], unique: 'global' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['a', 'b'], nullSafeColumns: [] }, + ]); + }); + + it('an object with no `indexes` at all declares nothing', () => { + expect(uniqueConstraintsFromDeclaredIndexes({ fields: { a: {} } })).toEqual([]); + expect(uniqueConstraintsFromDeclaredIndexes(undefined)).toEqual([]); + expect(uniqueConstraintsFromDeclaredIndexes({ fields: { a: {} }, indexes: null })).toEqual([]); + }); + + it('several declared indexes on one object each become their own constraint', () => { + const schema = { + name: 'x', + fields: { id: {}, organization_id: {}, a: {}, b: {}, c: {} }, + indexes: [ + { fields: ['a'], unique: true }, + { fields: ['b', 'c'], unique: 'organization' }, + { fields: ['c'] }, + ], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['a'], nullSafeColumns: [] }, + { columns: ['organization_id', 'b', 'c'], nullSafeColumns: ['organization_id'] }, + ]); + }); +}); + +/* ====================================================================== * + * 4. NULL handling — the measured SQL rule, both halves + * ==================================================================== */ + +describe('[#13239] NULL handling matches the composite index SQL actually builds', () => { + it('a NULL in a LISTED key column exempts the row — NULL-DISTINCT, as measured on SQLite', async () => { + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: null, code: 'X' }); + await driver.create('ledger', { id: '2', organization_id: 'acme', account_id: null, code: 'X' }); + await driver.create('ledger', { id: '3', organization_id: 'acme', account_id: 'A2' }); // `code` absent + await driver.create('ledger', { id: '4', organization_id: 'acme', account_id: 'A2' }); + expect(await driver.count('ledger')).toBe(4); + }); + + it('a NULL ORGANIZATION does NOT exempt — the D3 fold, reached without the `__global__` token', async () => { + // SQL folds NULL organizations with COALESCE onto a reserved literal because + // an index EXPRESSION needs a non-NULL one. A JS key holds `null` directly, + // so the same bucket is reached with no token at all. + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.create('ledger', { id: '1', account_id: 'A1', code: 'X' }); + const err = await refusalOf(() => driver.create('ledger', { id: '2', account_id: 'A1', code: 'X' })); + expectUniqueViolationEnvelope(err, 'account_id', 'code', 'organization_id'); + // …and a row that DOES carry an organization is untouched by that bucket. + await driver.create('ledger', { id: '3', organization_id: 'acme', account_id: 'A1', code: 'X' }); + expect(await driver.count('ledger')).toBe(2); + }); + + it('an index whose ONLY key part is the NULL-safe organization is "one row per organization"', async () => { + // `normalizeDeclaredIndex` has no "a unique ON the tenant column stays + // single-column" guard — that guard is the FIELD surface's. Here the listed + // organization column simply becomes the NULL-safe key part, so NULL-org + // rows share one bucket and are unique among themselves. + const schema = { + name: 'settings', + fields: { id: {}, organization_id: {} }, + indexes: [{ fields: ['organization_id'], unique: 'organization' }], + }; + expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ + { columns: ['organization_id'], nullSafeColumns: ['organization_id'] }, + ]); + + const driver = new InMemoryDriver(); + await driver.syncSchema('settings', schema); + await driver.create('settings', { id: '1', organization_id: 'acme' }); + await driver.create('settings', { id: '2' }); // NULL organization: its own bucket + const dupOrg = await refusalOf(() => driver.create('settings', { id: '3', organization_id: 'acme' })); + expectUniqueViolationEnvelope(dupOrg, 'organization_id'); + const dupNull = await refusalOf(() => driver.create('settings', { id: '4' })); + expectUniqueViolationEnvelope(dupNull, 'organization_id'); + expect(await driver.count('settings')).toBe(2); + }); +}); + +/* ====================================================================== * + * 5. `uniqueViolationColumn` — a composite has no single offending column + * ==================================================================== */ + +describe('[#13239] the refusal names no single column, because a composite has none (#6544)', () => { + it('a composite refusal answers `undefined` — never the first column, never an index name', async () => { + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + const err = await refusalOf(() => + driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'X' }), + ); + // Matches what SQLite answers for the same index — measured: the plain + // composite prints `UNIQUE constraint failed: t.account_id, t.code` (two + // targets -> undefined) and the NULL-safe form prints + // `UNIQUE constraint failed: index '…'` (an index name -> undefined). + expect(isUniqueViolationError(err)).toBe(true); + expect(uniqueViolationColumn(err)).toBeUndefined(); + }); + + it('a SINGLE-column declared index answers `undefined` too — this driver never names a column', async () => { + // Not an oversight and not a regression: #13197's field-level refusal + // already answers `undefined` (asserted here as the baseline), because this + // driver states the conflict in its own words rather than mimicking a + // dialect's grammar. `undefined` is the safe answer under the #6544 ruling + // — an identifier mistaken for a column is worse than no answer — and the + // engine's autonumber resync treats it as attributable by design. + const driver = new InMemoryDriver(); + await driver.syncSchema('doc', { + name: 'doc', + fields: { id: {}, token: {}, doc_no: { type: 'autonumber', unique: true } }, + indexes: [{ fields: ['token'], unique: true }], + }); + await driver.create('doc', { id: '1', token: 'T1', doc_no: 'D-1' }); + + const declared = await refusalOf(() => driver.create('doc', { id: '2', token: 'T1', doc_no: 'D-2' })); + expect(uniqueViolationColumn(declared)).toBeUndefined(); + + const fieldLevel = await refusalOf(() => driver.create('doc', { id: '3', token: 'T3', doc_no: 'D-1' })); + expect(uniqueViolationColumn(fieldLevel)).toBeUndefined(); + }); +}); + +/* ====================================================================== * + * 6. Every write path goes through the ONE seam + * ==================================================================== */ + +describe('[#13239] every write path is checked, not just create', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + await driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'Y' }); + }); + + it('update onto a taken pair is refused and the row keeps its old values', async () => { + const err = await refusalOf(() => driver.update('ledger', '2', { code: 'X' })); + expectUniqueViolationEnvelope(err, 'account_id', 'code'); + expect((await driver.findOne('ledger', { fields: ['code'], where: { id: '2' } }))!.code).toBe('Y'); + }); + + it('a row does not collide with ITSELF', async () => { + const updated = await driver.update('ledger', '2', { code: 'Y' }); + expect(updated!.code).toBe('Y'); + }); + + it('bulkCreate catches a duplicate WITHIN the batch, not only against stored rows', async () => { + const err = await refusalOf(() => + driver.bulkCreate('ledger', [ + { id: 'a', organization_id: 'acme', account_id: 'A9', code: 'Z' }, + { id: 'b', organization_id: 'acme', account_id: 'A9', code: 'Z' }, + ]), + ); + expectUniqueViolationEnvelope(err, 'account_id', 'code'); + // The DUPLICATE did not land — that is the constraint, and it is what this + // asserts. ⚠️ `bulkCreate` is `Promise.all(map(create))`, so rows accepted + // BEFORE the refusal stay: the batch is not atomic. That is older than + // either uniqueness card (any mid-batch `create` failure has always left a + // partial batch) and identical on the field surface, so it is recorded here + // as a known boundary rather than silently re-baselined — never "the + // constraint half-applied". + const colliding = await driver.find('ledger', { + fields: ['id'], + where: { account_id: 'A9', code: 'Z' }, + }); + expect(colliding).toHaveLength(1); + expect(await driver.count('ledger')).toBe(3); + }); + + it('updateMany refuses BEFORE mutating anything — no half-applied batch', async () => { + const err = await refusalOf(() => driver.updateMany('ledger', { where: {} }, { code: 'Z' })); + expectUniqueViolationEnvelope(err, 'account_id', 'code'); + const rows = await driver.find('ledger', { + fields: ['id', 'code'], + orderBy: [{ field: 'id', order: 'asc' }], + }); + expect(rows.map((r: any) => r.code)).toEqual(['X', 'Y']); + }); +}); + +/* ====================================================================== * + * 7. The boundaries, stated so they are not read as gaps + * ==================================================================== */ + +describe('[#13239] what is NOT constrained', () => { + it('an object that never passed through syncSchema is unconstrained', async () => { + const driver = new InMemoryDriver(); + await driver.create('undeclared', { id: '1', account_id: 'A1', code: 'X' }); + await driver.create('undeclared', { id: '2', account_id: 'A1', code: 'X' }); + expect(await driver.count('undeclared')).toBe(2); + }); + + it('dropTable forgets the declaration — a constraint must not outlive its table', async () => { + const driver = new InMemoryDriver(); + await driver.syncSchema('ledger', ledger('organization')); + await driver.dropTable('ledger'); + await driver.create('ledger', { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }); + await driver.create('ledger', { id: '2', organization_id: 'acme', account_id: 'A1', code: 'X' }); + expect(await driver.count('ledger')).toBe(2); + }); + + it('rows already present when the schema arrives are not retroactively refused', async () => { + const driver = new InMemoryDriver({ + initialData: { + ledger: [ + { id: '1', organization_id: 'acme', account_id: 'A1', code: 'X' }, + { id: '2', organization_id: 'acme', account_id: 'A1', code: 'X' }, + ], + }, + }); + await driver.connect(); + await expect(driver.syncSchema('ledger', ledger('organization'))).resolves.toBeUndefined(); + expect(await driver.count('ledger')).toBe(2); + // From here on every WRITE is checked. + const err = await refusalOf(() => + driver.create('ledger', { id: '3', organization_id: 'acme', account_id: 'A1', code: 'X' }), + ); + expectUniqueViolationEnvelope(err, 'account_id', 'code'); + }); + + it('an index over a column the object never declares constrains nothing — as SQL skips an unmaterialized one', async () => { + // `syncDeclaredIndexes` skips a declared index whose columns are not + // materialized. Here the degradation is automatic and needs no filter: the + // column is `undefined` on every row, and a NULL key part exempts the row. + const driver = new InMemoryDriver(); + await driver.syncSchema('ghost', { + name: 'ghost', + fields: { id: {}, a: {} }, + indexes: [{ fields: ['a', 'never_declared'], unique: 'global' }], + }); + await driver.create('ghost', { id: '1', a: 'same' }); + await driver.create('ghost', { id: '2', a: 'same' }); + expect(await driver.count('ghost')).toBe(2); + }); + + it('field-level and declared-index constraints coexist — both are enforced on one object', async () => { + const driver = new InMemoryDriver(); + await driver.syncSchema('both', { + name: 'both', + fields: { id: {}, organization_id: {}, email: { unique: 'global' }, a: {}, b: {} }, + indexes: [{ fields: ['a', 'b'], unique: 'global' }], + }); + await driver.create('both', { id: '1', email: 'x@y.com', a: '1', b: '2' }); + expectUniqueViolationEnvelope( + await refusalOf(() => driver.create('both', { id: '2', email: 'x@y.com', a: '9', b: '9' })), + 'email', + ); + expectUniqueViolationEnvelope( + await refusalOf(() => driver.create('both', { id: '3', email: 'z@y.com', a: '1', b: '2' })), + 'a', + 'b', + ); + expect(await driver.count('both')).toBe(1); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 65ae6cec73..2cc2341659 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -42,8 +42,9 @@ import { // module docblock. import { assertNoUniqueViolation, + uniqueConstraintsFromDeclaredIndexes, uniqueConstraintsFromFields, - type MemoryUniqueConstraint, + type MemoryUniqueEnforcement, } from './memory-unique-constraint.js'; /** @@ -187,10 +188,14 @@ interface MemoryTransaction { * * ## What this driver enforces, and what it still does not * - * Since #13197 it enforces **field-level `unique`**, with `driver-sql`'s + * Since #13197 it enforces **field-level `unique`**, and since #13239 + * **object-level declared `indexes[]` entries carrying `unique`** — both + * declaration surfaces `driver-sql` materializes uniqueness from, with its * 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 + * arm-for-arm tables, including the #4986 trap: bare `unique: true` means + * `'organization'` at field level and `'global'` on a declared index). 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 @@ -201,9 +206,10 @@ interface MemoryTransaction { * 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. + * `id` (unless `id` itself declares `unique`, or a declared index lists it) + * where a SQL driver raises a constraint violation, and a read returns both. + * A declared index WITHOUT `unique` is an access path and buys nothing here: + * this store is a linear scan. * * 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 @@ -237,13 +243,15 @@ export class InMemoryDriver implements IDataDriver { private temporalFields: Map> = 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. + * [#13197, #13239] Declared unique constraints per object, populated by + * {@link syncSchema} — both declaration surfaces in one list (field-level + * `unique` and object-level `indexes[]` entries carrying `unique`), with 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 = new Map(); + private uniqueConstraints: Map = new Map(); private transactions: Map = new Map(); private persistenceAdapter: PersistenceAdapterInterface | null = null; @@ -1488,15 +1496,20 @@ 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 + // [#13197, #13239] Learn the object's unique constraints in the same pass — + // BOTH declaration surfaces `driver-sql` materializes uniqueness from: + // field-level `unique` and object-level `indexes[]` entries carrying + // `unique`. 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)); + this.uniqueConstraints.set(object, [ + ...uniqueConstraintsFromFields(schema), + ...uniqueConstraintsFromDeclaredIndexes(schema), + ]); if (kinds.size > 0) { const table = this.db[object]; for (let i = 0; i < table.length; i++) { @@ -1687,8 +1700,8 @@ export class InMemoryDriver implements IDataDriver { } /** - * [#13197] Refuse `candidate` if it violates one of `object`'s declared - * field-level unique constraints. + * [#13197, #13239] Refuse `candidate` if it violates one of `object`'s + * declared unique constraints — field-level or object-level declared index. * * The ONE seam every write path goes through, so create, update and * update-many cannot disagree about what `unique` means — the same diff --git a/packages/drivers/driver-memory/src/memory-unique-constraint.ts b/packages/drivers/driver-memory/src/memory-unique-constraint.ts index 123b785674..2af468e651 100644 --- a/packages/drivers/driver-memory/src/memory-unique-constraint.ts +++ b/packages/drivers/driver-memory/src/memory-unique-constraint.ts @@ -1,8 +1,15 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Field-level uniqueness for the in-memory driver — the constraint this driver - * enforced NOWHERE until now (#13197). + * Uniqueness for the in-memory driver — the constraint this driver enforced + * NOWHERE until #13197, on both declaration surfaces since #13239. + * + * `driver-sql` materializes uniqueness from TWO surfaces and this module + * reproduces both: **field-level `unique`** (#13197, `uniqueIndexesFromFields`) + * and **object-level declared `indexes[]` entries carrying `unique`** (#13239, + * `normalizeDeclaredIndex`). They share one key model, one NULL rule and one + * refusal envelope, and they DISAGREE about what bare `true` means — see + * "⚠️ bare `true` inverts between the two surfaces" below. * * ## The defect this closes * @@ -38,11 +45,65 @@ * | absent / `false` | any | no index | not a constraint | * * ⚠️ **`unique: true` is the POSITIONAL spelling of `'organization'`, not of - * `'global'`** — at FIELD level. (On a declared `indexes[]` entry bare `true` - * means `'global'`; that surface is not this module's, see "Deliberately out of - * scope".) Getting that backwards makes two organizations' identical record - * numbers collide on a constraint neither can see, which is the exact - * cross-tenant existence oracle ADR-0120 D1 exists to remove. + * `'global'`** — at FIELD level. Getting that backwards makes two + * organizations' identical record numbers collide on a constraint neither can + * see, which is the exact cross-tenant existence oracle ADR-0120 D1 exists to + * remove. + * + * ## The DECLARED-INDEX surface (#13239) — `normalizeDeclaredIndex`'s arms + * + * Read off `driver-sql`'s `normalizeDeclaredIndex` (same file, same ADR) and + * reproduced arm for arm by {@link uniqueConstraintsFromDeclaredIndexes}. The + * arms are the SQL function's, not a simplification of them: + * + * | declared entry | tenant column | driver-sql index | here | + * |:---|:---|:---|:---| + * | `fields` empty / absent / no non-empty strings | any | `null` — unusable | no constraint | + * | `unique` absent / `false` | any | a PLAIN index | not a constraint | + * | `unique: true` / `'global'` | any | the listed columns VERBATIM | `columns` = listed, no NULL-safe part | + * | `unique: 'organization'`, tenant column NOT listed | present | `(COALESCE(tenant,'__global__'), …listed)` | `columns` = `[tenant, …listed]`, `nullSafeColumns` = `[tenant]` | + * | `unique: 'organization'`, tenant column ALREADY listed | present | listed verbatim, the tenant's own key part goes NULL-safe | `columns` = listed (order kept), `nullSafeColumns` = `[tenant]` | + * | `unique: 'organization'` | absent | degrades to the listed columns alone | `columns` = listed, no NULL-safe part | + * + * ⚠️ **bare `true` inverts between the two surfaces.** On a declared index it + * is the positional spelling of **`'global'`** — the listed columns verbatim, + * no organization key part — while at field level it means `'organization'`. + * That is the #4986 trap. It is DELIBERATE (maintainer ruling 2026-08-13 on + * #8323: routing the declared-index branch through the field-level predicate + * was rejected, because it would silently reinterpret every deployed declared + * `unique: true` as organization-scoped), it is staged for retirement at + * protocol 18 by #5082, and `driver-sql` pins both halves in + * `sql-driver-declared-index-organization-respelling.test.ts`. So this surface + * reads the STRICT `unique === 'organization'` test, exactly as + * `normalizeDeclaredIndex` does — never the field surface's + * `isUniqueDeclared && !isGlobalUnique`. + * + * Note the arm the FIELD surface has and this one does NOT: a field-level + * `unique` ON the tenant column stays single-column, because `(org_id, org_id)` + * is not a constraint. `normalizeDeclaredIndex` has no such guard — a declared + * `{ fields: ['organization_id'], unique: 'organization' }` becomes the + * single NULL-safe key part, i.e. "one row per organization". Reproduced as + * written, not as the field surface reads. + * + * ### Two arms of the SQL function that are deliberately NOT reproduced + * + * - **The index NAME.** `normalizeDeclaredIndex` resolves `idx.name` or + * generates one, hash-truncated to a dialect's 63/64-char identifier budget. + * A JS `Map` has no identifier budget, and #6544's ruling (maintainer, + * 2026-08-08) is that an index name must never be presented where a column + * is expected — so the refusal here names the COLUMNS and this module keeps + * no name at all. Reproducing half of `buildIndexName` would be a second + * answer to a question this package never asks. + * - **Pre-resolved `nullSafeColumns` on the input.** That is a driver-side + * extra for the drift-op apply path, which re-feeds already-normalized + * shapes. `IndexSchema` is a `strictObject` over `name` / `fields` / + * `unique` (plus tombstones), so the key cannot reach a driver from a + * declaration and there is no declaration surface here to honour it on. + * - **The unmaterialized-column skip.** `syncDeclaredIndexes` skips an index + * whose columns are not physical. Here that degradation is automatic and + * needs no filter: an undeclared column is `undefined` on every row, and a + * NULL key part exempts the row (below), so such an index constrains + * nothing. The field surface has the same exposure and the same answer. * * ### Why the NULL-organization fold needs no `'__global__'` token here * @@ -64,29 +125,75 @@ * here — a case the reserved-token guard at the organization-creation seam * makes unconstructible. * - * ### NULL values stay NULL-DISTINCT, deliberately + * ### NULL values stay NULL-DISTINCT, deliberately — on keys of any width * * A row whose unique FIELD is `null`/absent is exempt, exactly as under SQL * `UNIQUE`. Folding those together instead would refuse the second row of every * table with an optional unique column — a refusal `driver-sql` does not issue, * i.e. a fresh divergence introduced by the fix for a divergence. * - * ## Deliberately out of scope (#13197's dispatch, and stated so it is not read as done) + * A COMPOSITE key is the same rule with a wider key, and it was MEASURED + * against SQLite over the two DDL shapes `syncDeclaredIndexes` actually emits + * rather than assumed (#13239): + * + * ``` + * UNIQUE (account_id, code) -- bare true / 'global' + * ('acme', NULL, 'X') twice -> BOTH ACCEPTED (NULL-DISTINCT) + * ('acme', 'A2', NULL) twice -> BOTH ACCEPTED (NULL-DISTINCT) + * + * UNIQUE (COALESCE(organization_id,'__global__'), account_id, code) -- 'organization' + * (NULL, 'A1', 'X') twice -> second REFUSED (the organization part FOLDS) + * (NULL, 'A2', NULL) twice -> BOTH ACCEPTED (NULL-DISTINCT still wins) + * ``` + * + * So there is exactly ONE rule, and {@link uniqueKeyOf} is the one place it + * lives: **a NULL in any key part EXEMPTS the row, except in a NULL-SAFE part, + * where it folds onto the shared `null` bucket.** The organization key part is + * the only NULL-safe one, on either surface, because on the SQL side it is the + * only one materialized as an expression that can never be NULL. + * + * ## Which column conflicted: this driver answers `undefined`, always + * + * `@objectstack/types`' `uniqueViolationColumn` reads a dialect's own grammar, + * and the refusals here are stated in this module's words, so nothing is + * extractable from them — the answer is `undefined` for a field-level refusal + * (since #13197) and for a declared-index one alike. That is the SAFE answer + * under #6544's ruling (maintainer, 2026-08-08): an identifier mistaken for a + * column is worse than no answer. For a COMPOSITE it is also the answer + * `driver-sql` gives — measured: SQLite prints + * `UNIQUE constraint failed: t.account_id, t.code` (two targets, so + * `soleColumn` refuses) for the plain form and + * `UNIQUE constraint failed: index '…'` (an index name, refused at the gate) + * for the NULL-safe form. ⛔ Do not shape these sentences into a dialect's + * grammar to make the extractor bite: naming the first column of a composite is + * the wrong-answer class that export exists to avoid, and the engine's + * autonumber resync already treats an unnamed column as attributable by design. + * + * ## Deliberately out of scope (stated so it is not read as done) * - * - **Declared `indexes[]`** — object-level composite uniques - * (`normalizeDeclaredIndex`) are NOT enforced here. Same defect class, wider - * surface, its own card. * - **Primary keys.** A duplicate `id` still lands unless `id` itself declares - * `unique`. The driver docstring says so. + * `unique`, or a declared index lists it. The driver docstring says so. * - **Row-level tenant isolation.** This scopes a uniqueness KEY the way * ADR-0120 does; it does not make reads tenant-filtered. This driver still * refuses to boot multi-tenant (`memory-tenancy-guard.ts`, #6915) and that - * guard is untouched — which is also why the scope arm above is, in + * guard is untouched — which is also why the scope arms above are, in * practice, reached only through an object carrying an `organization_id` * column WITHOUT an explicit `tenancy` block. + * - **Non-unique declared indexes.** An `indexes[]` entry without `unique` is + * an ACCESS PATH, and this store is a linear scan: there is nothing to + * build and nothing to enforce. + * + * ⚠️ This list no longer contains declared `indexes[]` — #13239 moved that + * surface from "out of scope" to enforced, and the sentence that said otherwise + * was removed rather than left standing. */ -import { isGlobalUnique, isUniqueDeclared, isTenancyDisabled } from '@objectstack/spec/data'; +import { + isGlobalUnique, + isUniqueDeclared, + isOrganizationUnique, + isTenancyDisabled, +} from '@objectstack/spec/data'; /** * The wire identity of the refusal (ADR-0112). `UNIQUE_VIOLATION` is the @@ -116,10 +223,60 @@ export interface MemoryUniqueConstraint { readonly scopeField: string | null; } +/** + * [#13239] One OBJECT-LEVEL unique constraint, normalized from a declared + * `indexes[]` entry — a key of any width, resolved against the object's + * tenancy. + * + * The counterpart of {@link MemoryUniqueConstraint} for the other declaration + * surface, and deliberately a SECOND shape rather than a widening of the first: + * the field-level descriptor's `field`/`scopeField` pair is what + * `uniqueConstraintsFromFields` is pinned to answer, and a composite has no + * single `field`. Both shapes reduce to the same key parts in + * {@link uniqueKeyOf}, so there is still exactly one NULL rule and one bucket + * model — the second shape is a wider KEY, not a second seam. + */ +export interface MemoryDeclaredIndexConstraint { + /** + * The key columns, in `normalizeDeclaredIndex`'s order — the organization + * column first when it was prepended, otherwise the author's own order. + */ + readonly columns: readonly string[]; + /** + * The subset of {@link columns} whose NULL FOLDS onto one shared bucket + * instead of exempting the row (ADR-0120 D3). In practice the organization + * key part, and only on the `'organization'` spelling. + */ + readonly nullSafeColumns: readonly string[]; +} + +/** + * Either kind of unique constraint this module enforces. Every seam that + * carries constraints — the driver's per-object map, {@link uniqueKeyOf}, + * {@link assertNoUniqueViolation} — takes this, so create/update/update-many + * cannot disagree about what either surface means. + */ +export type MemoryUniqueEnforcement = MemoryUniqueConstraint | MemoryDeclaredIndexConstraint; + +/** Is this the object-level (declared-index) shape? */ +export function isDeclaredIndexConstraint( + constraint: MemoryUniqueEnforcement, +): constraint is MemoryDeclaredIndexConstraint { + return Array.isArray((constraint as MemoryDeclaredIndexConstraint).columns); +} + +/** One declared `indexes[]` entry, as this module reads it. */ +export interface DeclaredIndexInput { + fields?: unknown; + unique?: unknown; +} + /** The minimal schema shape this module reads. */ export interface UniqueAwareSchema { fields?: Record | null; tenancy?: { enabled?: boolean; tenantField?: string } | null; + /** [#13239] The object's declared `indexes[]`, if any. */ + indexes?: readonly DeclaredIndexInput[] | null; } /** @@ -170,6 +327,58 @@ export function uniqueConstraintsFromFields( return out; } +/** + * [#13239] The constraints an object's OBJECT-LEVEL declared `indexes[]` ask + * for — `normalizeDeclaredIndex`'s arms, reproduced. + * + * ⚠️ The scope test here is the STRICT `unique === 'organization'` + * ({@link isOrganizationUnique}), NOT the field surface's + * "declared and not global". On this surface bare `true` is the positional + * spelling of `'global'` and takes the listed columns VERBATIM — the #4986 + * trap, deliberate, and pinned on the SQL side by + * `sql-driver-declared-index-organization-respelling.test.ts`. Reading it the + * field surface's way would silently reinterpret every declared `unique: true` + * as organization-scoped, which is precisely what the #8323 maintainer ruling + * (2026-08-13) rejected. + * + * `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`. + */ +export function uniqueConstraintsFromDeclaredIndexes( + schema: UniqueAwareSchema | null | undefined, +): 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 + // empty entry is dropped, and an entry left with no columns is UNUSABLE + // (`normalizeDeclaredIndex` answers null there). + const listed = Array.isArray(idx?.fields) + ? idx.fields.filter((f: unknown): f is string => typeof f === 'string' && f.length > 0) + : []; + if (listed.length === 0) continue; + // Absent / `false` declares a PLAIN index — an access path, not a + // constraint. This store is a linear scan, so there is nothing to build. + if (!isUniqueDeclared(idx?.unique)) continue; + + if (isOrganizationUnique(idx?.unique) && tenantField) { + // A listed column that IS the tenant column is not prepended again — its + // own key part becomes the NULL-safe one instead (the hand-written S6 + // spelling, opted in), and the author's column order is kept. + const columns = listed.includes(tenantField) ? listed : [tenantField, ...listed]; + out.push({ columns, nullSafeColumns: [tenantField] }); + continue; + } + // `'global'`, bare `true`, or `'organization'` on an object with no tenant + // column: the listed columns, verbatim. + out.push({ columns: listed, nullSafeColumns: [] }); + } + return out; +} + /** * The bucket key a record occupies under one constraint, or `null` when the * record is EXEMPT because its unique field carries no value (SQL `UNIQUE` is @@ -183,12 +392,53 @@ export function uniqueConstraintsFromFields( */ export function uniqueKeyOf( record: Record, - constraint: MemoryUniqueConstraint, + constraint: MemoryUniqueEnforcement, ): string | null { - const value = record[constraint.field]; - if (value === null || value === undefined) return null; - const scope = constraint.scopeField === null ? null : (record[constraint.scopeField] ?? null); - return JSON.stringify([scope, value]); + const parts: unknown[] = []; + for (const part of keyPartsOf(constraint)) { + // A NULL-SAFE part folds: `null` IS its bucket, and the row stays + // constrained. That is the ADR-0120 D3 organization key part, and the only + // kind of part that behaves this way on either surface. + if (part.nullSafe) { + parts.push(part.column === null ? null : (record[part.column] ?? null)); + continue; + } + const value = record[part.column as string]; + // Any other NULL key part EXEMPTS the whole row, exactly as SQL `UNIQUE` is + // NULL-distinct — measured on both composite shapes (see the module note). + if (value === null || value === undefined) return null; + parts.push(value); + } + return JSON.stringify(parts); +} + +/** One key part: a column to read, or `null` for the constant platform scope. */ +interface UniqueKeyPart { + readonly column: string | null; + readonly nullSafe: boolean; +} + +/** + * The key parts of either constraint shape — the ONE place the two declaration + * surfaces converge, so they cannot grow two NULL rules or two bucket models. + * + * The field-level mapping is exact rather than merely equivalent: a field-level + * constraint is `[scope, value]` in that order, which is the encoding + * {@link uniqueKeyOf} produced before #13239 widened it, and the same order the + * SQL side puts the tenant column in (`(tenant, field)`, so the index also + * serves the `WHERE tenant = ?` prefix scans). + */ +function keyPartsOf(constraint: MemoryUniqueEnforcement): readonly UniqueKeyPart[] { + if (isDeclaredIndexConstraint(constraint)) { + return constraint.columns.map((column) => ({ + column, + nullSafe: constraint.nullSafeColumns.includes(column), + })); + } + return [ + { column: constraint.scopeField, nullSafe: true }, + { column: constraint.field, nullSafe: false }, + ]; } /** @@ -204,10 +454,53 @@ export function uniqueViolationError( value: unknown, ): Error & { code: string; status: number } { const scoped = constraint.scopeField ? ` within the same \`${constraint.scopeField}\`` : ''; - const err = new Error( + return conflictRefusal( `Unique constraint violated on \`${object}.${constraint.field}\`: a record with the value ` + `${JSON.stringify(value ?? null)} already exists${scoped}. No record was written.`, - ) as Error & { code: string; status: number }; + ); +} + +/** + * [#13239] The declared-index refusal — the SAME envelope, a different + * sentence, because the facts are different. + * + * A composite has no single offending column, so the message names the KEY + * COLUMNS and their values rather than one field, and the NULL-safe + * organization part (if any) is stated as the scope, mirroring the field-level + * sentence. It carries no index NAME, deliberately (see the module note on + * `uniqueViolationColumn`), and it is not shaped like any dialect's grammar, so + * `uniqueViolationColumn` answers `undefined` — the same answer `driver-sql` + * gives for a composite. + * + * Both factories stamp the envelope through {@link conflictRefusal}, so the two + * surfaces cannot drift into two `code`/`status` pairs. + */ +export function declaredIndexViolationError( + object: string, + constraint: MemoryDeclaredIndexConstraint, + record: Record, +): Error & { code: string; status: number } { + const scope = constraint.nullSafeColumns; + const keyed = constraint.columns.filter((c) => !scope.includes(c)); + // A key whose ONLY part is the NULL-safe organization ("one row per + // organization") has nothing left to list, so it reports its own column. + const reported = keyed.length > 0 ? keyed : constraint.columns; + const scoped = + keyed.length > 0 && scope.length > 0 + ? ` within the same \`${scope.join('\`, \`')}\`` + : ''; + const values = JSON.stringify( + Object.fromEntries(reported.map((c) => [c, record[c] ?? null])), + ); + return conflictRefusal( + `Unique constraint violated on \`${object}\` over (\`${reported.join('\`, \`')}\`): ` + + `a record with the values ${values} already exists${scoped}. No record was written.`, + ); +} + +/** The ADR-0112 envelope, stamped in ONE place for both declaration surfaces. */ +function conflictRefusal(message: string): Error & { code: string; status: number } { + const err = new Error(message) as Error & { code: string; status: number }; err.code = UNIQUE_VIOLATION_CODE; err.status = UNIQUE_VIOLATION_STATUS; return err; @@ -215,7 +508,8 @@ export function uniqueViolationError( /** * Refuse `candidate` if it collides with any row in `rows` under any of - * `constraints`. `exceptId` excludes the row being updated from its own check. + * `constraints` — of EITHER declaration surface (#13239). `exceptId` excludes + * the row being updated from its own check. * * A linear scan per constraint, deliberately: this driver's whole shape is * "plain arrays, no indexes", and an incremental index would be a second copy @@ -227,7 +521,7 @@ export function assertNoUniqueViolation( object: string, rows: readonly Record[], candidate: Record, - constraints: readonly MemoryUniqueConstraint[], + constraints: readonly MemoryUniqueEnforcement[], exceptId?: unknown, ): void { if (constraints.length === 0) return; @@ -237,7 +531,9 @@ export function assertNoUniqueViolation( for (const row of rows) { if (exceptId !== undefined && row.id === exceptId) continue; if (uniqueKeyOf(row, constraint) === key) { - throw uniqueViolationError(object, constraint, candidate[constraint.field]); + throw isDeclaredIndexConstraint(constraint) + ? declaredIndexViolationError(object, constraint, candidate) + : uniqueViolationError(object, constraint, candidate[constraint.field]); } } }