@warlock.js/cascade 5.24.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/README.md +101 -101
  3. package/bin/cascade.js +0 -0
  4. package/cjs/index.cjs +201 -71
  5. package/cjs/index.cjs.map +1 -1
  6. package/esm/contracts/database-driver.contract.d.mts +22 -1
  7. package/esm/contracts/database-driver.contract.d.mts.map +1 -1
  8. package/esm/contracts/index.d.mts +1 -1
  9. package/esm/data-source/data-source-registry.d.mts +8 -6
  10. package/esm/data-source/data-source-registry.d.mts.map +1 -1
  11. package/esm/data-source/data-source-registry.mjs +7 -1
  12. package/esm/data-source/data-source-registry.mjs.map +1 -1
  13. package/esm/drivers/mongodb/mongodb-driver.d.mts +13 -0
  14. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  15. package/esm/drivers/mongodb/mongodb-driver.mjs +71 -2
  16. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  17. package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
  18. package/esm/drivers/mongodb/mongodb-query-builder.mjs +2 -1
  19. package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
  20. package/esm/drivers/postgres/postgres-driver.d.mts +9 -0
  21. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  22. package/esm/drivers/postgres/postgres-driver.mjs +55 -7
  23. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  24. package/esm/drivers/postgres/postgres-query-parser.d.mts +1 -4
  25. package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
  26. package/esm/drivers/postgres/postgres-query-parser.mjs +4 -5
  27. package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
  28. package/esm/errors/unsafe-filter.error.mjs.map +1 -1
  29. package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -1
  30. package/esm/errors/unsupported-lock-for-update.error.d.mts +11 -0
  31. package/esm/errors/unsupported-lock-for-update.error.d.mts.map +1 -0
  32. package/esm/errors/unsupported-lock-for-update.error.mjs +16 -0
  33. package/esm/errors/unsupported-lock-for-update.error.mjs.map +1 -0
  34. package/esm/events/model-events.mjs.map +1 -1
  35. package/esm/index.d.mts +3 -2
  36. package/esm/index.mjs +2 -1
  37. package/esm/migration/migration-runner.mjs.map +1 -1
  38. package/esm/migration/sql-grammar.mjs.map +1 -1
  39. package/esm/model/generate-model-id.mjs +23 -0
  40. package/esm/model/generate-model-id.mjs.map +1 -0
  41. package/esm/model/methods/delete-methods.mjs.map +1 -1
  42. package/esm/model/methods/meta-methods.mjs.map +1 -1
  43. package/esm/model/methods/query-methods.mjs +25 -2
  44. package/esm/model/methods/query-methods.mjs.map +1 -1
  45. package/esm/model/methods/write-methods.mjs.map +1 -1
  46. package/esm/query-builder/query-builder.mjs +1 -1
  47. package/esm/query-builder/query-builder.mjs.map +1 -1
  48. package/esm/utils/sanitize-filter.mjs.map +1 -1
  49. package/esm/validation/plugins/database-rules-plugin.mjs.map +1 -1
  50. package/esm/writer/database-writer.d.mts +0 -33
  51. package/esm/writer/database-writer.d.mts.map +1 -1
  52. package/esm/writer/database-writer.mjs +3 -52
  53. package/esm/writer/database-writer.mjs.map +1 -1
  54. package/llms-full.txt +2901 -2864
  55. package/package.json +5 -5
  56. package/skills/aggregate-data/SKILL.md +165 -165
  57. package/skills/alter-migration/SKILL.md +531 -531
  58. package/skills/configure-delete-strategy/SKILL.md +130 -130
  59. package/skills/define-model/SKILL.md +205 -205
  60. package/skills/define-relations/SKILL.md +171 -171
  61. package/skills/manage-data-sources/SKILL.md +177 -140
  62. package/skills/manage-transactions/SKILL.md +215 -205
  63. package/skills/paginate-results/SKILL.md +122 -122
  64. package/skills/perform-atomic-ops/SKILL.md +156 -156
  65. package/skills/query-data/SKILL.md +213 -213
  66. package/skills/run-cascade-cli/SKILL.md +125 -125
  67. package/skills/search-by-vector/SKILL.md +127 -127
  68. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  69. package/skills/track-changes/SKILL.md +127 -127
  70. package/skills/write-migration/SKILL.md +206 -206
