Skip to content

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:

atscript
@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):

typescript
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.

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. VIA junction entries are recomputed against the new target set.

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

typescript
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 plus the PK — the parent record is partially updated
  • FROM and VIA relations: You must use patch operators ($insert, $remove, $update, $upsert, $replace) — plain arrays are rejected with a 400 error
  • Nested FROM inside TO: Not supported — you cannot patch a TO parent's FROM children in a single call. This returns a 400 error.

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.

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

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

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

typescript
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.

typescript
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.

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:

typescript
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 ​

Released under the MIT License.