Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
75f6f05
docs(nested-mutations): project spec, plan and first slice plan
SevInf Oct 6, 2026
e92110e
feat(sql-orm-client): relation callbacks return an array of operations
SevInf Oct 6, 2026
f38214b
test(integration): relation callbacks returning arrays on Postgres an…
SevInf Oct 6, 2026
32b7df8
test(integration): index the untyped accessor in the SQLite array tests
SevInf Oct 6, 2026
beeb005
docs(nested-mutations): dispatch briefs and rejection codes for filte…
SevInf Oct 6, 2026
3957cff
feat(sql-orm-client): where, updateAll and deleteAll on one-to-many r…
SevInf Oct 6, 2026
f6e700b
test(integration): where, updateAll and deleteAll on one-to-many rela…
SevInf Oct 6, 2026
fcbc001
test(sql-orm-client): nested updateAll and deleteAll with an OR filte…
SevInf Oct 6, 2026
51c50a1
refactor(sql-orm-client): one predicate for direct where input
SevInf Oct 6, 2026
7201124
docs(nested-mutations): type-level limit and dispatch 3 brief for fil…
SevInf Oct 6, 2026
fb482c8
feat(sql-orm-client): updateAll and deleteAll on many-to-many relatio…
SevInf Oct 6, 2026
4bcf425
test(integration): updateAll and deleteAll on many-to-many relations
SevInf Oct 6, 2026
1b1f670
docs(nested-mutations): dispatch 4 brief for filtered-many-writes
SevInf Oct 6, 2026
77d3454
docs(nested-mutations): dispatch 5 and 6 briefs for filtered-many-writes
SevInf Oct 6, 2026
9a50182
docs(sql-orm-client): nested updateAll, deleteAll and array returns
SevInf Oct 6, 2026
4403681
docs(sql-orm-client): many-to-many restrictions on nested create, con…
SevInf Oct 6, 2026
060db4b
docs(nested-mutations): dispatch 4 round 2 brief and owner decision o…
SevInf Oct 6, 2026
b1de663
docs(nested-mutations): owner decision to port the full matrix
SevInf Oct 6, 2026
d4d1727
docs(nested-mutations): port layout round for dispatch 4; tick verifi…
SevInf Oct 6, 2026
58ed1d5
docs(nested-mutations): carry the port project's rules in the dispatc…
SevInf Oct 6, 2026
a216a86
docs(nested-mutations): add dispatch 7 to filtered-many-writes
SevInf Oct 7, 2026
9484aab
fix(sql-orm-client): validate nested input before update() looks up t…
SevInf Oct 7, 2026
0a60e0f
test(integration): invalid nested input is rejected whether or not up…
SevInf Oct 7, 2026
6a43dc6
docs(port-all-tests): tick the verified nested updateMany and deleteM…
SevInf Oct 7, 2026
ead2243
fix(sql-orm-client): call nested where and create callbacks once per …
SevInf Oct 7, 2026
6f36f73
fix(sql-orm-client): reject nested create() data that is not an object
SevInf Oct 7, 2026
2d6da7b
test(integration): titles say input-only rejections happen before any…
SevInf Oct 7, 2026
ff03d84
docs(nested-mutations): trace for the filtered-many-writes slice
SevInf Oct 7, 2026
4bbf273
docs(nested-mutations): split the slice PR and share the matrix fixtures
SevInf Oct 7, 2026
7a75aab
docs(nested-mutations): record the PR split in the plan and trace
SevInf Oct 7, 2026
a92db4c
docs(nested-mutations): keep dispatch briefs and the trace out of the…
SevInf Oct 7, 2026
20cc1f6
refactor(sql-orm-client): drop apply-time checks that validation alre…
SevInf Oct 7, 2026
1028877
test(integration): keep only the SQLite nested-mutation tests that sh…
SevInf Oct 7, 2026
3ff93d9
test(integration): drop Postgres nested-mutation tests that assert no…
SevInf Oct 7, 2026
7163b7e
test(sql-orm-client): drop executor unit tests that repeat a covered …
SevInf Oct 7, 2026
9dc472b
test(sql-orm-client): drop type tests that assert one type through a …
SevInf Oct 7, 2026
23d5c11
test(sql-orm-client): disconnect in create input is a type error on a…
SevInf Oct 7, 2026
701bca1
fix(sql-orm-client): check the rows of a nested create() once per ope…
SevInf Oct 7, 2026
bb87bc4
docs(sql-orm-client): nested deleteAll on a junction table with no fo…
SevInf Oct 7, 2026
e7db2dd
refactor(sql-orm-client): resolve nested input once into a tree the e…
SevInf Oct 8, 2026
755e2b7
refactor(sql-orm-client): one graph function for nested create and up…
SevInf Oct 8, 2026
f8b3f19
refactor(sql-orm-client): relation ownership as a discriminated union
SevInf Oct 8, 2026
cb35141
refactor(sql-orm-client): check junction metadata when the relation d…
SevInf Oct 8, 2026
5bd092f
refactor(sql-orm-client): one helper reads the link column values of …
SevInf Oct 8, 2026
9621cda
refactor(sql-orm-client): share create and update defaults and the si…
SevInf Oct 8, 2026
ad71ac5
refactor(sql-orm-client): build the junction row once and disconnect …
SevInf Oct 8, 2026
9f5b791
refactor(sql-orm-client): one definition of the resolved relation and…
SevInf Oct 8, 2026
be3a956
refactor(sql-orm-client): local builders for nested-mutation input er…
SevInf Oct 8, 2026
ec18eec
refactor(sql-orm-client): one function resolves a connect or disconne…
SevInf Oct 8, 2026
90b4e9f
refactor(sql-orm-client): apply junction connect with the targets the…
SevInf Oct 8, 2026
cf4d0b0
refactor(sql-orm-client): one lookup for the related row of a connect…
SevInf Oct 8, 2026
71242a9
refactor(sql-orm-client): one helper updates the related rows of a re…
SevInf Oct 8, 2026
2cf3eac
refactor(sql-orm-client): split the nested-mutation executor into fou…
SevInf Oct 8, 2026
97fd2b2
refactor(sql-orm-client): remove a comment that names a removed function
SevInf Oct 8, 2026
eccb0de
fix(sql-orm-client): throw when a nested write's scoping condition wo…
SevInf Oct 8, 2026
bb245c5
docs(adapter-sqlite): name mutation-scope.ts for the runtime.transact…
SevInf Oct 8, 2026
c2522f5
fix(sql-orm-client): resolve a junction connect's target when the con…
SevInf Oct 9, 2026
3179620
feat(sql-orm-client): many-to-many connect links a row once and skips…
SevInf Oct 9, 2026
2688321
fix(sql-orm-client): many-to-many connect has no duplicate check and …
SevInf Oct 9, 2026
ada26d2
test(sql-orm-client): correct the title and comment of the NOT NULL j…
SevInf Oct 9, 2026
12b47bb
chore(sql-orm-client): move @internal/sql-errors to devDependencies
SevInf Oct 9, 2026
99bb81f
test(sql-orm-client): assert a nested create through a junction inser…
SevInf Oct 9, 2026
72a3cc7
docs(sql-orm-client): say link columns, not two link columns, for the…
SevInf Oct 9, 2026
c5b7879
docs(sql-orm-client): junction link columns, not two keys
SevInf Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,17 +1014,13 @@ A mutation that expected the database to return a row got none: `create()`/`upse

A valid ORM method was called in a configuration that does not support it: mutating an MTI variant collection with a method that requires `createAll()`, passing `onConflict: 'skip'` to `createAll()` on an MTI variant collection, Mongo `upsert()` with dot-path field operations, a Mongo `upsert()` whose `create` sets a field that has an update default and whose update pulls by a match document (that upsert runs as one update pipeline, which can pull only a single value), a Mongo mutation carrying windowing (`orderBy`/`offset`/`limit`) or includes, or `variant()` called on a collection that already has a variant selected (SQL and Mongo ORMs; call it on the base collection instead; `reason: 'variant-already-selected'`, with `variant` and `selectedValue` naming the selected variant model and its discriminator value). Payload: `method`, `model`, `reason`, `field`.

### ORM.RELATION_LINK_DUPLICATE

A `connect()` nested mutation violated a unique constraint on the junction table: the junction link is likely already present. The original driver error is preserved as `cause`. Payload: `relation`, `junction`.

### ORM.RELATION_MUTATION_INVALID

A nested relation mutation's input is malformed: a relation field without a mutator callback or returning an invalid descriptor, `create` without data, `connect`/`disconnect` with a missing or empty criterion, duplicate connect criteria resolving to the same junction link, or conflicting values for a junction column. Payload: `kind`, `relation`, `model`, `problem`, `junction`, `column`.
A nested relation mutation's input is malformed: a relation field without a mutator callback or returning an invalid descriptor, `create` without data or with a row that is not an object, `connect`/`disconnect` with a missing or empty criterion, conflicting values for a junction column, an array of operations that contains a nested array or a value that is not an operation, or `updateAll` data that sets the field linking the related row to its parent. Payload: `kind`, `relation`, `model`, `problem`, `junction`, `column`, `index`, `fields`.

### ORM.RELATION_MUTATION_UNSUPPORTED

A nested relation mutation kind is not supported in this position: `disconnect()` outside `update()` nested mutations, or `create()`/`connect()` through a junction table with required columns the relation API cannot populate (`disconnect()` stays available). Payload: `kind`, `relation`.
A nested relation mutation kind is not supported in this position: `disconnect()`, `updateAll()` or `deleteAll()` outside `update()` nested mutations, `updateAll()` or `deleteAll()` on a to-one relation, or `create()`/`connect()` through a junction table with required columns the relation API cannot populate (`disconnect()` stays available). Payload: `kind`, `relation`, `reason`.

### ORM.RELATION_ROW_MISSING

Expand Down
71 changes: 71 additions & 0 deletions packages/3-extensions/sql-orm-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,77 @@ The option needs the contract capability `insertOnConflictSkip`, and `insertOnCo

