create-questpie 2.0.3 → 2.0.4

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 (35) hide show
  1. package/dist/index.mjs +244 -30
  2. package/package.json +1 -1
  3. package/skills/questpie/AGENTS.md +299 -98
  4. package/skills/questpie/SKILL.md +50 -17
  5. package/skills/questpie/coverage.json +213 -0
  6. package/skills/questpie/references/auth.md +119 -4
  7. package/skills/questpie/references/business-logic.md +126 -56
  8. package/skills/questpie/references/crud-api.md +231 -29
  9. package/skills/questpie/references/data-modeling.md +22 -6
  10. package/skills/questpie/references/extend.md +34 -7
  11. package/skills/questpie/references/field-types.md +14 -2
  12. package/skills/questpie/references/infrastructure-adapters.md +207 -32
  13. package/skills/questpie/references/mcp.md +147 -0
  14. package/skills/questpie/references/multi-tenancy.md +1 -2
  15. package/skills/questpie/references/production.md +218 -53
  16. package/skills/questpie/references/quickstart.md +6 -8
  17. package/skills/questpie/references/rules.md +86 -21
  18. package/skills/questpie/references/sandbox.md +110 -0
  19. package/skills/questpie/references/tanstack-query.md +34 -11
  20. package/skills/questpie/references/type-inference.md +167 -0
  21. package/skills/questpie/references/workflows.md +155 -0
  22. package/skills/questpie-admin/AGENTS.md +47 -40
  23. package/skills/questpie-admin/SKILL.md +46 -39
  24. package/skills/questpie-admin/references/custom-ui.md +1 -1
  25. package/templates/tanstack-start/AGENTS.md +15 -8
  26. package/templates/tanstack-start/CLAUDE.md +12 -5
  27. package/templates/tanstack-start/README.md +7 -6
  28. package/templates/tanstack-start/package.json +1 -0
  29. package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
  30. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
  31. package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
  32. package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
  33. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
  34. package/templates/tanstack-start/src/routes/api/$.ts +1 -2
  35. package/templates/tanstack-start/vite.config.ts +2 -2
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: questpie-core-business-logic
3
- description: QUESTPIE routes jobs services emails route job service email background queue scheduling Zod input validation server-side logic reusable services email templates
3
+ description:
4
+ QUESTPIE routes jobs services emails route job service email background queue scheduling Zod input validation server-side logic reusable services email templates
4
5
  - questpie-core
5
6
  ---
6
7
 
@@ -16,7 +17,7 @@ JSON routes are typed server-side endpoints. Define an input schema with Zod, wr
16
17
 
17
18
  ```ts
18
19
  // routes/get-active-barbers.ts
19
- import { route } from "questpie";
20
+ import { route } from "questpie/services";
20
21
  import z from "zod";
21
22
 
22
23
  export default route()
@@ -37,7 +38,7 @@ JSON routes validate input with Zod automatically:
37
38
 
38
39
  ```ts
39
40
  // routes/create-booking.ts
40
- import { route } from "questpie";
41
+ import { route } from "questpie/services";
41
42
  import z from "zod";
42
43
 
43
44
  export default route()
@@ -77,15 +78,18 @@ export default route()
77
78
 
78
79
  Route handlers receive the full `AppContext`:
79
80
 
80
- | Property | Description |
81
- | ------------- | -------------------------------------- |
82
- | `input` | Validated data matching the Zod schema |
83
- | `collections` | Typed collection API |
84
- | `queue` | Publish background jobs |
85
- | `email` | Send emails |
86
- | `db` | Raw database access |
87
- | `session` | Current auth session |
88
- | `services` | Custom services from `services/` |
81
+ | Property | Description |
82
+ | ------------- | ---------------------------------------------------------- |
83
+ | `input` | Validated data matching the Zod schema |
84
+ | `collections` | Typed collection API |
85
+ | `queue` | Publish background jobs |
86
+ | `email` | Send emails |
87
+ | `db` | Raw database access |
88
+ | `session` | Current auth session |
89
+ | `services` | Custom services from `services/` |
90
+ | _extensions_ | `appConfig({ context })` result, flat (e.g. `workspaceId`) |
91
+
92
+ Derived request context (from `appConfig({ context })`) reaches route access rules and handlers alike — destructure the keys directly. Inside any nested code, `getContext<App>()` exposes the same keys (see `references/multi-tenancy.md`).
89
93
 
