@warlock.js/cascade 5.19.1 → 5.20.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 (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cjs/index.cjs +78 -7
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/context/database-transaction-context.d.mts +10 -0
  5. package/esm/context/database-transaction-context.d.mts.map +1 -1
  6. package/esm/context/database-transaction-context.mjs +17 -0
  7. package/esm/context/database-transaction-context.mjs.map +1 -1
  8. package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
  9. package/esm/drivers/mongodb/mongodb-driver.mjs +13 -5
  10. package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
  11. package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
  12. package/esm/drivers/postgres/postgres-driver.mjs +8 -3
  13. package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
  14. package/esm/index.d.mts +2 -1
  15. package/esm/index.mjs +2 -1
  16. package/esm/migration/migration-runner.mjs +1 -1
  17. package/esm/transactions/after-commit.d.mts +24 -0
  18. package/esm/transactions/after-commit.d.mts.map +1 -0
  19. package/esm/transactions/after-commit.mjs +47 -0
  20. package/esm/transactions/after-commit.mjs.map +1 -0
  21. package/llms-full.txt +2783 -2766
  22. package/llms.txt +15 -15
  23. package/package.json +6 -6
  24. package/skills/aggregate-data/SKILL.md +165 -165
  25. package/skills/alter-migration/SKILL.md +531 -531
  26. package/skills/configure-delete-strategy/SKILL.md +130 -130
  27. package/skills/define-model/SKILL.md +205 -205
  28. package/skills/define-relations/SKILL.md +171 -171
  29. package/skills/index.md +23 -0
  30. package/skills/manage-data-sources/SKILL.md +140 -140
  31. package/skills/manage-transactions/SKILL.md +208 -191
  32. package/skills/paginate-results/SKILL.md +122 -122
  33. package/skills/perform-atomic-ops/SKILL.md +156 -156
  34. package/skills/query-data/SKILL.md +213 -213
  35. package/skills/run-cascade-cli/SKILL.md +125 -125
  36. package/skills/search-by-vector/SKILL.md +127 -127
  37. package/skills/subscribe-to-model-events/SKILL.md +148 -148
  38. package/skills/track-changes/SKILL.md +127 -127
  39. package/skills/write-migration/SKILL.md +206 -206
@@ -1,191 +1,208 @@
1
- ---
2
- name: manage-transactions
3
- description: "Use transactions in @warlock.js/cascade; use when you need to manage transactions."
4
- ---
5
-
6
- # Use transactions
7
-
8
- A transaction is a sequence of database operations that succeed or fail as one unit. Cascade wraps the driver-level transaction with a function-shaped API — pass a callback, throw to roll back, return to commit.
9
-
10
- ## Shape
11
-
12
- ```ts
13
- import { transaction } from "@warlock.js/cascade";
14
-
15
- await transaction(async () => {
16
- const account = await Account.where("id", fromId).firstOrFail();
17
- const target = await Account.where("id", toId).firstOrFail();
18
-
19
- await account.merge({ balance: account.get<number>("balance") - amount }).save();
20
- await target.merge({ balance: target.get<number>("balance") + amount }).save();
21
- });
22
- ```
23
-
24
- If either `.save()` throws, both are rolled back. If the callback returns, both are committed atomically.
25
-
26
- ## Return values
27
-
28
- The callback's resolved value becomes the transaction's resolved value:
29
-
30
- ```ts
31
- const order = await transaction(async () => {
32
- const created = await Order.create({...});
33
- await OrderItem.createMany(items.map(item => ({ ...item, order_id: created.id })));
34
- return created;
35
- });
36
- // order is the created Order
37
- ```
38
-
39
- ## Rollback on throw
40
-
41
- Any thrown error inside the callback rolls back the entire transaction and re-throws to the caller:
42
-
43
- ```ts
44
- try {
45
- await transaction(async () => {
46
- await user.save();
47
- if (someInvariantFails) {
48
- throw new Error("invariant violated");
49
- }
50
- await audit.save();
51
- });
52
- } catch (error) {
53
- // both user and audit writes are rolled back; error propagates here
54
- }
55
- ```
56
-
57
- ## MongoDB requires a replica set
58
-
59
- MongoDB transactions only work on replica sets — a single-node `mongod` without `--replSet` will throw. For local dev, run a single-node replica set:
60
-
61
- ```bash
62
- mongod --replSet rs0 --port 27017
63
- # then in mongo shell:
64
- rs.initiate()
65
- ```
66
-
67
- Postgres has no such requirement — transactions work out of the box.
68
-
69
- ## Nesting
70
-
71
- `transaction(fn)` **flat-nests**: calling it inside an already-open transaction JOINS the outer one instead of opening a second, independent transaction. The inner block runs on the **same** session — so it sees the outer's uncommitted writes — and the **outermost** `transaction()` owns commit/rollback. A service that opens its own `transaction()` for standalone atomicity therefore also works unchanged when called inside a larger transaction — e.g. a seeder that creates a row, then calls a service that opens a transaction to insert a child referencing it (an independent inner transaction couldn't see the parent's uncommitted row, and the child insert would fail its foreign key).
72
-
73
- A throw anywhere inside unwinds the **whole** outer transaction — Postgres aborts a transaction on the first error, so there is no automatic per-block savepoint. For independent partial rollback of an inner block, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly.
74
-
75
- ## Explicit rollback
76
-
77
- The callback receives a transaction context; call `ctx.rollback()` to roll back without throwing:
78
-
79
- ```ts
80
- await transaction(async (ctx) => {
81
- await user.save();
82
- if (!isValid) {
83
- ctx.rollback("validation failed");
84
- }
85
- });
86
- ```
87
-
88
- Throwing works too (and re-throws to the caller); `ctx.rollback()` is the non-throwing path.
89
-
90
- ## Isolation level (Postgres)
91
-
92
- Default is the driver's default (typically `READ COMMITTED`). Request a different level via `isolationLevel`:
93
-
94
- ```ts
95
- await transaction(async () => {
96
- /* ... */
97
- }, { isolationLevel: "SERIALIZABLE" });
98
- ```
99
-
100
- On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurrent transactions conflict — wrap in retry logic at the caller.
101
-
102
- ## Row locking — `lockForUpdate({ skipLocked })` (Postgres)
103
-
104
- Chain `lockForUpdate()` on any query to emit `SELECT ... FOR UPDATE`: the matched rows are locked until the transaction ends, so no other transaction can modify or lock them. Only meaningful **inside** a transaction — outside one the lock releases immediately.
105
-
106
- `{ skipLocked: true }` adds `SKIP LOCKED` — rows another transaction already holds are silently skipped instead of waited on. That is the standard concurrent queue-claim shape: N workers each grab *different* pending rows with no double-processing and no blocking.
107
-
108
- ```ts
109
- import { transaction } from "@warlock.js/cascade";
110
-
111
- // Worker loop — each concurrent worker claims a disjoint batch:
112
- const jobs = await transaction(async () => {
113
- const claimed = await Job.query()
114
- .where("status", "pending")
115
- .orderBy("id", "asc")
116
- .limit(10)
117
- .lockForUpdate({ skipLocked: true })
118
- .get();
119
-
120
- for (const job of claimed) {
121
- await job.merge({ status: "processing" }).save();
122
- }
123
-
124
- return claimed;
125
- });
126
- ```
127
-
128
- `{ noWait: true }` errors immediately when a matching row is locked (instead of waiting or skipping) — mutually exclusive with `skipLocked`.
129
-
130
- **Postgres-only.** MongoDB has no row-level SELECT locking — the MongoDB driver **throws** on `lockForUpdate()`. On Mongo, claim atomically instead (`findOneAndUpdate` with a reservation filter — see `@warlock.js/cascade/perform-atomic-ops/SKILL.md`).
131
-
132
- ## Outside the transaction
133
-
134
- Once the callback returns, the transaction is committed. Subsequent calls — including reads — see the committed state. Don't try to "share" a model instance between inside-transaction and outside contexts; reload outside if you need fresh state.
135
-
136
- ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
137
-
138
- When the query builder can't express something, drop to raw SQL. Both helpers are **transaction-aware**: called inside an active `transaction()` scope they auto-join that transaction's client/session, otherwise they run on the pool.
139
-
140
- ```ts
141
- // Off a model — uses the model's driver
142
- const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
143
- "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
144
- [userId],
145
- );
146
-
147
- // Inside a transaction — auto-joins the active scope
148
- await transaction(async () => {
149
- await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
150
- });
151
-
152
- // Off a data source directly
153
- const { rows: counts } = await dataSource.raw<{ count: number }>(
154
- "SELECT COUNT(*)::int AS count FROM users",
155
- );
156
- ```
157
-
158
- `Model.raw<T>(sql, params?)` and `DataSource.raw<T>(sql, params?)` both return `Promise<RawQueryResult<T>>` — `{ rows: T[]; rowCount: number }`, **not** hydrated model instances. MongoDB drivers **throw**: there is no raw SQL on Mongo.
159
-
160
- ### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
161
-
162
- From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with *"malformed array literal"*.
163
-
164
- **This is handled automatically.** On connect the driver introspects `information_schema` once and remembers which columns are native arrays **per table**, so `arrayText()` / `arrayJson()` columns just work with **no configuration**. Because it's per-table, the same column name being `TEXT[]` in one table and `jsonb` in another is encoded correctly for each; the pgvector all-number array form is still preserved.
165
-
166
- `nativeArrayColumns` remains as an optional, table-agnostic **override** — for a column created outside a migration, or an environment where `information_schema` can't be read:
167
-
168
- ```ts
169
- connectToDatabase({
170
- name: "default",
171
- driver: "postgres",
172
- // ...
173
- nativeArrayColumns: ["tags", "category_ids"], // optional manual override
174
- });
175
- ```
176
-
177
- ## Side effects after commit — the outbox pattern
178
-
179
- For "side effects must only happen if the transaction succeeded" (publish to a queue, send an email, write to a search index), don't run them inside the transaction. Use the outbox pattern: write a row to an outbox table inside the transaction, dispatch from the outbox in a separate worker after commit.
180
-
181
- ## Things NOT to do
182
-
183
- - Don't call external APIs (HTTP, queues, file writes) inside a transaction. Long-running side effects extend the lock; failures don't roll back the external call.
184
- - Don't use a single-node mongod for transactions. Run a replica set even in dev.
185
- - Don't `try/catch` and swallow inside a transaction — the catch defeats the rollback. If you must, re-throw after handling.
186
- - Don't pass models loaded outside the transaction into it expecting fresh reads. Reload inside.
187
-
188
- ## See also
189
-
190
- - [`@warlock.js/cascade/perform-atomic-ops/SKILL.md`](@warlock.js/cascade/perform-atomic-ops/SKILL.md) — atomic single-document ops without a full transaction
191
- - [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) — transactions run on the default source
1
+ ---
2
+ name: manage-transactions
3
+ description: 'Wrap multi-statement work in `transaction(async () => {...})` — rollback on throw, commit on resolve, optional `isolation` level (Postgres), per-`dataSource` scope. Also the home for row locking (`lockForUpdate({ skipLocked })` → `SELECT ... FOR UPDATE [SKIP LOCKED | NOWAIT]`, Postgres-only), transaction-aware raw SQL (`Model.raw` / `DataSource.raw` → `RawQueryResult`) and Postgres native-array column handling (`JSONB[]` / `TEXT[]` / `INTEGER[]` auto-detected via schema introspection on connect; `nativeArrayColumns` is an optional override). Postgres native; MongoDB requires replica set. Triggers: `transaction`, `isolation`, `SERIALIZABLE`, `READ COMMITTED`, nested transaction, flat nesting, nested savepoints, `lockForUpdate`, `skipLocked`, `FOR UPDATE`, `SKIP LOCKED`, `NOWAIT`, row lock, pessimistic lock, queue claim, `Model.raw`, `DataSource.raw`, raw SQL, `RawQueryResult`, `nativeArrayColumns`, `JSONB[]`, `TEXT[]`; "wrap two writes atomically", "transfer balance between accounts", "rollback on error", "MongoDB replica set transactions", "lock rows so workers don''t double-process", "claim jobs from a table", "run raw SQL", "native array column", "malformed array literal", "array column not saving", "nested transaction not visible", "foreign key violation on insert inside transaction", "service transaction inside seeder"; typical import `import { transaction } from "@warlock.js/cascade"`. Skip: single-row atomic ops without a transaction — `@warlock.js/cascade/perform-atomic-ops/SKILL.md`; per-source scope — `@warlock.js/cascade/manage-data-sources/SKILL.md`; competing patterns `mongoose.startSession`, `pg` `BEGIN` manually, `prisma.$transaction`, `typeorm` `QueryRunner`.'
4
+ ---
5
+
6
+ # Use transactions
7
+
8
+ A transaction is a sequence of database operations that succeed or fail as one unit. Cascade wraps the driver-level transaction with a function-shaped API — pass a callback, throw to roll back, return to commit.
9
+
10
+ ## Shape
11
+
12
+ ```ts
13
+ import { transaction } from "@warlock.js/cascade";
14
+
15
+ await transaction(async () => {
16
+ const account = await Account.where("id", fromId).firstOrFail();
17
+ const target = await Account.where("id", toId).firstOrFail();
18
+
19
+ await account.merge({ balance: account.get<number>("balance") - amount }).save();
20
+ await target.merge({ balance: target.get<number>("balance") + amount }).save();
21
+ });
22
+ ```
23
+
24
+ If either `.save()` throws, both are rolled back. If the callback returns, both are committed atomically.
25
+
26
+ ## Return values
27
+
28
+ The callback's resolved value becomes the transaction's resolved value:
29
+
30
+ ```ts
31
+ const order = await transaction(async () => {
32
+ const created = await Order.create({...});
33
+ await OrderItem.createMany(items.map(item => ({ ...item, order_id: created.id })));
34
+ return created;
35
+ });
36
+ // order is the created Order
37
+ ```
38
+
39
+ ## Rollback on throw
40
+
41
+ Any thrown error inside the callback rolls back the entire transaction and re-throws to the caller:
42
+
43
+ ```ts
44
+ try {
45
+ await transaction(async () => {
46
+ await user.save();
47
+ if (someInvariantFails) {
48
+ throw new Error("invariant violated");
49
+ }
50
+ await audit.save();
51
+ });
52
+ } catch (error) {
53
+ // both user and audit writes are rolled back; error propagates here
54
+ }
55
+ ```
56
+
57
+ ## MongoDB requires a replica set
58
+
59
+ MongoDB transactions only work on replica sets — a single-node `mongod` without `--replSet` will throw. For local dev, run a single-node replica set:
60
+
61
+ ```bash
62
+ mongod --replSet rs0 --port 27017
63
+ # then in mongo shell:
64
+ rs.initiate()
65
+ ```
66
+
67
+ Postgres has no such requirement — transactions work out of the box.
68
+
69
+ ## Nesting
70
+
71
+ `transaction(fn)` **flat-nests**: calling it inside an already-open transaction JOINS the outer one instead of opening a second, independent transaction. The inner block runs on the **same** session — so it sees the outer's uncommitted writes — and the **outermost** `transaction()` owns commit/rollback. A service that opens its own `transaction()` for standalone atomicity therefore also works unchanged when called inside a larger transaction — e.g. a seeder that creates a row, then calls a service that opens a transaction to insert a child referencing it (an independent inner transaction couldn't see the parent's uncommitted row, and the child insert would fail its foreign key).
72
+
73
+ A throw anywhere inside unwinds the **whole** outer transaction — Postgres aborts a transaction on the first error, so there is no automatic per-block savepoint. For independent partial rollback of an inner block, drop to the manual API (`driver.beginTransaction()`) and use savepoints explicitly.
74
+
75
+ ## Explicit rollback
76
+
77
+ The callback receives a transaction context; call `ctx.rollback()` to roll back without throwing:
78
+
79
+ ```ts
80
+ await transaction(async (ctx) => {
81
+ await user.save();
82
+ if (!isValid) {
83
+ ctx.rollback("validation failed");
84
+ }
85
+ });
86
+ ```
87
+
88
+ Throwing works too (and re-throws to the caller); `ctx.rollback()` is the non-throwing path.
89
+
90
+ ## Isolation level (Postgres)
91
+
92
+ Default is the driver's default (typically `READ COMMITTED`). Request a different level via `isolationLevel`:
93
+
94
+ ```ts
95
+ await transaction(async () => {
96
+ /* ... */
97
+ }, { isolationLevel: "SERIALIZABLE" });
98
+ ```
99
+
100
+ On `SERIALIZABLE`, Postgres may abort with a serialization failure when concurrent transactions conflict — wrap in retry logic at the caller.
101
+
102
+ ## Row locking — `lockForUpdate({ skipLocked })` (Postgres)
103
+
104
+ Chain `lockForUpdate()` on any query to emit `SELECT ... FOR UPDATE`: the matched rows are locked until the transaction ends, so no other transaction can modify or lock them. Only meaningful **inside** a transaction — outside one the lock releases immediately.
105
+
106
+ `{ skipLocked: true }` adds `SKIP LOCKED` — rows another transaction already holds are silently skipped instead of waited on. That is the standard concurrent queue-claim shape: N workers each grab *different* pending rows with no double-processing and no blocking.
107
+
108
+ ```ts
109
+ import { transaction } from "@warlock.js/cascade";
110
+
111
+ // Worker loop — each concurrent worker claims a disjoint batch:
112
+ const jobs = await transaction(async () => {
113
+ const claimed = await Job.query()
114
+ .where("status", "pending")
115
+ .orderBy("id", "asc")
116
+ .limit(10)
117
+ .lockForUpdate({ skipLocked: true })
118
+ .get();
119
+
120
+ for (const job of claimed) {
121
+ await job.merge({ status: "processing" }).save();
122
+ }
123
+
124
+ return claimed;
125
+ });
126
+ ```
127
+
128
+ `{ noWait: true }` errors immediately when a matching row is locked (instead of waiting or skipping) — mutually exclusive with `skipLocked`.
129
+
130
+ **Postgres-only.** MongoDB has no row-level SELECT locking — the MongoDB driver **throws** on `lockForUpdate()`. On Mongo, claim atomically instead (`findOneAndUpdate` with a reservation filter — see `@warlock.js/cascade/perform-atomic-ops/SKILL.md`).
131
+
132
+ ## Outside the transaction
133
+
134
+ Once the callback returns, the transaction is committed. Subsequent calls — including reads — see the committed state. Don't try to "share" a model instance between inside-transaction and outside contexts; reload outside if you need fresh state.
135
+
136
+ ## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
137
+
138
+ When the query builder can't express something, drop to raw SQL. Both helpers are **transaction-aware**: called inside an active `transaction()` scope they auto-join that transaction's client/session, otherwise they run on the pool.
139
+
140
+ ```ts
141
+ // Off a model — uses the model's driver
142
+ const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
143
+ "SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
144
+ [userId],
145
+ );
146
+
147
+ // Inside a transaction — auto-joins the active scope
148
+ await transaction(async () => {
149
+ await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
150
+ });
151
+
152
+ // Off a data source directly
153
+ const { rows: counts } = await dataSource.raw<{ count: number }>(
154
+ "SELECT COUNT(*)::int AS count FROM users",
155
+ );
156
+ ```
157
+
158
+ `Model.raw<T>(sql, params?)` and `DataSource.raw<T>(sql, params?)` both return `Promise<RawQueryResult<T>>` — `{ rows: T[]; rowCount: number }`, **not** hydrated model instances. MongoDB drivers **throw**: there is no raw SQL on Mongo.
159
+
160
+ ### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
161
+
162
+ From a value alone the serializer can't tell a `json` / `jsonb` column (which needs JSON text) from a genuine **native-array** column (`arrayJson()` → `JSONB[]`, `arrayText()` → `TEXT[]`, …, which needs the raw JS array so node-pg emits a `{...}` literal). Guess wrong and Postgres rejects the insert with *"malformed array literal"*.
163
+
164
+ **This is handled automatically.** On connect the driver introspects `information_schema` once and remembers which columns are native arrays **per table**, so `arrayText()` / `arrayJson()` columns just work with **no configuration**. Because it's per-table, the same column name being `TEXT[]` in one table and `jsonb` in another is encoded correctly for each; the pgvector all-number array form is still preserved.
165
+
166
+ `nativeArrayColumns` remains as an optional, table-agnostic **override** — for a column created outside a migration, or an environment where `information_schema` can't be read:
167
+
168
+ ```ts
169
+ connectToDatabase({
170
+ name: "default",
171
+ driver: "postgres",
172
+ // ...
173
+ nativeArrayColumns: ["tags", "category_ids"], // optional manual override
174
+ });
175
+ ```
176
+
177
+ ## Side effects after commit — the outbox pattern
178
+
179
+ For "side effects must only happen if the transaction succeeded" (publish to a queue, send an email, write to a search index), don't run them inside the transaction. Use the outbox pattern: write a row to an outbox table inside the transaction, dispatch from the outbox in a separate worker after commit.
180
+
181
+ ## Run side effects after commit — `afterCommit(fn)`
182
+
183
+ Model events (`saved`, `created`, `updated`, `deleted`) fire **inside** the transaction, before COMMIT. For a side effect that must see committed data (cache clear, sitemap regeneration, email, webhook), queue it with `afterCommit`:
184
+
185
+ ```ts
186
+ import { afterCommit } from "@warlock.js/cascade";
187
+
188
+ Product.events().onSaved(() => afterCommit(() => regenerateSitemap()));
189
+ ```
190
+
191
+ - **Inside a transaction:** `fn` is queued and runs after the **outermost** COMMIT, in order, awaited one by one. Discarded on rollback or a failed COMMIT.
192
+ - **Outside a transaction:** runs on the next microtask (the caller does not await it).
193
+ - **Errors** are logged and never change the transaction result or stop the remaining callbacks.
194
+ - Callbacks run after the transaction context has exited, so DB work inside them is **not** part of the finished transaction.
195
+
196
+ Use the outbox pattern above instead when the side effect must survive a crash between COMMIT and the callback — `afterCommit` is in-memory.
197
+
198
+ ## Things NOT to do
199
+
200
+ - Don't call external APIs (HTTP, queues, file writes) inside a transaction. Long-running side effects extend the lock; failures don't roll back the external call.
201
+ - Don't use a single-node mongod for transactions. Run a replica set even in dev.
202
+ - Don't `try/catch` and swallow inside a transaction — the catch defeats the rollback. If you must, re-throw after handling.
203
+ - Don't pass models loaded outside the transaction into it expecting fresh reads. Reload inside.
204
+
205
+ ## See also
206
+
207
+ - [`@warlock.js/cascade/perform-atomic-ops/SKILL.md`](@warlock.js/cascade/perform-atomic-ops/SKILL.md) — atomic single-document ops without a full transaction
208
+ - [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) — transactions run on the default source
@@ -1,122 +1,122 @@
1
- ---
2
- name: paginate-results
3
- description: "Paginate results in @warlock.js/cascade; use when you need to paginate results."
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