@jskit-ai/agent-docs 0.1.1

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 (73) hide show
  1. package/DISTR_AGENT.md +25 -0
  2. package/guide/agent/app-extras/assistant.md +636 -0
  3. package/guide/agent/app-extras/realtime.md +223 -0
  4. package/guide/agent/app-setup/a-more-interesting-shell.md +643 -0
  5. package/guide/agent/app-setup/authentication.md +948 -0
  6. package/guide/agent/app-setup/console.md +316 -0
  7. package/guide/agent/app-setup/database-layer.md +775 -0
  8. package/guide/agent/app-setup/initial-scaffolding.md +714 -0
  9. package/guide/agent/app-setup/multi-homing.md +655 -0
  10. package/guide/agent/app-setup/users.md +355 -0
  11. package/guide/agent/app-setup/working-with-the-jskit-cli.md +983 -0
  12. package/guide/agent/generators/advanced-cruds.md +923 -0
  13. package/guide/agent/generators/crud-generators.md +556 -0
  14. package/guide/agent/generators/intro.md +63 -0
  15. package/guide/agent/generators/ui-generators.md +648 -0
  16. package/guide/agent/index.md +39 -0
  17. package/guide/human/app-extras/assistant.md +695 -0
  18. package/guide/human/app-extras/realtime.md +270 -0
  19. package/guide/human/app-setup/a-more-interesting-shell.md +734 -0
  20. package/guide/human/app-setup/authentication.md +963 -0
  21. package/guide/human/app-setup/console.md +352 -0
  22. package/guide/human/app-setup/database-layer.md +822 -0
  23. package/guide/human/app-setup/initial-scaffolding.md +738 -0
  24. package/guide/human/app-setup/multi-homing.md +795 -0
  25. package/guide/human/app-setup/users.md +404 -0
  26. package/guide/human/app-setup/working-with-the-jskit-cli.md +997 -0
  27. package/guide/human/generators/advanced-cruds.md +923 -0
  28. package/guide/human/generators/crud-generators.md +556 -0
  29. package/guide/human/generators/intro.md +109 -0
  30. package/guide/human/generators/ui-generators.md +665 -0
  31. package/guide/human/index.md +39 -0
  32. package/package.json +28 -0
  33. package/reference/autogen/KERNEL_MAP.md +536 -0
  34. package/reference/autogen/README.md +44 -0
  35. package/reference/autogen/packages/agent-docs.md +13 -0
  36. package/reference/autogen/packages/assistant-core.md +310 -0
  37. package/reference/autogen/packages/assistant-runtime.md +219 -0
  38. package/reference/autogen/packages/assistant.md +73 -0
  39. package/reference/autogen/packages/auth-core.md +352 -0
  40. package/reference/autogen/packages/auth-provider-supabase-core.md +223 -0
  41. package/reference/autogen/packages/auth-web.md +267 -0
  42. package/reference/autogen/packages/console-core.md +116 -0
  43. package/reference/autogen/packages/console-web.md +37 -0
  44. package/reference/autogen/packages/crud-core.md +283 -0
  45. package/reference/autogen/packages/crud-server-generator.md +220 -0
  46. package/reference/autogen/packages/crud-ui-generator.md +154 -0
  47. package/reference/autogen/packages/database-runtime-mysql.md +61 -0
  48. package/reference/autogen/packages/database-runtime-postgres.md +39 -0
  49. package/reference/autogen/packages/database-runtime.md +216 -0
  50. package/reference/autogen/packages/http-runtime.md +213 -0
  51. package/reference/autogen/packages/kernel.md +1350 -0
  52. package/reference/autogen/packages/realtime.md +95 -0
  53. package/reference/autogen/packages/shell-web.md +349 -0
  54. package/reference/autogen/packages/storage-runtime.md +39 -0
  55. package/reference/autogen/packages/ui-generator.md +101 -0
  56. package/reference/autogen/packages/uploads-image-web.md +76 -0
  57. package/reference/autogen/packages/uploads-runtime.md +85 -0
  58. package/reference/autogen/packages/users-core.md +307 -0
  59. package/reference/autogen/packages/users-web.md +473 -0
  60. package/reference/autogen/packages/workspaces-core.md +415 -0
  61. package/reference/autogen/packages/workspaces-web.md +372 -0
  62. package/reference/autogen/tooling/config-eslint.md +52 -0
  63. package/reference/autogen/tooling/create-app.md +194 -0
  64. package/reference/autogen/tooling/jskit-catalog.md +27 -0
  65. package/reference/autogen/tooling/jskit-cli.md +624 -0
  66. package/reference/autogen/tooling/test-support.md +27 -0
  67. package/reference/autogen/tooling/testUtils.md +31 -0
  68. package/templates/APP_BLUEPRINT.md +57 -0
  69. package/workflow/app-state.md +33 -0
  70. package/workflow/bootstrap.md +24 -0
  71. package/workflow/feature-delivery.md +21 -0
  72. package/workflow/review.md +22 -0
  73. package/workflow/scoping.md +26 -0
