@warlock.js/cascade 5.19.0 → 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.
- package/CHANGELOG.md +6 -0
- package/cjs/index.cjs +78 -7
- package/cjs/index.cjs.map +1 -1
- package/esm/context/database-transaction-context.d.mts +10 -0
- package/esm/context/database-transaction-context.d.mts.map +1 -1
- package/esm/context/database-transaction-context.mjs +17 -0
- package/esm/context/database-transaction-context.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.mjs +13 -5
- package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs +8 -3
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -1
- package/esm/index.mjs +2 -1
- package/esm/migration/migration-runner.mjs +1 -1
- package/esm/transactions/after-commit.d.mts +24 -0
- package/esm/transactions/after-commit.d.mts.map +1 -0
- package/esm/transactions/after-commit.mjs +47 -0
- package/esm/transactions/after-commit.mjs.map +1 -0
- package/llms-full.txt +2783 -2766
- package/llms.txt +15 -15
- package/package.json +6 -6
- package/skills/aggregate-data/SKILL.md +165 -165
- package/skills/alter-migration/SKILL.md +531 -531
- package/skills/configure-delete-strategy/SKILL.md +130 -130
- package/skills/define-model/SKILL.md +205 -205
- package/skills/define-relations/SKILL.md +171 -171
- package/skills/index.md +23 -0
- package/skills/manage-data-sources/SKILL.md +140 -140
- package/skills/manage-transactions/SKILL.md +208 -191
- package/skills/paginate-results/SKILL.md +122 -122
- package/skills/perform-atomic-ops/SKILL.md +156 -156
- package/skills/query-data/SKILL.md +213 -213
- package/skills/run-cascade-cli/SKILL.md +125 -125
- package/skills/search-by-vector/SKILL.md +127 -127
- package/skills/subscribe-to-model-events/SKILL.md +148 -148
- package/skills/track-changes/SKILL.md +127 -127
- package/skills/write-migration/SKILL.md +206 -206
|
@@ -1,191 +1,208 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: manage-transactions
|
|
3
|
-
description: "
|
|
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
|
-
##
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
-
|
|
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:
|
|
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
|