The optional `configure` callback may still be passed in second position when there are no options.

## Nested writes

A relation field in `create()` or `update()` data takes a callback. The callback receives a mutator for that relation and returns the operation to apply to it.

```ts
await db.User.where({ id: 1 }).update({
name: 'Alice',
posts: (posts) => posts.create({ title: 'Hello' }),
});
```

| Operation | Available in | Relations |
| --- | --- | --- |
| `r.create(data)` | `create()`, `update()` | all |
| `r.connect(criteria)` | `create()`, `update()` | all |
| `r.disconnect(criteria?)` | `update()` | all |
| `r.where(w).updateAll(data)`, `r.updateAll(data)` | `update()` | to-many |
| `r.where(w).deleteAll()`, `r.deleteAll()` | `update()` | to-many |

Two restrictions apply on a many-to-many relation:

- `r.disconnect()` without criteria is refused with `ORM.RELATION_MUTATION_INVALID`, and is a type error. Pass the criteria of the rows to disconnect.
- `r.connect` does nothing for a row that is already linked when the junction table has a primary key or unique constraint over its link columns; without such a key it inserts another junction row.
- `r.create` and `r.connect` are refused with `ORM.RELATION_MUTATION_UNSUPPORTED`, and are type errors, when the junction table has a column other than its link columns that is not nullable and has no default. `r.disconnect(criteria)` stays available.

