|
| 1 | +--- |
| 2 | +"@objectstack/driver-memory": minor |
| 3 | +"@objectstack/types": patch |
| 4 | +--- |
| 5 | + |
| 6 | +fix(driver-memory): enforce field-level `unique`, so a colliding write is refused instead of landing silently (#13197) |
| 7 | + |
| 8 | +`InMemoryDriver` enforced **no uniqueness at all**. `create` was a |
| 9 | +`table.push()` and `syncSchema` allocated an array, so a `unique: true` field |
| 10 | +was declared-and-not-enforced — the ADR-0078 / Prime-Directive-#10 shape the |
| 11 | +platform refuses everywhere else. A colliding write did not fail; it landed, and |
| 12 | +a read returned both rows. |
| 13 | + |
| 14 | +The motivating instance is the worst-shaped one. The engine's |
| 15 | +`createWithAutonumberResync` re-seeds the counter and re-issues a record number |
| 16 | +when the STORE rejects it as a duplicate, so on a store that rejected nothing |
| 17 | +the whole branch was unreachable: an autonumber allocated out of process |
| 18 | +duplicated an existing business identifier with **no error anywhere**. The |
| 19 | +remedy's location was already ruled in-tree at that method — «uniqueness |
| 20 | +enforcement in the driver, NOT a pre-issue existence probe here» — and this is |
| 21 | +that remedy. Nothing in the new code knows what an autonumber is; the defect was |
| 22 | +that the driver constrained nothing. |
| 23 | + |
| 24 | +**The refusal** carries the ADR-0112 envelope the SQL family answers a conflict |
| 25 | +with: `code: 'UNIQUE_VIOLATION'`, `status: 409`, no `[driver-memory]` prefix. So |
| 26 | +a suite that swaps this driver for SQLite sees one envelope — the parity |
| 27 | +`memory-filter-refusal-envelope.test.ts` already states for the filter family, |
| 28 | +now held for the constraint family. It is checked before the row is written, so |
| 29 | +a refused write leaves the table exactly as it found it, and `updateMany` |
| 30 | +prepares and checks the whole batch before mutating any of it. |
| 31 | + |
| 32 | +**The scoping is `driver-sql`'s, measured — not a simpler invention.** Read off |
| 33 | +`uniqueIndexesFromFields` (ADR-0120 D1/D3) and reproduced arm for arm: |
| 34 | +`unique: 'global'` is platform-wide; bare `true` and `'organization'` are |
| 35 | +per-organization (bare `true` is the POSITIONAL spelling of `'organization'` at |
| 36 | +FIELD level — reading it as `'global'` is the #4986 trap and would make two |
| 37 | +organizations' identical values collide on a constraint neither can see); both |
| 38 | +degrade to a single column when the object has no tenant column, and a `unique` |
| 39 | +declaration on the tenant column itself stays single-column. NULL values stay |
| 40 | +NULL-DISTINCT, exactly as under SQL `UNIQUE`. The D3 NULL-organization fold |
| 41 | +needs no `'__global__'` token here — that sentinel is a SQL-expression artefact, |
| 42 | +and a JavaScript key holds `null` directly. |
| 43 | + |
| 44 | +**Not** widened into: object-level declared `indexes[]` (composite uniques), |
| 45 | +primary keys, or row-level tenant isolation. This driver still refuses to boot |
| 46 | +multi-tenant (#6915) and that guard is untouched. |
| 47 | + |
| 48 | +`@objectstack/types` (`patch`): `isUniqueViolationError` now reads the |
| 49 | +platform's own registered `UNIQUE_VIOLATION` code on the `code` channel. Not |
| 50 | +cosmetic — a conflict that predicate does not recognise leaves the autonumber |
| 51 | +resync unable to re-seed, so the counter stays warm and every following insert |
| 52 | +collides too (#5495's PROBE3 storm), i.e. a silent duplicate traded for a |
| 53 | +non-converging insert loop. It is a tautology rather than a widened heuristic |
| 54 | +(the code already MEANS this condition), and no existing in-repo producer's |
| 55 | +classification changes: `@objectstack/rest`'s own response body is the only |
| 56 | +other site carrying that string, and it is downstream of the predicate. |
| 57 | + |
| 58 | +**Grade.** `minor` for the driver, not `patch`: a write that previously |
| 59 | +succeeded is now refused (`409`), which is an accept-set narrowing under the |
| 60 | +repo's launch-window convention for breaking changes, and the package also gains |
| 61 | +public exports (`UNIQUE_VIOLATION_CODE`, `uniqueConstraintsFromFields`, |
| 62 | +`tenantFieldOf`, `uniqueKeyOf`, `assertNoUniqueViolation`, |
| 63 | +`uniqueViolationError`). `patch` for `@objectstack/types`: no API added or |
| 64 | +removed and no in-repo verdict changes — the limb exists to serve the new |
| 65 | +producer. Fixtures that relied on duplicates landing on a declared-unique field |
| 66 | +must stop declaring `unique`, or stop writing the duplicate; the repo's own |
| 67 | +suites were measured and none did. |
0 commit comments