@warlock.js/cascade 5.23.2 → 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.
- package/CHANGELOG.md +352 -329
- package/README.md +101 -101
- package/bin/cascade.js +0 -0
- package/cjs/index.cjs +208 -69
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/database-driver.contract.d.mts +22 -1
- package/esm/contracts/database-driver.contract.d.mts.map +1 -1
- package/esm/contracts/index.d.mts +1 -1
- package/esm/data-source/data-source-registry.d.mts +8 -6
- package/esm/data-source/data-source-registry.d.mts.map +1 -1
- package/esm/data-source/data-source-registry.mjs +7 -1
- package/esm/data-source/data-source-registry.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.d.mts +13 -0
- package/esm/drivers/mongodb/mongodb-driver.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-driver.mjs +71 -2
- package/esm/drivers/mongodb/mongodb-driver.mjs.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.d.mts.map +1 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs +2 -1
- package/esm/drivers/mongodb/mongodb-query-builder.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-driver.d.mts +9 -0
- package/esm/drivers/postgres/postgres-driver.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-driver.mjs +61 -5
- package/esm/drivers/postgres/postgres-driver.mjs.map +1 -1
- package/esm/drivers/postgres/postgres-query-parser.d.mts +1 -4
- package/esm/drivers/postgres/postgres-query-parser.d.mts.map +1 -1
- package/esm/drivers/postgres/postgres-query-parser.mjs +4 -5
- package/esm/drivers/postgres/postgres-query-parser.mjs.map +1 -1
- package/esm/drivers/postgres/types.d.mts +5 -0
- package/esm/drivers/postgres/types.d.mts.map +1 -1
- package/esm/errors/unsafe-filter.error.mjs.map +1 -1
- package/esm/errors/unsafe-raw-expression.error.mjs.map +1 -1
- package/esm/errors/unsupported-lock-for-update.error.d.mts +11 -0
- package/esm/errors/unsupported-lock-for-update.error.d.mts.map +1 -0
- package/esm/errors/unsupported-lock-for-update.error.mjs +16 -0
- package/esm/errors/unsupported-lock-for-update.error.mjs.map +1 -0
- package/esm/events/model-events.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +2 -1
- package/esm/migration/migration-runner.mjs.map +1 -1
- package/esm/migration/sql-grammar.mjs.map +1 -1
- package/esm/model/generate-model-id.mjs +23 -0
- package/esm/model/generate-model-id.mjs.map +1 -0
- package/esm/model/methods/delete-methods.mjs.map +1 -1
- package/esm/model/methods/meta-methods.mjs.map +1 -1
- package/esm/model/methods/query-methods.mjs +25 -2
- package/esm/model/methods/query-methods.mjs.map +1 -1
- package/esm/model/methods/write-methods.mjs.map +1 -1
- package/esm/query-builder/query-builder.mjs +1 -1
- package/esm/query-builder/query-builder.mjs.map +1 -1
- package/esm/utils/sanitize-filter.mjs.map +1 -1
- package/esm/validation/plugins/database-rules-plugin.mjs.map +1 -1
- package/esm/writer/database-writer.d.mts +0 -33
- package/esm/writer/database-writer.d.mts.map +1 -1
- package/esm/writer/database-writer.mjs +4 -52
- package/esm/writer/database-writer.mjs.map +1 -1
- package/llms-full.txt +2835 -2783
- package/package.json +5 -5
- 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/manage-data-sources/SKILL.md +177 -140
- package/skills/manage-transactions/SKILL.md +233 -208
- 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,208 +1,233 @@
|
|
|
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
|
-
##
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
Model
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
+
## Transaction-local settings (Postgres)
|
|
103
|
+
|
|
104
|
+
Pass `settings` to configure PostgreSQL values only for the current transaction. This is useful for row-level security context, timeouts, and schema selection; Cascade applies each value with parameterized `set_config(..., true)`, so it cannot leak back into the pool:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
await transaction(async () => {
|
|
108
|
+
await Invoice.query().get();
|
|
109
|
+
}, {
|
|
110
|
+
settings: {
|
|
111
|
+
"app.tenant_id": tenantId,
|
|
112
|
+
statement_timeout: 5_000,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Row locking — `lockForUpdate({ skipLocked })` (Postgres)
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
`{ 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.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { transaction } from "@warlock.js/cascade";
|
|
125
|
+
|
|
126
|
+
// Worker loop — each concurrent worker claims a disjoint batch:
|
|
127
|
+
const jobs = await transaction(async () => {
|
|
128
|
+
const claimed = await Job.query()
|
|
129
|
+
.where("status", "pending")
|
|
130
|
+
.orderBy("id", "asc")
|
|
131
|
+
.limit(10)
|
|
132
|
+
.lockForUpdate({ skipLocked: true })
|
|
133
|
+
.get();
|
|
134
|
+
|
|
135
|
+
for (const job of claimed) {
|
|
136
|
+
await job.merge({ status: "processing" }).save();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return claimed;
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`{ noWait: true }` errors immediately when a matching row is locked (instead of waiting or skipping) — mutually exclusive with `skipLocked`.
|
|
144
|
+
|
|
145
|
+
**Postgres-only.** MongoDB has no row-level SELECT locking — the MongoDB driver **throws** `UnsupportedLockForUpdateError` (exported from `@warlock.js/cascade`) on `lockForUpdate()`, inside or outside a transaction. On Mongo, claim atomically with a conditional update instead. Operator-bearing filters are explicit, so pass `trustedFilter: true` only for a filter built in application code:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const claimed = await Job.atomic(
|
|
149
|
+
{ id, leaseUntil: { $lt: now } },
|
|
150
|
+
{ $set: { leaseUntil: nextLeaseUntil, status: "processing" } },
|
|
151
|
+
{ trustedFilter: true },
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
See `@warlock.js/cascade/perform-atomic-ops/SKILL.md` for `atomic` / `findOneAndUpdate`.
|
|
156
|
+
|
|
157
|
+
## Outside the transaction
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
## Raw SQL — `Model.raw` / `DataSource.raw` (transaction-aware)
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// Off a model — uses the model's driver
|
|
167
|
+
const { rows, rowCount } = await User.raw<{ id: number; total: number }>(
|
|
168
|
+
"SELECT id, COUNT(*) AS total FROM orders WHERE user_id = $1 GROUP BY id",
|
|
169
|
+
[userId],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Inside a transaction — auto-joins the active scope
|
|
173
|
+
await transaction(async () => {
|
|
174
|
+
await User.raw("UPDATE users SET active = true WHERE id = $1", [id]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Off a data source directly
|
|
178
|
+
const { rows: counts } = await dataSource.raw<{ count: number }>(
|
|
179
|
+
"SELECT COUNT(*)::int AS count FROM users",
|
|
180
|
+
);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`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.
|
|
184
|
+
|
|
185
|
+
### Native-array columns (`JSONB[]` / `TEXT[]` / `INTEGER[]`)
|
|
186
|
+
|
|
187
|
+
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"*.
|
|
188
|
+
|
|
189
|
+
**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.
|
|
190
|
+
|
|
191
|
+
`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:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
connectToDatabase({
|
|
195
|
+
name: "default",
|
|
196
|
+
driver: "postgres",
|
|
197
|
+
// ...
|
|
198
|
+
nativeArrayColumns: ["tags", "category_ids"], // optional manual override
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Side effects after commit — the outbox pattern
|
|
203
|
+
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
## Run side effects after commit — `afterCommit(fn)`
|
|
207
|
+
|
|
208
|
+
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`:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { afterCommit } from "@warlock.js/cascade";
|
|
212
|
+
|
|
213
|
+
Product.events().onSaved(() => afterCommit(() => regenerateSitemap()));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **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.
|
|
217
|
+
- **Outside a transaction:** runs on the next microtask (the caller does not await it).
|
|
218
|
+
- **Errors** are logged and never change the transaction result or stop the remaining callbacks.
|
|
219
|
+
- Callbacks run after the transaction context has exited, so DB work inside them is **not** part of the finished transaction.
|
|
220
|
+
|
|
221
|
+
Use the outbox pattern above instead when the side effect must survive a crash between COMMIT and the callback — `afterCommit` is in-memory.
|
|
222
|
+
|
|
223
|
+
## Things NOT to do
|
|
224
|
+
|
|
225
|
+
- 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.
|
|
226
|
+
- Don't use a single-node mongod for transactions. Run a replica set even in dev.
|
|
227
|
+
- Don't `try/catch` and swallow inside a transaction — the catch defeats the rollback. If you must, re-throw after handling.
|
|
228
|
+
- Don't pass models loaded outside the transaction into it expecting fresh reads. Reload inside.
|
|
229
|
+
|
|
230
|
+
## See also
|
|
231
|
+
|
|
232
|
+
- [`@warlock.js/cascade/perform-atomic-ops/SKILL.md`](@warlock.js/cascade/perform-atomic-ops/SKILL.md) — atomic single-document ops without a full transaction
|
|
233
|
+
- [`@warlock.js/cascade/manage-data-sources/SKILL.md`](@warlock.js/cascade/manage-data-sources/SKILL.md) — transactions run on the default source
|