Nested Writes
Create, update, and manage related records in a single operation
Nested writes let you create, connect, update, and delete related records in the same call as their parent. Transaction-capable drivers run the whole operation atomically, and generated IDs flow automatically from a parent to its children. Drivers with native atomic batches run work that can be planned up front in one batch and safe dynamic work as ordered segments after normalized success. Those segments are not one atomic operation; D1 documents one provider-specific segment-atomic contract.
Direct polymorphic relations use the same operation meanings but add a target
type and accept exactly one intent per payload. Their inverse slots — singular
s.toOne and plural s.toMany — use the ordinary singular and collection
shapes. See the
polymorphic write matrices
for the exact payloads and optionality rules.
To-one create payloads accept at most one active operation. A to-one update
payload accepts one composition — an optional vacate, one supplier, an optional
update of the supplied row — in either direction; see
Combining operations on one to-one relation.
An empty ordinary to-one payload is a no-op, and false is inactive for boolean
operations. A supplied direct polymorphic payload must contain exactly one typed
intent. To-many payloads may combine operation kinds.
Create Operations
create
Create nested records:
// Create user with profile
const user = await client.user.create({
data: {
email: "alice@example.com",
profile: {
create: { bio: "Hello!" },
},
},
include: { profile: true },
});
// Create user with multiple posts
const user = await client.user.create({
data: {
email: "bob@example.com",
posts: {
create: [
{ title: "Post 1" },
{ title: "Post 2" },
],
},
},
include: { posts: true },
});
createMany (nested)
Use nested createMany on a to-many relation. Scalar-only rows keep the grouped
bulk insert. A row that contains another relation is compiled as an ordinary
fresh record subtree, and those record subtrees run left to right:
await client.user.create({
data: {
email: "alice@example.com",
posts: {
createMany: {
data: [
{
title: "First",
comments: { create: { body: "Nested one level deeper" } },
},
{
title: "Second",
category: { connect: { slug: "news" } },
},
],
},
},
},
});
Any no-transaction driver with native atomic batches also executes this nested series when VibORM can re-assert the complete parent or relation membership in every later write batch. A shape without that exact guard is refused before its containing member writes. If reached inside a progressive root bulk call, earlier root members can already be committed and are reported.
With skipDuplicates, a row that may need root-conflict suppression is one
subtree. A transaction uses a savepoint. A batch-only route isolates the root
write, observes whether it skipped, and dispatches descendants only when it
inserted. A member with an earlier write before that skippable root is refused
pre-effect, as is one with an earlier nested record series, because skipping the
root would strand the earlier effect. A
many-to-many row whose flag is vacuous drops it, and one exact selector can use
adopt-and-link; neither route needs root suppression.
Explicit $transaction([...]) arrays remain one indivisible batch. They do not
use this ordered progressive fallback.
On a many-to-many relation, each createMany member gets its own
skipDuplicates outcome in input order; a nameable row and an unnameable sibling
do not force one shared route. When one complete unique selector can name the row
that conflicted, the target insert is skipped and the join row is still written,
so the parent ends up linked to the row that was already there. When it cannot —
the target’s key is database-generated and the row spells two independent
uniques, the conflict would fire on an index no where can spell, or the only
unique is compound with a null member — that member is suppressed whole: no
target, no join row, siblings unaffected, and the pre-existing row neither
rewritten nor linked. Later members observe the records earlier members wrote;
viborm does not pick a row to adopt on your behalf.
A nested write that needs a database-generated key to be read back — a child
whose foreign key points at the parent the same call is inserting — uses the
producer statement’s exact output. SQLite-family and MySQL batches keep their
statement-local generated-ID lowering. PostgreSQL-family batches, including Neon
HTTP, use RETURNING: an exact fold remains one statement, while an unfurled default
operation runs guarded atomic segments in dependency order. Those segments are not
one atomic operation, so a later failure can report partial committed progress. See
Compatibility.
connect
Connect to existing records:
// Connect existing category
const post = await client.post.create({
data: {
title: "My Post",
category: {
connect: { id: "cat_123" },
},
},
});
// Connect multiple tags
const post = await client.post.create({
data: {
title: "Tagged Post",
tags: {
connect: [
{ id: "tag_1" },
{ name: "featured" },
],
},
},
});
connectOrCreate
Connect if exists, create if not:
const post = await client.post.create({
data: {
title: "My Post",
category: {
connectOrCreate: {
where: { name: "Tech" },
create: { name: "Tech" },
},
},
tags: {
connectOrCreate: [
{
where: { name: "tutorial" },
create: { name: "tutorial" },
},
{
where: { name: "beginner" },
create: { name: "beginner" },
},
],
},
},
});
Update Operations
update (nested)
A to-one nested update targets the currently related row. If there’s no related row yet, the operation throws and nothing is written:
const user = await client.user.update({
where: { id: "user_123" },
data: {
profile: {
update: { bio: "Updated bio" },
},
},
});
A to-one nested update also accepts a { where, data } envelope. Unlike the
to-many form below, this where is an ordinary (non-unique) filter — a to-one
has exactly one related row, so the filter is a precondition on that row
rather than a way to pick between candidates. If the related row exists but
fails the filter, the whole operation is rejected and nothing is written:
const user = await client.user.update({
where: { id: "user_123" },
data: {
profile: {
update: {
where: { verified: true },
data: { bio: "Updated bio" },
},
},
},
});
The filter may reach through relations (where: { badges: { some: { active: true } } })
and use AND / OR / NOT.
A fixed-decimal field takes a bare Decimal | string | number or an object with
exactly one of set, increment, decrement, multiply, or divide. Nested
writes use the same schema: an empty or multi-operation decimal bag is refused
before the relation write starts.
A to-many nested update targets a specific related row. The where must identify a row that belongs to the parent you’re updating — targeting another record’s row is rejected:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
update: {
where: { id: "post_123" },
data: { published: true },
},
},
},
});
The to-many form also accepts an array of targeted updates:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
update: [
{
where: { id: "post_123" },
data: { published: true },
},
{
where: { slug: "draft-notes" },
data: { archived: true },
},
],
},
},
});
updateMany (nested)
Nested updateMany is supported for to-many relations only. It combines the child filter with parent correlation. Matching zero rows is allowed and does not throw:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
updateMany: {
where: { published: false },
data: { archived: true },
},
},
},
});
updateMany applies to to-many relations only. Scalar-only data stays on the
set-based bulk path. When data contains a relation, VibORM captures the
matching related rows once, sorts their complete primary keys, and runs the same
selected-record update compiler once per captured row:
await client.user.update({
where: { id: "user_123" },
data: {
posts: {
updateMany: {
where: { published: false },
data: {
archived: true,
auditEntries: { create: { action: "archive" } },
},
},
},
},
});
Matching zero rows remains a successful no-op. If more than one row is captured, VibORM refuses a deeper child-held operation that would move the same named target to several updated rows; one target row can store only one parent membership.
A no-transaction driver with native atomic batches also executes this nested series when VibORM can re-assert the complete parent or relation membership in every later write batch. A shape without that exact guard is refused before its containing member writes. If that member belongs to a progressive root bulk call, earlier root members can already be committed; the error reports that prefix.
upsert (nested)
A to-one nested upsert updates the currently related row when one exists; otherwise it creates and connects a new related row:
const user = await client.user.update({
where: { id: "user_123" },
data: {
profile: {
upsert: {
create: { bio: "New bio" },
update: { bio: "Updated bio" },
},
},
},
});
A to-many nested upsert requires where, create, and update. If where matches a row already correlated to the parent, VibORM updates that row. If where matches no row, VibORM creates a new row correlated to the parent. If where matches a row owned by another parent, VibORM rejects instead of mutating it or creating a duplicate:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
upsert: {
where: { slug: "launch-plan" },
update: { title: "Launch plan v2" },
create: {
slug: "launch-plan",
title: "Launch plan",
},
},
},
},
});
The to-many form also accepts an array of { where, create, update } envelopes.
When a nested update or found-upsert re-enters the exact parent selected by an enclosing upsert arm, VibORM keeps addressing that selected row. It does not re-run the public selector. If the enclosing update changes the parent’s primary key, relation placement chooses the captured key before the root write or the final complete key after it, so later work still follows the same row.
Two focused boundaries remain. Deleting or globally adopting the exact incoming parent is not a selected update continuation. A re-entry that itself changes that parent’s primary key would have to publish a second final key back to the enclosing compiler. VibORM refuses both cases instead of choosing a row or assignment order.
Connection Management
disconnect
Disconnect related records:
// Disconnect specific tags
const post = await client.post.update({
where: { id: "post_123" },
data: {
tags: {
disconnect: [{ id: "tag_1" }],
},
},
});
// Disconnect to-one (clear its stored membership)
const post = await client.post.update({
where: { id: "post_123" },
data: {
category: { disconnect: true },
},
});
An ordinary child-held relation exposes disconnect only when its physical
child membership can be cleared. A required child foreign key therefore omits
the key from its input. Junction relations always expose it because they remove
a join row instead of clearing child storage.
set
Replace all connections:
// Replace all tags
const post = await client.post.update({
where: { id: "post_123" },
data: {
tags: {
set: [{ id: "tag_2" }, { id: "tag_3" }],
},
},
});
// Clear all tags
const post = await client.post.update({
where: { id: "post_123" },
data: {
tags: { set: [] },
},
});
For a junction relation, every selector supplies the complete target key,
including every member of a compound primary key. Large set and connect
lists are split into INSERT statements that fit the active driver’s verified
bind-parameter budget. The clear step, all insert chunks, guards, and result stay
inside the same transaction or native atomic batch. Statement-level triggers fire
once per chunk for such a large payload; an under-limit list remains one INSERT.
Delete Operations
delete (nested)
Delete related records. A nested delete throws when the target isn’t found, including when it matches a row that belongs to another parent:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
delete: { id: "post_123" },
},
},
});
// Delete multiple
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
delete: [{ id: "post_1" }, { id: "post_2" }],
},
},
});
For a to-one relation, use delete: true. Deleting the related row needs an
empty slot to be valid, but it does not require clearable membership storage:
the child is removed rather than preserved. This is why a non-owning optional
one-to-one can allow delete while refusing disconnect.
deleteMany (nested)
Nested deleteMany is supported for to-many relations only. It combines the filter with parent correlation. Matching zero rows is allowed and does not throw:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
deleteMany: {
published: false,
createdAt: { lt: new Date("2023-01-01") },
},
},
},
});
The to-many form also accepts an array of filters:
const user = await client.user.update({
where: { id: "user_123" },
data: {
posts: {
deleteMany: [
{ published: false },
{ archived: true },
],
},
},
});
deleteMany applies to to-many relations only. For a to-one relation, use delete: true.
Combining operations on one to-one relation
A to-one slot holds exactly one row, so a payload for it is read as one composition rather than as a set of independent operations:
{ vacate?, supplier, modify? }
- vacate —
disconnect: trueordelete: true - supplier —
connect,connectOrCreate, orcreate - modify —
update, applied to the row the supplier just supplied
The parts always run in that order, whatever order you spell them in, and the whole thing is one atomic composition rather than three payloads that happen to be applied in sequence:
// Replace the badge, and edit the incoming one — one call
await client.hub.update({
where: { id: "h1" },
data: {
badge: {
disconnect: true,
connect: { id: "badge_new" },
update: { tag: "primary" },
},
},
});
// Parent-held: the vacate and the supplier fold into ONE foreign-key value
await client.hub.update({
where: { id: "h1" },
data: { owner: { delete: true, create: { id: "o9", name: "fresh" } } },
});
That parent-held example is worth a note: the old row is deleted and the new one
written, but no transient NULL is ever assigned to the foreign key. The final
value is computed and written once, in the record’s own UPDATE.
Which compositions run. Five replacement pairs are spellable on both
directions — disconnect + connect, disconnect + connectOrCreate,
disconnect + create, delete + connect, delete + create. delete beside
connectOrCreate is not an accepted pair, deliberately rather than by
omission. Any supplier may additionally carry an update, with or without a
vacate before it:
// Mint the badge, then edit THAT badge — one call, in this order
await client.hub.update({
where: { id: "h1" },
data: {
badge: {
create: { id: "badge_new", tag: "fresh", rank: 2 },
update: { rank: { increment: 3 } }, // sees rank 2, writes 5
},
},
});
The modify always applies to the row the supplier supplied, and it applies
after that supplier has run — so a relative update counts from the value the
supplier wrote, and a connectOrCreate that finds an existing row edits the row
it adopted rather than one it minted. The update is an ordinary update: it can
carry relations of its own, nested createMany / updateMany, and another
supplier-plus-modify one level deeper.
When the supplier is a create — or a connectOrCreate that ends up creating —
the row does not exist while the call is being planned, so the engine locates it
after the fact, by the membership the supplier just established. That costs an
extra round trip on this relation, and it needs a driver that can either run an
interactive transaction or submit native atomic batches in order. On the latter,
each next segment starts only after normalized success from the previous one. A
driver with neither capability declines before the supplier writes.
Everything else stays refused at the type and validation boundary: two
suppliers, upsert beside another target intent, a vacate with an update and
no supplier, two vacates, and every ambiguous three-intent combination. A
relation that cannot spell a key simply does not offer it — a required to-one
has no disconnect, and a create root has no update — so those compositions
are not merely rejected there, they are unspellable.
What’s not allowed
Required relations
When membership storage is required, it cannot be cleared while preserving the
related record. disconnect is absent from that relation input:
// post.authorId is required, so the author cannot be disconnected.
await client.post.update({
where: { id: "post_123" },
data: {
author: {
disconnect: true,
},
},
});
This call fails input validation before query-engine compilation.
// post.authorId is required. set is valid only when every current post remains.
await client.user.update({
where: { id: "user_123" },
data: {
posts: {
set: [],
},
},
});
The second call reaches the set owner, but a departing-member guard rejects it
when the relation currently contains a post. A required set may retain all
current members and adopt new ones; set: [] succeeds only when the relation is
already empty.
// Required foreign keys cannot be nulled through nested updateMany data.
await client.user.update({
where: { id: "user_123" },
data: {
posts: {
updateMany: {
where: { archived: false },
data: { authorId: null },
},
},
},
});
Update and delete inside a create
You can’t update or delete related records inside a create (or the create branch of an upsert) — there’s no existing related record yet:
await client.user.create({
data: {
email: "new@example.com",
profile: {
update: { bio: "No existing profile under a new user" },
},
},
});
await client.user.upsert({
where: { email: "new@example.com" },
create: {
email: "new@example.com",
posts: {
deleteMany: { archived: true },
},
},
update: {
name: "Existing user",
},
});
Examples
Complete User Registration
async function registerUser(data: {
email: string;
name: string;
bio?: string;
defaultTags?: string[];
}) {
return client.user.create({
data: {
email: data.email,
name: data.name,
profile: {
create: { bio: data.bio || "" },
},
settings: {
create: {
theme: "system",
notifications: true,
},
},
...(data.defaultTags && {
followedTags: {
connectOrCreate: data.defaultTags.map(name => ({
where: { name },
create: { name },
})),
},
}),
},
include: {
profile: true,
settings: true,
followedTags: true,
},
});
}
Update Post with Tags
async function updatePostTags(postId: string, tagNames: string[]) {
return client.post.update({
where: { id: postId },
data: {
tags: {
set: [], // Clear existing
connectOrCreate: tagNames.map(name => ({
where: { name },
create: { name },
})),
},
},
include: { tags: true },
});
}