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 comments

Operations at a glance#

OpContextRelationsWhat it does
createcreate + updatebelongsTo / hasOne / hasManyInsert new related row(s) and link them
connectcreate + updateall, incl. manyToManyLink existing row(s) by a unique where
connectOrCreatecreate + updatebelongsTo / hasOne / hasManyConnect the row matching where, else create it
disconnectupdate onlyall, incl. manyToManyUnlink: null the FK, or drop the junction row
setupdate onlyhasOne / hasMany / manyToManyReplace the whole set: unlink current, link listed
deleteupdate onlybelongsTo / hasOne / hasManyDelete the related row(s) matching where
updateupdate onlybelongsTo / hasOne / hasManyUpdate the related row(s) (where is optional for belongsTo)
upsertupdate onlybelongsTo / hasOne / hasManyUpdate 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 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 write

For 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.' },
    },
  },
});

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 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:

RelationCoveredWhy
hasManyYesThe connect sets the child's FK to this parent, taking the row from whoever held it.
hasOneYesSame rewrite, one row.
belongsToNot applicableThe 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-manyNoThe 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
    },
  },
});
await db.users.update({
  where: { id: 1 },
  data: {
    posts: {
      delete: { id: 10 }, // single object or array
    },
  },
});

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:

OpContextWhat it writes
connectcreate + updateInserts the missing junction rows for the named targets
disconnectupdate onlyDeletes the junction rows for exactly the named targets
setupdate onlyDeletes 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:

  • connect is 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 on ON 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.
  • disconnect is 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.
  • set replaces the whole set. set: [] clears every link this parent has. The clearing delete is always scoped by the parent key.
  • Order within one payload: set runs first, then disconnect, then connect.
  • 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. belongsTo targets are resolved before the parent insert (so NOT NULL FKs hold); hasMany/hasOne children 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-nullable disconnect, missing connect targets, and the unsupported many-to-many ops (create, connectOrCreate, update, upsert, delete) all throw ValidationError (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 / upsert can only touch rows that actually belong to the parent being written: the relation correlation is ANDed onto your where, and a target outside the relation reports NotFoundError (E001) instead of modifying a row owned by someone else. A selector that binds nothing ({}, or a field that is undefined) 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_agg machinery.

See also#

  • Relations, how hasMany / hasOne / belongsTo are 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.