create-questpie 2.0.2 → 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 +310 -103
- package/skills/questpie/SKILL.md +196 -84
- 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 +26 -6
- package/skills/questpie/references/extend.md +98 -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 +31 -18
- package/skills/questpie/references/rules.md +140 -13
- 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 +141 -68
- package/skills/questpie-admin/SKILL.md +96 -63
- package/skills/questpie-admin/references/blocks.md +28 -4
- package/skills/questpie-admin/references/custom-ui.md +1 -1
- package/skills/questpie-admin/references/views.md +21 -5
- 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/lib/query-client.ts +10 -1
- 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/admin/$.tsx +12 -1
- package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
- package/templates/tanstack-start/src/routes/api/$.ts +1 -2
- package/templates/tanstack-start/vite.config.ts +2 -2
|
@@ -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.
|
|
@@ -62,6 +62,24 @@ export default collection("posts")
|
|
|
62
62
|
| `.options({...})` | Timestamps, versioning, soft delete |
|
|
63
63
|
| `.search({...})` | Search indexing |
|
|
64
64
|
| `.searchable(string[])` | Searchable fields |
|
|
65
|
+
| `.merge(other)` | Extend a same-name builder (see below) |
|
|
66
|
+
|
|
67
|
+
### Extending Collections — `.merge()`
|
|
68
|
+
|
|
69
|
+
To extend a collection a module already provides, merge its builder — never redefine the collection from scratch (same-key registration replaces the module's collection wholesale):
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { starterModule } from "questpie/app";
|
|
73
|
+
import { collection } from "#questpie/factories";
|
|
74
|
+
|
|
75
|
+
export default collection("user")
|
|
76
|
+
.merge(starterModule.collections.user)
|
|
77
|
+
.fields(({ f }) => ({
|
|
78
|
+
internalNotes: f.textarea(),
|
|
79
|
+
}));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Fields/options/extension keys combine by key (merged-in side wins), hooks concatenate, and `.fields()` after `.merge()` is cumulative — it never wipes merged fields. The result stays fully typed.
|
|
65
83
|
|
|
66
84
|
### Collection Options
|
|
67
85
|
|
|
@@ -94,6 +112,10 @@ import { uniqueIndex } from "drizzle-orm/pg-core";
|
|
|
94
112
|
})
|
|
95
113
|
```
|
|
96
114
|
|
|
115
|
+
Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names.
|
|
116
|
+
|
|
117
|
+
When workflow is the publication source for pages, public reads use `stage: "published"` and preview/draft-mode reads can load the working stage for authorized editors. Do not add duplicate publication booleans for the same concern.
|
|
118
|
+
|
|
97
119
|
### Access Control
|
|
98
120
|
|
|
99
121
|
```ts
|
|
@@ -238,8 +260,7 @@ Every field accepts:
|
|
|
238
260
|
### Virtual (Computed) Fields
|
|
239
261
|
|
|
240
262
|
```ts
|
|
241
|
-
import { sql } from "questpie";
|
|
242
|
-
|
|
263
|
+
import { sql } from "questpie/builders";
|
|
243
264
|
displayTitle: f.text().virtual(sql<string>`(
|
|
244
265
|
SELECT COALESCE(name, 'Unknown') || ' - ' ||
|
|
245
266
|
TO_CHAR("scheduledAt", 'YYYY-MM-DD HH24:MI')
|
|
@@ -256,7 +277,7 @@ All relations are defined via `f.relation()` inside `.fields()`.
|
|
|
256
277
|
### Belongs-To (Single)
|
|
257
278
|
|
|
258
279
|
```ts
|
|
259
|
-
author: f.relation("
|
|
280
|
+
author: f.relation("user").required(),
|
|
260
281
|
barber: f.relation("barbers").required().onDelete("cascade"),
|
|
261
282
|
```
|
|
262
283
|
|
|
@@ -315,8 +336,7 @@ const appointments = await collections.appointments.find({
|
|
|
315
336
|
### Locale Configuration
|
|
316
337
|
|
|
317
338
|
```ts title="config/app.ts"
|
|
318
|
-
import { appConfig } from "questpie";
|
|
319
|
-
|
|
339
|
+
import { appConfig } from "questpie/app";
|
|
320
340
|
export default appConfig({
|
|
321
341
|
locale: {
|
|
322
342
|
locales: [
|
|
@@ -446,7 +466,7 @@ collection("posts").relations({ author: belongsTo("users") });
|
|
|
446
466
|
|
|
447
467
|
// CORRECT -- use f.relation() inside .fields()
|
|
448
468
|
collection("posts").fields(({ f }) => ({
|
|
449
|
-
author: f.relation("
|
|
469
|
+
author: f.relation("user"),
|
|
450
470
|
}));
|
|
451
471
|
```
|
|
452
472
|
|
|
@@ -15,7 +15,7 @@ A plugin tells codegen what to discover and what types to generate. Plugins cont
|
|
|
15
15
|
### Plugin Structure
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import type { CodegenPlugin } from "questpie";
|
|
18
|
+
import type { CodegenPlugin } from "questpie/codegen";
|
|
19
19
|
|
|
20
20
|
export function myPlugin(): CodegenPlugin {
|
|
21
21
|
return {
|
|
@@ -76,7 +76,7 @@ export function myPlugin(): CodegenPlugin {
|
|
|
76
76
|
### Register in Config
|
|
77
77
|
|
|
78
78
|
```ts title="questpie.config.ts"
|
|
79
|
-
import { runtimeConfig } from "questpie";
|
|
79
|
+
import { runtimeConfig } from "questpie/app";
|
|
80
80
|
import { myPlugin } from "my-plugin-package";
|
|
81
81
|
|
|
82
82
|
export default runtimeConfig({
|
|
@@ -86,6 +86,70 @@ export default runtimeConfig({
|
|
|
86
86
|
});
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
Use direct `runtimeConfig({ plugins })` registration only for standalone codegen plugins or custom setups that do not ship a module. Reusable packages should usually attach the plugin to a static module and let codegen extract it from `modules.ts`.
|
|
90
|
+
|
|
91
|
+
### Configurable Codegen-Aware Modules
|
|
92
|
+
|
|
93
|
+
When a package ships a module and a `CodegenPlugin`, keep module identity static and put runtime options in a plugin-discovered config file. Codegen imports `modules.ts` before runtime app creation, so it must be able to see the same module/plugin tree regardless of environment or runtime options.
|
|
94
|
+
|
|
95
|
+
#### DO THIS
|
|
96
|
+
|
|
97
|
+
```ts title="modules.ts"
|
|
98
|
+
import { observabilityModule } from "@questpie/observability/server";
|
|
99
|
+
|
|
100
|
+
export default [observabilityModule] as const;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ts title="config/observability.ts"
|
|
104
|
+
import { observabilityConfig } from "@questpie/observability/server";
|
|
105
|
+
|
|
106
|
+
export default observabilityConfig({
|
|
107
|
+
serviceName: "barbershop",
|
|
108
|
+
enabled: process.env.NODE_ENV === "production",
|
|
109
|
+
otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ts title="@questpie/observability/server.ts"
|
|
114
|
+
export const observabilityModule = module({
|
|
115
|
+
name: "questpie-observability",
|
|
116
|
+
plugin: observabilityPlugin(),
|
|
117
|
+
services: {
|
|
118
|
+
observability: service({
|
|
119
|
+
namespace: null,
|
|
120
|
+
lifecycle: "singleton",
|
|
121
|
+
create: ({ app, logger }) =>
|
|
122
|
+
createObservabilityService(app.state.config?.observability, logger),
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The plugin contributes `config/observability.ts` as a discover pattern and a typed singleton factory such as `observabilityConfig()`. The service reads the resolved config at runtime from `app.state.config.observability`.
|
|
129
|
+
|
|
130
|
+
#### DON'T DO THIS
|
|
131
|
+
|
|
132
|
+
Do not make runtime options the main API for modules that contribute codegen plugins:
|
|
133
|
+
|
|
134
|
+
```ts title="modules.ts"
|
|
135
|
+
export default [
|
|
136
|
+
observabilityModule({
|
|
137
|
+
serviceName: "barbershop",
|
|
138
|
+
enabled: process.env.NODE_ENV === "production",
|
|
139
|
+
}),
|
|
140
|
+
] as const;
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Do not conditionally include codegen-aware modules or plugins:
|
|
144
|
+
|
|
145
|
+
```ts title="modules.ts"
|
|
146
|
+
export default [
|
|
147
|
+
process.env.OTEL_ENABLED ? observabilityModule : undefined,
|
|
148
|
+
].filter(Boolean);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Factory modules are acceptable only for simple runtime-only modules whose plugin identity and generated contributions do not change. If the package contributes discover patterns, generated factories, module categories, views, components, fields, or collection/global extensions, use **static module + `config/*.ts` singleton factory**.
|
|
152
|
+
|
|
89
153
|
### Plugin Lifecycle
|
|
90
154
|
|
|
91
155
|
1. **Discovery** -- codegen scans for files matching category patterns and discover patterns
|
|
@@ -103,7 +167,9 @@ The admin module contributes a codegen plugin to both `"server"` and `"admin-cli
|
|
|
103
167
|
A module is a reusable package that contributes entities to any QUESTPIE project.
|
|
104
168
|
|
|
105
169
|
```ts
|
|
106
|
-
import { module
|
|
170
|
+
import { module } from "questpie/app";
|
|
171
|
+
import { collection } from "questpie/builders";
|
|
172
|
+
import { job } from "questpie/services";
|
|
107
173
|
import { z } from "zod";
|
|
108
174
|
|
|
109
175
|
const notificationsCollection = collection("notifications")
|
|
@@ -171,10 +237,35 @@ export const notificationsModule = module({
|
|
|
171
237
|
| `seeds` | `Seed[]` | Seed data |
|
|
172
238
|
| `messages` | `Record` | i18n translations |
|
|
173
239
|
|
|
240
|
+
### How Module Contributions Merge
|
|
241
|
+
|
|
242
|
+
When several modules (and the app) contribute the same key, `createApp()` merges them deterministically — later modules win per entry:
|
|
243
|
+
|
|
244
|
+
| Key | Strategy |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| `collections`, `globals`, `jobs`, `routes`, `fields`, `services` | record spread-merge — same key: later wins |
|
|
247
|
+
| `messages` | deep-merge by locale — same message key: later wins |
|
|
248
|
+
| `migrations`, `seeds` | array concatenation |
|
|
249
|
+
| `config.*` (app, auth, admin, plugin config keys) | per-key strategies; `auth`/`admin` deep-merge; unknown keys: incoming replaces existing |
|
|
250
|
+
| anything else | auto-detect: object+object → spread, array+array → concat, otherwise incoming wins |
|
|
251
|
+
|
|
252
|
+
The merge helpers behind these strategies are exported from `questpie/app` for module authors combining config fragments of their own:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
import { lastWins, mergeConcat, mergeDeepConcat, mergeRecord, type MergeFn } from "questpie/app";
|
|
256
|
+
|
|
257
|
+
mergeRecord(a, b); // { ...a, ...b }
|
|
258
|
+
mergeConcat(a, b); // [...a, ...b]
|
|
259
|
+
mergeDeepConcat(a, b); // spread objects, concat array-valued props
|
|
260
|
+
lastWins(a, b); // b
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Use them (instead of hand-rolled spreads) when a module exposes its own "combine these contributions" surface — the semantics then match what the framework does for built-in keys.
|
|
264
|
+
|
|
174
265
|
### Using a Module
|
|
175
266
|
|
|
176
267
|
```ts title="modules.ts"
|
|
177
|
-
import { adminModule } from "@questpie/admin/
|
|
268
|
+
import { adminModule } from "@questpie/admin/modules/admin";
|
|
178
269
|
import { notificationsModule } from "my-notifications-package";
|
|
179
270
|
|
|
180
271
|
export default [adminModule, notificationsModule] as const;
|
|
@@ -217,7 +308,7 @@ A custom field defines:
|
|
|
217
308
|
The `Field` class is an immutable builder:
|
|
218
309
|
|
|
219
310
|
```ts
|
|
220
|
-
import { Field } from "questpie";
|
|
311
|
+
import { Field } from "questpie/builders";
|
|
221
312
|
|
|
222
313
|
// Each method returns a new Field with updated type state
|
|
223
314
|
f.text(255).required().label({ en: "Name" }).admin({ placeholder: "..." });
|
|
@@ -248,7 +339,7 @@ QUESTPIE ships with adapters for Hono, Elysia, and Next.js. For other frameworks
|
|
|
248
339
|
### Generic Fetch Handler
|
|
249
340
|
|
|
250
341
|
```ts
|
|
251
|
-
import { createFetchHandler } from "questpie";
|
|
342
|
+
import { createFetchHandler } from "questpie/http";
|
|
252
343
|
import { app } from "#questpie";
|
|
253
344
|
|
|
254
345
|
const handler = createFetchHandler(app, { basePath: "/api" });
|
|
@@ -296,7 +387,7 @@ export const { GET, POST, PATCH, DELETE } = questpieNextRouteHandlers(app, {
|
|
|
296
387
|
|
|
297
388
|
```ts title="src/routes/api/$.ts"
|
|
298
389
|
import { createAPIFileRoute } from "@tanstack/react-start/api";
|
|
299
|
-
import { createFetchHandler } from "questpie";
|
|
390
|
+
import { createFetchHandler } from "questpie/http";
|
|
300
391
|
import { app } from "#questpie";
|
|
301
392
|
|
|
302
393
|
const handler = createFetchHandler(app, { basePath: "/api" });
|
|
@@ -14,6 +14,9 @@ Complete configuration patterns for built-in QUESTPIE field types. Fields use fl
|
|
|
14
14
|
| `.inputOptional()` | Optional in API input but required in DB |
|
|
15
15
|
| `.admin(config)` | Admin UI rendering hints |
|
|
16
16
|
| `.virtual(sql)` | SQL expression for computed read-only field |
|
|
17
|
+
| `.zod(fn)` | Extend/replace Zod schema (output narrows value type) |
|
|
18
|
+
| `.drizzle(fn)` | Raw Drizzle column builder — constraints/SQL defaults land in DDL; `$type` narrows value type |
|
|
19
|
+
| `.$type<T>()` | Explicitly set TS value type (type-level; mainly json) |
|
|
17
20
|
|
|
18
21
|
## `f.text(options?)`
|
|
19
22
|
|
|
@@ -206,7 +209,7 @@ Reference to another collection.
|
|
|
206
209
|
Belongs-to (single):
|
|
207
210
|
|
|
208
211
|
```ts
|
|
209
|
-
author: f.relation("
|
|
212
|
+
author: f.relation("user").required(),
|
|
210
213
|
category: f.relation("categories").onDelete("set null"),
|
|
211
214
|
```
|
|
212
215
|
|
|
@@ -355,13 +358,22 @@ pageContent: f.blocks(),
|
|
|
355
358
|
|
|
356
359
|
## `f.json(options?)`
|
|
357
360
|
|
|
358
|
-
Raw JSON data. No schema validation
|
|
361
|
+
Raw JSON data. No schema validation by default; value types as loose `JsonValue`.
|
|
359
362
|
|
|
360
363
|
```ts
|
|
361
364
|
metadata: f.json(),
|
|
362
365
|
rawConfig: f.json().label("Configuration"),
|
|
363
366
|
```
|
|
364
367
|
|
|
368
|
+
Type it explicitly with `.$type<T>()` (type only) or `.zod()` (type + runtime validation) — the type flows into CRUD select/insert types:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
type Layout = { rows: { id: string; span: number }[] };
|
|
372
|
+
|
|
373
|
+
layout: f.json().$type<Layout>(),
|
|
374
|
+
settings: f.json().zod(() => z.object({ theme: z.enum(["light", "dark"]) })),
|
|
375
|
+
```
|
|
376
|
+
|
|
365
377
|
## Admin Meta Options
|
|
366
378
|
|
|
367
379
|
The `meta.admin` object controls field rendering in the admin panel:
|