@@ -1,122 +1,122 @@
1
- ---
2
- name: paginate-results
3
- description: 'Paginate query results — `.paginate({page, limit, filter?})` for offset (returns `data` + `pagination` total/page/limit/pages), `.cursorPaginate({limit, cursor})` for very large datasets, `.chunk(size, callback)` for streaming. Triggers: `.paginate`, `.cursorPaginate`, `.chunk`, `nextCursor`, `hasMore`, `pagination.total`; "paginate the list", "infinite scroll / load more", "stream a large table", "page 2 of users"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: filter chain — `@warlock.js/cascade/query-data/SKILL.md`; eager loading on pages — `@warlock.js/cascade/define-relations/SKILL.md`; competing libs `mongoose-paginate-v2`, `prisma` cursor, `typeorm-pagination`.'
4
- ---
5
-
6
- # Paginate results
7
-
8
- Three paginations. Pick by dataset size and access pattern.
9
-
10
- ## Offset pagination — `.paginate({ page, limit })`
11
-
12
- The everyday case for listings with page numbers:
13
-
14
- ```ts
15
- const page = await User.paginate({ page: 1, limit: 20 });
16
-
17
- page.data; // User[]
18
- page.pagination; // { total, page, limit, pages }
19
- ```
20
-
21
- `PaginationOptions` is `{ page?, limit? }` — there is no `filter` field; filter by chaining `.where()` before `.paginate()` (below).
22
-
23
- Chain off `.where()` for filtered pagination:
24
-
25
- ```ts
26
- const activePage = await User
27
- .where("status", "active")
28
- .paginate({ page: 2, limit: 20 });
29
- ```
30
-
31
- **Cost characteristic.** Offset pagination scans `offset + limit` rows on every page — page 100 with limit 20 scans 2020 rows just to skip 2000. Fine for the first few pages; not great deep in the result set.
32
-
33
- ## Cursor pagination — `.cursorPaginate({ limit, cursor? })`
34
-
35
- For very large datasets where deep pagination matters:
36
-
37
- ```ts
38
- const first = await User.query().orderBy("created_at", "desc").cursorPaginate({ limit: 20 });
39
-
40
- first.data; // User[]
41
- first.pagination.nextCursor; // opaque value — pass to the next call
42
- first.pagination.hasMore; // boolean
43
-
44
- const next = await User.query()
45
- .orderBy("created_at", "desc")
46
- .cursorPaginate({ limit: 20, cursor: first.pagination.nextCursor });
47
- ```
48
-
49
- The cursor fields live under `pagination` (`{ hasMore, nextCursor?, hasPrev?, prevCursor? }`), not at the top level. `cursorPaginate` and `orderBy` are query-builder methods, so start the chain with `User.query()` (or any static that returns a builder, like `User.where(...)`).
50
-
51
- **Cost characteristic.** Constant time per page regardless of how far in. The cursor encodes the last record's sort key — the next query is "give me records after this point," indexed.
52
-
53
- **Tradeoff.** No "total page count" — cursor pagination doesn't know how many records remain. If the UI shows "Page 3 of 50," reach for `.paginate()` instead. If it shows "Load more," cursor wins.
54
-
55
- ## Chunked processing — `.chunk(size, callback)`
56
-
57
- For backfills, exports, and "process every record" loops:
58
-
59
- ```ts
60
- await User.where("status", "active").chunk(500, async (users) => {
61
- for (const user of users) {
62
- await sendEmail(user);
63
- }
64
- });
65
- ```
66
-
67
- `.chunk(size, fn)` streams the table 500 records at a time, calling `fn` per batch. Constant memory regardless of total row count.
68
-
69
- Return `false` from the callback to stop early:
70
-
71
- ```ts
72
- let processed = 0;
73
- await User.query().chunk(500, async (users) => {
74
- for (const user of users) {
75
- await process(user);
76
- processed++;
77
- if (processed >= 10_000) return false;
78
- }
79
- });
80
- ```
81
-
82
- `chunk` is a query-builder method — start from `User.query()` (or `User.where(...)`) before chaining it.
83
-
84
- ## Pagination + relations
85
-
86
- Eager-load relations on a paginated page:
87
-
88
- ```ts
89
- const page = await Post.with("author").paginate({ page: 1, limit: 20 });
90
- ```
91
-
92
- See [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md).
93
-
94
- ## Pagination shape
95
-
96
- The default offset paginator returns:
97
-
98
- ```ts
99
- {
100
- data: T[],
101
- pagination: {
102
- total: number, // total matching records (extra COUNT query)
103
- page: number, // current page
104
- limit: number, // page size
105
- pages: number, // total pages
106
- },
107
- }
108
- ```
109
-
110
- The total count requires an extra query. On very large filtered tables, this can dominate page-load time — switch to cursor pagination if the total isn't user-facing.
111
-
112
- ## Things NOT to do
113
-
114
- - Don't use offset pagination for "Load more" infinite-scroll UIs. Cursor pagination is built for it; offset re-scans on every load.
115
- - Don't fetch `.all()` and slice in memory for pagination. Always page at the query layer.
116
- - Don't omit `.orderBy()` from `.cursorPaginate()`. The cursor encodes the sort key — without one, the cursor is meaningless and ordering is driver-dependent.
117
- - Don't keep cursor strings around past their stability window — schema changes that alter the sort key can invalidate stored cursors.
118
-
119
- ## See also
120
-
121
- - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — `.where`, `.orderBy`, filter chains
122
- - [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md) — eager loading on a paginated page
1
+ ---
2
+ name: paginate-results
3
+ description: 'Paginate query results — `.paginate({page, limit, filter?})` for offset (returns `data` + `pagination` total/page/limit/pages), `.cursorPaginate({limit, cursor})` for very large datasets, `.chunk(size, callback)` for streaming. Triggers: `.paginate`, `.cursorPaginate`, `.chunk`, `nextCursor`, `hasMore`, `pagination.total`; "paginate the list", "infinite scroll / load more", "stream a large table", "page 2 of users"; typical import `import { Model } from "@warlock.js/cascade"`. Skip: filter chain — `@warlock.js/cascade/query-data/SKILL.md`; eager loading on pages — `@warlock.js/cascade/define-relations/SKILL.md`; competing libs `mongoose-paginate-v2`, `prisma` cursor, `typeorm-pagination`.'
4
+ ---
5
+
6
+ # Paginate results
7
+
8
+ Three paginations. Pick by dataset size and access pattern.
9
+
10
+ ## Offset pagination — `.paginate({ page, limit })`
11
+
12
+ The everyday case for listings with page numbers:
13
+
14
+ ```ts
15
+ const page = await User.paginate({ page: 1, limit: 20 });
16
+
17
+ page.data; // User[]
18
+ page.pagination; // { total, page, limit, pages }
19
+ ```
20
+
21
+ `PaginationOptions` is `{ page?, limit? }` — there is no `filter` field; filter by chaining `.where()` before `.paginate()` (below).
22
+
23
+ Chain off `.where()` for filtered pagination:
24
+
25
+ ```ts
26
+ const activePage = await User
27
+ .where("status", "active")
28
+ .paginate({ page: 2, limit: 20 });
29
+ ```
30
+
31
+ **Cost characteristic.** Offset pagination scans `offset + limit` rows on every page — page 100 with limit 20 scans 2020 rows just to skip 2000. Fine for the first few pages; not great deep in the result set.
32
+
33
+ ## Cursor pagination — `.cursorPaginate({ limit, cursor? })`
34
+
35
+ For very large datasets where deep pagination matters:
36
+
37
+ ```ts
38
+ const first = await User.query().orderBy("created_at", "desc").cursorPaginate({ limit: 20 });
39
+
40
+ first.data; // User[]
41
+ first.pagination.nextCursor; // opaque value — pass to the next call
42
+ first.pagination.hasMore; // boolean
43
+
44
+ const next = await User.query()
45
+ .orderBy("created_at", "desc")
46
+ .cursorPaginate({ limit: 20, cursor: first.pagination.nextCursor });
47
+ ```
48
+
49
+ The cursor fields live under `pagination` (`{ hasMore, nextCursor?, hasPrev?, prevCursor? }`), not at the top level. `cursorPaginate` and `orderBy` are query-builder methods, so start the chain with `User.query()` (or any static that returns a builder, like `User.where(...)`).
50
+
51
+ **Cost characteristic.** Constant time per page regardless of how far in. The cursor encodes the last record's sort key — the next query is "give me records after this point," indexed.
52
+
53
+ **Tradeoff.** No "total page count" — cursor pagination doesn't know how many records remain. If the UI shows "Page 3 of 50," reach for `.paginate()` instead. If it shows "Load more," cursor wins.
54
+
55
+ ## Chunked processing — `.chunk(size, callback)`
56
+
57
+ For backfills, exports, and "process every record" loops:
58
+
59
+ ```ts
60
+ await User.where("status", "active").chunk(500, async (users) => {
61
+ for (const user of users) {
62
+ await sendEmail(user);
63
+ }
64
+ });
65
+ ```
66
+
67
+ `.chunk(size, fn)` streams the table 500 records at a time, calling `fn` per batch. Constant memory regardless of total row count.
68
+
69
+ Return `false` from the callback to stop early:
70
+
71
+ ```ts
72
+ let processed = 0;
73
+ await User.query().chunk(500, async (users) => {
74
+ for (const user of users) {
75
+ await process(user);
76
+ processed++;
77
+ if (processed >= 10_000) return false;
78
+ }
79
+ });
80
+ ```
81
+
82
+ `chunk` is a query-builder method — start from `User.query()` (or `User.where(...)`) before chaining it.
83
+
84
+ ## Pagination + relations
85
+
86
+ Eager-load relations on a paginated page:
87
+
88
+ ```ts
89
+ const page = await Post.with("author").paginate({ page: 1, limit: 20 });
90
+ ```
91
+
92
+ See [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md).
93
+
94
+ ## Pagination shape
95
+
96
+ The default offset paginator returns:
97
+
98
+ ```ts
99
+ {
100
+ data: T[],
101
+ pagination: {
102
+ total: number, // total matching records (extra COUNT query)
103
+ page: number, // current page
104
+ limit: number, // page size
105
+ pages: number, // total pages
106
+ },
107
+ }
108
+ ```
109
+
110
+ The total count requires an extra query. On very large filtered tables, this can dominate page-load time — switch to cursor pagination if the total isn't user-facing.
111
+
112
+ ## Things NOT to do
113
+
114
+ - Don't use offset pagination for "Load more" infinite-scroll UIs. Cursor pagination is built for it; offset re-scans on every load.
115
+ - Don't fetch `.all()` and slice in memory for pagination. Always page at the query layer.
116
+ - Don't omit `.orderBy()` from `.cursorPaginate()`. The cursor encodes the sort key — without one, the cursor is meaningless and ordering is driver-dependent.
117
+ - Don't keep cursor strings around past their stability window — schema changes that alter the sort key can invalidate stored cursors.
118
+
119
+ ## See also
120
+
121
+ - [`@warlock.js/cascade/query-data/SKILL.md`](@warlock.js/cascade/query-data/SKILL.md) — `.where`, `.orderBy`, filter chains
122
+ - [`@warlock.js/cascade/define-relations/SKILL.md`](@warlock.js/cascade/define-relations/SKILL.md) — eager loading on a paginated page
@@ -1,156 +1,156 @@
1
- ---
2
- name: perform-atomic-ops
3
- description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops, options?)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull` / `$addToSet` / `$setOnInsert`, pipeline updates, `upsert`, `returnDocument`, `arrayFilters`, `trustedFilter`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. `atomic()` / `findAndUpdate()` / `findOneAndUpdate()` / `findAndReplace()` / `findOneAndDelete()` sanitize their `filter` argument — `$`-prefixed keys throw `UnsafeFilterError`, same check as `where()`. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.findOneAndUpdate`, `Model.findAndReplace`, `Model.findOneAndDelete`, `Model.delete`, `$inc`, `$set`, `UnsafeFilterError`, `UnsupportedUpdateOperationError`, `upsert`, `returnDocument`, `$setOnInsert`; "upsert a counter", "reserve quota atomically", "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading", "is atomic() safe with a request body filter"; 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`.'
4
- ---
5
-
6
- # Use atomic operations
7
-
8
- 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.
9
-
10
- ## Counters — `Model.increase` / `Model.decrease`
11
-
12
- ```ts
13
- await Post.increase({ id: postId }, "views", 1);
14
- await Product.decrease({ id: productId }, "inventory", 1);
15
- ```
16
-
17
- 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.
18
-
19
- ## Arbitrary atomic mutations — `Model.atomic`
20
-
21
- ```ts
22
- await User.atomic({ id: userId }, {
23
- $set: { last_seen: new Date() },
24
- $inc: { login_count: 1 },
25
- });
26
- ```
27
-
28
- `Model.atomic(filter, operations)` → `Promise<number>`. Driver-flavored atomic mutation — MongoDB has `$set` / `$inc` / `$push` / `$pull` natively; the Postgres driver supports `$set` / `$unset` / `$inc` / `$dec` (and `$setOnInsert` on upsert) and throws `UnsupportedUpdateOperationError` for the rest. Use when you need to combine multiple field changes atomically without loading the model first.
29
-
30
- **`filter` is sanitized like `where()`.** `atomic()`, `findAndUpdate()`, `findOneAndUpdate()`, `findAndReplace()` and `findOneAndDelete()` all run their `filter` argument through the same `$`-prefixed-key check as `Model.where()` (see [`query-data`](@warlock.js/cascade/query-data/SKILL.md)) and throw `UnsafeFilterError` on a key like `$ne`. Before this, these five bypassed `where()` entirely, so a filter forwarded straight from a request body — `User.atomic(req.body.filter, { $set: { role: "admin" } })` — could still smuggle an operator like `{ role: { $ne: "admin" } }` through as a live query even though `where()` itself was already guarded. Only the FILTER is checked — the update-operator object (`$set`/`$inc`/`$unset`/…) is untouched, since that's meant to carry `$` keys. If you legitimately need operator conditions in the filter, express them through `Model.query().where(...)`, or pass `{ trustedFilter: true }` for a code-authored filter (see below).
31
-
32
- ## Upsert, returnDocument, pipelines — the options argument
33
-
34
- ```ts
35
- // Counter: filter + $inc + $setOnInsert + upsert → the new document, one call
36
- const counter = await Counter.findOneAndUpdate(
37
- { key: "signups" },
38
- { $inc: { count: 1 }, $setOnInsert: { startedAt: new Date() } },
39
- { upsert: true }, // returnDocument defaults to "after"
40
- );
41
-
42
- // Quota reservation: the conditional filter needs trustedFilter (code-authored only!)
43
- const granted = await Quota.atomic(
44
- { id: quotaId, used: { $lt: 10 } },
45
- { $inc: { used: 1 } },
46
- { trustedFilter: true },
47
- ); // 1 = reserved, 0 = quota full. 50 parallel calls → exactly 10 succeed
48
-
49
- // MongoDB only: pipeline update, $addToSet, arrayFilters
50
- await Post.findOneAndUpdate({ slug }, [{ $set: { score: { $add: ["$likes", "$shares"] } } }]);
51
- await Post.atomic({ slug }, { $set: { "grades.$[low].score": 50 } }, { arrayFilters: [{ "low.score": { $lt: 50 } }] });
52
- ```
53
-
54
- - `atomic(filter, update, { upsert?, arrayFilters?, trustedFilter? })` → modified + upserted count.
55
- - `findOneAndUpdate(filter, update, { ...same, returnDocument?: "before" | "after" })`.
56
- - `findAndUpdate(filter, update, { upsert?, arrayFilters? })`. No `trustedFilter`, because it reads the rows back with `where(filter)`.
57
-
58
- **Postgres.** An upsert runs as `INSERT … ON CONFLICT (target) DO UPDATE … RETURNING *`. The target is the primary key, or a unique index, whose columns are ALL equality keys of the filter. Other filter conditions become `DO UPDATE … WHERE`. If the row exists and fails them, nothing is written and `findOneAndUpdate` returns `null`. The driver throws `UnsupportedUpdateOperationError` (`.operation`, `.driver`) for:
59
- - pipeline updates
60
- - `arrayFilters`
61
- - `$push`, `$pull`, `$addToSet` and unknown operators
62
- - `returnDocument: "before"` together with `upsert`
63
- - an upsert with no unique target it can find
64
-
65
- It never ignores one of these without throwing.
66
-
67
- ## Bulk insert — `Model.createMany`
68
-
69
- ```ts
70
- const created = await OrderItem.createMany([
71
- { order_id, product_id: 1, quantity: 2 },
72
- { order_id, product_id: 2, quantity: 1 },
73
- { order_id, product_id: 3, quantity: 5 },
74
- ]);
75
- // created: OrderItem[]
76
- ```
77
-
78
- `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.
79
-
80
- ### `batchSize` — chunking (both paths)
81
-
82
- 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:
83
-
84
- ```ts
85
- await OrderItem.createMany(millionRows, { batchSize: 1000 });
86
- ```
87
-
88
- ### `bulk: true` — native multi-row insert (10–100× faster)
89
-
90
- 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:
91
-
92
- ```ts
93
- const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
94
- ```
95
-
96
- **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.
97
-
98
- ## Bulk update — `Model.findAndUpdate(filter, operations)`
99
-
100
- ```ts
101
- const updated = await User.findAndUpdate(
102
- { status: "pending" },
103
- { $set: { status: "active" } },
104
- );
105
- // updated: User[] — the matched-and-updated models
106
- ```
107
-
108
- `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>`.
109
-
110
- **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.
111
-
112
- ## Bulk delete — `Model.delete(filter)`
113
-
114
- ```ts
115
- await User.delete({ status: "spam" }); // delete all matching → count
116
- await User.deleteOne({ status: "spam" }); // delete the first match → count
117
- ```
118
-
119
- `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.
120
-
121
- For per-row event-aware (and delete-strategy-aware) bulk delete, iterate:
122
-
123
- ```ts
124
- const targets = await User.where("status", "spam").get();
125
- for (const user of targets) {
126
- await user.destroy();
127
- }
128
- ```
129
-
130
- ## When to reach for what
131
-
132
- | Task | Reach for |
133
- | --- | --- |
134
- | Increment a counter | `Model.increase(filter, field, n)` |
135
- | Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
136
- | Insert N records | `Model.createMany(rows)` |
137
- | Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
138
- | Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
139
- | Update one record by id | `Model.update(id, data)` |
140
- | Delete many rows (raw) | `Model.delete(filter)` |
141
- | Multi-row read-modify-write | Wrap in a [transaction](@warlock.js/cascade/manage-transactions/SKILL.md) |
142
- | Need lifecycle events / delete strategy per row | `Model.where(...).get()` + iterate + `.save()` / `.destroy()` |
143
-
144
- ## Things NOT to do
145
-
146
- - 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)`.
147
- - Don't reach for `insertMany` / `updateMany` / `deleteMany` — those names don't exist on the model. Use `createMany` / `findAndUpdate` / `delete`.
148
- - 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.
149
- - Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
150
- - 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()`).
151
- - Don't forward a raw request-body object as the `filter` to `atomic()`/`findAndUpdate()`/`findOneAndUpdate()`/`findAndReplace()`/`findOneAndDelete()` expecting it to be un-sanitized — it isn't, `$`-prefixed keys throw `UnsafeFilterError` — but don't rely on that as your only authorization check either; it blocks operator injection, not "does this caller own this row."
152
-
153
- ## See also
154
-
155
- - [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md) — multi-row atomicity
156
- - [`@warlock.js/cascade/paginate-results/SKILL.md`](@warlock.js/cascade/paginate-results/SKILL.md) — `.chunk` for bulk-processing iteration
1
+ ---
2
+ name: perform-atomic-ops
3
+ description: 'Avoid races on concurrent writes — `Model.increase(filter, field, n)` / `Model.decrease` for atomic counters, `Model.atomic(filter, ops, options?)` for arbitrary mutations (`$set` / `$inc` / `$push` / `$pull` / `$addToSet` / `$setOnInsert`, pipeline updates, `upsert`, `returnDocument`, `arrayFilters`, `trustedFilter`), `Model.createMany` / `Model.findAndUpdate` / `Model.delete` for bulk. `atomic()` / `findAndUpdate()` / `findOneAndUpdate()` / `findAndReplace()` / `findOneAndDelete()` sanitize their `filter` argument — `$`-prefixed keys throw `UnsafeFilterError`, same check as `where()`. Triggers: `Model.increase`, `Model.decrease`, `Model.atomic`, `Model.createMany`, `createMany bulk`, `batchSize`, `Model.findAndUpdate`, `Model.findOneAndUpdate`, `Model.findAndReplace`, `Model.findOneAndDelete`, `Model.delete`, `$inc`, `$set`, `UnsafeFilterError`, `UnsupportedUpdateOperationError`, `upsert`, `returnDocument`, `$setOnInsert`; "upsert a counter", "reserve quota atomically", "increment counter under concurrency", "bulk insert without N+1", "fast bulk insert", "insert thousands of rows", "atomic update without loading", "is atomic() safe with a request body filter"; 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`.'
4
+ ---
5
+
6
+ # Use atomic operations
7
+
8
+ 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.
9
+
10
+ ## Counters — `Model.increase` / `Model.decrease`
11
+
12
+ ```ts
13
+ await Post.increase({ id: postId }, "views", 1);
14
+ await Product.decrease({ id: productId }, "inventory", 1);
15
+ ```
16
+
17
+ 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.
18
+
19
+ ## Arbitrary atomic mutations — `Model.atomic`
20
+
21
+ ```ts
22
+ await User.atomic({ id: userId }, {
23
+ $set: { last_seen: new Date() },
24
+ $inc: { login_count: 1 },
25
+ });
26
+ ```
27
+
28
+ `Model.atomic(filter, operations)` → `Promise<number>`. Driver-flavored atomic mutation — MongoDB has `$set` / `$inc` / `$push` / `$pull` natively; the Postgres driver supports `$set` / `$unset` / `$inc` / `$dec` (and `$setOnInsert` on upsert) and throws `UnsupportedUpdateOperationError` for the rest. Use when you need to combine multiple field changes atomically without loading the model first.
29
+
30
+ **`filter` is sanitized like `where()`.** `atomic()`, `findAndUpdate()`, `findOneAndUpdate()`, `findAndReplace()` and `findOneAndDelete()` all run their `filter` argument through the same `$`-prefixed-key check as `Model.where()` (see [`query-data`](@warlock.js/cascade/query-data/SKILL.md)) and throw `UnsafeFilterError` on a key like `$ne`. Before this, these five bypassed `where()` entirely, so a filter forwarded straight from a request body — `User.atomic(req.body.filter, { $set: { role: "admin" } })` — could still smuggle an operator like `{ role: { $ne: "admin" } }` through as a live query even though `where()` itself was already guarded. Only the FILTER is checked — the update-operator object (`$set`/`$inc`/`$unset`/…) is untouched, since that's meant to carry `$` keys. If you legitimately need operator conditions in the filter, express them through `Model.query().where(...)`, or pass `{ trustedFilter: true }` for a code-authored filter (see below).
31
+
32
+ ## Upsert, returnDocument, pipelines — the options argument
33
+
34
+ ```ts
35
+ // Counter: filter + $inc + $setOnInsert + upsert → the new document, one call
36
+ const counter = await Counter.findOneAndUpdate(
37
+ { key: "signups" },
38
+ { $inc: { count: 1 }, $setOnInsert: { startedAt: new Date() } },
39
+ { upsert: true }, // returnDocument defaults to "after"
40
+ );
41
+
42
+ // Quota reservation: the conditional filter needs trustedFilter (code-authored only!)
43
+ const granted = await Quota.atomic(
44
+ { id: quotaId, used: { $lt: 10 } },
45
+ { $inc: { used: 1 } },
46
+ { trustedFilter: true },
47
+ ); // 1 = reserved, 0 = quota full. 50 parallel calls → exactly 10 succeed
48
+
49
+ // MongoDB only: pipeline update, $addToSet, arrayFilters
50
+ await Post.findOneAndUpdate({ slug }, [{ $set: { score: { $add: ["$likes", "$shares"] } } }]);
51
+ await Post.atomic({ slug }, { $set: { "grades.$[low].score": 50 } }, { arrayFilters: [{ "low.score": { $lt: 50 } }] });
52
+ ```
53
+
54
+ - `atomic(filter, update, { upsert?, arrayFilters?, trustedFilter? })` → modified + upserted count.
55
+ - `findOneAndUpdate(filter, update, { ...same, returnDocument?: "before" | "after" })`.
56
+ - `findAndUpdate(filter, update, { upsert?, arrayFilters? })`. No `trustedFilter`, because it reads the rows back with `where(filter)`.
57
+
58
+ **Postgres.** An upsert runs as `INSERT … ON CONFLICT (target) DO UPDATE … RETURNING *`. The target is the primary key, or a unique index, whose columns are ALL equality keys of the filter. Other filter conditions become `DO UPDATE … WHERE`. If the row exists and fails them, nothing is written and `findOneAndUpdate` returns `null`. The driver throws `UnsupportedUpdateOperationError` (`.operation`, `.driver`) for:
59
+ - pipeline updates
60
+ - `arrayFilters`
61
+ - `$push`, `$pull`, `$addToSet` and unknown operators
62
+ - `returnDocument: "before"` together with `upsert`
63
+ - an upsert with no unique target it can find
64
+
65
+ It never ignores one of these without throwing.
66
+
67
+ ## Bulk insert — `Model.createMany`
68
+
69
+ ```ts
70
+ const created = await OrderItem.createMany([
71
+ { order_id, product_id: 1, quantity: 2 },
72
+ { order_id, product_id: 2, quantity: 1 },
73
+ { order_id, product_id: 3, quantity: 5 },
74
+ ]);
75
+ // created: OrderItem[]
76
+ ```
77
+
78
+ `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.
79
+
80
+ ### `batchSize` — chunking (both paths)
81
+
82
+ 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:
83
+
84
+ ```ts
85
+ await OrderItem.createMany(millionRows, { batchSize: 1000 });
86
+ ```
87
+
88
+ ### `bulk: true` — native multi-row insert (10–100× faster)
89
+
90
+ 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:
91
+
92
+ ```ts
93
+ const rows = await OrderItem.createMany(millionRows, { bulk: true, batchSize: 1000 });
94
+ ```
95
+
96
+ **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.
97
+
98
+ ## Bulk update — `Model.findAndUpdate(filter, operations)`
99
+
100
+ ```ts
101
+ const updated = await User.findAndUpdate(
102
+ { status: "pending" },
103
+ { $set: { status: "active" } },
104
+ );
105
+ // updated: User[] — the matched-and-updated models
106
+ ```
107
+
108
+ `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>`.
109
+
110
+ **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.
111
+
112
+ ## Bulk delete — `Model.delete(filter)`
113
+
114
+ ```ts
115
+ await User.delete({ status: "spam" }); // delete all matching → count
116
+ await User.deleteOne({ status: "spam" }); // delete the first match → count
117
+ ```
118
+
119
+ `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.
120
+
121
+ For per-row event-aware (and delete-strategy-aware) bulk delete, iterate:
122
+
123
+ ```ts
124
+ const targets = await User.where("status", "spam").get();
125
+ for (const user of targets) {
126
+ await user.destroy();
127
+ }
128
+ ```
129
+
130
+ ## When to reach for what
131
+
132
+ | Task | Reach for |
133
+ | --- | --- |
134
+ | Increment a counter | `Model.increase(filter, field, n)` |
135
+ | Atomically change multiple fields on one record | `Model.atomic(filter, ops)` |
136
+ | Insert N records | `Model.createMany(rows)` |
137
+ | Insert a large batch fast (no per-row events) | `Model.createMany(rows, { bulk: true, batchSize })` |
138
+ | Update many rows with operators | `Model.findAndUpdate(filter, { $set: {...} })` |
139
+ | Update one record by id | `Model.update(id, data)` |
140
+ | Delete many rows (raw) | `Model.delete(filter)` |
141
+ | Multi-row read-modify-write | Wrap in a [transaction](@warlock.js/cascade/manage-transactions/SKILL.md) |
142
+ | Need lifecycle events / delete strategy per row | `Model.where(...).get()` + iterate + `.save()` / `.destroy()` |
143
+
144
+ ## Things NOT to do
145
+
146
+ - 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)`.
147
+ - Don't reach for `insertMany` / `updateMany` / `deleteMany` — those names don't exist on the model. Use `createMany` / `findAndUpdate` / `delete`.
148
+ - 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.
149
+ - Don't hand-roll chunking around `createMany` — it already chunks by `batchSize` (default 500). Tune `batchSize` instead of slicing the array yourself.
150
+ - 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()`).
151
+ - Don't forward a raw request-body object as the `filter` to `atomic()`/`findAndUpdate()`/`findOneAndUpdate()`/`findAndReplace()`/`findOneAndDelete()` expecting it to be un-sanitized — it isn't, `$`-prefixed keys throw `UnsafeFilterError` — but don't rely on that as your only authorization check either; it blocks operator injection, not "does this caller own this row."
152
+
153
+ ## See also
154
+
155
+ - [`@warlock.js/cascade/manage-transactions/SKILL.md`](@warlock.js/cascade/manage-transactions/SKILL.md) — multi-row atomicity
156
+ - [`@warlock.js/cascade/paginate-results/SKILL.md`](@warlock.js/cascade/paginate-results/SKILL.md) — `.chunk` for bulk-processing iteration