90
94
  ### Calling Routes
91
95
 
@@ -125,7 +129,7 @@ Jobs are background tasks that run outside the request lifecycle. Ideal for send
125
129
 
126
130
  ```ts
127
131
  // jobs/send-appointment-confirmation.ts
128
- import { job } from "questpie";
132
+ import { job } from "questpie/services";
129
133
  import z from "zod";
130
134
 
131
135
  export default job({
@@ -182,6 +186,31 @@ Publish from hooks, routes, or other jobs via the typed `queue` context:
182
186
 
183
187
  The `queue` object provides full autocompletion for all jobs and their payloads.
184
188
 
189
+ ### Recurring Jobs (Cron)
190
+
191
+ Jobs accept a job-level cron expression in `options.cron`. Schedules are registered automatically when the queue worker starts (`app.queue.listen()` calls `registerSchedules()`):
192
+
193
+ ```ts
194
+ // jobs/cleanup-expired.ts
195
+ import { job } from "questpie/services";
196
+ import z from "zod";
197
+
198
+ export default job({
199
+ name: "cleanupExpired",
200
+ schema: z.object({}),
201
+ options: { cron: "0 3 * * *" }, // every day at 03:00
202
+ handler: async ({ collections }) => {
203
+ await collections.sessions.deleteMany({
204
+ where: { expiresAt: { lt: new Date() } },
205
+ });
206
+ },
207
+ });
208
+ ```
209
+
210
+ Programmatic scheduling from any handler: `queue.cleanupExpired.schedule({}, "0 3 * * *")` and `queue.cleanupExpired.unschedule()`.
211
+
212
+ Use job-level cron for simple recurring tasks (cleanup, digests, syncs). Reach for **workflow-level cron** (`references/workflows.md`) only when the recurring process needs steps, durable waits, or replay — a workflow is the heavier primitive.
213
+
185
214
  ### Job Handler Context
186
215
 
187
216
  | Property | Description |
@@ -198,7 +227,8 @@ Configure the queue adapter in your runtime config:
198
227
 
199
228
  ```ts
200
229
  // questpie.config.ts
201
- import { pgBossAdapter, runtimeConfig } from "questpie";
230
+ import { runtimeConfig } from "questpie/app";
231
+ import { pgBossAdapter } from "questpie/adapters/pg-boss";
202
232
 
