Update & Patch
This page covers fine-grained update operations on single-table data — embedded objects and embedded arrays stored directly on a record. For patching related records across foreign keys (FROM and VIA navigation properties), see Relations — Relational Patches.
Nested writes need @db.depth.limit
bulkUpdate, bulkReplace, and insertMany validate nested-write depth against @db.depth.limit N on the target table. The default — annotation absent — is 0, which rejects every payload that crosses a @db.rel.from / @db.rel.via relation. Opt in explicitly when you want the server to accept deep writes.
Simple Updates
The simplest update sets scalar fields directly. The primary key must be in the payload to identify the record:
await users.updateOne({
id: 1,
name: "Alice Smith",
status: "active",
});Only the provided fields are changed — other fields remain untouched.
Field Operations
Field operations let you atomically increment, decrement, or multiply numeric fields without reading the current value first. This is essential for counters, stock levels, scores, and any field where concurrent updates must not lose data.
Available Operations
| Operator | Effect | SQL | MongoDB |
|---|---|---|---|
$inc | Add a number to the field | SET col = col + ? | $inc |
$dec | Subtract a number from the field | SET col = col - ? | $inc (negative) |
$mul | Multiply the field by a number | SET col = col * ? | $mul |
All operations are atomic — they execute as a single database operation, safe for concurrent use.
Usage
Import the operation helpers from @atscript/db/ops — this module has zero dependencies and is safe for frontend use:
import { $inc, $dec, $mul } from "@atscript/db/ops";
// Increment views by 1
await posts.updateOne({ id: 1, views: $inc() });
// Decrement stock by 5
await products.updateOne({ id: 42, stock: $dec(5) });
// Apply a 10% price increase
await products.updateOne({ id: 42, price: $mul(1.1) });Field operations can be mixed with regular field assignments in the same update:
await posts.updateOne({
id: 1,
views: $inc(),
title: "Updated Title",
status: "published",
});With updateMany
Field operations also work with filter-based batch updates:
// Give all active users 100 bonus points
await users.updateMany({ status: "active" }, { points: $inc(100) });With bulkUpdate
Multiple records can receive different operations in one call:
await products.bulkUpdate([
{ id: 1, stock: $dec(2) },
{ id: 2, stock: $dec(5) },
{ id: 3, price: $mul(0.9) },
]);HTTP usage
The helpers return plain JSON objects ($inc(5) → { $inc: 5 }), so they serialize naturally. See the HTTP PATCH endpoint for REST examples.
$cas — Optimistic Concurrency
$cas is a top-level payload operator (sibling to plain SET fields and the field ops above) that turns any updateOne, replaceOne, or per-item bulkUpdate into a conditional write. It is the opt-in surface for optimistic concurrency control on tables that declare a @db.column.version column.
const ok = await tasks.updateOne({
id: 1,
status: "done",
$cas: { version: row.version }, // ← only commits if the row is still at this version
});
if (ok.matchedCount === 0) {
// Either the row doesn't exist OR another writer touched it. Retry.
}On a mismatch the call returns { matchedCount: 0, modifiedCount: 0 } — no exception, no partial write. $cas composes atomically with $inc / $dec / $mul in the same payload (single statement on SQL, single findOneAndUpdate on Mongo).
$cas is not supported on updateMany (a single expectedVersion cannot sensibly match N rows). Per-row version locking goes through bulkUpdate where each payload carries its own $cas.
See Optimistic Concurrency (Row Versioning) for the full reference — annotation constraints, the withOptimisticRetry helper, CasExhaustedError, direct-write rejection, and the HTTP wire contract.
Operation Helpers
The @atscript/db/ops module also exports helpers for array patch operators:
import { $insert, $remove, $replace, $upsert, $update } from "@atscript/db/ops";
await posts.updateOne({ id: 1, tags: $insert(["urgent"]) });
await posts.updateOne({ id: 1, tags: $remove(["draft"]) });Where Field Ops Work
Field operations require that the target field maps to its own database column (or document key) so the database engine can apply the arithmetic atomically. This means:
| Context | Works? | Why |
|---|---|---|
| Top-level numeric field | Yes | Maps directly to a column / document key |
Nested field with @db.patch.strategy 'merge' | Yes | Each sub-field is stored as its own column (SQL) or dot-path key (MongoDB) |
| Inside a navigation property (relation) | Yes | The related table's bulkUpdate handles ops on its own fields |
updateMany — same contexts as updateOne | Yes | The patch is decomposed the same way — nested merge-strategy ops become dot-path ops |
Where Field Ops Do NOT Work
| Context | Result | Why |
|---|---|---|
Inside a @db.json field | Validation error | JSON fields are stored as an opaque blob — the database cannot reach inside to increment a single key |
Inside a nested object without @db.patch.strategy 'merge' | Validation error | Without merge strategy the entire object is replaced — there is no individual column to increment |
import { $inc } from "@atscript/db/ops";
// ❌ Validation error — @db.json field
await table.updateOne({ id: 1, metadata: { clicks: $inc() } });
// ❌ Validation error — no merge strategy
await table.updateOne({ id: 1, address: { zip: $inc() } });
// ✅ Works — merge strategy present
await table.updateOne({ id: 1, stats: { views: $inc() } });TIP
If you need atomic operations on nested fields, add @db.patch.strategy 'merge' to the parent field. This stores each sub-field as a separate column (SQL) or allows dot-path updates (MongoDB).
Embedded Object Patches
Nested objects stored on a record (not navigation properties) use a strategy-based approach controlled by @db.patch.strategy. The mental model is:
- Default = replace = strict. Every branch is treated as replace unless explicitly annotated otherwise. The validator requires every required child to be present on a replace branch — partial sub-shapes that omit required fields are rejected with a 400 to keep the database constraint and the API contract aligned.
@db.patch.strategy 'merge'is a one-level opt-in. It applies only to the level it's annotated on; descendants revert to default replace unless they too carry the annotation. Merge does not propagate.- Optional children of a replace branch are null-filled when omitted. That's what distinguishes replace from merge — replace clears unspecified optional sub-fields, merge preserves them.
@db.jsonis always strict — the column is an opaque blob; partial sub-shapes can't be decomposed.
Replace Strategy (Default)
Without @db.patch.strategy, the nested object is overwritten: every required child must be supplied, and any optional child the user omits is explicitly set to null so the storage matches the input shape on both SQL (column-decomposed) and MongoDB (sub-document):
// schema
address: {
line1: string
line2?: string // optional
city: string
state: string
zip: string
}// Current: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Portland', state: 'OR', zip: '97201' } }
// ✅ Full required shape — optional line2 omitted, gets null-filled
await table.updateOne({
id: 1,
address: { line1: "1 Pike Pl", city: "Seattle", state: "WA", zip: "98101" },
});
// Result: address.line1='1 Pike Pl', address.line2=null, address.city='Seattle', …
// ❌ Missing a required child — rejected at the validator
await table.updateOne({ id: 1, address: { city: "Seattle" } });
// ValidatorError: address.line1 is required, address.state is required, …
// (replace = strict; required children must be supplied)If you want to update one field of address and preserve the rest, switch the parent to merge:
@db.patch.strategy 'merge'
address: { ... }Field operations ($inc, $dec, $mul) are not allowed inside replace-strategy objects — the validator will reject them. Use @db.patch.strategy 'merge' if you need atomic operations on nested fields, or to preserve omitted siblings.
Merge Strategy
With @db.patch.strategy 'merge', only the provided nested fields are updated — others are preserved. The annotation applies to that one level only; nested objects under a merge parent revert to default replace unless they also carry the annotation:
@db.patch.strategy 'merge'
address: {
line1: string
line2?: string
city: string
}// Current: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Portland' } }
await table.updateOne({ id: 1, address: { city: "Seattle" } });
// Result: { address: { line1: '123 Main St', line2: 'Apt 4', city: 'Seattle' } }
// ✅ line1 and line2 preservedMerge-strategy fields also support field operations on their nested numeric sub-fields:
import { $inc } from "@atscript/db/ops";
await products.updateOne({
id: 1,
stats: { views: $inc(), rating: 4.5 },
});
// views is atomically incremented, rating is set to 4.5, other stats fields preservedEmbedded Array Patches
Arrays stored directly on the record support five patch operators for fine-grained manipulation:
| Operator | Effect |
|---|---|
$replace | Replace the entire array |
$insert | Append new items |
$upsert | Insert or update items by key |
$update | Update existing items by key |
$remove | Remove items by key or value |
When multiple operators appear on the same field, they are always applied in order: remove → update → upsert → insert — regardless of the order they appear in the object.
SQL Adapters
In relational databases (SQLite, PostgreSQL, MySQL), arrays are stored as JSON columns. Patch operators work via read-modify-write. For collections that need frequent partial updates in SQL, consider modeling them as separate tables with FROM or VIA relations instead.
Primitive Arrays
For simple value arrays like tags: string[], operators work by value equality — no key fields are needed:
import { $insert, $remove, $replace } from "@atscript/db/ops";
// Append items
await table.updateOne({ id: 1, tags: $insert(["urgent", "reviewed"]) });
// Remove by value
await table.updateOne({ id: 1, tags: $remove(["draft"]) });
// Full replacement
await table.updateOne({ id: 1, tags: $replace(["final", "approved"]) });Unique Primitive Arrays
When @expect.array.uniqueItems is set, $insert automatically skips duplicates:
@expect.array.uniqueItems
tags: string[]import { $insert } from "@atscript/db/ops";
// Current tags: ['api', 'backend']
await table.updateOne({ id: 1, tags: $insert(["api", "frontend"]) });
// Result: ['api', 'backend', 'frontend'] — 'api' was silently skippedKeyed Object Arrays
@expect.array.key marks which properties identify an element inside an embedded object array. Keys are required for $update, $upsert, and key-based $remove:
variants: {
@expect.array.key
sku: string
color: string
stock: number
}[]Multiple fields can be marked as keys to form a composite key — an element matches only when all key fields match.
Operations with Replace Strategy (Default)
import { $insert, $update, $remove, $upsert } from "@atscript/db/ops";
// Insert a new variant
await table.updateOne({
id: 1,
variants: $insert([{ sku: "B2", color: "blue", stock: 10 }]),
});
// Update — replaces the entire matched element
await table.updateOne({
id: 1,
variants: $update([{ sku: "B2", color: "navy", stock: 8 }]),
});
// Remove by key
await table.updateOne({
id: 1,
variants: $remove([{ sku: "B2" }]),
});
// Upsert — insert if not found, replace if found
await table.updateOne({
id: 1,
variants: $upsert([{ sku: "C3", color: "green", stock: 3 }]),
});Under replace strategy, $update and $upsert replace the entire matched element — every required field must be present.
Operations with Merge Strategy
With @db.patch.strategy 'merge', updates merge into the existing element, preserving fields not explicitly provided:
@db.patch.strategy 'merge'
attributes: {
@expect.array.key
name: string
value: string
visible: boolean
}[]import { $update } from "@atscript/db/ops";
// Current: [{ name: 'size', value: 'M', visible: true }]
await table.updateOne({
id: 1,
attributes: $update([{ name: "size", value: "XL" }]),
});
// Result: [{ name: 'size', value: 'XL', visible: true }] — 'visible' preservedKeyless Object Arrays
For object arrays without @expect.array.key, matching falls back to full deep value equality. This means $remove works (match entire objects), but $update is effectively a no-op (there are no key fields to locate a target element for partial update):
import { $insert, $remove, $replace } from "@atscript/db/ops";
// Append
await table.updateOne({
id: 1,
logs: $insert([{ message: "Deployed", ts: 1710000000 }]),
});
// Remove by exact match
await table.updateOne({
id: 1,
logs: $remove([{ message: "Deployed", ts: 1710000000 }]),
});
// Full replacement
await table.updateOne({ id: 1, logs: $replace([]) });For anything beyond simple append/remove, add @expect.array.key to enable key-based matching.
JSON Fields
Fields annotated with @db.json reject all patch operators and field operations ($inc, $dec, $mul). The field is stored as a single opaque JSON column — the database cannot operate on individual keys inside it. Only plain replacement is allowed:
@db.json
settings: {
theme: string
notifications: boolean
}// ✅ Works — plain replacement
await table.updateOne({
id: 1,
settings: { theme: "dark", notifications: false },
});
// ❌ Fails — patch operators rejected on @db.json fields
await table.updateOne({
id: 1,
settings: $replace({ theme: "dark" }),
});
// ❌ Fails — field ops rejected inside @db.json fields
await table.updateOne({
id: 1,
settings: { notifications: $inc() },
});The same applies to @db.json arrays — use a plain array value instead of patch operators.
Combining Operators
Multiple operators can be used on the same field, and multiple fields can be patched in one request. When combining operators on a single field, use the raw object form — sentinel helpers return single-operator objects and cannot be merged:
import { $insert, $inc } from "@atscript/db/ops";
await table.updateOne({
id: 1,
// Multiple ops on one field — use raw object
variants: {
$remove: [{ sku: "OLD" }],
$update: [{ sku: "A1", color: "red", stock: 5 }],
$insert: [{ sku: "NEW", color: "green", stock: 10 }],
},
// Single op on a field — use sentinel helper
tags: $insert(["reviewed"]),
views: $inc(),
title: "Updated title",
});Operators are always applied in order: remove → update → upsert → insert.
maxDepth Option
bulkUpdate and bulkReplace (and through them, updateOne / replaceOne) accept an optional second argument that caps how deep the runtime is allowed to recurse into nested relation payloads:
await users.bulkUpdate(payloads, { maxDepth: 5 });- The default is
3— enough for typical "user → profile → address" chains. - This is a runtime cap on recursion; it sits below
@db.depth.limit, which is the declarative server-boundary check. Both have to allow the depth for a deep write to succeed. - Setting
maxDepthlower than your payload's depth raises aDepthLimitExceededError(DEPTH_EXCEEDED) before any database write.
What This Page Does NOT Cover
Navigation property patches — operating on related records across foreign keys using the same $insert, $update, $remove, $upsert, and $replace operators on FROM and VIA relations — are covered in Relations — Relational Patches.
Next Steps
- CRUD Operations — Basic insert, read, update, delete
- Queries & Filters — Filtering, sorting, and projection
- Transactions — Atomic multi-table operations
- Relations — Relational Patches — Patching navigation properties