Skip to content

Commit ee7c024

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21227-create-reads-back-stored-row
2 parents 453a5da + 95e24b0 commit ee7c024

14 files changed

Lines changed: 2074 additions & 25 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/mcp': minor
3+
---
4+
5+
fix(mcp)!: the MCP stdio transport serves a stored metadata body only as its type's read projection, and refuses to evaluate it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) the MCP stdio transport's engine-only reader (its query, get and aggregate verbs and its record resource) now serves a stored metadata body through the stored-metadata-body family's one projection, and refuses a group, filter, sort or aggregate member on the stored body column — the posture the generic data door already takes. No authorable key, spelling, export or stored shape moves, and no stored row is read differently by any metadata consumer. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a served body projection or a refused query shape on this door (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what one door serves and accepts for the two stored-metadata tables (the stored row and its version history). On the MCP stdio transport, a read now carries the stored body as its type's read projection instead of the stored bytes, and a call that would evaluate the stored body is refused. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What changes.**
14+
15+
- **Reads.** The stdio bridge's query and get verbs, and the record resource, serve the stored body through the same projection the generic data door and every metadata read serve: stored credential material is withheld, a body that cannot be judged is omitted, and a credential-free body is served unchanged.
16+
- **Evaluate shapes.** A group, filter, sort or aggregate member on the stored body column is refused with `400 INVALID_FIELD` before the engine runs — the data door's code and envelope.
17+
18+
**What stays answerable.** Every scalar column of the two tables is still served, filtered, sorted, grouped and counted; only the stored body column is affected, and every other object is unchanged. A member's read of these tables is refused as before.

0 commit comments

Comments
 (0)