203
233
  export default runtimeConfig({
204
234
  queue: {
@@ -209,26 +239,28 @@ export default runtimeConfig({
209
239
  });
210
240
  ```
211
241
 
212
- ## Routes
242
+ ## Raw Routes
213
243
 
214
- Routes give raw HTTP request/response handling for webhooks, OAuth callbacks, health checks, file downloads, and streaming.
244
+ Raw routes (`route().raw()`) give raw HTTP request/response handling for webhooks, OAuth callbacks, health checks, file downloads, and streaming. The handler receives the standard `Request` and must return a `Response`.
215
245
 
216
- ### Defining a Route
246
+ ### Defining a Raw Route
217
247
 
218
248
  ```ts
219
249
  // routes/health.ts
220
- import { route } from "questpie";
250
+ import { sql } from "questpie/drizzle";
251
+ import { route } from "questpie/services";
221
252
 
222
- export default route({
223
- method: "GET",
224
- handler: async ({ db }) => {
253
+ export default route()
254
+ .get()
255
+ .raw()
256
+ .access(true)
257
+ .handler(async ({ db }) => {
225
258
  const healthy = await db
226
259
  .execute(sql`SELECT 1`)
227
260
  .then(() => true)
228
261
  .catch(() => false);
229
262
  return Response.json({ status: healthy ? "ok" : "degraded" });
230
- },
231
- });
263
+ });
232
264
  ```
233
265
 
234
266
  Place files in `routes/`. The file path maps to a flat URL under your `basePath` (`/api` by default):
@@ -243,27 +275,29 @@ routes/
243
275
 
244
276
  ### Route Methods
245
277
 
278
+ Chain HTTP method calls on the builder (multiple methods = multiple calls):
279
+
246
280
  ```ts
247
- route({ method: "POST", handler: ... }) // Single method
248
- route({ method: ["GET", "POST"], handler: ... }) // Multiple methods
249
- route({ handler: ... }) // All methods (default)
281
+ route().post().raw().handler(...) // POST only
282
+ route().get().post().raw().handler(...) // GET + POST
250
283
  ```
251
284
 
252
- Supported: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`.
285
+ Supported: `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()`. The built-in `/auth/*` catch-all is itself a raw route (`route().get().post().raw()` delegating to Better Auth) — raw handlers run inside `runWithContext`, so the full request context is live in any code they call.
253
286
 
254
287
  ### Route Handler Context
255
288
 
256
- | Property | Type | Description |
257
- | ------------- | ------------------------ | ------------------------ |
258
- | `request` | `Request` | Standard Web API Request |
259
- | `params` | `Record<string, string>` | URL path parameters |
260
- | `locale` | `string` | Current locale |
261
- | `db` | `Database` | Database instance |
262
- | `session` | `Session \| null` | Current auth session |
263
- | `collections` | `CollectionsAPI` | Typed collection API |
264
- | `queue` | `QueueClient` | Queue client |
265
- | `email` | `MailerService` | Email service |
266
- | `services` | | User-defined services |
289
+ | Property | Type | Description |
290
+ | ------------- | ------------------------ | -------------------------------------------- |
291
+ | `request` | `Request` | Standard Web API Request |
292
+ | `params` | `Record<string, string>` | URL path parameters |
293
+ | `locale` | `string` | Current locale |
294
+ | `db` | `Database` | Database instance |
295
+ | `session` | `Session \| null` | Current auth session |
296
+ | `collections` | `CollectionsAPI` | Typed collection API |
297
+ | `queue` | `QueueClient` | Queue client |
298
+ | `email` | `MailerService` | Email service |
299
+ | `services` | | User-defined services |
300
+ | _extensions_ | | `appConfig({ context })` result, flat |
267
301
 
268
302
  Route handlers must return a `Response` object.
269
303
 
@@ -279,27 +313,66 @@ Route handlers must return a `Response` object.
279
313
 
280
314
  **Rule of thumb**: Use JSON routes for typed input/output with automatic validation. Use raw routes for HTTP-level control (custom headers, binary data, streams, signature verification).
281
315
 
282
- ### Webhook Example
316
+ ### Webhook Example (Signature Verification)
317
+
318
+ Webhooks need the raw body for signature verification — exactly what `.raw()` is for:
283
319
 
284
320
  ```ts
285
321
  // routes/webhooks/stripe.ts
286
- import { route } from "questpie";
322
+ import { route } from "questpie/services";
287
323
 
288
- export default route({
289
- method: "POST",
290
- handler: async ({ request, db }) => {
324
+ export default route()
325
+ .post()
326
+ .raw()
327
+ .access(true) // signature IS the auth — verify it yourself below
328
+ .handler(async ({ request, collections, queue }) => {
291
329
  const body = await request.text();
292
330
  const signature = request.headers.get("stripe-signature");
293
- const event = verifyStripeWebhook(body, signature);
331
+ const event = verifyStripeWebhook(body, signature); // throws on bad signature
332
+ if (!event) return new Response("Invalid signature", { status: 401 });
294
333
 
295
- await db.insert(webhookEvents).values({
334
+ await collections.webhook_events.create({
296
335
  type: event.type,
297
336
  payload: body,
298
337
  });
338
+ await queue.processStripeEvent.publish({ eventId: event.id });
299
339
 
300
340
  return new Response("OK", { status: 200 });
301
- },
302
- });
341
+ });
342
+ ```
343
+
344
+ ### Streamed Response Example
345
+
346
+ Raw routes can return any `Response`, including streams — CSV exports, server-sent progress, large file proxies:
347
+
348
+ ```ts
349
+ // routes/export.ts
350
+ import { route } from "questpie/services";
351
+
352
+ export default route()
353
+ .get()
354
+ .raw()
355
+ .access(({ session }) => !!session)
356
+ .handler(async ({ collections }) => {
357
+ const { docs } = await collections.orders.find({ limit: 10_000 });
358
+
359
+ const stream = new ReadableStream({
360
+ start(controller) {
361
+ controller.enqueue("id,total,createdAt\n");
362
+ for (const order of docs) {
363
+ controller.enqueue(`${order.id},${order.total},${order.createdAt.toISOString()}\n`);
364
+ }
365
+ controller.close();
366
+ },
367
+ });
368
+
369
+ return new Response(stream, {
370
+ headers: {
371
+ "Content-Type": "text/csv; charset=utf-8",
372
+ "Content-Disposition": 'attachment; filename="orders.csv"',
373
+ },
374
+ });
375
+ });
303
376
  ```
304
377
 
305
378
  ## Services
@@ -310,8 +383,7 @@ Services are reusable units of logic injected into `AppContext` under the `servi
310
383
 
311
384
  ```ts
312
385
  // services/blog.ts
313
- import { service } from "questpie";
314
-
386
+ import { service } from "questpie/services";
315
387
  const WORDS_PER_MINUTE = 200;
316
388
 
317
389
  function stripHtml(html: string): string {
@@ -372,7 +444,7 @@ Services are available via `services` destructuring in any handler:
372
444
 
373
445
  ```ts
374
446
  // services/stripe.ts
375
- import { service } from "questpie";
447
+ import { service } from "questpie/services";
376
448
  import Stripe from "stripe";
377
449
 
378
450
  export default service({
@@ -385,8 +457,7 @@ export default service({
385
457
 
386
458
  ```ts
387
459
  // services/tenant-db.ts
388
- import { service } from "questpie";
389
-
460
+ import { service } from "questpie/services";
390
461
  export default service({
391
462
  lifecycle: "request",
392
463
  deps: ["db", "session"] as const,
@@ -403,8 +474,7 @@ Services can depend on other services and infrastructure via `deps`. Use `as con
403
474
 
404
475
  ```ts
405
476
  // services/analytics.ts
406
- import { service } from "questpie";
407
-
477
+ import { service } from "questpie/services";
408
478
  export default service({
409
479
  deps: ["db", "logger"] as const,
410
480
  create: ({ db, logger }) => {
@@ -449,7 +519,7 @@ Email templates are defined in `emails/` and discovered by codegen. Each templat
449
519
 
450
520
  ```ts
451
521
  // emails/appointment-confirmation.ts
452
- import { email } from "questpie";
522
+ import { email } from "questpie/services";
453
523
  import { z } from "zod";
454
524
 
455
525
  export default email({
@@ -500,7 +570,7 @@ Email handlers receive the full `AppContext` for fetching data:
500
570
 
501
571
  ```ts
502
572
  // emails/weekly-digest.ts
503
- import { email } from "questpie";
573
+ import { email } from "questpie/services";
504
574
  import { z } from "zod";
505
575
 
506
576
  export default email({
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: questpie-core/crud-api
3
- description: QUESTPIE CRUD API find findOne create update delete count updateMany deleteMany query operators where filter sort orderBy pagination limit offset with select relations depth context accessMode collections globals client server typesafe
3
+ description: QUESTPIE CRUD API find findOne create updateById updateMany updateBatch deleteById deleteMany restoreById count atomic conditional update claim optimistic locking query operators where filter sort orderBy pagination limit offset with select relations depth context accessMode collections globals client server typesafe
4
4
  - questpie-core
5
5
  ---
6
6
 
@@ -16,8 +16,7 @@ Inside any handler, `collections` and `globals` are injected via context. The cu
16
16
 
17
17
  ```ts
18
18
  // routes/get-published.ts
19
- import { route } from "questpie";
20
-
19
+ import { route } from "questpie/services";
21
20
  export default route()
22
21
  .get()
23
22
  .handler(async ({ collections }) => {
@@ -47,6 +46,23 @@ const result = await app.collections.posts.find(
47
46
 
48
47
  ## Collection Operations
49
48
 
49
+ One vocabulary on both surfaces (server CRUD and client SDK):
50
+
51
+ | Concept | Method | Returns |
52
+ | --- | --- | --- |
53
+ | list (paginated) | `find(options)` | `{ docs: T[], totalDocs: number }` |
54
+ | single by query | `findOne(options)` | `T \| null` |
55
+ | count | `count(options)` | `number` |
56
+ | create | `create(data)` | `T` |
57
+ | update by id | `updateById({ id, data })` | `T` (throws notFound) |
58
+ | bulk update by where | `updateMany({ where, data })` | `T[]` (winners) |
59
+ | per-record batch | `updateBatch({ updates })` | `T[]` |
60
+ | delete by id | `deleteById({ id })` | `{ success }` (throws notFound) |
61
+ | bulk delete by where | `deleteMany({ where })` | `{ success, count }` |
62
+ | restore by id | `restoreById({ id })` | `T` (softDelete only) |
63
+
64
+ Deprecated aliases (removed in v4): server `update`/`delete` = bulk (`updateMany`/`deleteMany`); client `update`/`delete`/`restore` = by-id (`updateById`/`deleteById`/`restoreById`). Avoid them — the same names mean different things on each surface. Accessing a method that does not exist on server CRUD throws a `TypeError` listing valid methods (it does NOT return `undefined`).
65
+
50
66
  ### `find(options)`
51
67
 
52
68
  List documents with filtering, sorting, and pagination.
@@ -91,58 +107,111 @@ const post = await collections.posts.create({
91
107
  // post: T (created record with id)
92
108
  ```
93
109
 
94
- ### `update(options)`
110
+ ### `updateById(options)`
95
111
 
96
- Update a document matching `where`. Pass changed fields in `data`.
112
+ Update a single document by id. Returns the updated record; throws `notFound` if the record does not exist (or vanished concurrently).
97
113
 
98
114
  ```ts
99
- const updated = await collections.posts.update({
100
- where: { id: "abc-123" },
115
+ const updated = await collections.posts.updateById({
116
+ id: "abc-123",
101
117
  data: { status: "published" },
102
118
  });
103
119
  // updated: T (updated record)
104
120
  ```
105
121
 
106
- ### `delete(options)`
122
+ ### `updateMany(options)`
107
123
 
108
- Delete documents matching `where`.
124
+ Bulk update all documents matching `where`. Returns an **array** of the updated records — never a single object.
109
125
 
110
126
  ```ts
111
- await collections.posts.delete({
112
- where: { id: "abc-123" },
127
+ const updated = await collections.posts.updateMany({
128
+ where: { status: "draft" },
129
+ data: { status: "archived" },
113
130
  });
131
+ // updated: T[] — exactly the rows that were written
114
132
  ```
115
133
 
116
- ### `count(options)`
134
+ `updateMany` is claim-checked: inside the write transaction the matched rows are locked and `where` is re-evaluated, so rows changed by a concurrent writer are skipped instead of silently overwritten. The returned array reports exactly the winners.
117
135
 
118
- Count documents matching a filter.
136
+ #### Atomic conditional updates (claims, optimistic locking)
137
+
138
+ Use a conditional `where` + the array length as the win/lose signal:
119
139
 
120
140
  ```ts
121
- const total = await collections.posts.count({
122
- where: { status: "published" },
123
- });
124
- // total: number
141
+ // Claim: of two parallel claims, EXACTLY ONE wins
142
+ const claimed = await collections.event_members.updateMany(
143
+ {
144
+ where: { id: memberId, user: { isNull: true } },
145
+ data: { user: newUserId },
146
+ },
147
+ { accessMode: "system" },
148
+ );
149
+ if (claimed.length === 0) {
150
+ // Lost the race (or row vanished) — handle explicitly
151
+ }
152
+
153
+ // Optimistic concurrency: write only if the revision is unchanged
154
+ const bumped = await collections.documents.updateMany(
155
+ { where: { id, revision: doc.revision }, data: { body, revision: doc.revision + 1 } },
156
+ ctx,
157
+ );
158
+ if (bumped.length === 0) throw new Error("Conflict — reload and retry");
125
159
  ```
126
160
 
127
- ### `updateMany(options)`
161
+ Hook timing: `beforeValidate`/`beforeChange` run before the transaction on candidates (intent — may fire for losers); `afterChange`, versioning, and the return value are winners-only (fact).
128
162
 
129
- Bulk update all documents matching `where`.
163
+ ### `updateBatch(options)`
164
+
165
+ Distinct data per record, one transaction.
130
166
 
131
167
  ```ts
132
- await collections.posts.updateMany({
133
- where: { status: "draft" },
134
- data: { status: "archived" },
168
+ const updated = await collections.posts.updateBatch({
169
+ updates: [
170
+ { id: "a", data: { order: 1 } },
171
+ { id: "b", data: { order: 2 } },
172
+ ],
135
173
  });
174
+ // updated: T[]
175
+ ```
176
+
177
+ ### `deleteById(options)`
178
+
179
+ Delete a single document by id (soft delete when enabled). Throws `notFound` if missing.
180
+
181
+ ```ts
182
+ await collections.posts.deleteById({ id: "abc-123" });
183
+ // { success: true }
136
184
  ```
137
185
 
138
186
  ### `deleteMany(options)`
139
187
 
140
- Bulk delete all documents matching `where`.
188
+ Bulk delete all documents matching `where`. Claim-checked like `updateMany` — `count` is the number of rows that still matched at delete time.
141
189
 
142
190
  ```ts
143
- await collections.posts.deleteMany({
191
+ const result = await collections.posts.deleteMany({
144
192
  where: { status: "archived" },
145
193
  });
194
+ // result: { success: true, count: number }
195
+ ```
196
+
197
+ ### `restoreById(options)`
198
+
199
+ Restore a soft-deleted document (collections with `softDelete: true`).
200
+
201
+ ```ts
202
+ const restored = await collections.posts.restoreById({ id: "abc-123" });
203
+ // restored: T
204
+ ```
205
+
206
+ ### `count(options)`
207
+
208
+ Count documents matching a filter.
209
+
210
+ ```ts
211
+ const total = await collections.posts.count({
212
+ where: { status: "published" },
213
+ });
214
+ // total: number
146
215
  ```
147
216
 
148
217
  ## Global Operations
@@ -204,6 +273,20 @@ const result = await collections.posts.find({
204
273
  });
205
274
  ```
206
275
 
276
+ Multi-field sorting: order determines priority (first = primary sort). All
277
+ three syntaxes work, including inside relation `with` options:
278
+
279
+ ```ts
280
+ // Array syntax (preferred for explicit priority)
281
+ orderBy: [{ status: "desc" }, { createdAt: "desc" }]
282
+
283
+ // Object syntax (key order = priority)
284
+ orderBy: { status: "desc", createdAt: "desc" }
285
+
286
+ // Function syntax
287
+ orderBy: (table, { asc, desc }) => [desc(table.status), asc(table.title)]
288
+ ```
289
+
207
290
  ## Pagination
208
291
 
209
292
  Use `limit` and `offset`:
@@ -216,6 +299,38 @@ const page2 = await collections.posts.find({
216
299
  // page2.totalDocs = total count across all pages
217
300
  ```
218
301
 
302
+ ### Keyset (cursor) pagination
303
+
304
+ For stable pagination over changing data, use a tuple cursor of
305
+ `(createdAt, id)` with a matching multi-field `orderBy`. System timestamps
306
+ are stored with millisecond precision (`timestamp(3)`), so a `Date` you read
307
+ back equals the stored value exactly — cursor comparisons are exact:
308
+
309
+ ```ts
310
+ const page = await collections.posts.find({
311
+ where: cursor
312
+ ? {
313
+ OR: [
314
+ { createdAt: { lt: cursor.createdAt } },
315
+ {
316
+ AND: [
317
+ { createdAt: { eq: cursor.createdAt } },
318
+ { id: { lt: cursor.id } },
319
+ ],
320
+ },
321
+ ],
322
+ }
323
+ : undefined,
324
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
325
+ limit: 20,
326
+ });
327
+ const last = page.docs.at(-1);
328
+ const nextCursor = last ? { createdAt: last.createdAt, id: last.id } : null;
329
+ ```
330
+
331
+ Always use the explicit `{ eq: ... }` operator for `Date` cursor values —
332
+ do not pass a bare `Date` as an equality shorthand.
333
+
219
334
  ## Relations
220
335
 
221
336
  Relations are NOT populated by default. Use `with` to eager-load:
@@ -252,9 +367,46 @@ export default route()
252
367
  });
253
368
  ```
254
369
 
370
+ ### Partial Overrides (Inside Request Scope)
371
+
372
+ The optional second argument of every CRUD call merges with the ambient request scope. Priority: **explicit param → ALS scope (`runWithContext`) → defaults** (`accessMode: "system"`, `locale: "en"`). A bare `{ accessMode: "system" }` elevates only the mode — the request's `session`, `db`, and `locale` ride along automatically. The inverse holds too:
373
+
374
+ ```ts
375
+ // Inside any handler / hook / Better Auth callback:
376
+ await collections.posts.updateMany(
377
+ { where: { author: oldId }, data: { author: newId } },
378
+ { accessMode: "system" }, // mode elevated; session/db/locale inherited
379
+ );
380
+
381
+ await collections.posts.find({}, { accessMode: "user" }); // rules re-enabled against inherited session
382
+ ```
383
+
384
+ Never re-thread `session`/`locale` by hand when you only want a different access mode.
385
+
386
+ ### Transactions
387
+
388
+ `withTransaction(db, fn)` (from `questpie`) runs multiple CRUD calls atomically — calls inside the callback inherit the transaction connection through the ALS scope, and nested `withTransaction` calls reuse the open transaction. Queue side effects for after COMMIT with `onAfterCommit`:
389
+
390
+ ```ts
391
+ import { onAfterCommit, withTransaction } from "questpie";
392
+
393
+ await withTransaction(db, async () => {
394
+ const order = await collections.orders.create({ ... });
395
+ await collections.inventory.updateMany({
396
+ where: { sku: order.sku, status: "available" },
397
+ data: { status: "reserved" },
398
+ });
399
+ onAfterCommit(async () => {
400
+ await queue.notifyWarehouse.publish({ orderId: order.id });
401
+ });
402
+ });
403
+ ```
404
+
405
+ Do not run output-hook-heavy reads (blocks/upload `afterRead`) inside an open transaction unless necessary — they inherit the tx connection too.
406
+
255
407
  ### In Scripts / Seeds
256
408
 
257
- Create an explicit context with `app.createContext()`:
409
+ Outside any request scope, create an explicit context with `app.createContext()`:
258
410
 
259
411
  ```ts
260
412
  // System mode -- bypasses all access control
@@ -266,22 +418,44 @@ const ctx = await app.createContext({ accessMode: "user" });
266
418
 
267
419
  ## Client API
268
420
 
269
- The client SDK mirrors server operations:
421
+ The client SDK uses the same vocabulary:
270
422
 
271
423
  ```ts
272
424
  const posts = await client.collections.posts.find({ limit: 10 });
273
425
  const post = await client.collections.posts.findOne({ where: { id: "abc" } });
274
426
  const created = await client.collections.posts.create({ title: "New" });
275
- const updated = await client.collections.posts.update({
427
+ const updated = await client.collections.posts.updateById({
276
428
  id: "abc",
277
429
  data: { title: "Updated" },
278
430
  });
279
- await client.collections.posts.delete({ id: "abc" });
431
+ await client.collections.posts.deleteById({ id: "abc" });
432
+ const many = await client.collections.posts.updateMany({
433
+ where: { status: "draft" },
434
+ data: { status: "review" },
435
+ });
436
+ await client.collections.posts.deleteMany({ where: { status: "archived" } });
280
437
  const count = await client.collections.posts.count({
281
438
  where: { status: "draft" },
282
439
  });
283
440
  ```
284
441
 
442
+ ### Live Queries (Client Only)
443
+
444
+ Every read has a live form — `live()` mirrors `find()` (same options, same snapshot type) and pushes access-controlled snapshots over SSE. Globals mirror `get()`: `client.globals.<name>.live(...)`. See AGENTS.md §19 Realtime:
445
+
446
+ ```ts
447
+ const stop = client.collections.posts.live(
448
+ { where: { status: "published" }, with: { author: true } },
449
+ (snap) => render(snap.docs), // typed find() result
450
+ );
451
+ stop(); // unsubscribe
452
+
453
+ // AsyncGenerator form (workers, agents, tests)
454
+ for await (const snap of client.collections.posts.liveIter({ limit: 10 })) {
455
+ render(snap.docs);
456
+ }
457
+ ```
458
+
285
459
  ### Upload (Client Only)
286
460
 
287
461
  For upload collections:
@@ -363,6 +537,34 @@ export default route()
363
537
  });
364
538
  ```
365
539
 
540
+ ### HIGH: Expecting updateMany() to return a single record
541
+
542
+ Server bulk update returns an **array** of updated records:
543
+
544
+ ```ts
545
+ // WRONG -- updateMany returns T[], not T
546
+ const updated = await collections.posts.updateMany({
547
+ where: { id: "abc" },
548
+ data: { status: "published" },
549
+ });
550
+ console.log(updated.status); // undefined!
551
+
552
+ // CORRECT
553
+ const [updated] = await collections.posts.updateMany({
554
+ where: { id: "abc" },
555
+ data: { status: "published" },
556
+ });
557
+ // or, for a single record by id:
558
+ const updated2 = await collections.posts.updateById({
559
+ id: "abc",
560
+ data: { status: "published" },
561
+ });
562
+ ```
563
+
564
+ ### HIGH: `update`/`delete` mean different things on server vs client
565
+
566
+ On server CRUD, `update`/`delete` are deprecated aliases of the BULK operations (`{ where, data }` → `T[]`). On the client SDK they are by-id operations (`{ id, data }` → `T`). Always use the unambiguous names: `updateById`/`deleteById`/`restoreById` for single records, `updateMany`/`deleteMany` for bulk. Calling a method that does not exist (e.g. a typo) on server CRUD throws a `TypeError` listing the valid methods.
567
+
366
568
  ### MEDIUM: Wrong create() signature
367
569
 
368
570
  `create()` takes a flat data object, NOT `{ data: {...} }`:
@@ -375,4 +577,4 @@ await collections.posts.create({ data: { title: "Hello" } });
375
577
  await collections.posts.create({ title: "Hello", body: "World" });
376
578
  ```
377
579
 
378
- Note: `update()` DOES use `{ where, data }` -- only `create()` is flat.
580
+ Note: `updateById()`/`updateMany()` DO use `{ id/where, data }` -- only `create()` is flat.