Nested Writes
A nested write creates, links or changes related rows inside one create() or update() call. The whole tree runs in one transaction, and the call returns the row with the touched relations loaded.
const user = await db.users.create({
data: {
email: 'alice@example.com',
posts: { create: [{ title: 'First post' }, { title: 'Second post' }] },
organization: { connect: { id: 1 } },
},
});
user.posts; // the two new postsA key in data is a nested write when it names a relation and its value is a plain object. The examples assume users has many posts, has one profile and belongs to an organization, and posts has many comments.
Operations at a glance#
| Op | Context | Relations | What it does |
|---|---|---|---|
create | create + update | belongsTo / hasOne / hasMany | Insert new related rows and link them |
connect | create + update | all | Link existing rows by a unique where |
connectOrCreate | create + update | belongsTo / hasOne / hasMany | Connect the row matching where, else create it |
disconnect | update only | all | Unlink: null the foreign key, or drop the junction row |
set | update only | hasOne / hasMany / manyToMany | Replace the linked set |
delete | update only | belongsTo / hasOne / hasMany | Delete related rows matching where |
update | update only | belongsTo / hasOne / hasMany | Update related rows matching where |
upsert | update only | belongsTo / hasOne / hasMany | Update the row matching where, else create it |
An update-only operation inside create(), or an unknown operation name, throws ValidationError (E003). Many-to-many relations take only connect, disconnect and set; see Many-to-many relations.
Create context#
create, insert and link#
await db.users.create({
data: {
email: 'grace@example.com',
profile: { create: { bio: 'Building things with Postgres.' } }, // hasOne: one object
organization: { create: { name: 'Acme Inc' } }, // belongsTo
posts: {
create: { title: 'Hello', comments: { create: [{ body: 'First!' }] } },
},
},
});For hasMany and hasOne, Turbine inserts the parent first, then fills each child's foreign key. For belongsTo, it writes the related row first, so a NOT NULL foreign key holds on the first insert. Trees nest up to depth 10; deeper throws CircularRelationError (E007).
connect, link existing rows#
await db.users.create({
data: {
email: 'bob@example.com',
posts: { connect: [{ id: 10 }, { id: 11 }] },
organization: { connect: { id: 1 } }, // sets users.org_id
},
});A target that does not exist throws ValidationError (E003).
Connecting a row someone else owns, scopedConnect#
On a to-many relation, connect: { id: 42 } moves row 42 to this parent, even when another parent owns it. A handler that forwards client-supplied ids into connect lets a caller take another tenant's rows:
// The caller owns the collection, but nothing checks who owns each item.
await db.collections.update({
where: { id: collection.id },
data: { items: { connect: req.body.itemIds.map((id) => ({ id })) } },
});Authorize the child ids in the handler. As a backstop, turn on scopedConnect:
const db = turbine({ connectionString: process.env.DATABASE_URL, scopedConnect: true });
// ValidationError (E003): post 42 belongs to another user
await db.users.update({ where: { id: 7 }, data: { posts: { connect: { id: 42 } } } });With it on, a connect or connectOrCreate on hasMany or hasOne that finds an existing child succeeds only when the child has no parent or already belongs to this one. Composite foreign keys are covered. Many-to-many connects add a junction row instead of moving one, so scopedConnect does not check them; authorize those ids yourself.
It is off by default because some code moves rows between parents on purpose. Turn it on in a test environment to find those call sites, and rewrite them as explicit updates.
connectOrCreate, connect or insert#
await db.users.create({
data: {
email: 'carol@example.com',
posts: {
connectOrCreate: {
where: { slug: 'welcome' },
create: { slug: 'welcome', title: 'Welcome' },
},
},
},
});Update context#
Inside update(), the operations above still work, and five more are available.
disconnect, null the foreign key#
await db.users.update({
where: { id: 1 },
data: {
posts: { disconnect: [{ id: 10 }] }, // posts.user_id = NULL
organization: { disconnect: true }, // users.org_id = NULL
},
});The foreign key must be nullable. Otherwise disconnect throws ValidationError (E003); use delete instead.
set, replace the whole collection#
await db.users.update({
where: { id: 1 },
data: { posts: { set: [{ id: 12 }, { id: 13 }] } }, // now the user's only posts
});set disconnects every current child, then connects the listed rows.
delete, update and upsert#
await db.users.update({
where: { id: 1 },
data: {
posts: {
delete: { id: 10 },
update: { where: { id: 11 }, data: { title: 'Edited title' } },
upsert: {
where: { slug: 'changelog' },
create: { slug: 'changelog', title: 'Changelog' },
update: { title: 'Changelog (updated)' },
},
},
organization: { update: { data: { name: 'Acme LLC' } } }, // where comes from users.org_id
},
});Each takes one object or an array. These operations reach only rows that belong to the parent: a where outside the relation throws NotFoundError (E001), and an empty selector is refused.
belongsTo is one row#
A belongsTo relation is the single row the record points at:
disconnect: trueclears the foreign key.delete: trueclears it, then deletes the row it pointed at; if other rows still reference that row, the write fails withForeignKeyError(E009) and rolls back. Both need a nullable foreign key.create,connect,connectOrCreate,disconnectanddeleteeach set what the record points at, so only one of them is allowed per write.updateandupsertcan sit beside it.- Each operation takes one item; an array with more than one throws. There is no
set. - When nothing is connected,
disconnect: truedoes nothing, anddelete: truethrowsNotFoundError(E001).
await db.users.update({
where: { id: 1 },
data: {
organization: {
connectOrCreate: { where: { slug: 'acme' }, create: { slug: 'acme', name: 'Acme' } },
},
},
});Many-to-many relations#
A many-to-many link lives in a junction row. Turbine writes those rows in the parent write's transaction, on every engine:
| 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 |
await db.posts.create({
data: { title: 'Single-query relations', tags: { connect: [{ slug: 'postgres' }, { slug: 'orm' }] } },
});
await db.posts.update({
where: { id: 1 },
data: { tags: { connect: { slug: 'performance' }, disconnect: { slug: 'sql' } } },
});
await db.posts.update({
where: { id: 1 },
data: { tags: { set: [] } }, // remove every tag
});connectis idempotent: linking a tag that is already linked does nothing.disconnectandsettouch only this parent's links.- Within one payload,
setruns first, thendisconnect, thenconnect. - A selector that matches no row throws
ValidationError(E003). - Composite junction keys throw. Write those junction rows directly.
create, connectOrCreate, update, upsert and delete throw ValidationError (E003) on a many-to-many relation, before the parent row is written. To create a target and link it, write it first:
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 } } } });
});For a junction with its own columns, write its rows through the junction's accessor or table():
await db.table('post_tags').createMany({
data: [{ postId: 1, tagId: 7, addedBy: 'editor' }],
});Guarantees#
- Atomic. A failure anywhere rolls the whole tree back.
- Ordered.
belongsTotargets are written before the parent;hasManyandhasOnechildren after it. - Validated first. Invalid operations throw
ValidationError(E003) before anything commits. - Scoped to the parent.
delete,update,disconnectandupsertnever touch a row outside the relation.
See also#
- Relations: how relations are inferred and read.
- Transactions: the transaction that wraps every nested write.
- Typed errors:
ValidationError,CircularRelationErrorand the constraint errors.