|
| 1 | +--- |
| 2 | +'@objectstack/driver-sql': minor |
| 3 | +'@objectstack/driver-turso': minor |
| 4 | +'@objectstack/spec': patch |
| 5 | +--- |
| 6 | + |
| 7 | +fix(driver-sql,driver-turso)!: an upsert whose conflict lands on another organization's row is refused with `UNIQUE_VIOLATION` and writes nothing, and an upsert never changes a row's organization (#21185) |
| 8 | + |
| 9 | +Clause-②: no (narrowing) |
| 10 | + |
| 11 | +<!-- adr-0087: registered driver-upsert-cross-organization-conflict-refused --> |
| 12 | + |
| 13 | +**BREAKING for `upsert` callers on the SQL drivers and on `TursoDriver`.** |
| 14 | + |
| 15 | +**What changed.** `upsert` resolves its conflict against the whole table, and the |
| 16 | +primary key and a `unique: 'global'` column are installation-wide, so the row a |
| 17 | +tenant-scoped call (`options.tenantId` on an object with a tenant column) collided |
| 18 | +with could belong to another organization. The merge wrote the payload onto that |
| 19 | +row, tenant column included. Now: |
| 20 | + |
| 21 | +- **A tenant-scoped upsert merges only into a row of the organization the row is |
| 22 | + written under**, for any conflict target, the primary key included. A conflict |
| 23 | + that lands on a row of another organization, or on a row with no organization, |
| 24 | + is refused with `code: 'UNIQUE_VIOLATION'`, `status: 409`, and nothing is |
| 25 | + written. That is the answer `create()` gets for the same collision: from the |
| 26 | + caller's organization the call is an insert, and that insert collides. The |
| 27 | + refusal names no organization and no value of the row it collided with. |
| 28 | +- **The tenant column is insert-only** (`insertOnlyUpsertColumns`), like `id`, |
| 29 | + `created_at` and `auto_number` columns: an upsert with no tenant context merges |
| 30 | + into the row it lands on and keeps that row's organization. |
| 31 | + |
| 32 | +Mechanism, per face: on SQLite, PostgreSQL and the remote (libSQL) face, the merge |
| 33 | +statement carries the organization predicate (`DO UPDATE … WHERE`), so another |
| 34 | +organization's row is never written. On MySQL, whose `ON DUPLICATE KEY UPDATE` |
| 35 | +takes no `WHERE`, the statement and a read of the landed row run in one |
| 36 | +transaction (a savepoint inside a caller's transaction), and the read's failure |
| 37 | +rolls the write back. The remote face now also stamps the caller's organization on |
| 38 | +the row it inserts, as the local faces do. |
| 39 | + |
| 40 | +## FROM → TO |
| 41 | + |
| 42 | +| you relied on | now | |
| 43 | +|:--|:--| |
| 44 | +| a tenant-scoped `upsert` merging into a row of another organization | refused with `UNIQUE_VIOLATION` / 409, nothing written | |
| 45 | +| an `upsert` payload's tenant value moving the row it merges into | the row keeps its organization; to move a row between organizations, use `update()` | |
| 46 | + |
| 47 | +**What is not affected.** A tenant-scoped upsert whose conflict lands on a row of |
| 48 | +its own organization merges as before, on every target. An upsert that inserts |
| 49 | +lands under the caller's organization, or under the organization the payload |
| 50 | +names explicitly, as before. |
0 commit comments