Skip to content

Commit b7f645a

Browse files
claude[bot]claude
andauthored
fix(driver-memory): enforce object-level declared indexes[] uniqueness (#13239) (#13341)
`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. Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63cf416 commit b7f645a

5 files changed

Lines changed: 1013 additions & 47 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory): enforce object-level declared `indexes[]` uniqueness, so a colliding composite write is refused instead of landing silently (#13239)
6+
7+
`driver-sql` materializes uniqueness from **two** declaration surfaces —
8+
field-level `unique` (`uniqueIndexesFromFields`) and object-level `indexes[]`
9+
entries carrying `unique` (`normalizeDeclaredIndex`). #13197 closed the first
10+
one here. The second was still **declared and not enforced**: an object
11+
declaring
12+
13+
```json
14+
{ "indexes": [{ "fields": ["account_id", "code"], "unique": "organization" }] }
15+
```
16+
17+
got a real composite UNIQUE on the SQL family and **nothing at all** in memory —
18+
the colliding write landed and a read returned both rows. That is the same
19+
ADR-0078 / Prime-Directive-#10 shape, one surface over.
20+
21+
**⚠️ Bare `true` means the OPPOSITE on the two surfaces, and this reproduces the
22+
disagreement rather than smoothing it.** At field level `unique: true` is the
23+
positional spelling of `'organization'`; on a declared index it is the
24+
positional spelling of `'global'` — the listed columns VERBATIM, no organization
25+
key part. That is the #4986 trap, it is deliberate (the #8323 maintainer ruling
26+
of 2026-08-13 rejected routing the declared-index branch through the field-level
27+
predicate, because it would silently reinterpret every deployed declared
28+
`unique: true` as organization-scoped), and it is staged for retirement at
29+
protocol 18 by #5082. So the scope test on this surface is the strict
30+
`unique === 'organization'`, exactly as `normalizeDeclaredIndex` does it, and
31+
`memory-declared-index-unique.test.ts` holds both readings side by side on one
32+
object so a future edit cannot move one without moving the other.
33+
34+
`normalizeDeclaredIndex`'s arms are reproduced — not imported: `driver-memory`
35+
must not depend on `driver-sql`, the same reason `computeTenantField` was
36+
reproduced for #13197.
37+
38+
- `unique: true` / `'global'` → the listed columns verbatim.
39+
- `unique: 'organization'` with a tenant column → the organization key part is
40+
prepended (and is NOT prepended twice when the author already listed it — its
41+
own key part goes NULL-safe instead, order preserved).
42+
- `unique: 'organization'` with no tenant column → degrades to the listed
43+
columns alone.
44+
- `unique` absent / `false`, or an entry with no usable `fields` → not a
45+
constraint.
46+
47+
**NULL handling was measured against SQLite, not assumed.** A NULL in any listed
48+
key column exempts the row (SQL `UNIQUE` is NULL-distinct), while a NULL
49+
ORGANIZATION folds onto one bucket, because ADR-0120 D3 materializes that key
50+
part as `COALESCE(organization_id, '__global__')` — an expression that is never
51+
NULL. Both halves hold here through one key model shared with the field surface,
52+
so there is exactly one NULL rule in the package.
53+
54+
**The refusal** is the field surface's envelope — `code: 'UNIQUE_VIOLATION'`,
55+
`status: 409`, no `[driver-memory]` prefix — stamped in one place for both
56+
surfaces. It names the key COLUMNS and carries no index name, so
57+
`uniqueViolationColumn` answers `undefined`: the same answer `driver-sql` gives
58+
for a composite, and the safe one under the #6544 ruling that an identifier
59+
mistaken for a column is worse than no answer.
60+
61+
**Why `minor` rather than `patch`:** this refuses writes that previously
62+
succeeded, and the blast radius was measured rather than assumed. 57 in-repo
63+
production/metadata declaration sites carry a `unique` `indexes[]` entry —
64+
`sys_user`, `sys_session`, `sys_setting`, `sys_metadata`, `sys_member`,
65+
`sys_team_member` and most of the identity surface among them — so any stack
66+
served by `InMemoryDriver` newly enforces constraints the SQL family already
67+
enforced. Every one of those refusals is a write SQL would have refused too, and
68+
existing rows are never retroactively refused (a declaration arriving over
69+
`initialData` is recorded, not applied backwards), but a dev or demo stack that
70+
relied on the store accepting a duplicate will now see a 409.

packages/drivers/driver-memory/src/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,30 @@ export {
2222
} from './memory-tenancy-guard.js';
2323
export type { TenancyAwareSchema } from './memory-tenancy-guard.js';
2424

25-
// [#13197] Field-level uniqueness — the refusal's wire identity and the
26-
// scoping helpers, exported so a consumer can assert the envelope (`code` AND
27-
// `status`, never merely "it threw") without string-matching the message.
25+
// [#13197, #13239] Uniqueness on BOTH declaration surfaces — field-level
26+
// `unique` and object-level declared `indexes[]` — with the refusal's wire
27+
// identity and the scoping helpers, exported so a consumer can assert the
28+
// envelope (`code` AND `status`, never merely "it threw") without
29+
// string-matching the message.
2830
export {
2931
UNIQUE_VIOLATION_CODE,
3032
UNIQUE_VIOLATION_STATUS,
3133
assertNoUniqueViolation,
34+
declaredIndexViolationError,
35+
isDeclaredIndexConstraint,
3236
tenantFieldOf,
37+
uniqueConstraintsFromDeclaredIndexes,
3338
uniqueConstraintsFromFields,
3439
uniqueKeyOf,
3540
uniqueViolationError,
3641
} from './memory-unique-constraint.js';
37-
export type { MemoryUniqueConstraint, UniqueAwareSchema } from './memory-unique-constraint.js';
42+
export type {
43+
DeclaredIndexInput,
44+
MemoryDeclaredIndexConstraint,
45+
MemoryUniqueConstraint,
46+
MemoryUniqueEnforcement,
47+
UniqueAwareSchema,
48+
} from './memory-unique-constraint.js';
3849

3950
export default {
4051
id: 'com.objectstack.driver.memory',

0 commit comments

Comments
 (0)