Writes & offline
Write with the same model
Section titled “Write with the same model”const { id } = await db.task.create({ data: { title: "Ship it", column: { connect: columnId } },});
await db.task.update({ where: { id }, data: { done: true } });await db.task.upsert({ where: { id }, create: { title: "A" }, update: { title: "B" } });await db.task.delete({ where: { id } });
await db.column.update({ where: { id: columnId }, data: { tasks: { connect: [a, b], disconnect: [c] } },});
// Several writes, one log entry, all or nothingawait db.$transaction([ db.task.update({ where: { id: a }, data: { position: 2 } }), db.task.update({ where: { id: b }, data: { position: 1 } }),]);create fails if the id exists; update fails if the row is missing; upsert chooses inside the engine’s transaction, so it cannot race.
Writes apply optimistically, queue in an IndexedDB outbox while offline, and resolve to { id, status: "applied" | "queued" }. A write the engine refuses for good during a background flush reaches onWriteRejected.
Retries are deduplicated by client transaction id without an age limit. Receipts survive snapshots, log pruning and recovery from R2. Each keeps a random id, sequence and time for the workspace lifetime. The operation hash is removed after half the history retention, 15 days by default. Until then, reusing an id with different operations is rejected; afterwards, the same id acknowledges the original transaction without applying another write.
An upgrade or restore from state that discarded receipts keeps a fixed boundary for missing receipts. An unknown retry with an original issuedAt at or before that boundary is refused with retry_expired (HTTP 410) and reaches onWriteRejected. The app can submit it again as a new write. The boundary does not advance with time.
Related rows
Section titled “Related rows”Related rows can be created with the row that needs them:
await db.board.create({ data: { name: "Launch", columns: { create: [{ name: "Todo", position: 1, tasks: { create: { title: "Ship" } } }] }, },});
await db.column.create({ data: { name: "Solo", board: { create: { name: "New board" } } } });It is one transaction, ordered so each row exists before anything points at it, and one failure rolls the whole tree back. The types follow every level.
Writing many rows
Section titled “Writing many rows”const { count } = await db.task.updateMany({ where: { done: true }, data: { archived: true } });await db.task.deleteMany({ where: { columnId: null } });The engine resolves which rows match, inside the transaction, and logs the rows rather than the filter. A replay has to change what the original changed, and re-running a filter later cannot promise that. A bulk write reaches only rows the caller may read, and still runs the row rules for update and delete on each one.
Every matching row is written in one transaction, however many match: the write commits whole or not at all, and one row refused by a rule rolls it all back. count is the number of rows written.
Like single-row writes, bulk writes are applied to the cache at once and queued offline. The cache changes the rows it holds that certainly match; a row whose match depends on a relation, or on a field the cache does not hold, waits for the engine. The engine resolves the filter when the write reaches it, so a write queued offline applies to the rows matching then. Queued, the result is { count, queued: true }.