Nested Writes
A nested write resolves relation fields in your data argument into the right INSERTs, UPDATEs, and DELETEs, so you can build or mutate a whole object graph in one create() or update() call instead of orchestrating the statements by hand.
The whole tree is written inside one transaction (all-or-nothing) and walked depth-first, capped at depth 10, past that Turbine throws CircularRelationError (E007). After the write completes, Turbine reads the row back with the touched relations populated, so the return value is the full tree.
When it triggers#
A key in data is treated as a nested write when it matches a relation name and its value is a plain object (not null, not an array, not a Date). Everything else is a scalar column. No flag to flip, relation fields just work.
The examples below assume this shape:
// users hasMany posts, hasOne profile, belongsTo organization
// posts hasMany commentsOperations at a glance#
| Op | Context | Relations | What it does |
|---|---|---|---|
create | create + update | belongsTo / hasOne / hasMany | Insert new related row(s) and link them |
connect | create + update | all, incl. manyToMany | Link existing row(s) by a unique where |
connectOrCreate | create + update | belongsTo / hasOne / hasMany | Connect the row matching where, else create it |
disconnect | update only | all, incl. manyToMany | Unlink: null the FK, or drop the junction row |
set | update only | hasOne / hasMany / manyToMany | Replace the whole set: unlink current, link listed |
delete | update only | belongsTo / hasOne / hasMany | Delete the related row(s) matching where |
update | update only | belongsTo / hasOne / hasMany | Update the related row(s) (where is optional for belongsTo) |
upsert | update only | belongsTo / hasOne / hasMany | Update the row matching where, else create it |
create, connect, and connectOrCreate are valid in both create() and update(). The other five are update-only: using them inside create() throws ValidationError (E003). An unknown operation name throws too.
Many-to-many relations support the three operations that only ever touch the junction table: connect, disconnect, and set (see Many-to-many relations below). The operations that would also have to write the target row (create, connectOrCreate, update, upsert, delete) still throw ValidationError (E003), because there is no safe default for the junction's own extra columns.
Create context#
create, insert and link#
create accepts a single object or an array. For a hasMany/hasOne relation the parent is inserted first, then the children inherit the parent's primary key as their foreign key.
const user = await db.users.create({
data: {
email: 'alice@example.com',
name: 'Alice',
posts: {
create: [{ title: 'First post' }, { title: 'Second post' }],
},
},
});
// user.posts is populated, the tree is read back after the writeFor a hasOne relation the shape is the same, just a single object:
await db.users.create({
data: {
email: 'grace@example.com',
profile: {
create: { bio: 'Building things with Postgres.' },
},
},
});belongsTo create, link the parent#
For a belongsTo relation the foreign key lives on the row you're creating, so Turbine resolves the related row before the parent INSERT and folds its key in. That means a NOT NULL FK column is satisfied on the first insert.
await db.users.create({
data: {
email: 'erin@example.com',
organization: {
create: { name: 'Acme Inc' },
},
},
});connect, link existing rows#
connect takes a where (one object or an array) that must match existing rows. If a target doesn't exist, Turbine throws ValidationError.
await db.users.create({
data: {
email: 'bob@example.com',
posts: {
connect: [{ id: 10 }, { id: 11 }],
},
organization: {
connect: { id: 1 }, // belongsTo, sets users.org_id
},
},
});Connecting a row someone else owns, scopedConnect#
connect: { id: 42 } on a to-many relation means "make row 42 mine", and by default it does exactly that: if another parent currently owns row 42, the row is taken from them. That is the intended behavior for an admin tool moving records around. It is also a cross-tenant write primitive in any handler that forwards a client-supplied id into a nested connect.
The shape that is exposed
This handler is the whole vulnerability. It has an authorization check, and the check is on the wrong row:
// POST /api/collections/:id/items { itemIds: [42] }
app.post('/api/collections/:id/items', async (req, res) => {
const collection = await db.collections.findUnique({ where: { id: req.params.id } });
if (collection.ownerId !== req.user.id) return res.sendStatus(403);
await db.collections.update({
where: { id: collection.id },
data: { items: { connect: req.body.itemIds.map((id) => ({ id })) } },
});
res.sendStatus(204);
});The caller owns the collection, so the check passes. Nothing checks who owns item 42. The connect rewrites items.collection_id, so an attacker who can guess or enumerate ids moves another tenant's rows into their own collection, reads them back through their own authorized endpoints, and the victim's rows simply disappear from the victim's list. There is no error, no log line, and one statement did it.
connectOrCreate is the same primitive: when the lookup finds an existing row, it connects it.
The complete fix is to authorize the child rows too (where: { id: { in: itemIds }, ownerId: req.user.id } before connecting). scopedConnect is the backstop for the handlers you have not audited yet.
The config
Set scopedConnect: true on the client:
const db = turbine({ connectionString: process.env.DATABASE_URL, scopedConnect: true });
// Refused with ValidationError (TURBINE_E003): post 42 belongs to another user.
await db.users.update({ where: { id: 7 }, data: { posts: { connect: { id: 42 } } } });With the flag on, a connect succeeds when the child is unowned (its foreign key is null) or already owned by this parent (an idempotent re-connect), and is refused otherwise. Re-parenting then has to be an explicit update, which is the point: it stops being something a request body can do by accident.
It is off by default because it changes the outcome of writes that currently succeed. Turn it on in a non-production environment first and run your test suite: anything that legitimately re-parents will start throwing, and that is the list of call sites you need to rewrite as explicit updates.
What it covers, and what it does not
The check compares the child's current foreign key against the parent's reference value, so it only exists where a connect rewrites a foreign key:
| Relation | Covered | Why |
|---|---|---|
hasMany | Yes | The connect sets the child's FK to this parent, taking the row from whoever held it. |
hasOne | Yes | Same rewrite, one row. |
belongsTo | Not applicable | The connect points the row being written at a parent. It takes nothing from anyone, and the row it changes is the one you already authorized. |
| Many-to-many | No | The connect inserts a junction row rather than moving one, so there is no foreign key to compare and nothing is taken from the other side. |
Read the many-to-many row carefully: nothing is taken, but a junction row still links another tenant's row into your caller's parent, so the caller can now read it through the relation. scopedConnect does not close that. Authorize the child ids in the handler.
Composite foreign keys are covered: every column of the key is compared, and values are normalized to a primitive first, so a bigint FK read back as a string cannot look like a mismatch against the number the parent write returned.
connectOrCreate, connect or insert#
Each item is { where, create }: Turbine looks up where, links it if found, otherwise inserts the create payload (with the FK injected for hasMany/hasOne).
await db.users.create({
data: {
email: 'carol@example.com',
posts: {
connectOrCreate: {
where: { slug: 'welcome' },
create: { slug: 'welcome', title: 'Welcome' },
},
},
},
});Going deeper#
Nested writes recurse. A create payload can itself contain relation ops, up to depth 10:
await db.users.create({
data: {
email: 'frank@example.com',
posts: {
create: {
title: 'Hello',
comments: {
create: [{ body: 'First!' }, { body: 'Nice post.' }],
},
},
},
},
});Update context#
Inside update(), the create-context ops (create / connect / connectOrCreate) behave exactly as above, and five more become available.
await db.users.update({
where: { id: 1 },
data: {
name: 'Alice R.',
posts: {
create: { title: 'Fresh post' }, // create still works in update()
},
},
});disconnect, null the foreign key#
disconnect sets the child's foreign key to NULL. The FK column must be nullable, otherwise Turbine throws ValidationError telling you to use delete instead.
await db.users.update({
where: { id: 1 },
data: {
posts: {
disconnect: [{ id: 10 }], // posts.user_id = NULL
},
},
});For a belongsTo relation, disconnect nulls the FK on the parent row instead (again requiring a nullable column):
await db.users.update({
where: { id: 1 },
data: {
organization: { disconnect: {} }, // users.org_id = NULL
},
});set, replace the whole collection#
set takes an array and makes the relation contain exactly those rows: every current child is disconnected, then the listed rows are connected. hasMany/hasOne only.
await db.users.update({
where: { id: 1 },
data: {
posts: {
set: [{ id: 12 }, { id: 13 }], // these become the user's only posts
},
},
});delete, remove related rows#
await db.users.update({
where: { id: 1 },
data: {
posts: {
delete: { id: 10 }, // single object or array
},
},
});update, patch related rows#
Each item is { where, data }. For belongsTo, where is optional, Turbine derives it from the parent's foreign key.
await db.users.update({
where: { id: 1 },
data: {
posts: {
update: { where: { id: 10 }, data: { title: 'Edited title' } },
},
organization: {
update: { data: { name: 'Acme LLC' } }, // where derived from users.org_id
},
},
});upsert, update or insert#
Each item is { where, create, update }: update the row matching where, or create it if it doesn't exist.
await db.users.update({
where: { id: 1 },
data: {
posts: {
upsert: {
where: { slug: 'changelog' },
create: { slug: 'changelog', title: 'Changelog' },
update: { title: 'Changelog (updated)' },
},
},
},
});Many-to-many relations#
A many-to-many relation has no foreign key on either side: the link lives in a junction row. Turbine writes those junction rows for you, in the same transaction as the parent write, on every engine (the SQL dialects and PowDB share one nested-write engine).
Three operations are supported, because three operations only ever touch the junction table:
| Op | Context | What it writes |
|---|---|---|
connect | create + update | Inserts the missing junction rows for the named targets |
disconnect | update only | Deletes the junction rows for exactly the named targets |
set | update only | Deletes this parent's junction rows, then inserts the named ones |
The examples below assume posts and tags are linked through a post_tags junction:
// On create: connect only (disconnect and set are update-only on every relation)
await db.posts.create({
data: {
title: 'Single-query relations',
tags: {
connect: [{ slug: 'postgres' }, { slug: 'orm' }],
},
},
});
// On update: all three
await db.posts.update({
where: { id: 1 },
data: {
tags: {
set: [{ slug: 'postgres' }, { slug: 'sql' }], // these become the post's only tags
},
},
});
await db.posts.update({
where: { id: 1 },
data: {
tags: {
connect: { slug: 'performance' },
disconnect: { slug: 'sql' },
},
},
});Behavior worth knowing:
connectis idempotent. Turbine reads the parent's existing links for exactly the named targets inside the transaction and inserts only the missing ones. It does not rely onON CONFLICT DO NOTHING, which SQL Server and PowDB refuse and which assumes a unique constraint an implicit junction need not declare. Connecting an already-linked tag is a no-op, not a duplicate row and not a constraint error.disconnectis scoped by both keys. The delete names the parent key and the listed targets, so it can neither clear the parent's other links nor touch another parent's rows.setreplaces the whole set.set: []clears every link this parent has. The clearing delete is always scoped by the parent key.- Order within one payload:
setruns first, thendisconnect, thenconnect. - A selector that matches no row is refused, with
ValidationError(E003) naming the relation and the selector, rather than being skipped. - Composite junction keys are refused. A link row that needs more than one column per side cannot be addressed by the single-column predicates these writes use, so Turbine throws instead of writing a partial key. Write those junction rows directly.
What still throws#
create, connectOrCreate, update, upsert, and delete on a many-to-many relation throw ValidationError (E003). Each of those would have to write the target row as well as the link, and there is no safe default for the junction's own extra columns. The error names the operation and the supported set.
To create a target and link it, write the target and connect it:
await db.$transaction(async (tx) => {
const tag = await tx.tags.create({ data: { slug: 'indexes', name: 'Indexes' } });
await tx.posts.update({
where: { id: 1 },
data: { tags: { connect: { id: tag.id } } },
});
});To write link rows by hand (a junction with its own payload columns, for instance), address the junction table directly. On the core client it is an ordinary table, reachable through the camelCased accessor or table():
await db.$transaction(async (tx) => {
await tx.table('post_tags').createMany({
data: [{ postId: 1, tagId: 7, addedBy: 'editor' }],
});
});Since 0.50 the prisma-compat client exposes the same junction tables too, under their raw name, so this escape hatch is reachable from a migrated codebase as well.
Guarantees#
- Atomic. Every statement runs inside a single transaction. A failure anywhere rolls the whole tree back.
- Ordered correctly.
belongsTotargets are resolved before the parent insert (soNOT NULLFKs hold);hasMany/hasOnechildren are written after the parent exists. - Depth-capped at 10. Deeper trees throw
CircularRelationError(E007) with the full relation path. - Validated. Update-only ops in
create(), unknown op names, non-nullabledisconnect, missingconnecttargets, and the unsupported many-to-many ops (create,connectOrCreate,update,upsert,delete) all throwValidationError(E003) before anything commits. An unsupported many-to-many op is refused before the parent row is written, so a refused create leaves nothing behind. - Scoped to the parent.
delete/update/disconnect/upsertcan only touch rows that actually belong to the parent being written: the relation correlation is ANDed onto yourwhere, and a target outside the relation reportsNotFoundError(E001) instead of modifying a row owned by someone else. A selector that binds nothing ({}, or a field that isundefined) is refused rather than widened to the whole relation. - Read back. The return value is the parent row with the touched relations loaded via Turbine's
json_aggmachinery.
See also#
- Relations, how
hasMany/hasOne/belongsToare inferred and read. - Transactions & Pipelines, the transaction that wraps every nested write.
- Typed Errors,
ValidationError,CircularRelationError, and the constraint errors a write can raise. - API Reference,
create,update, and the full query surface.