On a runtime that provides transactions, the parent write and its nested operations run in one transaction, so either all of them are applied or none is.

### Updating and deleting related rows

`updateAll` and `deleteAll` change or delete only rows related to the record being updated. On a one-to-many relation, a row is related when its foreign key equals that record's key. On a many-to-many relation, a row is related when the junction table has a row holding both that record's key and the row's key. A row that matches the filter but is related only to another record is not changed. A row related to this record and also to other records is changed.

```ts
await db.User.where({ id: 1 }).update({
posts: (posts) => posts.where({ published: false }).updateAll({ published: true }),
});

await db.User.where({ id: 1 }).update({
posts: (posts) => posts.where((post) => post.title.like('Draft%')).deleteAll(),
});
```

`where` takes the same inputs as a collection's `where`: a callback over the related model, a field/value object, or an expression. Chained `where` calls combine with AND.

Unlike a collection's `updateAll` and `deleteAll`, the nested forms do not require `where`. Without it they apply to every related row:

```ts
await db.User.where({ id: 1 }).update({
posts: (posts) => posts.deleteAll(),
});
```

`updateAll` data is the related model's own fields. It cannot set the foreign-key field that refers to the parent; that is refused with `ORM.RELATION_MUTATION_INVALID`. `updateAll` with no fields to set changes no rows. It is not an error when no row matches.

Both operations are refused with `ORM.RELATION_MUTATION_UNSUPPORTED` inside `create()` and on a to-one relation. With an emitted `contract.d.ts` these are also type errors.

