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.
Files changed (40) hide show
  1. package/dist/index.mjs +244 -30
  2. package/package.json +1 -1
  3. package/skills/questpie/AGENTS.md +310 -103
  4. package/skills/questpie/SKILL.md +196 -84
  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 +26 -6
  10. package/skills/questpie/references/extend.md +98 -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 +31 -18
  17. package/skills/questpie/references/rules.md +140 -13
  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 +141 -68
  23. package/skills/questpie-admin/SKILL.md +96 -63
  24. package/skills/questpie-admin/references/blocks.md +28 -4
  25. package/skills/questpie-admin/references/custom-ui.md +1 -1
  26. package/skills/questpie-admin/references/views.md +21 -5
  27. package/templates/tanstack-start/AGENTS.md +15 -8
  28. package/templates/tanstack-start/CLAUDE.md +12 -5
  29. package/templates/tanstack-start/README.md +7 -6
  30. package/templates/tanstack-start/package.json +1 -0
  31. package/templates/tanstack-start/src/lib/query-client.ts +10 -1
  32. package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
  33. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
  34. package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
  35. package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
  36. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
  37. package/templates/tanstack-start/src/routes/admin/$.tsx +12 -1
  38. package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
  39. package/templates/tanstack-start/src/routes/api/$.ts +1 -2
  40. 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 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.
@@ -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("users").required(),
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("users"),
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, collection, job } from "questpie";
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/server";
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("users").required(),
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: