@querykitjs/mongoose 1.0.0 → 3.0.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/README.en.md CHANGED
@@ -69,6 +69,15 @@ bun add @querykitjs/mongoose mongoose
69
69
 
70
70
  `mongoose` (>= 8) is a peer dependency.
71
71
 
72
+ ### Requirements
73
+
74
+ | | Version | Tested (`bun run test:matrix`) |
75
+ | ----------------- | ------------------ | ------------------------------ |
76
+ | Node.js | `>=22.12` | 24 |
77
+ | `mongoose` (peer) | `^8.24 \|\| ^9.11` | 8.24.7, 9.11.4 |
78
+
79
+ Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested.
80
+
72
81
  ## Setup
73
82
 
74
83
  ### 1. Registry (once)
@@ -83,7 +92,27 @@ export const registry = createRegistry(mongoose.connection);
83
92
  // export const registry = createRegistry(mongoose.connection, { defaultPerPage: 20, defaultLimit: 20 });
84
93
  ```
85
94
 
86
- `defaultPerPage` (findList) / `defaultLimit` (infinite/cursor) fall back to `@querykitjs/core`'s **20** when omitted. The `connection` is passed in (so transactions share the right session).
95
+ The `connection` is passed in (so transactions share the right session).
96
+
97
+ **`createRegistry` options** — identical to the drizzle-pg adapter:
98
+
99
+ | Option | Default | Meaning |
100
+ | -------------------- | --------------------------------- | ------------------------------------------------------------ |
101
+ | `defaultPerPage` | core `DEFAULT_PER_PAGE` (20) | `findList` page size |
102
+ | `defaultLimit` | core `DEFAULT_LIMIT` (20) | `findInfinite`/`findCursor` limit |
103
+ | `maxPerPage` | core `DEFAULT_MAX_PER_PAGE` (200) | upper bound for `perPage` (`Infinity` disables it) |
104
+ | `maxLimit` | core `DEFAULT_MAX_LIMIT` (200) | upper bound for `limit` |
105
+ | `strict` | `false` | unknown key → `QueryKitError` (400) instead of a silent drop |
106
+ | `onSkippedCondition` | — | callback for every dropped condition |
107
+
108
+ `maxPerPage`/`maxLimit` are a **second layer**: even if validation
109
+ (`@querykitjs/zod` factories) is bypassed, the repository clamps.
110
+
111
+ `timestamps` — the createdAt / deletedAt field names (for example in a snake_case project): the default sort and the default `sortable` use that `createdAt`, soft delete (`softDelete`, `restore`, `withDeleted`) uses that `deletedAt`.
112
+
113
+ ```ts
114
+ export const registry = createRegistry(mongoose.connection, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
115
+ ```
87
116
 
88
117
  ### 2. Per-model repositories
89
118
 
@@ -97,6 +126,82 @@ export const usersRepository = registry.repository(User, base => ({
97
126
  }));
98
127
  ```
99
128
 
129
+ `repository()` takes four shapes (the same as the drizzle-pg adapter):
130
+
131
+ ```ts
132
+ registry.repository(User); // plain
133
+ registry.repository(User, base => ({ … })); // + custom methods
134
+ registry.repository(User, options); // + per-repo options
135
+ registry.repository(User, options, base => ({ … })); // both
136
+ ```
137
+
138
+ ### 3. Projection guards (`RepositoryOptions`)
139
+
140
+ `columns` can arrive straight from the client, so the safe selection is declared
141
+ on the **repository** — where `scope` (RBAC) already lives:
142
+
143
+ ```ts
144
+ export const usersRepository = registry.repository(User, {
145
+ forcedColumns: { _id: true, fullName: true, email: true }, // password can never come out
146
+ });
147
+
148
+ // or softer: the client picks, but only from this list
149
+ export const postsRepository = registry.repository(Post, {
150
+ allowedColumns: ["_id", "title", "createdAt"],
151
+ });
152
+ ```
153
+
154
+ | Option | Behaviour |
155
+ | ---------------- | ----------------------------------------------------------------------------- |
156
+ | `forcedColumns` | the client's `columns` is **ignored entirely** |
157
+ | `allowedColumns` | the client's selection is **intersected**; empty intersection → the allowlist |
158
+ | `scope` | constant equality filter on every read/write (also available via `scoped()`) |
159
+ | `relations` | populatable relations (type-only — drives `with` inference) |
160
+
161
+ Details worth knowing:
162
+
163
+ - An empty intersection yields the allowlist, **not the full document**.
164
+ - `aggregate` is guarded too: `min("password")` or `groupBy: "password"` leaks as
165
+ much as a projection.
166
+ - `cursorKey` comes from the client and the cursor field must be selected for
167
+ pagination — under a guard it stays in the query but is **stripped** from the
168
+ documents you get back.
169
+ - `forcedColumns: {}` or `allowedColumns: []` **throws** when the repository is built.
170
+ - ⚠️ The guard affects **read** methods only (`findAll`/`findOne`/`findById`/
171
+ `findList`/`findInfinite`/`findCursor` + `aggregate`). Write methods (`create`,
172
+ `upsert`, `updateById`, `softDelete`, …) return the **full document** by type
173
+ contract. Re-read with `findById`, or map it yourself, before handing a write
174
+ result to a client.
175
+
176
+ ### 4. Bad conditions: watch them, or refuse them
177
+
178
+ An unknown filter/sort key is **dropped silently** by default. The catch: a filter
179
+ is meant to _narrow_ a result set, so a mistyped key that vanishes makes the
180
+ endpoint return **more** data than intended.
181
+
182
+ ```ts
183
+ // step 1: watch (behaviour unchanged)
184
+ createRegistry(mongoose.connection, {
185
+ onSkippedCondition: info => logger.warn({ querykit: info }, "condition dropped"),
186
+ });
187
+
188
+ // step 2: once the log is clean — strict mode
189
+ createRegistry(mongoose.connection, { strict: true });
190
+ ```
191
+
192
+ ```ts
193
+ import { QueryKitError } from "@querykitjs/core";
194
+
195
+ try {
196
+ return await usersRepository.findList(params);
197
+ } catch (err) {
198
+ if (err instanceof QueryKitError) return c.json({ error: err.message, info: err.info }, 400);
199
+ throw err;
200
+ }
201
+ ```
202
+
203
+ A `scope` or `cursorKey` key is **always** fatal, regardless of `strict`.
204
+
100
205
  ## MongoDB specifics
101
206
 
102
207
  - **`id` ↔ `_id`.** Use `"id"` anywhere (filters, `columns`, `idKey`, `cursorKey`) — it resolves to Mongo's `_id`, so the wire contract is identical to the SQL adapter. String ids from the frontend cast to `ObjectId` automatically:
@@ -109,6 +214,41 @@ export const usersRepository = registry.repository(User, base => ({
109
214
  - **Cursor** keys on `_id` by default (override with `cursorKey`) — stable under inserts.
110
215
  - **Transactions need a replica set** — a single-node one is enough for local dev (`mongod --replSet rs0` then `rs.initiate()`, or [`mongodb-memory-server`](https://github.com/typegoose/mongodb-memory-server) in tests).
111
216
 
217
+ - **Indexes and operators.** `startsWith` is an anchored (`^`) but case-insensitive (`i`) regex: MongoDB uses an index poorly for it. On a large collection keep a lowercase shadow field (`nameLower`) and give that `=` / `startsWith`. `contains`, `endsWith`, `!=` and `notIn` cannot use an index (COLLSCAN) — closed in the default `filterable`, open them only for small collections. Use `in` for enum fields.
218
+
219
+ ## Safe by default — `filterable`, `sortable`, `limits`
220
+
221
+ A client filters and sorts only by the paths the server allows, and only with the operators it allows. A refused request stops with a `QueryKitError` before it reaches MongoDB — whatever `strict` and `onSkippedCondition` say. Option names, defaults and `reason`/`site` are the same as drizzle-pg's and prisma-pg's (`id` ≡ `_id`).
222
+
223
+ ```ts
224
+ const usersRepository = registry.repository(User, {
225
+ forcedColumns: { _id: true, name: true, status: true, createdAt: true },
226
+ filterable: {
227
+ status: ["=", "in"],
228
+ createdAt: ["between", ">=", "<="],
229
+ name: ["startsWith"], // open only where listed
230
+ },
231
+ sortable: ["_id", "createdAt", "name"],
232
+ limits: { maxInItems: 500 },
233
+ maxTimeMS: 1000,
234
+ });
235
+ ```
236
+
237
+ | Option | When not given (default) | When refused |
238
+ | ------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
239
+ | `filterable` | `allowedColumns` (else the `forcedColumns` keys, else only the primary key), with `"="` and `"in"` only | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
240
+ | `sortable` | the primary key and `createdAt` (when the table has it); a client-sent `cursorKey` too | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
241
+ | `limits` | core `DEFAULT_QUERY_LIMITS` (depth 5, conditions 30, array 200, string 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
242
+
243
+ - `aggregate`: the `filter` and every `groupBy`/`sum`/`avg`/`min`/`max` column must be in `filterable` (`site: "aggregate"`).
244
+ - `findById`: only the client `filter` is checked; `idKey` is the server's choice.
245
+ - A key the table does **not** have (`nope`) stays `unknown-key` — skipped, or an error under `strict`.
246
+ - Mutation filters (`updateMany`, `deleteMany`, …) count as server code and are not checked.
247
+ - Operator aliases are one: `["="]` also admits `eq`, `["contains"]` also `%_%`. `"*"` admits every operator.
248
+ - An unbranded raw Mongo filter (`{ $where: … }`, `{ secret: { $regex: … } }`, `{ $expr: … }`) can come from a JSON body — `forbidden-key`, nothing is sent (R19). Server code brands it with `raw({...})`; ⚠️ `raw()` is not checked by `filterable`.
249
+ - LIKE patterns: more than `maxLikeWildcards` (2) `%`/`_` → `limit-exceeded`; a run of `%%` counts once.
250
+ - Every read (`find`, `findOne`, `countDocuments`, `aggregate`) carries `maxTimeMS` (default 2 000); past it ⇒ `QueryKitTimeoutError` (`code: "QUERYKIT_TIMEOUT"`) — map it to 503/504, not 400.
251
+
112
252
  ## Advanced filters
113
253
 
114
254
  A filter is a tree of field conditions and logical groups (`and`/`or`/`not`). A flat array is treated as implicit `AND`. `id` resolves to `_id`.
@@ -142,6 +282,8 @@ Falls back to `createdAt DESC`, then `_id DESC`, when no sort is given — a det
142
282
  ```ts
143
283
  // 1. Offset — total_items / total_pages
144
284
  const page = await usersRepository.findList({ page: 2, perPage: 20, filter, sort });
285
+ // withTotal: false — no count query (large tables): perPage + 1 rows, total_items / total_pages = null
286
+ const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
145
287
 
146
288
  // 2. Infinite scroll (limit + offset) — has_more / next_offset
147
289
  const feed = await usersRepository.findInfinite({ limit: 20, offset: 40 });
@@ -152,8 +294,32 @@ const p2 = await usersRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
152
294
  const back = await usersRepository.findCursor({ cursor: p2.meta.prev_cursor, direction: "backward" });
153
295
  ```
154
296
 
297
+ ### Cursor: ties and signing
298
+
299
+ - The seek key is `(cursorKey, _id)`: with a non-unique `cursorKey` (for example a shared `createdAt`) no row is skipped or repeated. When `cursorKey` is the primary key itself the seek uses that one field.
300
+ - A token (v2) holds only scalar values (string, number, boolean, ISO date) and they are cast to the field type; an object, an array or a value that does not fit → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (the driver is not called).
301
+ - The registry option `cursorSecret` signs tokens with HMAC-SHA256; an unsigned or altered token → `invalid-value`. Without it tokens are unsigned — a client can build one itself (still only within the allowed `sortable` keys and the filter).
302
+
303
+ ```ts
304
+ export const registry = createRegistry(mongoose.connection, { cursorSecret: process.env.CURSOR_SECRET });
305
+ ```
306
+
155
307
  ## Typed relations & field selection
156
308
 
309
+ A client `with` opens only the relations listed in `relations` (task 014, R21):
310
+
311
+ ```ts
312
+ const postsRepository = registry.repository(Post, {
313
+ relations: { author: { model: User, columns: ["name"] } }, // model — for typing only
314
+ });
315
+ await postsRepository.findAll({ with: { author: true } }); // author with name only
316
+ ```
317
+
318
+ - `columns` is required — a list or an explicit `"*"`. `with: { rel: true }` also selects only those columns.
319
+ - An unlisted relation, column, filter field or nested relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` `relation` or `relation.field`; the driver is not called.
320
+ - Allowed options: `select`, a nested `populate` (with `nested`), `match` (its fields in `filterable`; no `$where` / `$expr`), `options.limit` / `options.skip`.
321
+ - The shorthand `{ author: User }` is for typing only: `with: { author: … }` → `forbidden-key`.
322
+
157
323
  Read methods infer their return type from `with` (populate) and `columns` — no manual generics:
158
324
 
159
325
  ```ts
@@ -165,10 +331,31 @@ rows[0].id; // ObjectId
165
331
  rows[0].email; // ❌ type error — not selected
166
332
  ```
167
333
 
168
- To type populated relations at runtime, pass the referenced models: `registry.repository(Post, { relations: { author: User } })`.
334
+ To type and open populated relations, give the referenced model inside the rule: `registry.repository(Post, { relations: { author: { model: User, columns: ["name"] } } })` (the shorthand `{ author: User }` only types — it does not allow `with`).
335
+
336
+ ## Bulk update and delete
337
+
338
+ `updateWhere` / `deleteWhere` are one write (no read before it) and return `{ count }`:
339
+
340
+ ```ts
341
+ const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
342
+ await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
343
+
344
+ // returning: { limit } — { count, rows }, at most limit rows (maxLimit, default 200)
345
+ const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
346
+
347
+ // no filter — only when you say so (the scope still applies)
348
+ await usersRepository.deleteWhere({ all: true });
349
+ ```
350
+
351
+ - An empty filter (`undefined`, `{}`, `[]`) or one whose every condition was skipped (for example only an unknown key) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — the driver is not called, the table is never wiped silently.
352
+ - A mutation filter passes `filterable` and `limits` like a read's (`forbidden-key`, `forbidden-operator`, `limit-exceeded`) — the server's own filters too.
353
+ - `rows` are **not** atomic: `updateWhere` reads them right **after** the update with the same filter (if the patch changes a filtered field, other documents may show up); `deleteWhere` reads them just **before** the delete. `count` is `matchedCount` / `deletedCount`.
169
354
 
170
355
  ## Transactions
171
356
 
357
+ > ⚠️ Only **database work** inside the callback. External effects (HTTP calls, messages, email) go through an outbox: the driver may retry the transaction, and an external effect would run again and cannot be rolled back.
358
+
172
359
  ```ts
173
360
  await registry.transaction(async () => {
174
361
  await dispatchesRepository.create({ ... });
@@ -186,6 +373,85 @@ await mine.findList({ page: 1 }); // { supervisorId: user.id, ... }
186
373
  await mine.create({ ... }); // supervisorId forced to user.id
187
374
  ```
188
375
 
376
+ A scope can also be given as `registry.repository(Model, { scope })`. An unknown
377
+ scope key **throws** when the repository is built — dropping it silently would
378
+ remove the RBAC filter.
379
+
380
+ A scope value may also be a list (task 014): `{ shopId: { in: [1, 2] } }` limits every read and write to those values; an empty list (`{ in: [] }`) matches no row (not an error). `create` fills in the equality scopes only.
381
+
382
+ ```ts
383
+ const mine = repository.scoped({ shopId: { in: user.shopIds } });
384
+ ```
385
+
386
+ ## Values, NULL order and parity
387
+
388
+ All three adapters (drizzle-pg, prisma-pg, mongoose) return the same result (task 021):
389
+
390
+ - **Literal value operators.** `contains`, `startsWith` and `endsWith` match their value literally: `startsWith "%"` finds only rows starting with `%`, `contains "_"` a real `_`. Wildcards belong to `like` / `ilike` / `notLike` only.
391
+ - **Type casting.** Every value is cast to its field type (number, boolean, date, ObjectId, enum). A mismatch (`age = "abc"`) is `invalid-value`: the condition is dropped and `onSkippedCondition` is called, a `QueryKitError` under `strict`; the driver is not called.
392
+ - **`not`** follows SQL three-valued logic: `not(and(…))` / `not(or(…))` over a NULL field agree on every adapter.
393
+ - **NULL order.** NULL first on `asc`, last on `desc` (MongoDB's order).
394
+ - **A malformed sort item** (`null`, `{}`) is dropped and reported as `invalid-value` (`site: "sort"`), an error under `strict`.
395
+
396
+ ## Migrating to 3.0
397
+
398
+ In 3.0 a repository without a policy is **narrow**: `=` and `in` on `allowedColumns` / `forcedColumns` / `_id` only, sorting by `_id` and `createdAt`. To bring back the 2.x behaviour for one repository on purpose:
399
+
400
+ ```ts
401
+ const open = registry.repository(User, {
402
+ filterable: { _id: "*", name: "*", email: "*", status: "*", createdAt: "*" },
403
+ sortable: ["_id", "name", "email", "status", "createdAt"],
404
+ });
405
+ ```
406
+
407
+ A raw Mongo filter now needs `raw()` (R19):
408
+
409
+ ```ts
410
+ import { raw } from "@querykitjs/core";
411
+
412
+ // 2.x — forbidden-key in 3.0:
413
+ // await usersRepository.findAll({ filter: { age: { $gt: 18 } } });
414
+
415
+ // 3.0:
416
+ await usersRepository.findAll({ filter: raw({ age: { $gt: 18 } }) });
417
+ ```
418
+
419
+ - New reasons: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; new site: `page` — return them as 400.
420
+ - Pages deeper than offset 10 000 are refused — use `findCursor`, or raise `limits: { maxOffset }`.
421
+ - Every read runs with `maxTimeMS: 2000`; raise `maxTimeMS` on the repository for long report queries. A timeout throws `QueryKitTimeoutError`.
422
+ - A LIKE pattern with more than 2 wildcards is refused (`maxLikeWildcards`).
423
+ - Cursor tokens are v2 now (task 012): an old (v1) `next_cursor` / `prev_cursor` is refused with `invalid-value` — after the upgrade start paging from the first page again.
424
+ - `updateWhere` / `deleteWhere` return `{ count }` instead of `Row[]` (task 013); for rows pass `{ returning: { limit } }` → `{ count, rows }`.
425
+ - A filterless `updateWhere` / `deleteWhere` is `empty-filter`; write `{ all: true }` for every row. A mutation filter now passes the `filterable` policy.
426
+ - `OffsetMeta.total_items` / `total_pages` are typed `number | null` (`null` with `withTotal: false`).
427
+ - `with` now needs the `relations` allow-list (task 014): on a repository without it `with` is `forbidden-key`, `site: "with"`.
428
+ - `relations: { author: User }` → `relations: { author: { model: User, columns: ["name"] } }` (the shorthand no longer allows `with`).
429
+ - A scope value may be `{ in: [...] }`; the registry option `timestamps` names the createdAt / deletedAt fields.
430
+
431
+ ## Wire (JSON) values and migrating from DbService
432
+
433
+ A request body is JSON, so a date always arrives as a **string**. The adapter
434
+ casts it according to the schema path:
435
+
436
+ ```json
437
+ { "key": "createdAt", "operation": "<=", "value": "2026-07-28T12:00:00.000Z" }
438
+ ```
439
+
440
+ - `Date` paths get the ISO string cast to a `Date`. The scope is **deliberately**
441
+ the same as drizzle-pg's: each adapter only judges the types it maps through JS
442
+ and leaves the rest to the database.
443
+ - `in`/`notIn` arrays, `between`/`notBetween` tuples and cursor tokens are covered
444
+ too (`cursorKey: "createdAt"` paginates correctly).
445
+ - Mongo cannot `$regex` a `Date` path (`Can't use $options with Date`), so a
446
+ text-pattern operator (`contains`/`ilike`/…) on a date field is **skipped** as
447
+ `invalid-value` and reported in `onSkippedCondition` — the SQL adapters do the same.
448
+ - An unparseable value (`"not-a-date"`) drops the condition (or 400s under
449
+ `strict`). **One** bad element in an `in` list drops the whole condition.
450
+
451
+ **Migrating from DbService:** the old wire's `type: "date"` field is no longer
452
+ needed — `@querykitjs/zod` strips it (without erroring) and coercion happens
453
+ server-side from the path type.
454
+
189
455
  ## Soft-delete
190
456
 
191
457
  Auto-enabled when the schema has a `deletedAt` path. Reads exclude soft-deleted docs by default; `withDeleted: true` includes them. When the schema has no `{ timestamps: true }` but a manual `updatedAt` path, it is bumped on every update.
@@ -207,22 +473,22 @@ await ordersRepository.aggregate({ sum: "amount", groupBy: "region" });
207
473
 
208
474
  ## API reference
209
475
 
210
- | Method | Returns |
211
- | -------------------------------------------------------------- | ---------------------------- |
212
- | `findAll(params?)` | `Row[]` |
213
- | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
214
- | `findList(params?)` | `{ data, meta }` offset |
215
- | `findInfinite(params?)` | `{ data, meta }` infinite |
216
- | `findCursor(params?)` | `{ data, meta }` cursor |
217
- | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
218
- | `aggregate(spec)` | `AggregateRow[]` |
219
- | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
220
- | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
221
- | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch)` | `Row \| undefined` / `Row[]` |
222
- | `deleteById(id, idKey?)` / `deleteWhere(filter)` | `Row \| undefined` / `Row[]` |
223
- | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
224
- | `scoped(scope)` | scoped `Repository` |
225
- | `registry.transaction(fn)` | result of `fn` |
476
+ | Method | Returns |
477
+ | ------------------------------------------------------------------------ | --------------------------------------------------------------------- |
478
+ | `findAll(params?)` | `Row[]` |
479
+ | `findOne(params?)` / `findById(id, params?)` | `Row \| undefined` |
480
+ | `findList(params?)` | `{ data, meta }` offset |
481
+ | `findInfinite(params?)` | `{ data, meta }` infinite |
482
+ | `findCursor(params?)` | `{ data, meta }` cursor |
483
+ | `count(filter?)` / `exists(filter?)` | `number` / `boolean` |
484
+ | `aggregate(spec)` | `AggregateRow[]` |
485
+ | `create(values)` / `createMany(values)` | `Row` / `Row[]` |
486
+ | `upsert(values, opts)` / `upsertMany(values, opts)` | `Row` / `Row[]` |
487
+ | `updateById(id, patch, idKey?)` / `updateWhere(filter, patch, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
488
+ | `deleteById(id, idKey?)` / `deleteWhere(filter, options?)` | `Row \| undefined` / `{ count }` (`{ count, rows }` with `returning`) |
489
+ | `softDelete(id)` / `restore(id)` | `Row \| undefined` |
490
+ | `scoped(scope)` | scoped `Repository` |
491
+ | `registry.transaction(fn)` | result of `fn` |
226
492
 
227
493
  ## Development
228
494