Deep Operations
When inserting, replacing, or updating records, you can include related data inline — the DB layer handles the multi-phase orchestration automatically. Instead of manually creating parent records, wiring up foreign keys, and inserting children one by one, you pass a single nested payload and Atscript takes care of the rest.
How Deep Operations Work
The table API detects navigation properties (fields defined with @db.rel.to, @db.rel.from, or @db.rel.via) in your payload and creates, updates, or deletes related records in the correct order. The entire operation is wrapped in a transaction, so if any phase fails, everything rolls back.
Navigation properties are the relation fields you define in your .as schema — the same ones used for querying relations. When they appear in a write payload, they trigger deep processing instead of being stored directly.
Schema Context
The examples on this page use the following schema. It includes TO, FROM, and VIA relations on a Task interface:
@db.table 'projects'
export interface Project {
@meta.id
@db.default.increment
id: number
title: string
@db.rel.FK
ownerId: User.id
}
@db.table 'tasks'
export interface Task {
@meta.id
@db.default.increment
id: number
title: string
status: string
@db.rel.FK
@db.rel.onDelete 'cascade'
projectId: Project.id
@db.rel.to
project: Project
@db.rel.from
comments: Comment[]
@db.rel.via TaskTag
tags: Tag[]
}
@db.table 'comments'
export interface Comment {
@meta.id
@db.default.increment
id: number
body: string
@db.rel.FK
authorId: User.id
@db.rel.FK
@db.rel.onDelete 'cascade'
taskId: Task.id
}
@db.table 'tags'
export interface Tag {
@meta.id
@db.default.increment
id: number
name: string
@db.rel.via TaskTag
tasks: Task[]
}
@db.table 'task_tags'
export interface TaskTag {
@meta.id
@db.default.increment
id: number
@db.rel.FK
@db.rel.onDelete 'cascade'
taskId: Task.id
@db.rel.FK
@db.rel.onDelete 'cascade'
tagId: Tag.id
}Nested Inserts
The DB layer orchestrates multi-phase writes so foreign keys resolve in the correct order — TO parents first, the main record next, FROM children and VIA junction entries last. Failures roll back all phases.
Inserting a Task with an inline Project (TO), Comments (FROM), and Tags (VIA):
await taskTable.insertOne({
title: "Design homepage",
project: { title: "Website Redesign", ownerId: 1 }, // TO: created first
comments: [
// FROM: created after
{ body: "Looks good!", authorId: 2 },
],
tags: [{ name: "design" }, { name: "frontend" }], // VIA: targets + junctions
});You never need to manually set projectId on the task or taskId on the comments — Atscript resolves the FK chain references from your schema and wires them up automatically.
Each foreign-key field gets the value of the field it references in the row as written — after the write guard, defaults and the TO phase, with primary-key parts (generated ones included) from the inserted id: a generated key, a unique field such as Project.code (an SDK default like @db.default.uuid included), or every part of a composite key (@db.rel.FK 'line' on orderId and productId). A value the payload carries for that foreign key is overwritten. A key that cannot be resolved fails the call with FK_VIOLATION (since 0.1.169): before anything is written when the referenced field is missing from the row (a non-key field only the database fills — an increment, a native uuid / now default — counts as missing), after the insert (rolled back) when the database generated a key part it does not report. Every level of the tree is checked before the root row is written (since 0.1.170 — before, only the root's own relations were). A @db.rel.from child whose parent key the database generates (a native uuid default on PostgreSQL / MySQL) is validated without that key and linked once the parent is written (since 0.1.170; before, such children failed validation). How replace and patch link nested rows, and how an existing @db.rel.via target is linked, is in Nested write rules.
Nested Replaces (PUT)
A deep replace fully swaps the record and its relations. FROM children are diff-synced by primary key rather than wiped: children whose @meta.id appears in the new payload are updated in place (preserving any downstream relations), absent ones are cascade-deleted per their referential rules, and unrecognized entries are inserted — an entry carrying a key that names no stored row too, with that key (since 0.1.170; before, it was skipped). VIA junction entries are recomputed against the new target set.
A child primary key that belongs to another record's child is rejected with CONFLICT before anything is written (since 0.1.143). A replace never re-parents or overwrites someone else's child. A replace that matches no row writes no related rows either. See Nested write rules.
await taskTable.replaceOne({
id: 1,
title: "Redesign homepage",
status: "in-progress",
projectId: 1,
comments: [{ body: "Updated scope", authorId: 1 }],
tags: [
{ id: 5 }, // existing tag — junction entry created
{ name: "urgent" }, // new tag — record + junction entry created
],
});Sending an empty array removes all related records for that relation:
await taskTable.replaceOne({
id: 1,
title: "Solo task",
status: "done",
projectId: 1,
comments: [], // deletes all comments for this task
tags: [], // removes all junction entries
});Nested Updates (PATCH)
Partial updates support relations, but with important constraints:
- TO relations: Send changed fields — the record the stored FK references is partially updated (a payload that also changes that FK is rejected, see TO Relation Patches)
- FROM and VIA relations: You must use patch operators (
$insert,$remove,$update,$upsert,$replace) — plain arrays are rejected with a400error - Nested FROM inside TO: Not supported — you cannot patch a TO parent's FROM children in a single call. This returns a
400error.
The reason for requiring operators is straightforward: a partial update cannot infer intent from a plain array. Should comments: [{ body: 'Hi' }] add a comment, replace all comments, or something else? Patch operators make your intent explicit.
await taskTable.updateOne({
id: 1,
project: { id: 2, title: "Updated Title" }, // TO: partial update
comments: {
// FROM: requires operators
$insert: [{ body: "New comment", authorId: 1 }],
},
tags: {
// VIA: requires operators
$insert: [{ name: "urgent" }],
$remove: [{ id: 3 }],
},
});You can combine multiple operators in a single relation field — for example, inserting new items and removing others in one call. See Relational Patches for the full list of array operators and their behavior.
Depth Control
Nested writes are gated by two independent limits: the @db.depth.limit annotation on the parent table (the schema-level gate) and the maxDepth runtime option (the per-call cap).
@db.depth.limit — Required to Allow Nested Writes
By default, no nested writes through 1:N (@db.rel.from) or M:N (@db.rel.via) navigation are allowed. Any payload containing such nested data on a parent without @db.depth.limit is rejected with DEPTH_EXCEEDED (HTTP 400):
// No @db.depth.limit — nested writes through `comments` are rejected.
@db.table 'tasks'
export interface Task {
@meta.id
id: number
title: string
@db.rel.from
comments: Comment[]
}Add @db.depth.limit N on the parent table to allow nested writes up to N levels of @db.rel.from / @db.rel.via chaining:
@db.table 'tasks'
@db.depth.limit 2 // ← enables nested writes up to 2 levels deep
export interface Task {
@meta.id
id: number
title: string
@db.rel.from
comments: Comment[]
}Why FROM/VIA and not TO?
Forward references (@db.rel.to) point to a single parent record and don't fan out — they aren't subject to the @db.depth.limit gate. The gate exists to prevent unbounded child-tree writes from a single payload (e.g. a deeply nested posts → comments → replies chain).
maxDepth — Runtime Per-Call Cap
Independently of @db.depth.limit, you can cap the runtime cost of a single nested-write call:
await taskTable.insertOne(data, { maxDepth: 5 });The default is 3. maxDepth only lowers the ceiling for one call — it does not override the schema-level @db.depth.limit gate. If the payload exceeds maxDepth:
Error: Nested data in 'comments' exceeds maxDepth (1).
Increase maxDepth or strip nested data before writing.This applies to all write operations — insertOne, insertMany, replaceOne, and updateOne — via both the programmatic API and the HTTP controller.
Automatic Transactions
All deep operations are wrapped in adapter.withTransaction(). If any phase fails — whether creating a parent record, inserting the main record, or adding children — the entire operation rolls back. No partial writes are left behind.
If you are already inside a transaction (e.g., from an explicit withTransaction() call), deep operations participate in the existing transaction rather than creating a nested one.
await adapter.withTransaction(async () => {
// Both deep inserts share the same transaction
await taskTable.insertOne({ title: "Task A", comments: [{ body: "Note" }] });
await taskTable.insertOne({ title: "Task B", tags: [{ name: "urgent" }] });
});See Transactions for explicit transaction management and nesting behavior.
Conflict-Ignoring Inserts
Nested FROM children and VIA links of a skipped row are not written, a nested TO parent is rejected, and a conflict inside a nested child insert still throws — see Skip conflicting rows.
Batch Deep Operations
insertMany, bulkReplace, and bulkUpdate all support nested data per item in the array. Each item goes through the same multi-phase process:
await taskTable.insertMany([
{
title: "Task A",
project: { title: "Project X", ownerId: 1 },
comments: [{ body: "Comment on A", authorId: 1 }],
},
{
title: "Task B",
project: { title: "Project Y", ownerId: 2 },
tags: [{ name: "backend" }],
},
]);All records and their related data are processed within a single transaction.
Next Steps
- Relational Patches — fine-grained updates with
$insert,$remove,$update,$upsert,$replace - Foreign Keys — declaring FK constraints and chain references
- Loading Relations — querying related data with
$withcontrols