| name | perform-atomic-ops |
| description | Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.delete`, `$inc`, `$set`; "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: multi-row atomicity — `@warlock.js/cascade/manage-transactions/SKILL.md`; competing patterns `mongoose findOneAndUpdate`, `pg` `UPDATE ... SET x = x + 1`. |
Use atomic operations
When two requests want to change the same row at the same time, you need atomicity — a guarantee that one operation completes before the other reads. For multi-document atomicity use transactions; for single-document atomic mutations these are the right tools.
Counters — Model.increase / Model.decrease
await Post.increase({ id: postId }, "views", 1);
await Product.decrease({ id: productId }, "inventory", 1);
Signature: Model.increase(filter, field, amount) / Model.decrease(filter, field, amount) → Promise<number> (matched count). Atomic at the storage layer — no read-modify-write race even under high concurrency.
Arbitrary atomic mutations — Model.atomic
await User.atomic({ id: userId }, {
$set: { last_seen: new Date() },
$inc: { login_count: 1 },
});
Model.atomic(filter, operations) → Promise<number>. Driver-flavored atomic mutation — MongoDB has $set / $inc / $push / $pull; the Postgres driver translates the equivalents. Use when you need to combine multiple field changes atomically without loading the model first.
Bulk insert — Model.createMany
const created = await OrderItem.createMany([
{ order_id, product_id: 1, quantity: 2 },
{ order_id, product_id: 2, quantity: 1 },
{ order_id, product_id: 3, quantity: 5 },
]);
Model.createMany(rows, options?) → Promise<TModel[]>. options is { batchSize?: number; bulk?: boolean }. Validation runs per row; wrap in a transaction if you need strict all-or-nothing semantics.
batchSize — chunking (both paths)
A huge array is processed in sequential chunks so it can't flood the driver. batchSize sets the chunk size; the default is 500 (DEFAULT_CREATE_MANY_BATCH_SIZE). Each chunk completes before the next starts:
await OrderItem.createMany(millionRows, { batchSize: 1000 });
bulk: true — native multi-row insert (10–100× faster)
The default path persists each row through save(), so model hooks, lifecycle events (saving / creating / created / saved), casts, and generated ids are all preserved. Pass bulk: true to instead route each chunk to the driver's native multi-row insertMany for 10–100× throughput on large arrays:
const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
Tradeoff. The bulk path SKIPS the per-row lifecycle — no saving / creating / created / saved events, no instance hooks, no sync. What it KEEPS: rows are still prepped through the writer pipeline, so validation, casts, timestamps, defaults, and id-generation still run and the persisted columns match the default path; driver-returned values (generated _id, timestamps, SQL RETURNING *) are merged back onto the returned models. Reach for bulk: true when inserting large batches where per-row events don't matter (seeders, imports, denormalization); stay on the default path when you need the hooks/events.
Bulk update — Model.findAndUpdate(filter, operations)
const updated = await User.findAndUpdate(
{ status: "pending" },
{ $set: { status: "active" } },
);
Model.findAndUpdate(filter, operations) takes update operators ($set / $inc / $unset), not a plain data object, and returns the updated models. For a single record there's Model.findOneAndUpdate(filter, operations) → TModel | null, and to update strictly by id, Model.update(id, data) → Promise<number>.
Important. Per-instance lifecycle saved events do NOT fire for each row on findAndUpdate. If you need saved per row, iterate with .get() and .save() instead — slower but event-correct.
Bulk delete — Model.delete(filter)
await User.delete({ status: "spam" });
await User.deleteOne({ status: "spam" });
Model.delete(filter?) and Model.deleteOne(filter?) both return Promise<number>. These bypass the per-instance delete strategy and deleted events — they are raw driver deletes.
For per-row event-aware (and delete-strategy-aware) bulk delete, iterate:
const targets = await User.where("status", "spam").get();
for (const user of targets) {
await user.destroy();
}
When to reach for what
| Task | Reach for |
|---|
| Increment a counter | Model.increase(filter, field, n) |
| Atomically change multiple fields on one record | Model.atomic(filter, ops) |
| Insert N records | Model.createMany(rows) |
| Insert a large batch fast (no per-row events) | Model.createMany(rows, { bulk: true, batchSize }) |
| Update many rows with operators | Model.findAndUpdate(filter, { $set: {...} }) |
| Update one record by id | Model.update(id, data) |
| Delete many rows (raw) | Model.delete(filter) |
| Multi-row read-modify-write | Wrap in a transaction |
| Need lifecycle events / delete strategy per row | Model.where(...).get() + iterate + .save() / .destroy() |
Things NOT to do
- Don't
const post = await Post.find(id); post.set("views", post.get<number>("views") + 1); await post.save(); for a counter. That's a lost-update race under concurrency. Use Post.increase(filter, "views", 1).
- Don't reach for
insertMany / updateMany / deleteMany — those names don't exist on the model. Use createMany / findAndUpdate / delete.
- Don't expect
findAndUpdate / delete to fire per-row saved / deleted events or honor the delete strategy. They don't. Iterate if you need that.
- Don't hand-roll chunking around
createMany — it already chunks by batchSize (default 500). Tune batchSize instead of slicing the array yourself.
- Don't assume
bulk: true fires per-row saving / created / saved events or runs instance hooks — it doesn't. Casts/timestamps/defaults/ids still apply, but if you need the lifecycle, use the default path (or iterate with .save()).
See also