On a many-to-many relation, `deleteAll` deletes the related rows and does not delete or change junction rows itself. What happens to their junction rows is decided by the foreign-key action in your schema: with a cascading foreign key they are removed, and with a restricting one the database refuses the delete and the whole `update()` is rolled back. When the junction table has no foreign key to the related table, `deleteAll` deletes the related rows and their junction rows remain, holding keys of rows that no longer exist. Declare a foreign key with a cascading delete on the junction table so that the junction rows are removed with the rows they refer to.

### Several operations on one relation

A callback may return an array of operations. They are applied in array order, each after the previous one has been written. An empty array applies no operation.

```ts
await db.User.where({ id: 1 }).update({
posts: (posts) => [
posts.where({ published: false }).deleteAll(),
posts.create({ title: 'Fresh start' }),
posts.updateAll({ featured: false }),
],
});
```

## Prepared row descriptions

Built-in collection chains expose terminal-only `.prepared.all(configure?)`, `.prepared.first(filter?, configure?)` and `.prepared.firstOrThrow(filter?, configure?)` views. They synchronously return a `Preparable<DbRow, Result>` without executing it: a description containing a SQL `plan` and a required `consume` function. The description is not itself a `SqlQueryPlan`. Filters, projection, includes, variants, first-row limit replacement and read annotations use the ordinary row pipeline.
Expand Down
2 changes: 1 addition & 1 deletion packages/3-extensions/sql-orm-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"@internal/framework-components": "workspace:8.0.0-rc.17",
"@internal/operations": "workspace:8.0.0-rc.17",
"@internal/sql-contract": "workspace:8.0.0-rc.17",
"@internal/sql-errors": "workspace:8.0.0-rc.17",
"@internal/sql-operations": "workspace:8.0.0-rc.17",
"@internal/sql-relational-core": "workspace:8.0.0-rc.17",
"@internal/sql-runtime": "workspace:8.0.0-rc.17",
Expand All @@ -35,6 +34,7 @@
"@internal/family-sql": "workspace:8.0.0-rc.17",
"@internal/ids": "workspace:8.0.0-rc.17",
"@internal/sql-contract-ts": "workspace:8.0.0-rc.17",
"@internal/sql-errors": "workspace:8.0.0-rc.17",
"@internal/target-postgres": "workspace:8.0.0-rc.17",
"@repo/test-utils": "workspace:8.0.0-rc.17",
"@repo/tsconfig": "workspace:8.0.0-rc.17",
Expand Down
33 changes: 31 additions & 2 deletions packages/3-extensions/sql-orm-client/src/collection-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ export function getColumnToFieldMap(
return cached;
}

export function resolveColumnToField(
contract: Contract<SqlStorage>,
namespaceId: string,
modelName: string,
columnName: string,
): string {
const columnToField = getColumnToFieldMap(contract, namespaceId, modelName);
return columnToField[columnName] ?? columnName;
}

const completeColumnToFieldCache = new WeakMap<object, Map<string, Record<string, string>>>();

/**
Expand Down Expand Up @@ -278,11 +288,11 @@ export function getCompleteColumnToFieldMap(
return cached;
}

interface ResolvedThrough extends ContractRelationThrough {
export interface ResolvedThrough extends ContractRelationThrough {
readonly requiredPayloadColumns: readonly string[];
}

interface ResolvedRelation {
export interface ResolvedRelation {
readonly to: string;
readonly toNamespace: string;
readonly cardinality: RelationCardinalityTag | undefined;
Expand Down Expand Up @@ -580,6 +590,25 @@ export function resolveRowIdentityColumns(
return [];
}

export function hasUniqueKeyOverColumns(
contract: Contract<SqlStorage>,
namespaceId: string,
tableName: string,
columns: readonly string[],
): boolean {
const table = resolveTableForContract(contract, namespaceId, tableName)?.table;
if (table === undefined) {
return false;
}
const wanted = new Set(columns);
return [table.primaryKey, ...table.uniques].some(
(key) =>
key !== undefined &&
key.columns.length === wanted.size &&
key.columns.every((column) => wanted.has(column)),
);
}

export function assertReturningCapability(contract: Contract<SqlStorage>, action: string): void {
if (hasContractCapability(contract, 'returning')) {
return;
Expand Down
Loading
Loading