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.
- package/dist/index.mjs +244 -30
- package/package.json +1 -1
- package/skills/questpie/AGENTS.md +299 -98
- package/skills/questpie/SKILL.md +50 -17
- package/skills/questpie/coverage.json +213 -0
- package/skills/questpie/references/auth.md +119 -4
- package/skills/questpie/references/business-logic.md +126 -56
- package/skills/questpie/references/crud-api.md +231 -29
- package/skills/questpie/references/data-modeling.md +22 -6
- package/skills/questpie/references/extend.md +34 -7
- package/skills/questpie/references/field-types.md +14 -2
- package/skills/questpie/references/infrastructure-adapters.md +207 -32
- package/skills/questpie/references/mcp.md +147 -0
- package/skills/questpie/references/multi-tenancy.md +1 -2
- package/skills/questpie/references/production.md +218 -53
- package/skills/questpie/references/quickstart.md +6 -8
- package/skills/questpie/references/rules.md +86 -21
- package/skills/questpie/references/sandbox.md +110 -0
- package/skills/questpie/references/tanstack-query.md +34 -11
- package/skills/questpie/references/type-inference.md +167 -0
- package/skills/questpie/references/workflows.md +155 -0
- package/skills/questpie-admin/AGENTS.md +47 -40
- package/skills/questpie-admin/SKILL.md +46 -39
- package/skills/questpie-admin/references/custom-ui.md +1 -1
- package/templates/tanstack-start/AGENTS.md +15 -8
- package/templates/tanstack-start/CLAUDE.md +12 -5
- package/templates/tanstack-start/README.md +7 -6
- package/templates/tanstack-start/package.json +1 -0
- package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
- package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
- package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
- package/templates/tanstack-start/src/routes/api/$.ts +1 -2
- package/templates/tanstack-start/vite.config.ts +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: questpie-core-business-logic
|
|
3
|
-
description:
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
250
|
+
import { sql } from "questpie/drizzle";
|
|
251
|
+
import { route } from "questpie/services";
|
|
221
252
|
|
|
222
|
-
export default route(
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
248
|
-
route(
|
|
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:
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
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
|
|
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
|
-
### `
|
|
110
|
+
### `updateById(options)`
|
|
95
111
|
|
|
96
|
-
Update a document
|
|
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.
|
|
100
|
-
|
|
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
|
-
### `
|
|
122
|
+
### `updateMany(options)`
|
|
107
123
|
|
|
108
|
-
|
|
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.
|
|
112
|
-
where: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
### `updateBatch(options)`
|
|
164
|
+
|
|
165
|
+
Distinct data per record, one transaction.
|
|
130
166
|
|
|
131
167
|
```ts
|
|
132
|
-
await collections.posts.
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
427
|
+
const updated = await client.collections.posts.updateById({
|
|
276
428
|
id: "abc",
|
|
277
429
|
data: { title: "Updated" },
|
|
278
430
|
});
|
|
279
|
-
await client.collections.posts.
|
|
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: `
|
|
580
|
+
Note: `updateById()`/`updateMany()` DO use `{ id/where, data }` -- only `create()` is flat.
|