Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

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? }
  • vacatedisconnect: true or delete: true
  • supplierconnect, connectOrCreate, or create
  • modifyupdate, 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 },
  });
}

Was this page helpful?