@jskit-ai/agent-docs 0.1.3 → 0.1.5

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