@@ -0,0 +1,923 @@
1
+ <!-- Generated by `npm run agent-docs:build` from `docs/guide/generators/advanced-cruds.md`. Do not edit manually. -->
2
+
3
+ # Advanced CRUDs
4
+
5
+ The earlier CRUD chapter shows the workflow. This chapter shows the anatomy.
6
+
7
+ If you have not read [CRUD Generators](/guide/generators/crud-generators) yet, start there first. This chapter assumes you already understand the basic generation flow and want to inspect or customize what it produced.
8
+
9
+ Once you generate `contacts`, you do **not** get one magical black-box CRUD object. You get:
10
+
11
+ - an app-local server package under `packages/contacts/`
12
+ - an app-local route tree under `src/pages/.../contacts/`
13
+ - a shared resource contract that sits between the two
14
+
15
+ That distinction matters, because it tells you where to change things safely.
16
+
17
+ This chapter stays grounded in the exact resources from the previous chapter:
18
+
19
+ - `contacts`
20
+ - `addresses`
21
+ - `comments`
22
+
23
+ The point here is not to introduce a different app. It is to explain the code you just generated and show how those same CRUDs evolve once you start customizing them.
24
+
25
+ ## Starting point
26
+
27
+ This chapter starts from the end of the baseline `contacts` example in [CRUD Generators](/guide/generators/crud-generators):
28
+
29
+ ```bash
30
+ npx jskit generate crud-server-generator scaffold \
31
+ --namespace contacts \
32
+ --surface admin \
33
+ --ownership-filter workspace \
34
+ --table-name contacts
35
+
36
+ npx jskit generate crud-ui-generator crud \
37
+ w/[workspaceSlug]/admin/contacts \
38
+ --resource-file packages/contacts/src/shared/contactResource.js \
39
+ --id-param contactId \
40
+ --display-fields fullName,email,phone
41
+ ```
42
+
43
+ Later sections pull `addresses` and `comments` back in when we talk about child CRUDs, parent scoping, and embedded lists.
44
+
45
+ After those two commands, the important thing to understand is ownership:
46
+
47
+ - `crud-server-generator` creates a runtime package that your app owns locally
48
+ - `crud-ui-generator` creates route files that your app owns locally
49
+ - `crud-core`, `users-web`, and the other runtime packages provide the machinery underneath those files
50
+
51
+ The generated pages are intentionally thin. Most of the heavy lifting lives uphill in shared runtime composables, action execution, validation, lookup hydration, and repository helpers.
52
+
53
+ ## How ownership shapes the generated CRUD
54
+
55
+ The earlier chapter explains how to choose an ownership filter. This chapter explains what that choice **does** structurally.
56
+
57
+ The key idea is:
58
+
59
+ The generated CRUD does not treat ownership as a UI hint. It turns ownership into the visibility model for the whole resource.
60
+
61
+ That affects:
62
+
63
+ - route visibility
64
+ - repository filtering
65
+ - create-time owner stamping
66
+ - lookup hydration for related CRUDs
67
+
68
+ ### The generated package stores a concrete ownership filter
69
+
70
+ Even if you scaffold with:
71
+
72
+ ```bash
73
+ --ownership-filter auto
74
+ ```
75
+
76
+ the generated package does **not** keep `auto` forever.
77
+
78
+ During generation, JSKIT resolves it to a concrete value:
79
+
80
+ - `public`
81
+ - `user`
82
+ - `workspace`
83
+ - or `workspace_user`
84
+
85
+ That resolved value is then written into the generated CRUD package and used as the real route visibility / repository ownership model.
86
+
87
+ So `auto` is only a scaffold-time convenience. Once generation is done, the CRUD has a concrete ownership shape.
88
+
89
+ ### Ownership becomes route visibility
90
+
91
+ The generated `registerRoutes.js` uses the resolved ownership filter as the route visibility token for every CRUD route:
92
+
93
+ - list
94
+ - view
95
+ - create
96
+ - update
97
+ - delete
98
+
99
+ So if the CRUD resolves to:
100
+
101
+ - `public`
102
+ - the routes run with public visibility
103
+ - `user`
104
+ - the routes run with actor/user visibility
105
+ - `workspace`
106
+ - the routes run with workspace visibility
107
+ - `workspace_user`
108
+ - the routes run with workspace-plus-actor visibility
109
+
110
+ This is why ownership is such a foundational choice. It becomes part of the generated server contract, not just the database shape.
111
+
112
+ ### Ownership controls which owner columns are expected
113
+
114
+ The repository layer ultimately applies visibility through the standard owner columns:
115
+
116
+ - `workspace_id`
117
+ - `user_id`
118
+
119
+ That means the generated CRUD behaves like this:
120
+
121
+ - `public`
122
+ - no owner filter is applied
123
+ - rows are not expected to be scoped by `workspace_id` or `user_id`
124
+ - `user`
125
+ - the repository filters by `user_id`
126
+ - `workspace`
127
+ - the repository filters by `workspace_id`
128
+ - `workspace_user`
129
+ - the repository filters by both `workspace_id` and `user_id`
130
+
131
+ This is also why explicit ownership filters are validated against the real table shape during generation:
132
+
133
+ - `workspace` requires `workspace_id`
134
+ - `user` requires `user_id`
135
+ - `workspace_user` requires both
136
+
137
+ If the table does not match, generation fails instead of silently creating a broken CRUD.
138
+
139
+ ### Ownership also affects create behavior
140
+
141
+ The ownership model is not only used for reads.
142
+
143
+ When the generated repository creates a row, it applies visibility owners into the insert payload too.
144
+
145
+ In practice that means:
146
+
147
+ - a `workspace` CRUD stamps `workspace_id`
148
+ - a `user` CRUD stamps `user_id`
149
+ - a `workspace_user` CRUD stamps both
150
+
151
+ So the ownership choice shapes both:
152
+
153
+ - which rows are visible later
154
+ - how new rows are stamped when they are created
155
+
156
+ That is another reason ownership needs to match the real intent of the table.
157
+
158
+ ### Lookup hydration uses ownership too
159
+
160
+ This is easy to miss at first.
161
+
162
+ Generated CRUDs often hydrate related records through lookup providers. Those child lookups also need to know what ownership model they run under.
163
+
164
+ For example:
165
+
166
+ - a `workspace_user` parent may need to hydrate a relation from a `workspace` child provider
167
+ - a `workspace` parent may hydrate a `public` lookup
168
+
169
+ The lookup runtime uses each provider's ownership filter to remap visibility correctly. So ownership is not only about the top-level resource. It also affects how related CRUD-backed records are fetched safely.
170
+
171
+ That is why ownership mistakes often surface later as "weird relation visibility" bugs rather than as immediate scaffold failures.
172
+
173
+ ### How to reason about changing it later
174
+
175
+ Changing ownership later is possible, but it is not a tiny edit.
176
+
177
+ If you change a CRUD from one ownership shape to another, you may need to change:
178
+
179
+ - the table schema
180
+ - existing row data
181
+ - the generated ownership filter in the CRUD package
182
+ - route expectations
183
+ - relation lookup ownership
184
+ - sometimes the target surface itself
185
+
186
+ For example:
187
+
188
+ - changing `workspace` to `workspace_user`
189
+ - usually means adding `user_id`
190
+ - backfilling existing rows
191
+ - changing how records are expected to be visible
192
+ - changing `public` to `workspace`
193
+ - usually means adding `workspace_id`
194
+ - deciding how old rows should be assigned to workspaces
195
+
196
+ So the safe mental model is:
197
+
198
+ - ownership is part of the CRUD's structural design
199
+ - choose it early and deliberately
200
+ - do not treat it like a cosmetic generator option
201
+
202
+ ## The full generated shape
203
+
204
+ For a normal top-level CRUD like `contacts`, the generator output looks like this:
205
+
206
+ ```text
207
+ migrations/
208
+ *_crud_initial_contacts.cjs
209
+
210
+ packages/contacts/
211
+ package.json
212
+ package.descriptor.mjs
213
+ src/server/ContactsProvider.js
214
+ src/server/actionIds.js
215
+ src/server/actions.js
216
+ src/server/listConfig.js
217
+ src/server/registerRoutes.js
218
+ src/server/repository.js
219
+ src/server/service.js
220
+ src/shared/index.js
221
+ src/shared/contactResource.js
222
+
223
+ src/pages/w/[workspaceSlug]/admin/contacts/
224
+ index.vue
225
+ new.vue
226
+ [contactId]/index.vue
227
+ [contactId]/edit.vue
228
+ _components/CrudAddEditForm.vue
229
+ _components/CrudAddEditFormFields.js
230
+
231
+ config/roles.js
232
+ src/placement.js
233
+ ```
234
+
235
+ Two important notes:
236
+
237
+ 1. `config/roles.js` and `src/placement.js` are app mutations, not part of the `packages/contacts/` package itself.
238
+ 2. If you generate only some CRUD operations, the route tree changes. For example, no `list` means no `index.vue`, and no `edit` means the add/edit shared files may be generated differently.
239
+
240
+ ## What each server file owns
241
+
242
+ ### `package.json` and `package.descriptor.mjs`
243
+
244
+ These make the CRUD a real local package.
245
+
246
+ They own:
247
+
248
+ - package identity
249
+ - runtime dependencies
250
+ - provider registration metadata
251
+ - descriptor-driven install/runtime metadata
252
+
253
+ They do **not** own CRUD behavior directly. They describe how the package plugs into the app.
254
+
255
+ ### `src/shared/contactResource.js`
256
+
257
+ This is the shared CRUD contract, and it is the closest thing JSKIT has to a generated "model" file.
258
+
259
+ If you come from an ORM stack, this is the key adjustment:
260
+
261
+ - there is no generated `ContactModel.js`
262
+ - there is no ActiveRecord-style class
263
+ - the "model layer" is split between the resource contract and the repository/service layers
264
+
265
+ The resource file owns:
266
+
267
+ - the resource name and table name
268
+ - the id column
269
+ - input and output validators
270
+ - operation metadata for `list`, `view`, `create`, `patch`, and `delete`
271
+ - lookup contract configuration
272
+ - messages and realtime event declarations
273
+ - field metadata when column overrides or richer metadata are needed
274
+
275
+ This file is the bridge between the server and the client. The UI generator reads it, and the server runtime also depends on it.
276
+
277
+ ### `src/shared/index.js`
278
+
279
+ This is just the shared package barrel. It re-exports the resource contract and shared symbols.
280
+
281
+ ### `src/server/ContactsProvider.js`
282
+
283
+ This is the package entrypoint. It wires the CRUD into the container.
284
+
285
+ It owns:
286
+
287
+ - singleton registration for repositories
288
+ - service registration such as `crud.contacts`
289
+ - action registration
290
+ - lookup provider registration
291
+ - route registration during boot
292
+
293
+ It is wiring, not business logic. If you need to change how contacts are validated or saved, this is usually **not** the file to edit first.
294
+
295
+ ### `src/server/actionIds.js`
296
+
297
+ This is the stable list of action ids:
298
+
299
+ ```js
300
+ const actionIds = Object.freeze({
301
+ list: "crud.contacts.list",
302
+ view: "crud.contacts.view",
303
+ create: "crud.contacts.create",
304
+ update: "crud.contacts.update",
305
+ delete: "crud.contacts.delete"
306
+ });
307
+ ```
308
+
309
+ Keep this file boring. It is identity, not behavior.
310
+
311
+ ### `src/server/actions.js`
312
+
313
+ This is the action contract boundary.
314
+
315
+ It owns:
316
+
317
+ - action ids
318
+ - channels
319
+ - surfaces
320
+ - permission requirements
321
+ - input validator composition
322
+ - output validators
323
+ - execution handoff into the service
324
+
325
+ This is where "what is allowed, and what shape must the input/output have?" is decided.
326
+
327
+ It does **not** own SQL and it should not become a business-rules dumping ground.
328
+
329
+ ### `src/server/registerRoutes.js`
330
+
331
+ This is the HTTP transport layer.
332
+
333
+ It owns:
334
+
335
+ - the real routes and HTTP methods
336
+ - route params/query/body validators
337
+ - API response validators
338
+ - mapping HTTP requests to action execution
339
+
340
+ In other words:
341
+
342
+ - `registerRoutes.js` is about HTTP
343
+ - `actions.js` is about action contracts and permissions
344
+
345
+ Those are related, but not the same concern.
346
+
347
+ ### `src/server/listConfig.js`
348
+
349
+ This is the baseline list behavior for the repository.
350
+
351
+ It owns things like:
352
+
353
+ - `defaultLimit`
354
+ - `maxLimit`
355
+ - `orderBy`
356
+ - `searchColumns`
357
+
358
+ One subtle but important detail: `searchColumns` are **database column names**, not camelCase resource field keys.
359
+
360
+ Example:
361
+
362
+ ```js
363
+ const LIST_CONFIG = Object.freeze({
364
+ searchColumns: ["full_name", "email", "phone"],
365
+ orderBy: [
366
+ {
367
+ column: "created_at",
368
+ direction: "desc"
369
+ }
370
+ ]
371
+ });
372
+ ```
373
+
374
+ ### `src/server/repository.js`
375
+
376
+ This is the data-access layer.
377
+
378
+ It owns:
379
+
380
+ - SQL-level list/find/create/update/delete behavior
381
+ - joins and subqueries
382
+ - custom query filters
383
+ - custom search behavior when the generic defaults are not enough
384
+
385
+ If you need to change how records are selected from the database, this is usually the right file.
386
+
387
+ ### `src/server/service.js`
388
+
389
+ This is the business-logic/orchestration layer.
390
+
391
+ It owns:
392
+
393
+ - cross-repository rules
394
+ - create/update/delete rules
395
+ - validation that depends on other records or services
396
+ - orchestration before or after persistence
397
+
398
+ If a rule is domain-specific rather than transport-specific or SQL-specific, it usually belongs here.
399
+
400
+ ## What the client files own
401
+
402
+ The generated route tree is intentionally thin.
403
+
404
+ For the baseline `contacts` example, the UI generator writes:
405
+
406
+ ```text
407
+ src/pages/w/[workspaceSlug]/admin/contacts/
408
+ index.vue
409
+ new.vue
410
+ [contactId]/index.vue
411
+ [contactId]/edit.vue
412
+ _components/CrudAddEditForm.vue
413
+ _components/CrudAddEditFormFields.js
414
+ ```
415
+
416
+ ### `index.vue`
417
+
418
+ This is the list-page container.
419
+
420
+ Its job is usually to:
421
+
422
+ - call `useCrudList()`
423
+ - bind `records.searchQuery`
424
+ - render list rows
425
+ - resolve list/view/edit/new URLs
426
+ - pass route query state through when navigating deeper
427
+
428
+ The actual list machinery lives uphill in `users-web` composables and the shared resource contract.
429
+
430
+ ### `[contactId]/index.vue`
431
+
432
+ This is the view-page container.
433
+
434
+ Its job is usually to:
435
+
436
+ - call `useCrudView()`
437
+ - render the selected record
438
+ - resolve "back" and "edit" navigation
439
+
440
+ Again, the runtime behavior is mostly uphill. The page is a route-level composition layer.
441
+
442
+ ### `new.vue` and `[contactId]/edit.vue`
443
+
444
+ These are add/edit route wrappers.
445
+
446
+ They usually:
447
+
448
+ - call `useCrudAddEdit()`
449
+ - wire lookup runtime for lookup-backed fields
450
+ - hand the form runtime into the shared form component
451
+
452
+ These files are mostly containers. That is deliberate.
453
+
454
+ ### `_components/CrudAddEditForm.vue`
455
+
456
+ This is the shared rendering shell for the add/edit form.
457
+
458
+ It owns:
459
+
460
+ - the card/surface layout
461
+ - save/cancel buttons
462
+ - which set of generated form fields is rendered in `new` vs `edit`
463
+
464
+ It does **not** own persistence logic.
465
+
466
+ ### `_components/CrudAddEditFormFields.js`
467
+
468
+ This is the generated field-definition module used by `useCrudAddEdit()`.
469
+
470
+ It owns the field list for:
471
+
472
+ - create
473
+ - edit
474
+
475
+ This is often one of the first places you customize after generation, because it is where the form field definitions live.
476
+
477
+ ### `src/placement.js`
478
+
479
+ When a list page is generated, the generator also appends a placement entry so the app can link to that page from the shell.
480
+
481
+ That is navigation wiring, not CRUD logic.
482
+
483
+ ## Where the real machinery lives
484
+
485
+ A generated CRUD works because several layers cooperate:
486
+
487
+ 1. the route page calls `useCrudList()`, `useCrudView()`, or `useCrudAddEdit()`
488
+ 2. those composables use the shared resource contract and runtime helpers from `users-web`
489
+ 3. the request hits the HTTP route from `registerRoutes.js`
490
+ 4. the route executes an action from `actions.js`
491
+ 5. the action delegates to the service in `service.js`
492
+ 6. the service calls the repository in `repository.js`
493
+ 7. the repository uses `crud-core` helpers and the resource contract to talk to the database
494
+ 8. the response comes back through validators and is rendered by the page
495
+
496
+ That is why the generated route files are mostly containers: they are the outermost layer of a larger pipeline.
497
+
498
+ ## A good mental model for ownership
499
+
500
+ Use this rule of thumb when deciding where to edit:
501
+
502
+ | Need | Primary owner | Why |
503
+ | --- | --- | --- |
504
+ | Change API/input/output contract | `contactResource.js` and `actions.js` | This is where operation shape and validators live |
505
+ | Change route path or HTTP transport | `registerRoutes.js` | This is the HTTP layer |
506
+ | Change permissions or channels | `actions.js` | This is the action contract boundary |
507
+ | Change default ordering, limits, or searchable DB columns | `listConfig.js` | This is list runtime configuration |
508
+ | Change SQL, joins, parent filters, or advanced search | `repository.js` | This is the data-access layer |
509
+ | Add cross-record or domain rules on save/delete | `service.js` | This is business logic |
510
+ | Change page layout and display behavior | the route pages and app-owned composables | This is presentation |
511
+ | Change form field layout and inputs | `_components/CrudAddEditForm.vue` and `CrudAddEditFormFields.js` | This is the generated form layer |
512
+
513
+ ## How mature CRUDs grow
514
+
515
+ The baseline generator output is only the start. As the tutorial's `contacts`, `addresses`, and `comments` CRUDs become real app features, it is normal to add files such as:
516
+
517
+ - `src/server/listQueryValidators.js` when a list needs extra query filters beyond `q`
518
+ - `src/server/service.test.js` once save/delete rules stop being trivial
519
+ - `src/composables/contacts/useContactsListFilters.js` when the contacts page gains route-backed filter state
520
+ - `src/composables/addresses/useAddressDisplay.js` when addresses need app-specific display formatting
521
+ - `src/composables/comments/useCommentsListRuntime.js` when an embedded comments list needs local UI state
522
+
523
+ That is the right direction of growth:
524
+
525
+ - server customizations stay in the CRUD package
526
+ - presentation and page-specific UI state stay in app-owned client files
527
+
528
+ ## Search and filters: the deep dive
529
+
530
+ Search is where ownership mistakes happen most often, so it deserves its own section.
531
+
532
+ The first important rule is this:
533
+
534
+ - free-text search is not the same thing as structured filters
535
+
536
+ Use `q` for free-text. Use separate query params for flags, ids, and other structured filters.
537
+
538
+ ### Pattern 1: basic free-text search on `contacts`
539
+
540
+ This is the default generated list-page pattern for the `contacts` resource from the previous chapter.
541
+
542
+ #### Client side
543
+
544
+ Enable query search in the list page:
545
+
546
+ ```js
547
+ const records = useCrudList({
548
+ resource: uiResource,
549
+ apiSuffix: "/contacts",
550
+ search: {
551
+ enabled: true,
552
+ mode: "query"
553
+ },
554
+ syncToRoute: {
555
+ enabled: true,
556
+ search: true
557
+ }
558
+ });
559
+ ```
560
+
561
+ Then bind the input:
562
+
563
+ ```vue
564
+ <v-text-field
565
+ v-model="records.searchQuery"
566
+ :loading="records.isSearchDebouncing"
567
+ />
568
+ ```
569
+
570
+ The client runtime debounces the search input, writes the query string to `q`, and trims the list back to the first page when search changes.
571
+
572
+ #### Server side
573
+
574
+ The generic CRUD stack already understands `q`.
575
+
576
+ - `listSearchQueryValidator` reads and normalizes the `q` query param
577
+ - the repository applies search to `list.searchColumns`
578
+ - if `searchColumns` is not configured, CRUD falls back to a derived set of searchable string columns from the resource output schema
579
+
580
+ For the tutorial `contacts` table, that usually means the columns behind:
581
+
582
+ - `fullName`
583
+ - `email`
584
+ - `phone`
585
+ - `notes`
586
+
587
+ #### Best practices
588
+
589
+ - Once the UX is stable, set explicit `searchColumns` instead of relying on the fallback.
590
+ - Keep search focused on the columns users actually expect.
591
+ - Remember that `searchColumns` are database columns.
592
+
593
+ ### Pattern 2: explicit `contacts` search columns
594
+
595
+ This is the first thing to do when the fallback search becomes too broad or too accidental.
596
+
597
+ #### Server side
598
+
599
+ Set the searchable columns explicitly in `listConfig.js`:
600
+
601
+ ```js
602
+ const LIST_CONFIG = Object.freeze({
603
+ searchColumns: ["full_name", "email", "phone"],
604
+ orderBy: [
605
+ {
606
+ column: "created_at",
607
+ direction: "desc"
608
+ }
609
+ ]
610
+ });
611
+ ```
612
+
613
+ #### Client side
614
+
615
+ Usually nothing changes. The client can keep sending `q`.
616
+
617
+ #### Best practices
618
+
619
+ - Prefer explicit search columns for long-lived CRUDs.
620
+ - Do not dump every text column into search just because you can.
621
+ - In this tutorial CRUD, `notes` is a good example of a field you might leave out if you want fast, predictable list search.
622
+
623
+ ### Pattern 3: structured `contacts` filters such as `hasEmail`, `hasPhone`, and `hasNotes`
624
+
625
+ This is the first extension that still fits the `contacts` table from the previous chapter with no schema changes.
626
+
627
+ #### Client side
628
+
629
+ Keep filter state in a small app-owned composable, then pass it into `useCrudList()` as `queryParams`.
630
+
631
+ Example shape:
632
+
633
+ ```js
634
+ const listFilters = useContactsListFilters();
635
+
636
+ const records = useCrudList({
637
+ resource: uiResource,
638
+ apiSuffix: "/contacts?include=pets",
639
+ search: {
640
+ enabled: true,
641
+ mode: "query"
642
+ },
643
+ queryParams: computed(() => ({
644
+ ...listFilters.filters
645
+ })),
646
+ syncToRoute: {
647
+ enabled: true,
648
+ search: true,
649
+ queryParams: true,
650
+ queryParamBlacklist: ["include", "cursor", "limit"]
651
+ }
652
+ });
653
+ ```
654
+
655
+ This keeps the filters:
656
+
657
+ - visible in the URL
658
+ - restorable on refresh
659
+ - preserved when opening a record and coming back
660
+
661
+ #### Server side
662
+
663
+ Add a dedicated query validator:
664
+
665
+ ```js
666
+ const contactsListFiltersQueryValidator = Object.freeze({
667
+ schema: Type.Object(
668
+ {
669
+ hasEmail: Type.Optional(...),
670
+ hasPhone: Type.Optional(...),
671
+ hasNotes: Type.Optional(...)
672
+ },
673
+ { additionalProperties: false }
674
+ ),
675
+ normalize(payload = {}) {
676
+ const normalized = {};
677
+ if (Object.hasOwn(payload, "hasEmail")) normalized.hasEmail = 1;
678
+ if (Object.hasOwn(payload, "hasPhone")) normalized.hasPhone = 1;
679
+ if (Object.hasOwn(payload, "hasNotes")) normalized.hasNotes = 1;
680
+ return normalized;
681
+ }
682
+ });
683
+ ```
684
+
685
+ Wire it into the route and action validators for the list operation, then apply the filter in `repository.js`:
686
+
687
+ ```js
688
+ async function list(query = {}, callOptions = {}) {
689
+ return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions, {
690
+ modifyQuery(dbQuery, context = {}) {
691
+ const sourceQuery = context.query || {};
692
+
693
+ if (sourceQuery.hasEmail !== undefined) {
694
+ dbQuery.whereNotNull("email").where("email", "<>", "");
695
+ }
696
+ if (sourceQuery.hasPhone !== undefined) {
697
+ dbQuery.whereNotNull("phone").where("phone", "<>", "");
698
+ }
699
+ if (sourceQuery.hasNotes !== undefined) {
700
+ dbQuery.whereNotNull("notes").where("notes", "<>", "");
701
+ }
702
+ }
703
+ });
704
+ }
705
+ ```
706
+
707
+ #### Best practices
708
+
709
+ - Keep the client keys, validator keys, and repository keys identical.
710
+ - Use dedicated query params for booleans and facets. Do not overload `q`.
711
+ - Put SQL in the repository, not in the page.
712
+
713
+ ### Pattern 4: free-text search plus structured `contacts` filters together
714
+
715
+ This is the most common real-world CRUD list.
716
+
717
+ #### Client side
718
+
719
+ Use both:
720
+
721
+ - `records.searchQuery` for free-text
722
+ - `queryParams` for structured filters
723
+
724
+ The runtime already handles both together.
725
+
726
+ #### Server side
727
+
728
+ Let the generic list search handle `q`, and let your custom validator/repository code handle the structured filters.
729
+
730
+ #### Best practices
731
+
732
+ - Keep free-text and structure separate.
733
+ - Preserve the current route query when linking to view/edit pages so users can return to the same filtered list state.
734
+ - Let list changes reset pagination; `useCrudList()` already does this for search and query-param changes.
735
+
736
+ ### Pattern 5: parent-scoped child CRUD search for `addresses`
737
+
738
+ For nested CRUDs such as the `addresses` resource from the previous chapter, parent scoping and search usually work together.
739
+
740
+ #### Client side
741
+
742
+ Keep the parent id in the route:
743
+
744
+ ```text
745
+ w/[workspaceSlug]/admin/contacts/[contactId]/addresses
746
+ ```
747
+
748
+ Then use the normal list runtime. For empty child lists, use `useCrudListParentTitle()` so the page can still resolve the parent identity.
749
+
750
+ #### Server side
751
+
752
+ The CRUD stack can derive parent filter keys from the resource contract via `createCrudParentFilterQueryValidator(resource)`.
753
+
754
+ For the tutorial `addresses` table, the list search itself can stay very simple:
755
+
756
+ ```js
757
+ const LIST_CONFIG = Object.freeze({
758
+ searchColumns: ["label", "line_1", "line_2", "suburb", "state", "postcode"],
759
+ orderBy: [
760
+ {
761
+ column: "created_at",
762
+ direction: "desc"
763
+ }
764
+ ]
765
+ });
766
+ ```
767
+
768
+ That keeps child-list filtering grounded in the actual resource definition instead of ad-hoc route parsing.
769
+
770
+ #### Best practices
771
+
772
+ - Keep parent identity in the route, not hidden component state.
773
+ - Let the resource contract define parent filter shape.
774
+ - Treat parent-scoped filtering as repository/query behavior, not as presentation logic.
775
+
776
+ ### Pattern 6: local-only search for embedded `comments`
777
+
778
+ Sometimes server search is unnecessary.
779
+
780
+ This is useful for:
781
+
782
+ - small already-loaded lists
783
+ - embedded child collections
784
+ - temporary local filtering inside a view page
785
+
786
+ This matches the `comments` shape from the previous chapter especially well, because comments were intentionally described as an embedded child collection rather than a full-screen destination.
787
+
788
+ #### Client side
789
+
790
+ Use local search mode:
791
+
792
+ ```js
793
+ const records = useCrudList({
794
+ resource: commentsResource,
795
+ apiSuffix: "/comments",
796
+ search: {
797
+ enabled: true,
798
+ mode: "local",
799
+ fields: ["body"]
800
+ }
801
+ });
802
+ ```
803
+
804
+ #### Server side
805
+
806
+ No server change is needed.
807
+
808
+ #### Best practices
809
+
810
+ - Use this only for small datasets or already-loaded pages.
811
+ - Local search only filters the items currently in memory.
812
+ - Do not treat local search as a replacement for real server-side search on a large paginated CRUD.
813
+
814
+ ### Pattern 7: relation-aware search across the tutorial tables
815
+
816
+ This is where people most often put code in the wrong place.
817
+
818
+ Examples that still fit the tutorial's tables are:
819
+
820
+ - "Search `addresses` by the parent contact's `full_name`"
821
+ - "Search `comments` by the parent contact's `full_name`"
822
+
823
+ The important limitation is:
824
+
825
+ - generic CRUD search happens in the repository query
826
+ - parent lookups or hydrated records happen later
827
+
828
+ So a parent record being visible in the UI does **not** automatically make it searchable.
829
+
830
+ #### Client side
831
+
832
+ The client can still keep sending `q`, or it can expose a dedicated filter control.
833
+
834
+ The difficult part is not the page. It is the repository query.
835
+
836
+ #### Server side
837
+
838
+ If you need parent-aware search, you have two main options:
839
+
840
+ 1. Prefer a denormalized/searchable base-table column when the search is core to the feature.
841
+ 2. If denormalization is not appropriate, extend the repository query with joins, `whereExists(...)`, or other SQL in `modifyQuery(...)`.
842
+
843
+ #### Best practices
844
+
845
+ - Keep relation-aware search in `repository.js`, because it is a SQL concern.
846
+ - Do not try to fake relation search in the client when the dataset is paginated.
847
+ - Do not assume parent titles or hydrated child/parent records automatically become searchable.
848
+ - Prefer denormalized columns for core search paths that must stay fast and stable.
849
+
850
+ ## The safest way to add new search behavior
851
+
852
+ If you want to add a new search/filter use case, the safest sequence is:
853
+
854
+ 1. Decide whether it is free-text, structured, local-only, or relation-aware.
855
+ 2. Put client state in the page or an app-owned composable.
856
+ 3. Put transport validation in `actions.js` or `registerRoutes.js`.
857
+ 4. Put SQL behavior in `repository.js`.
858
+ 5. Put cross-record business rules in `service.js` only if they are truly domain rules rather than query rules.
859
+
860
+ That separation is what keeps CRUDs from turning into slop.
861
+
862
+ ## A practical checklist for common changes
863
+
864
+ ### "I added a new DB column and want it editable."
865
+
866
+ Touch:
867
+
868
+ - the table/migration
869
+ - `contactResource.js`
870
+ - `CrudAddEditFormFields.js`
871
+ - the relevant page/table display
872
+
873
+ Use `scaffold-field` when it fits, then review the generated result.
874
+
875
+ ### "I want a new boolean or enum list filter."
876
+
877
+ Touch:
878
+
879
+ - a client filter composable or page state
880
+ - `useCrudList({ queryParams: ... })`
881
+ - a dedicated server query validator
882
+ - `repository.js`
883
+
884
+ ### "I want a new save rule."
885
+
886
+ Touch:
887
+
888
+ - `service.js`
889
+ - tests for the service rule
890
+
891
+ Do **not** start in the page unless the rule is purely visual.
892
+
893
+ ### "I want a different permission rule."
894
+
895
+ Touch:
896
+
897
+ - `actions.js`
898
+ - possibly `config/roles.js`
899
+
900
+ Do not hide permission rules inside client components.
901
+
902
+ ## Final mental model
903
+
904
+ A generated CRUD is not a monolith.
905
+
906
+ It is a composition of:
907
+
908
+ - a shared contract
909
+ - a repository
910
+ - a service
911
+ - actions
912
+ - routes
913
+ - thin page containers
914
+ - runtime composables and helpers underneath
915
+
916
+ Once you see that structure clearly, CRUD customization becomes much easier:
917
+
918
+ - SQL changes go in the repository
919
+ - domain rules go in the service
920
+ - transport and permission changes go in actions/routes
921
+ - presentation changes stay in the app-owned client files
922
+
923
+ That is the line to protect as the CRUD grows.