@jskit-ai/agent-docs 0.1.130 → 0.1.132

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 (171) hide show
  1. package/guide/agent/app-extras/assistant.md +29 -605
  2. package/guide/agent/app-extras/mobile-capacitor.md +29 -362
  3. package/guide/agent/app-extras/realtime.md +29 -277
  4. package/guide/agent/app-setup/a-more-interesting-shell.md +44 -815
  5. package/guide/agent/app-setup/authentication.md +43 -1073
  6. package/guide/agent/app-setup/console.md +26 -298
  7. package/guide/agent/app-setup/database-layer.md +110 -790
  8. package/guide/agent/app-setup/initial-scaffolding.md +50 -784
  9. package/guide/agent/app-setup/multi-homing.md +39 -712
  10. package/guide/agent/app-setup/quickstart.md +43 -179
  11. package/guide/agent/app-setup/users.md +34 -353
  12. package/guide/agent/index.md +16 -23
  13. package/package.json +2 -2
  14. package/patterns/INDEX.md +7 -7
  15. package/patterns/child-cruds.md +3 -3
  16. package/patterns/client-requests.md +6 -6
  17. package/patterns/crud-authoring.md +94 -0
  18. package/patterns/crud-links.md +1 -1
  19. package/patterns/feature-package/PATTERN.md +108 -0
  20. package/patterns/feature-package/example/booking-engine/package.json +48 -0
  21. package/patterns/feature-package/example/booking-engine/src/server/BookingEngineProvider.js +33 -0
  22. package/patterns/feature-package/example/booking-engine/src/server/actions.js +26 -0
  23. package/patterns/feature-package/example/booking-engine/src/server/inputSchemas.js +19 -0
  24. package/patterns/feature-package/example/variations/AvailabilityEngineProvider.js +31 -0
  25. package/patterns/feature-package/example/variations/InvoiceRollupProvider.js +36 -0
  26. package/patterns/feature-package/example/variations/customKnexRepository.js +34 -0
  27. package/patterns/feature-package/example/variations/orchestratorService.js +23 -0
  28. package/patterns/filters.md +8 -8
  29. package/patterns/live-actions.md +5 -18
  30. package/patterns/minimal-foundation/PATTERN.md +98 -0
  31. package/patterns/minimal-foundation/example/.nvmrc +1 -0
  32. package/patterns/minimal-foundation/example/AGENTS.md +17 -0
  33. package/patterns/minimal-foundation/example/Procfile +2 -0
  34. package/patterns/minimal-foundation/example/app.json +14 -0
  35. package/patterns/minimal-foundation/example/bin/develop.js +71 -0
  36. package/patterns/minimal-foundation/example/bin/server.js +8 -0
  37. package/patterns/minimal-foundation/example/config/public.js +40 -0
  38. package/patterns/minimal-foundation/example/config/server.js +1 -0
  39. package/patterns/minimal-foundation/example/config/surfaceAccessPolicies.js +3 -0
  40. package/patterns/minimal-foundation/example/eslint.config.mjs +19 -0
  41. package/patterns/minimal-foundation/example/favicon.svg +7 -0
  42. package/patterns/minimal-foundation/example/gitignore +9 -0
  43. package/patterns/minimal-foundation/example/index.html +13 -0
  44. package/patterns/minimal-foundation/example/jsconfig.json +8 -0
  45. package/patterns/minimal-foundation/example/package.json +57 -0
  46. package/patterns/minimal-foundation/example/packages/main/package.json +42 -0
  47. package/patterns/minimal-foundation/example/packages/main/src/shared/index.js +10 -0
  48. package/patterns/minimal-foundation/example/packages/main/src/shared/schemas/index.js +22 -0
  49. package/patterns/minimal-foundation/example/playwright.config.mjs +31 -0
  50. package/patterns/minimal-foundation/example/server/lib/runtimeEnv.js +45 -0
  51. package/patterns/minimal-foundation/example/server/lib/surfaceRuntime.js +10 -0
  52. package/patterns/minimal-foundation/example/server.js +195 -0
  53. package/patterns/minimal-foundation/example/src/App.vue +13 -0
  54. package/patterns/minimal-foundation/example/src/main.js +85 -0
  55. package/patterns/minimal-foundation/example/src/pages/home/index.vue +48 -0
  56. package/patterns/minimal-foundation/example/src/pages/home.vue +13 -0
  57. package/patterns/minimal-foundation/example/src/views/NotFound.vue +13 -0
  58. package/patterns/minimal-foundation/example/tests/client/smoke.vitest.js +7 -0
  59. package/patterns/minimal-foundation/example/tests/e2e/base-shell.spec.ts +23 -0
  60. package/patterns/minimal-foundation/example/tests/server/smoke.test.js +16 -0
  61. package/patterns/minimal-foundation/example/vite.config.mjs +81 -0
  62. package/patterns/page-scaffolding.md +20 -17
  63. package/patterns/placements.md +17 -15
  64. package/patterns/row-policies.md +4 -5
  65. package/patterns/server-search.md +3 -3
  66. package/patterns/shell-foundation/PATTERN.md +104 -0
  67. package/patterns/shell-foundation/example/.nvmrc +1 -0
  68. package/patterns/shell-foundation/example/AGENTS.md +17 -0
  69. package/patterns/shell-foundation/example/Procfile +2 -0
  70. package/patterns/shell-foundation/example/app.json +14 -0
  71. package/patterns/shell-foundation/example/bin/develop.js +71 -0
  72. package/patterns/shell-foundation/example/bin/server.js +8 -0
  73. package/patterns/shell-foundation/example/config/public.js +40 -0
  74. package/patterns/shell-foundation/example/config/server.js +1 -0
  75. package/patterns/shell-foundation/example/config/surfaceAccessPolicies.js +3 -0
  76. package/patterns/shell-foundation/example/eslint.config.mjs +19 -0
  77. package/patterns/shell-foundation/example/favicon.svg +7 -0
  78. package/patterns/shell-foundation/example/gitignore +9 -0
  79. package/patterns/shell-foundation/example/index.html +13 -0
  80. package/patterns/shell-foundation/example/jsconfig.json +8 -0
  81. package/patterns/shell-foundation/example/package.json +59 -0
  82. package/patterns/shell-foundation/example/packages/main/package.json +56 -0
  83. package/patterns/shell-foundation/example/packages/main/src/client/index.js +9 -0
  84. package/patterns/shell-foundation/example/packages/main/src/client/providers/MainClientProvider.js +18 -0
  85. package/patterns/shell-foundation/example/packages/main/src/shared/index.js +10 -0
  86. package/patterns/shell-foundation/example/packages/main/src/shared/schemas/index.js +22 -0
  87. package/patterns/shell-foundation/example/playwright.config.mjs +31 -0
  88. package/patterns/shell-foundation/example/server/lib/runtimeEnv.js +45 -0
  89. package/patterns/shell-foundation/example/server/lib/surfaceRuntime.js +10 -0
  90. package/patterns/shell-foundation/example/server.js +195 -0
  91. package/patterns/shell-foundation/example/src/App.vue +11 -0
  92. package/patterns/shell-foundation/example/src/components/ShellLayout.vue +12 -0
  93. package/patterns/shell-foundation/example/src/components/menus/MenuLinkItem.vue +30 -0
  94. package/patterns/shell-foundation/example/src/components/menus/SurfaceAwareMenuLinkItem.vue +42 -0
  95. package/patterns/shell-foundation/example/src/components/menus/TabLinkItem.vue +42 -0
  96. package/patterns/shell-foundation/example/src/error.js +19 -0
  97. package/patterns/shell-foundation/example/src/main.js +85 -0
  98. package/patterns/shell-foundation/example/src/pages/home/index.vue +116 -0
  99. package/patterns/shell-foundation/example/src/pages/home/settings/general/index.vue +40 -0
  100. package/patterns/shell-foundation/example/src/pages/home/settings/index.vue +7 -0
  101. package/patterns/shell-foundation/example/src/pages/home/settings.vue +109 -0
  102. package/patterns/shell-foundation/example/src/pages/home.vue +20 -0
  103. package/patterns/shell-foundation/example/src/placement.js +56 -0
  104. package/patterns/shell-foundation/example/src/placementTopology.js +149 -0
  105. package/patterns/shell-foundation/example/src/views/NotFound.vue +13 -0
  106. package/patterns/shell-foundation/example/tests/client/smoke.vitest.js +7 -0
  107. package/patterns/shell-foundation/example/tests/e2e/adaptive-shell.spec.ts +10 -0
  108. package/patterns/shell-foundation/example/tests/e2e/base-shell.spec.ts +23 -0
  109. package/patterns/shell-foundation/example/tests/server/smoke.test.js +16 -0
  110. package/patterns/shell-foundation/example/vite.config.mjs +81 -0
  111. package/patterns/ui-contract.md +56 -0
  112. package/patterns/ui-testing.md +10 -12
  113. package/reference/autogen/KERNEL_MAP.md +29 -107
  114. package/reference/autogen/PATTERN_INDEX.md +230 -0
  115. package/reference/autogen/README.md +4 -8
  116. package/reference/autogen/packages/agent-docs.md +259 -0
  117. package/reference/autogen/packages/assistant-core.md +3 -3
  118. package/reference/autogen/packages/assistant-runtime.md +32 -17
  119. package/reference/autogen/packages/auth-core.md +31 -33
  120. package/reference/autogen/packages/auth-provider-local-core.md +4 -12
  121. package/reference/autogen/packages/auth-provider-local-db-core.md +4 -4
  122. package/reference/autogen/packages/auth-provider-supabase-core.md +14 -18
  123. package/reference/autogen/packages/auth-web.md +42 -22
  124. package/reference/autogen/packages/console-core.md +8 -25
  125. package/reference/autogen/packages/console-web.md +5 -5
  126. package/reference/autogen/packages/crud-core.md +61 -17
  127. package/reference/autogen/packages/database-runtime-mysql.md +12 -2
  128. package/reference/autogen/packages/database-runtime-postgres.md +12 -2
  129. package/reference/autogen/packages/database-runtime.md +26 -25
  130. package/reference/autogen/packages/google-rewarded-core.md +19 -104
  131. package/reference/autogen/packages/http-runtime.md +4 -8
  132. package/reference/autogen/packages/http-web.md +32 -0
  133. package/reference/autogen/packages/json-rest-api-core.md +4 -6
  134. package/reference/autogen/packages/kernel.md +109 -390
  135. package/reference/autogen/packages/mobile-capacitor.md +2 -13
  136. package/reference/autogen/packages/realtime.md +29 -26
  137. package/reference/autogen/packages/resource-crud-core.md +6 -0
  138. package/reference/autogen/packages/shell-web.md +69 -54
  139. package/reference/autogen/packages/storage-runtime.md +3 -3
  140. package/reference/autogen/packages/uploads-image-web.md +0 -1
  141. package/reference/autogen/packages/uploads-runtime.md +3 -3
  142. package/reference/autogen/packages/users-core.md +45 -90
  143. package/reference/autogen/packages/users-web.md +5 -7
  144. package/reference/autogen/packages/workspaces-core.md +53 -74
  145. package/reference/autogen/packages/workspaces-web.md +15 -16
  146. package/reference/autogen/tooling/jskit-catalog.md +34 -0
  147. package/reference/autogen/tooling/testUtils.md +4 -4
  148. package/skills/jskit/SKILL.md +36 -28
  149. package/skills/jskit/agents/openai.yaml +2 -2
  150. package/skills/jskit/references/app-operations.md +68 -53
  151. package/skills/jskit/references/crud-operations.md +58 -106
  152. package/skills/jskit/references/material-3.md +105 -0
  153. package/skills/jskit/references/ui-operations.md +41 -44
  154. package/templates/app/AGENTS.md +7 -3
  155. package/guide/agent/app-setup/upgrade-beta-1-to-final.md +0 -252
  156. package/guide/agent/app-setup/working-with-the-jskit-cli.md +0 -325
  157. package/guide/agent/generators/advanced-cruds.md +0 -1935
  158. package/guide/agent/generators/crud-generators.md +0 -948
  159. package/guide/agent/generators/intro.md +0 -65
  160. package/guide/agent/generators/row-policies.md +0 -537
  161. package/guide/agent/generators/ui-generators.md +0 -690
  162. package/patterns/crud-scaffolding.md +0 -198
  163. package/patterns/generated-ui-contract-tracking.md +0 -66
  164. package/reference/autogen/packages/assistant.md +0 -68
  165. package/reference/autogen/packages/crud-server-generator.md +0 -215
  166. package/reference/autogen/packages/crud-ui-generator.md +0 -192
  167. package/reference/autogen/packages/feature-server-generator.md +0 -65
  168. package/reference/autogen/packages/ui-generator.md +0 -127
  169. package/reference/autogen/tooling/create-app.md +0 -317
  170. package/reference/autogen/tooling/jskit-cli.md +0 -933
  171. package/reference/autogen/tooling/test-support.md +0 -27
@@ -1,1935 +0,0 @@
1
- <!-- Generated by `npm run agent-docs:build` from `packages/agent-docs/site/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
- When a CRUD's mandatory visibility needs joins, `EXISTS`, grouped grants, or hierarchy traversal instead of direct owner columns, continue with [Row Policies](/guide/generators/row-policies). Do not filter an already-paginated result in `service.js`.
10
-
11
- Once you generate `contacts`, you do **not** get one magical black-box CRUD object. You get:
12
-
13
- - an app-local server package under `packages/contacts/`
14
- - an app-local route tree under `src/pages/.../contacts/`
15
- - a shared resource contract that sits between the two
16
-
17
- That distinction matters, because it tells you where to change things safely.
18
-
19
- This chapter stays grounded in the exact resources from the previous chapter:
20
-
21
- - `contacts`
22
- - `addresses`
23
- - `comments`
24
-
25
- 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.
26
-
27
- ## Starting point
28
-
29
- This chapter starts from the end of the baseline `contacts` example in [CRUD Generators](/guide/generators/crud-generators):
30
-
31
- ```bash
32
- npx jskit generate crud-server-generator scaffold \
33
- --namespace contacts \
34
- --surface admin \
35
- --ownership-filter workspace \
36
- --table-name contacts \
37
- --grant-role member
38
-
39
- npx jskit generate crud-ui-generator crud \
40
- w/[workspaceSlug]/admin/contacts \
41
- --resource-file packages/contacts/src/shared/contactResource.js \
42
- --id-param contactId \
43
- --display-fields fullName,email,phone
44
- ```
45
-
46
- Later sections pull `addresses` and `comments` back in when we talk about child CRUDs, parent scoping, and embedded lists.
47
-
48
- After those two commands, the important thing to understand is ownership:
49
-
50
- - `crud-server-generator` creates a runtime package that your app owns locally
51
- - `crud-ui-generator` creates route files that your app owns locally
52
- - `crud-core` provides the server CRUD runtime, `resource-crud-core` owns shared
53
- CRUD contracts, and `http-web` provides browser operations and generated
54
- screen components
55
-
56
- 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.
57
-
58
- ## How ownership shapes the generated CRUD
59
-
60
- The earlier chapter explains how to choose an ownership filter. This chapter explains what that choice **does** structurally.
61
-
62
- The key idea is:
63
-
64
- The generated CRUD does not treat ownership as a UI hint. It turns ownership into the visibility model for the whole resource.
65
-
66
- That affects:
67
-
68
- - route visibility
69
- - repository filtering
70
- - create-time owner stamping
71
- - lookup hydration for related CRUDs
72
-
73
- ### The generated package stores a concrete ownership filter
74
-
75
- Even if you scaffold with:
76
-
77
- ```bash
78
- --ownership-filter auto
79
- ```
80
-
81
- the generated package does **not** keep `auto` forever.
82
-
83
- During generation, JSKIT resolves it to a concrete value:
84
-
85
- - `public`
86
- - `user`
87
- - `workspace`
88
- - or `workspace_user`
89
-
90
- That resolved value is then written into the generated CRUD package and used as the real route visibility / repository ownership model.
91
-
92
- So `auto` is only a scaffold-time convenience. Once generation is done, the CRUD has a concrete ownership shape.
93
-
94
- ### Ownership becomes route visibility
95
-
96
- The generated `registerRoutes.js` uses the resolved ownership filter as the route visibility token for every CRUD route:
97
-
98
- - list
99
- - view
100
- - create
101
- - update
102
- - delete
103
-
104
- So if the CRUD resolves to:
105
-
106
- - `public`
107
- - the routes run with public visibility
108
- - `user`
109
- - the routes run with actor/user visibility
110
- - `workspace`
111
- - the routes run with workspace visibility
112
- - `workspace_user`
113
- - the routes run with workspace-plus-actor visibility
114
-
115
- This is why ownership is such a foundational choice. It becomes part of the generated server contract, not just the database shape.
116
-
117
- ### `--internal` changes HTTP exposure, not CRUD ownership
118
-
119
- `crud-server-generator scaffold` also supports:
120
-
121
- ```bash
122
- --internal
123
- ```
124
-
125
- That flag does **not** change:
126
-
127
- - the generated repository
128
- - the generated service
129
- - the shared resource contract
130
- - the ownership filter
131
- - the generated actions
132
-
133
- It changes only one thing:
134
-
135
- - the generated CRUD HTTP routes are marked internal, so the public HTTP runtime does not register them
136
-
137
- This is useful when the entity should already be CRUD-owned but should not have public CRUD URLs yet.
138
-
139
- So the distinction is:
140
-
141
- - ownership answers "who owns and can see the rows?"
142
- - `--internal` answers "do the public HTTP CRUD routes exist right now?"
143
-
144
- That is why `--internal` is not a permissions shortcut and not a UI setting. It is a server-route exposure choice on top of the same CRUD ownership model.
145
-
146
- The workspace role-grant decision is separate too. Every workspace-required generation must choose `--grant-role <role-id>` or `--no-role-grant`; there is no implicit role. An application that assigns generated CRUD permissions to `member` uses `--grant-role member`. In particular, `--internal` does not imply `--no-role-grant`.
147
-
148
- ### Ownership controls which owner columns are expected
149
-
150
- The repository layer ultimately applies visibility through the standard owner columns:
151
-
152
- - `workspace_id`
153
- - `user_id`
154
-
155
- Those names are exact and reserved. `workspace_id` is workspace ownership; `user_id` is user ownership; both together are workspace-user ownership. Other foreign keys such as `recipient_user_id`, `created_by_user_id`, and `assignee_user_id` remain domain relationships and must not be treated as ownership aliases.
156
-
157
- The generated ownership filter must match that exact set of reserved columns. Neither an explicit filter nor generated metadata can override what those direct columns mean.
158
-
159
- If a row is workspace-owned but refers to a recipient, keep both concepts explicit: use `workspace_id` for ownership and `recipient_user_id` for the relationship. Renaming the relationship to `user_id` would opt the field into JSKIT's hidden owner-field and user-filtering behavior.
160
-
161
- That means the generated CRUD behaves like this:
162
-
163
- - `public`
164
- - no owner filter is applied
165
- - rows are not expected to be scoped by `workspace_id` or `user_id`
166
- - `user`
167
- - the repository filters by `user_id`
168
- - `workspace`
169
- - the repository filters by `workspace_id`
170
- - `workspace_user`
171
- - the repository filters by both `workspace_id` and `user_id`
172
-
173
- This is also why explicit ownership filters are validated against the real table shape during generation:
174
-
175
- - `workspace` requires `workspace_id`
176
- - `user` requires `user_id`
177
- - `workspace_user` requires both
178
-
179
- If the table does not match, generation fails instead of silently creating a broken CRUD.
180
-
181
- ### Ownership also affects create behavior
182
-
183
- The ownership model is not only used for reads.
184
-
185
- When the generated repository creates a row, it applies visibility owners into the insert payload too.
186
-
187
- In practice that means:
188
-
189
- - a `workspace` CRUD stamps `workspace_id`
190
- - a `user` CRUD stamps `user_id`
191
- - a `workspace_user` CRUD stamps both
192
-
193
- So the ownership choice shapes both:
194
-
195
- - which rows are visible later
196
- - how new rows are stamped when they are created
197
-
198
- That is another reason ownership needs to match the real intent of the table.
199
-
200
- ### Lookup hydration uses ownership too
201
-
202
- This is easy to miss at first.
203
-
204
- Generated CRUDs often hydrate related records through lookup providers. Those child lookups also need to know what ownership model they run under.
205
-
206
- For example:
207
-
208
- - a `workspace_user` parent may need to hydrate a relation from a `workspace` child provider
209
- - a `workspace` parent may hydrate a `public` lookup
210
-
211
- 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.
212
-
213
- That is why ownership mistakes often surface later as "weird relation visibility" bugs rather than as immediate scaffold failures.
214
-
215
- ### How to reason about changing it later
216
-
217
- Changing ownership later is possible, but it is not a tiny edit.
218
-
219
- If you change a CRUD from one ownership shape to another, you may need to change:
220
-
221
- - the table schema
222
- - existing row data
223
- - the generated ownership filter in the CRUD package
224
- - route expectations
225
- - relation lookup ownership
226
- - sometimes the target surface itself
227
-
228
- For example:
229
-
230
- - changing `workspace` to `workspace_user`
231
- - usually means adding `user_id`
232
- - backfilling existing rows
233
- - changing how records are expected to be visible
234
- - changing `public` to `workspace`
235
- - usually means adding `workspace_id`
236
- - deciding how old rows should be assigned to workspaces
237
-
238
- So the safe mental model is:
239
-
240
- - ownership is part of the CRUD's structural design
241
- - choose it early and deliberately
242
- - do not treat it like a cosmetic generator option
243
-
244
- ## The full generated shape
245
-
246
- For a normal top-level CRUD like `contacts`, the generator output looks like this:
247
-
248
- ```text
249
- migrations/
250
- *_crud_initial_contacts.cjs
251
-
252
- packages/contacts/
253
- package.json
254
- src/server/ContactsProvider.js
255
- src/server/actions.js
256
- src/server/registerRoutes.js
257
- src/server/repository.js
258
- src/server/service.js
259
- src/shared/index.js
260
- src/shared/contactResource.js
261
-
262
- src/pages/w/[workspaceSlug]/admin/contacts/
263
- index.vue
264
- listBulkActions.js
265
- listFilters.js
266
- new.vue
267
- [contactId]/index.vue
268
- [contactId]/edit.vue
269
-
270
- src/components/w/[workspaceSlug]/admin/contacts/
271
- CrudAddEditForm.vue
272
- CrudAddEditFormFields.js
273
-
274
- config/roles.js
275
- src/placement.js
276
- ```
277
-
278
- Two important notes:
279
-
280
- 1. `config/roles.js` and `src/placement.js` are app mutations, not part of the `packages/contacts/` package itself.
281
- 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.
282
-
283
- ## What each server file owns
284
-
285
- ### `package.json`
286
-
287
- These make the CRUD a real local package.
288
-
289
- They own:
290
-
291
- - package identity
292
- - runtime dependencies
293
- - provider registration metadata
294
- - `package.json.jskit` install and runtime metadata
295
-
296
- They do **not** own CRUD behavior directly. They describe how the package plugs into the app.
297
-
298
- ### `src/shared/contactResource.js`
299
-
300
- This is the shared CRUD contract, and it is the closest thing JSKIT has to a generated "model" file.
301
-
302
- If you come from an ORM stack, this is the key adjustment:
303
-
304
- - there is no generated `ContactModel.js`
305
- - there is no ActiveRecord-style class
306
- - the "model layer" is split between the resource contract and the repository/service layers
307
-
308
- The resource file owns:
309
-
310
- - the resource name and table name
311
- - the canonical `schema`
312
- - `searchSchema`
313
- - `defaultSort`
314
- - `autofilter`
315
- - lookup contract configuration
316
- - messages
317
- - field metadata, including which fields participate in `output`, `create`, `replace`, and `patch`
318
-
319
- This file is the bridge between the server and the client. The UI generator reads it, and the server runtime also depends on it.
320
-
321
- For a standard CRUD resource like `contacts`, the authored file is intentionally compact. It uses `defineCrudResource(...)` from `@jskit-ai/resource-crud-core`:
322
-
323
- ```js
324
- import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
325
-
326
- const resource = defineCrudResource({
327
- namespace: "contacts",
328
- tableName: "contacts",
329
- schema: {
330
- name: {
331
- type: "string",
332
- maxLength: 190,
333
- required: true,
334
- search: true,
335
- operations: {
336
- output: { required: true },
337
- create: { required: true },
338
- patch: { required: false }
339
- }
340
- }
341
- },
342
- searchSchema: {
343
- id: { type: "id", actualField: "id" }
344
- },
345
- defaultSort: ["-createdAt"],
346
- autofilter: "workspace",
347
- messages: {
348
- saveSuccess: "Record saved."
349
- },
350
- contract: {
351
- lookup: {
352
- containerKey: "lookups"
353
- }
354
- }
355
- });
356
- ```
357
-
358
- `defineCrudResource(...)` derives the standard CRUD operation contracts once at module load time and exposes them on `resource.operations`. That means:
359
-
360
- - you author the canonical resource shape once
361
- - JSKIT derives the standard `list` / `view` / `create` / `replace` / `patch` / `delete` contracts
362
- - routes, actions, client code, and generators can keep reading `resource.operations.*` without each resource file repeating that boilerplate
363
-
364
- For non-CRUD or heavily custom resources, use `defineResource(...)` from `@jskit-ai/resource-core` instead. That keeps standard CRUD derivation and custom operation bundles clearly separated.
365
-
366
- ### `src/shared/index.js`
367
-
368
- This is just the shared package barrel. It re-exports the resource contract and shared symbols.
369
-
370
- ### `src/server/ContactsProvider.js`
371
-
372
- This is the package entrypoint. It wires the CRUD into the container.
373
-
374
- It owns:
375
-
376
- - singleton registration for repositories
377
- - service registration such as `crud.contacts`
378
- - action registration
379
- - lookup provider registration
380
- - route registration during boot
381
-
382
- 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.
383
-
384
- ### `src/server/actions.js`
385
-
386
- This is the action contract boundary.
387
-
388
- It owns:
389
-
390
- - action ids
391
- - channels
392
- - surfaces
393
- - permission requirements
394
- - input validator composition
395
- - output validators
396
- - execution handoff into the service
397
-
398
- This is where "what is allowed, and what shape must the input/output have?" is decided.
399
-
400
- It does **not** own SQL and it should not become a business-rules dumping ground.
401
-
402
- ### `src/server/registerRoutes.js`
403
-
404
- This is the HTTP transport layer.
405
-
406
- It owns:
407
-
408
- - the real routes and HTTP methods
409
- - route params/query/body validators
410
- - API response validators
411
- - mapping HTTP requests to action execution
412
-
413
- In other words:
414
-
415
- - `registerRoutes.js` is about HTTP
416
- - `actions.js` is about action contracts and permissions
417
-
418
- Those are related, but not the same concern.
419
-
420
- ### `src/server/repository.js`
421
-
422
- This is the data-access layer.
423
-
424
- It owns:
425
-
426
- - SQL-level list/find/create/update/delete behavior
427
- - joins and subqueries
428
- - custom query filters
429
- - custom search behavior when the generic defaults are not enough
430
-
431
- If you need to change how records are selected from the database, this is usually the right file.
432
-
433
- ### `src/server/service.js`
434
-
435
- This is the business-logic/orchestration layer.
436
-
437
- It owns:
438
-
439
- - cross-repository rules
440
- - create/update/delete rules
441
- - validation that depends on other records or services
442
- - orchestration before or after persistence
443
-
444
- If a rule is domain-specific rather than transport-specific or SQL-specific, it usually belongs here.
445
-
446
- ## What the client files own
447
-
448
- The generated route tree is intentionally thin.
449
-
450
- For the baseline `contacts` example, the UI generator writes:
451
-
452
- ```text
453
- src/pages/w/[workspaceSlug]/admin/contacts/
454
- index.vue
455
- new.vue
456
- [contactId]/index.vue
457
- [contactId]/edit.vue
458
- listBulkActions.js
459
- listFilters.js
460
-
461
- src/components/w/[workspaceSlug]/admin/contacts/
462
- CrudAddEditForm.vue
463
- CrudAddEditFormFields.js
464
- ```
465
-
466
- The shared Vue form lives in the mirrored `src/components/` tree because the
467
- file router treats every Vue file below `src/pages/` as a route. The normalized
468
- CRUD target root keeps the helper scoped to the same configured surface and
469
- route family without exposing another browser route.
470
-
471
- ### `index.vue`
472
-
473
- This is the list-page container.
474
-
475
- Its job is usually to:
476
-
477
- - call `useCrudListScreen()`
478
- - pass page-local `listFilters`, `listBulkActions`, and `listRowActions` when needed
479
- - pass `syntheticRows` when the page needs non-CRUD display rows such as an owner/master row
480
- - pass read options such as `requestQueryParams` and `readEnabled` when the list read needs them
481
- - render the shared `CrudListScreen`
482
- - resolve list/view/edit/new URLs
483
- - pass route query state through when navigating deeper
484
-
485
- The actual list machinery lives in `http-web` screen composables and the shared
486
- resource contract lives in `resource-crud-core`.
487
-
488
- ### `[contactId]/index.vue`
489
-
490
- This is the view-page container.
491
-
492
- Its job is usually to:
493
-
494
- - call `useCrudViewScreen()`
495
- - render the shared `CrudViewScreen`
496
- - resolve "back" and "edit" navigation
497
- - pass read options such as `requestQueryParams`, `readEnabled`, and `queryKeyFactory` when the detail read needs them
498
- - use the shared view slots for page-specific sections around the generated field list
499
-
500
- Again, the runtime behavior is mostly uphill. The page is a route-level composition layer.
501
-
502
- ### `new.vue` and `[contactId]/edit.vue`
503
-
504
- These are add/edit route wrappers.
505
-
506
- They usually:
507
-
508
- - call `useCrudAddEditScreen()`
509
- - wire lookup runtime for lookup-backed fields
510
- - hand the form runtime into the shared `CrudAddEditScreen`
511
-
512
- These files are mostly containers. That is deliberate.
513
-
514
- ### CRUD link resolution
515
-
516
- This deserves an explicit warning, because it was implemented incorrectly in a real app.
517
-
518
- When you customize generated CRUD pages, use the CRUD runtime that owns the current route scope to resolve CRUD-bound links.
519
-
520
- Use `paths.page()` for **surface-aware** navigation:
521
-
522
- - `/account`
523
- - `/assistant`
524
- - `/lists`
525
- - other links that only need normal surface params such as `workspaceSlug`
526
-
527
- Do **not** use `paths.page()` with CRUD record placeholders inside the relative path or URL template, such as:
528
-
529
- - `:contactId`
530
- - `:addressId`
531
- - `:todoListId`
532
- - `:todoItemId`
533
-
534
- For CRUD-bound links, use the runtime-provided resolvers instead:
535
-
536
- - list pages:
537
- - `records.resolveViewUrl(record)`
538
- - `records.resolveEditUrl(record)`
539
- - `records.resolveParams(template, extraParams)`
540
- - view pages:
541
- - `view.listUrl`
542
- - `view.editUrl`
543
- - `view.resolveParams(template, extraParams)`
544
- - add/edit pages:
545
- - `formRuntime.addEdit.resolveParams(template, extraParams)`
546
-
547
- Why this matters:
548
-
549
- - `paths.page()` only knows about the current surface route params
550
- - CRUD runtimes also know about the current CRUD route shape, current record id, parent record ids, and nested child route context
551
- - once a page is CRUD-bound, those runtime resolvers are the safe way to build record-scoped links
552
-
553
- Scope rule:
554
-
555
- - use the runtime anchored to the record that owns the action
556
- - on a parent record view page with nested child routes, parent actions should still resolve from the parent `view` runtime even while a child route like `/items/new` is active
557
- - child-item actions should resolve from the child/item runtime only when the current route is actually child-scoped
558
-
559
- Examples:
560
-
561
- - good: `view.resolveParams("./items/new")`
562
- - good: `view.resolveParams("./items/:todoItemId/edit", { todoItemId: item.id })`
563
- - good: `formRuntime.addEdit.resolveParams("../../..")`
564
- - bad: `paths.page("/lists/:todoListId/items/new")`
565
- - bad: `paths.page("/lists/:todoListId/edit")`
566
-
567
- The safe mental model is:
568
-
569
- - use `paths.page()` to get to the right surface
570
- - use CRUD runtime resolvers to move around inside the CRUD
571
-
572
- ### Live actions and `useCommand()`
573
-
574
- There is one more client-side pattern worth naming explicitly:
575
-
576
- Use `useCommand()` for live actions such as:
577
-
578
- - checkboxes that toggle a record field
579
- - archive / publish / reopen buttons
580
- - delete buttons
581
- - small one-click PATCH / POST / DELETE actions that are not full forms
582
-
583
- This is the pattern the `todo` app uses for "mark item done".
584
-
585
- The page renders a checkbox like this:
586
-
587
- ```vue
588
- <v-checkbox-btn
589
- :model-value="item.done"
590
- :disabled="!canUpdateItem || isItemBusy(item.id)"
591
- @update:model-value="toggleItem(item, $event)"
592
- />
593
- ```
594
-
595
- Then the page wires a command:
596
-
597
- ```js
598
- const itemPatchModel = reactive({
599
- id: "",
600
- patch: {}
601
- });
602
-
603
- const updateItemCommand = useCommand({
604
- model: itemPatchModel,
605
- apiSuffix: ({ model }) => `/todo-items/${model?.id || ""}`,
606
- writeMethod: "PATCH",
607
- runPermissions: ["crud.todo_items.update"],
608
- suppressSuccessMessage: true,
609
- fallbackRunError: "Unable to update item.",
610
- buildRawPayload(model) {
611
- return model.patch;
612
- },
613
- async onRunSuccess(_payload, context = {}) {
614
- if (context.queryClient) {
615
- await context.queryClient.invalidateQueries({
616
- queryKey: ["ui-generator", "todo_items"]
617
- });
618
- }
619
- }
620
- });
621
-
622
- async function toggleItem(item = {}, nextValue = false) {
623
- itemPatchModel.id = String(item.id || "");
624
- itemPatchModel.patch = {
625
- done: Boolean(nextValue)
626
- };
627
-
628
- await updateItemCommand.run();
629
- }
630
- ```
631
-
632
- That gives you a clean action pipeline:
633
-
634
- 1. the UI captures the click
635
- 2. the page writes a tiny action model
636
- 3. `useCommand()` resolves the correct scoped API path for the current route/surface
637
- 4. it sends the request through the standard HTTP runtime
638
- 5. on success, it invalidates the relevant query keys so the list/view refreshes
639
-
640
- So yes: for this class of interaction, `useCommand()` is the right helper.
641
-
642
- Use this rule of thumb:
643
-
644
- - `useCommand()`
645
- - for live actions
646
- - button clicks
647
- - toggles
648
- - small PATCH/POST/DELETE interactions
649
- - `useAddEdit()` / `useCrudAddEdit()`
650
- - for real forms
651
- - create/edit screens
652
- - save/cancel flows
653
- - `useCrudList()` / `useCrudView()`
654
- - for routed list/view loading and CRUD URL resolution
655
-
656
- Best practices for live CRUD actions:
657
-
658
- - keep the payload narrow
659
- - send the field change you mean, not a whole copied record
660
- - disable the control while the command for that record is running
661
- - `todo` does this with `isItemBusy(item.id)`
662
- - invalidate the relevant list/view query keys on success
663
- - do not hand-maintain parallel local record copies unless you really need optimistic UI
664
- - suppress success toasts for high-frequency actions when they would become noisy
665
- - a checkbox toggle usually does not need "Saved." every time
666
- - keep business rules on the server
667
- - in `todo`, the client sends `{ done: true|false }`
668
- - the server service decides how `completedAt` should be set or cleared
669
-
670
- The safe mental model is:
671
-
672
- - use form runtimes for forms
673
- - use command runtimes for actions
674
- - keep the server as the source of truth for derived state
675
-
676
- ### Choosing the right client request seam
677
-
678
- When you need client-side HTTP work in JSKIT, do not start with raw `fetch(...)`.
679
-
680
- Choose the highest-level runtime that matches the interaction:
681
-
682
- ```js
683
- // 1. Button/toggle/small mutation
684
- const command = useCommand({ ... });
685
-
686
- // 2. List endpoint
687
- const list = useList({ ... });
688
-
689
- // 3. Single-record endpoint
690
- const view = useView({ ... });
691
-
692
- // 4. Form save flow
693
- const form = useAddEdit({ ... });
694
-
695
- // 5. Truly custom endpoint
696
- const resource = useEndpointResource({ ... });
697
- ```
698
-
699
- Use the CRUD wrappers when they fit:
700
-
701
- - `useCrudList()` for routed CRUD lists
702
- - `useCrudView()` for routed CRUD record loading
703
- - `useCrudAddEdit()` for routed CRUD forms
704
-
705
- Why this is the standard JSKIT shape:
706
-
707
- - `useCommand()` resolves the correct scoped API path for the current route and surface.
708
- - The higher-level list, view, add/edit, and command runtimes send requests through the standard HTTP runtime instead of ad hoc request code.
709
- - The default client runtime uses `httpWebClient`, which already handles credentials and CSRF token behavior.
710
- - `useEndpointResource()` gives the shared endpoint primitive for loading, saving, and standard load/save error handling. Higher-level runtimes like `useCommand()` and `useAddEdit()` layer UI feedback and field-error behavior on top of that primitive.
711
- - `shell-web` observes the shared TanStack Query client for recoverable transport failures. Generated CRUD reads and custom reads built with `useEndpointResource()`, `useList()`, `useView()`, or `useAddEdit()` get the shell recovery banner with a Retry action that refetches the failed query.
712
- - Automatic shell request recovery is only for safe `GET`/`HEAD` read refetches. JSKIT read composables mark Query entries with `jskit.requestRecoveryMethod`, and the shell ignores unmarked or unsafe methods. Do not rely on it to replay `POST`, `PATCH`, `PUT`, or `DELETE`; mutation screens own save state, field errors, and user feedback.
713
-
714
- The request composables and generated CRUD client surfaces above come from `@jskit-ai/http-web`. That package is neutral: installing it does not install users, authentication, uploads, storage, or a database.
715
-
716
- When an app needs all JSKIT reads and commands to rewrite API URLs before fetch, configure the http-web client once instead of passing custom paths or replacing `fetchImpl` in each local helper:
717
-
718
- ```js
719
- import { configureHttpWebClient } from "@jskit-ai/http-web/client/lib/httpClient";
720
-
721
- configureHttpWebClient({
722
- csrf: {
723
- enabled: false
724
- },
725
- resolveRequestUrl(url) {
726
- return scopedApiUrlForCurrentRoute(url);
727
- }
728
- });
729
- ```
730
-
731
- After that, `useEndpointResource()`, `useList()`, `useView()`, `useAddEdit()`, and `useCommand()` keep their normal call shape. JSKIT still owns Query metadata, request recovery, JSON:API transport, command feedback, credentials, and CSRF behavior. Use `createTransientRetryHttpClient({ resolveRequestUrl })` directly only when a package needs its own separate client instance.
732
-
733
- When a custom read needs a better recovery banner label, pass it through the read primitive rather than reporting the failure manually:
734
-
735
- ```js
736
- const resource = useEndpointResource({
737
- queryKey: ["project-access", projectId],
738
- path: `/api/projects/${projectId}/access`,
739
- requestRecoveryLabel: "Project access"
740
- });
741
- ```
742
-
743
- If you need a custom scoped endpoint path outside the higher-level runtimes, prefer `usePaths().api(...)` rather than hand-building scoped URLs:
744
-
745
- ```js
746
- import { usePaths } from "@jskit-ai/shell-web/client/navigation/usePaths";
747
-
748
- const paths = usePaths();
749
- const reportsApiPath = computed(() => paths.api("/reports"));
750
- ```
751
-
752
- Use `requestQueryParams` when a runtime needs endpoint query parameters. Do not put query strings into `apiUrlTemplate`; URL templates are for path shape only.
753
-
754
- For routed CRUD edit forms, pass request query params through `addEditOptions`:
755
-
756
- ```js
757
- const formRuntime = useCrudAddEdit({
758
- resource: uiResource,
759
- operationName: "patch",
760
- formFields: UI_EDIT_FORM_FIELDS,
761
- addEditOptions: {
762
- apiUrlTemplate: "/products/:productId",
763
- requestQueryParams: {
764
- include: "serviceId,bookingSteps,bookingSteps.requiredRoleId"
765
- }
766
- }
767
- });
768
- ```
769
-
770
- `requestQueryParams` may also be a callback. The add/edit callback receives the same scoped request context used by view/list, plus the current record id and model:
771
-
772
- ```js
773
- const formRuntime = useCrudAddEdit({
774
- resource: uiResource,
775
- operationName: "patch",
776
- formFields: UI_EDIT_FORM_FIELDS,
777
- addEditOptions: {
778
- apiUrlTemplate: "/products/:productId",
779
- requestQueryParams({ recordId, model }) {
780
- return {
781
- include: "serviceId,bookingSteps,bookingSteps.requiredRoleId"
782
- };
783
- }
784
- }
785
- });
786
- ```
787
-
788
- For add/edit runtimes, these request query params apply to both the initial load and the save request path. That keeps the saved response shape aligned with the loaded form shape when an endpoint supports response includes.
789
-
790
- The safe mental model is:
791
-
792
- - do not raw `fetch(...)` for normal app work
793
- - do not invent ad hoc local AJAX helpers
794
- - use the operation/runtime composable that matches the UI interaction
795
- - drop to `httpWebClient.request(...)` only for exceptional low-level cases
796
- - use `usePaths().api(...)` when you need a custom scoped API path and the higher-level runtime does not already resolve it for you
797
- - keep `apiUrlTemplate` path-only and put endpoint query strings in `requestQueryParams`
798
-
799
- ### `src/components/.../CrudAddEditForm.vue`
800
-
801
- This is the generated field bridge for the shared add/edit screen.
802
-
803
- It owns:
804
-
805
- - which set of generated form fields is rendered in `new` vs `edit`
806
- - lookup field prop forwarding into those fields
807
-
808
- It does **not** own persistence logic or the shared screen chrome. `CrudAddEditScreen` from `@jskit-ai/http-web` owns the common title, load state, retry action, save/cancel action row, and form surface.
809
-
810
- ### `src/components/.../CrudAddEditFormFields.js`
811
-
812
- This is the generated field-definition module used by `useCrudAddEdit()`.
813
-
814
- It owns the field list for:
815
-
816
- - create
817
- - edit
818
-
819
- This is often one of the first places you customize after generation, because it is where the form field definitions live.
820
-
821
- ### `src/placement.js`
822
-
823
- When a list page is generated, the generator also appends a placement entry so the app can link to that page from the shell.
824
-
825
- That is navigation wiring, not CRUD logic.
826
-
827
- ## Where the real machinery lives
828
-
829
- A generated CRUD works because several layers cooperate:
830
-
831
- 1. the route page calls `useCrudListScreen()`, `useCrudViewScreen()`, or `useCrudAddEditScreen()`
832
- 2. those screen composables configure the lower-level list/view/add-edit runtimes from `@jskit-ai/http-web`
833
- 3. the request hits the HTTP route from `registerRoutes.js`
834
- 4. the route executes an action from `actions.js`
835
- 5. the action delegates to the service in `service.js`
836
- 6. the service calls the repository in `repository.js`
837
- 7. the repository uses the shared resource contract, `crud-core` helpers, and the internal JSON:API host to talk to the database
838
- 8. the response comes back through validators and is rendered by the page
839
-
840
- That is why the generated route files are mostly containers: they are the outermost layer of a larger pipeline.
841
-
842
- ## A good mental model for ownership
843
-
844
- Use this rule of thumb when deciding where to edit:
845
-
846
- | Need | Primary owner | Why |
847
- | --- | --- | --- |
848
- | Change API/input/output contract | `contactResource.js` first, then `actions.js` only if the action boundary must diverge | Standard CRUD validators are derived from the resource; `actions.js` owns channels, permissions, and any transport-specific input composition |
849
- | Change route path or HTTP transport | `registerRoutes.js` | This is the HTTP layer |
850
- | Change permissions or channels | `actions.js` | This is the action contract boundary |
851
- | Change default ordering, searchable fields, or ownership autofilter | `contactResource.js` | The shared resource is the single source of truth for both CRUD contract and internal JSON:API resource config |
852
- | Change SQL, joins, parent filters, or advanced search | `repository.js` | This is the data-access layer |
853
- | Add mandatory SQL visibility that must run before count and pagination | server policy module plus the provider's `createJsonRestResourceScopeOptions(..., { rowPolicy })` call | The internal JSON REST host applies the policy to every storage query for that resource |
854
- | Add cross-record or domain rules on save/delete | `service.js` | This is business logic |
855
- | Change shared CRUD screen chrome, load states, or retry behavior | `http-web` shared screen components | Generated pages consume the shared screen contract |
856
- | Add per-row commands to a generated list page | page-local `listRowActions.js`, usually calling `useCommand()`-backed composables | The shared list screen renders action chrome; the page owns explicit mutation behavior |
857
- | Add non-CRUD display rows to a generated list page | route page `syntheticRows` input | Synthetic rows are presentation rows, not repository records |
858
- | Change page-specific display behavior | the route pages, generated slots, and app-owned composables | This is presentation |
859
- | Change form field layout and inputs | the mirrored `src/components/.../CrudAddEditForm.vue` and `CrudAddEditFormFields.js` | This is the generated non-routed form field layer |
860
-
861
- ## How mature CRUDs grow
862
-
863
- 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:
864
-
865
- - `src/server/listQueryValidators.js` when a list needs extra query filters beyond `q`
866
- - `src/server/service.test.js` once save/delete rules stop being trivial
867
- - `packages/contacts/src/shared/contactListFilters.js` when the contacts CRUD gains structured list filters that both server and client should share
868
- - `src/pages/.../contacts/listRowActions.js` when a generated list needs row-level commands such as Delete, Block, or Unblock
869
- - `src/composables/addresses/useAddressDisplay.js` when addresses need app-specific display formatting
870
- - `src/composables/comments/useCommentsListRuntime.js` when an embedded comments list needs local UI state
871
-
872
- That is the right direction of growth:
873
-
874
- - server customizations stay in the CRUD package
875
- - presentation and page-specific UI state stay in app-owned client files
876
- - shared structured list filters live best in a CRUD-package shared module that both server and client can import
877
- - shared generated screen chrome stays in `http-web`; adapted pages feed it definitions, slots, and explicit command handlers
878
-
879
- ### Shared screen read options and detail slots
880
-
881
- Use the shared list and detail screens first, even when a page needs includes, permission gating, or domain sections.
882
-
883
- `useCrudListScreen(...)` accepts the common list read-pass-through options that adapted list pages usually need:
884
-
885
- - `requestQueryParams`
886
- - `readEnabled`
887
-
888
- For example, a permission-gated list can stay on the shared list screen:
889
-
890
- ```js
891
- const canReadProjectAccess = computed(() =>
892
- access.value?.canManageProjectAccess === true
893
- );
894
-
895
- const screen = useCrudListScreen({
896
- resource: projectAccessResource,
897
- apiSuffix: "/project-access",
898
- readEnabled: canReadProjectAccess,
899
- requestQueryParams() {
900
- return {
901
- include: "userId,roleId"
902
- };
903
- }
904
- });
905
- ```
906
-
907
- `readEnabled` gates the underlying Query-backed read. The screen still owns load, empty, error, retry, filters, row actions, and responsive list chrome. The page owns only the permission condition.
908
-
909
- `useCrudViewScreen(...)` accepts the same read-pass-through options that adapted detail pages usually need:
910
-
911
- - `requestQueryParams`
912
- - `readEnabled`
913
- - `queryKeyFactory`
914
-
915
- For example:
916
-
917
- ```js
918
- const screen = useCrudViewScreen({
919
- resource: drumResource,
920
- apiUrlTemplate: "/drums/:recordId",
921
- requestQueryParams() {
922
- return {
923
- include: "drumSpecId,locationId,contents,contents.processingLotId"
924
- };
925
- }
926
- });
927
- ```
928
-
929
- Render domain sections through `CrudViewScreen` slots instead of replacing the shared load/error/retry chrome:
930
-
931
- ```vue
932
- <CrudViewScreen :screen="screen" resource-singular-title="Drum">
933
- <template #before-fields="{ view }">
934
- <DrumArrivalSummary :drum="view.record" />
935
- </template>
936
-
937
- <template #fields="{ view }">
938
- <GeneratedDrumFields :record="view.record" />
939
- </template>
940
-
941
- <template #after-fields="{ view }">
942
- <DrumProvenancePanel :drum="view.record" />
943
- </template>
944
-
945
- <template #supporting-content="{ view }">
946
- <DrumContentsPanel :drum="view.record" />
947
- </template>
948
- </CrudViewScreen>
949
- ```
950
-
951
- The screen still owns loading, not-found, retry, title, back/edit actions, and responsive shell structure. The page owns only the domain panels.
952
-
953
- ## Search and filters: the deep dive
954
-
955
- Search is where ownership mistakes happen most often, so it deserves its own section.
956
-
957
- The first important rule is this:
958
-
959
- - free-text search is not the same thing as structured filters
960
-
961
- Use `q` for free-text. Use separate query params for flags, ids, and other structured filters.
962
-
963
- ### Pattern 1: basic free-text search on `contacts`
964
-
965
- This is the default generated list-page pattern for the `contacts` resource from the previous chapter.
966
-
967
- #### Client side
968
-
969
- The generated list page delegates search wiring to the shared list screen:
970
-
971
- ```js
972
- const screen = useCrudListScreen({
973
- resource: uiResource,
974
- apiSuffix: "/contacts",
975
- search: {
976
- enabled: true,
977
- mode: "query"
978
- },
979
- syncToRoute: {
980
- enabled: true,
981
- search: true
982
- }
983
- });
984
- ```
985
-
986
- Then render the shared screen:
987
-
988
- ```vue
989
- <CrudListScreen :screen="screen" />
990
- ```
991
-
992
- The shared screen binds the search input and passes the search query into the list request.
993
-
994
- 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.
995
-
996
- #### Server side
997
-
998
- The generic CRUD stack already understands `q`.
999
-
1000
- - `listSearchQueryValidator` reads and normalizes the `q` query param
1001
- - the repository applies search through `resource.searchSchema.q`
1002
- - the generated CRUD starts with an explicit `q` search definition in `contactResource.js`, so you edit that definition directly when you want to narrow or expand the search surface
1003
-
1004
- For the tutorial `contacts` table, that usually means the columns behind:
1005
-
1006
- - `fullName`
1007
- - `email`
1008
- - `phone`
1009
- - `notes`
1010
-
1011
- #### Best practices
1012
-
1013
- - Once the UX is stable, edit `contactResource.js` and set `resource.searchSchema.q.oneOf` explicitly instead of relying on the initial generated defaults.
1014
- - Keep search focused on the fields users actually expect.
1015
- - Remember that these are JSON:API/internal resource search keys, not a free-form UI concern.
1016
-
1017
- ### Pattern 2: explicit `contacts` search fields
1018
-
1019
- This is the first thing to do when the generated `q` search becomes too broad or too accidental.
1020
-
1021
- #### Server side
1022
-
1023
- Set the searchable fields explicitly in `contactResource.js`:
1024
-
1025
- ```js
1026
- const resource = Object.freeze({
1027
- searchSchema: {
1028
- id: { type: "id", actualField: "id" },
1029
- q: {
1030
- type: "string",
1031
- oneOf: ["fullName", "email", "phone"],
1032
- filterOperator: "like",
1033
- splitBy: " ",
1034
- matchAll: true
1035
- }
1036
- },
1037
- defaultSort: ["-createdAt"]
1038
- });
1039
- ```
1040
-
1041
- #### Client side
1042
-
1043
- Usually nothing changes. The client can keep sending `q`.
1044
-
1045
- #### Best practices
1046
-
1047
- - Prefer explicit search columns for long-lived CRUDs.
1048
- - Do not dump every text column into search just because you can.
1049
- - In this tutorial CRUD, `notes` is a good example of a field you might leave out if you want fast, predictable list search.
1050
-
1051
- ### Pattern 2B: repository mapping and computed output fields
1052
-
1053
- Do not treat the output schema as if it also defined database storage.
1054
-
1055
- In JSKIT CRUD:
1056
-
1057
- - the schema defines the API contract
1058
- - field definitions may also carry storage/lookup/ui metadata
1059
- - the repository runtime owns computed SQL projections
1060
- - internal JSON REST resources can expose SQL-selected query projections through `createJsonRestResourceScopeOptions(...)`
1061
-
1062
- Use these rules:
1063
-
1064
- - for explicit DB column overrides, use `actualField`
1065
- - standard writable `date-time` fields are serialized automatically during CRUD writes
1066
- - use `storage.writeSerializer` only for non-default DB write serialization
1067
- - for computed output fields, use `storage: { virtual: true }`
1068
- - do not put computed fields in create/patch write schemas
1069
-
1070
- Example field metadata:
1071
-
1072
- ```js
1073
- import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
1074
-
1075
- const resource = defineCrudResource({
1076
- namespace: "receivals",
1077
- tableName: "receivals",
1078
- schema: {
1079
- createdAt: {
1080
- type: "dateTime",
1081
- required: true,
1082
- actualField: "created_at",
1083
- operations: {
1084
- output: { required: true }
1085
- }
1086
- },
1087
- arrivalDatetime: {
1088
- type: "dateTime",
1089
- required: true,
1090
- storage: {
1091
- writeSerializer: "datetime-utc"
1092
- },
1093
- operations: {
1094
- output: { required: true },
1095
- create: { required: true },
1096
- patch: { required: false }
1097
- }
1098
- },
1099
- remainingBatchWeight: {
1100
- type: "number",
1101
- required: true,
1102
- storage: {
1103
- virtual: true
1104
- },
1105
- operations: {
1106
- output: { required: true }
1107
- }
1108
- }
1109
- },
1110
- crudOperations: ["list", "view", "create", "patch"]
1111
- });
1112
- ```
1113
-
1114
- Register the computed projection once in the repository runtime:
1115
-
1116
- ```js
1117
- const repositoryRuntime = createCrudResourceRuntime(resource, {
1118
- context: "receivals repository",
1119
- list: LIST_CONFIG,
1120
- virtualFields: {
1121
- remainingBatchWeight: {
1122
- applyProjection(dbQuery, { knex, tableName, alias }) {
1123
- const { sql, bindings } = getRemainingBatchWeightSqlParts({ tableName });
1124
- dbQuery.select(knex.raw(`${sql} as ??`, [...bindings, alias]));
1125
- }
1126
- }
1127
- }
1128
- });
1129
- ```
1130
-
1131
- For JSON REST-backed generated CRUD packages, keep the output field virtual in the resource and register the SQL projection at the JSON REST resource boundary:
1132
-
1133
- ```js
1134
- await addResourceIfMissing(
1135
- api,
1136
- "receivals",
1137
- createJsonRestResourceScopeOptions(resource, {
1138
- queryFields: {
1139
- remainingBatchWeight: {
1140
- type: "number",
1141
- select({ knex, column }) {
1142
- return knex.raw("?? - coalesce(??, 0)", [
1143
- column("received_weight"),
1144
- column("processed_weight")
1145
- ]);
1146
- }
1147
- }
1148
- }
1149
- })
1150
- );
1151
- ```
1152
-
1153
- `createJsonRestResourceScopeOptions(...)` moves matching virtual fields out of the storage schema and into JSON REST `queryFields`, where they are selected for reads and ignored for writes. Prefer the `queryFields` option when the resource module is imported by both server and client code; use `storage.queryProjection` only in server-only resource modules.
1154
-
1155
- For the repository runtime, once registered there:
1156
-
1157
- - generic CRUD `list`
1158
- - generic CRUD `findById`
1159
- - generic CRUD `listByIds`
1160
- - generic CRUD `listByForeignIds`
1161
-
1162
- and generic CRUD writes automatically serialize standard writable `date-time` fields during create/update payload mapping, so normal datetime DB formatting does not need per-field metadata or repository-specific `preparePayload` hooks. Keep `storage.writeSerializer` for non-default cases only.
1163
-
1164
- all pick up the projection automatically, so you should not hand-patch `clearSelect()` / re-select logic into each method.
1165
-
1166
- Important limits:
1167
-
1168
- - `virtual` fields are output-only in v1
1169
- - fallback search derivation only uses column-backed fields
1170
- - parent-filter fallback derivation only uses column-backed fields
1171
- - `listByIds(..., { valueKey })` requires a column-backed `valueKey`
1172
-
1173
- For the agent-facing quick rule, see `patterns/crud-repository-mapping.md`.
1174
-
1175
- ### Pattern 3: client-side structured list filters
1176
-
1177
- Generated CRUD list pages include a client-side filter seam by default. The generated page imports `./listFilters.js` and passes `listFilters` into `useCrudListScreen(...)`. The shared list screen builds the filter runtime, passes the resulting query params into the list request, and renders `CrudListFilterSurface`.
1178
-
1179
- If `listFilters.js` is empty, the filter surface renders nothing.
1180
-
1181
- Use this first when adding filters. The server still needs explicit support for any query params you declare; JSKIT does not infer server filter semantics from the UI.
1182
-
1183
- Do not hand-build:
1184
-
1185
- - one filter shape in the page
1186
- - custom chip/reset/query-param state
1187
- - a second copy of the same client filter definitions
1188
-
1189
- Instead:
1190
-
1191
- 1. edit the generated page-local `listFilters.js`
1192
- 2. let `useCrudListScreen(...)` wire the filter query params into the list request
1193
- 3. let `CrudListFilterSurface` render controls, chips, clear-one, and clear-all behavior
1194
- 4. add explicit server support separately for the same query params
1195
-
1196
- For example, a generated page-local filter-definition module can look like this:
1197
-
1198
- ```js
1199
- import { defineCrudListFilters } from "@jskit-ai/http-web/client/filters";
1200
-
1201
- const listFilters = defineCrudListFilters({
1202
- onlyStaff: {
1203
- type: "flag",
1204
- label: "Staff"
1205
- },
1206
- onlyVip: {
1207
- type: "flag",
1208
- label: "VIP"
1209
- },
1210
- onlyArchived: {
1211
- type: "flag",
1212
- label: "Archived"
1213
- }
1214
- });
1215
-
1216
- export { listFilters };
1217
- ```
1218
-
1219
- ### Pattern 3B: client-side bulk list actions
1220
-
1221
- Generated CRUD list pages also include a client-side bulk-action seam by default. The generated page imports `./listBulkActions.js` and passes `listBulkActions` into `useCrudListScreen(...)`. The shared list screen builds the bulk-action runtime and renders `CrudListBulkActionSurface`.
1222
-
1223
- If `listBulkActions.js` is empty, selection controls and the bulk action bar stay hidden.
1224
-
1225
- Use this first when adding selected-record actions. JSKIT does not invent server operations such as delete, archive, approve, or export; the action definition owns that behavior.
1226
-
1227
- For example:
1228
-
1229
- ```js
1230
- import { defineCrudListBulkActions } from "@jskit-ai/http-web/client/bulkActions";
1231
-
1232
- const listBulkActions = defineCrudListBulkActions([
1233
- {
1234
- key: "archive",
1235
- label: "Archive",
1236
- async run({ selectedIds, clearSelection, reload }) {
1237
- await archiveContacts(selectedIds);
1238
- clearSelection();
1239
- await reload();
1240
- }
1241
- }
1242
- ]);
1243
-
1244
- export { listBulkActions };
1245
- ```
1246
-
1247
- The generated runtime passes action handlers:
1248
-
1249
- - `selectedIds`
1250
- - `ids` as an alias for `selectedIds`
1251
- - `selectedRecords`
1252
- - `clearSelection()`
1253
- - `records`
1254
- - `reload`
1255
-
1256
- Keep bulk action definitions page-local unless another page needs to share them.
1257
-
1258
- ### Pattern 3C: row actions and synthetic display rows
1259
-
1260
- Generated CRUD list pages can keep the shared list screen while adding per-row actions and non-CRUD display rows.
1261
-
1262
- Use row actions for explicit commands on one record. JSKIT renders the action menu in card and table layouts, tracks per-row execution state, and passes the handler enough context to run app-owned commands. It does not invent or auto-replay writes.
1263
-
1264
- For example:
1265
-
1266
- ```js
1267
- import { defineCrudListRowActions } from "@jskit-ai/http-web/client/rowActions";
1268
-
1269
- const listRowActions = defineCrudListRowActions([
1270
- {
1271
- key: "delete",
1272
- label: "Delete",
1273
- color: "error",
1274
- visible: ({ record }) => record.isOwnerRow !== true,
1275
- disabled: ({ record }) => record.isOwnerRow === true,
1276
- loading: ({ record }) => deleteCommand.isRunningFor(record.id),
1277
- async run({ record, reload }) {
1278
- await deleteCommand.runFor(record);
1279
- await reload();
1280
- }
1281
- }
1282
- ]);
1283
-
1284
- export { listRowActions };
1285
- ```
1286
-
1287
- Pass the actions into the generated list screen:
1288
-
1289
- ```js
1290
- import { listRowActions } from "./listRowActions.js";
1291
-
1292
- const screen = useCrudListScreen({
1293
- resource: uiResource,
1294
- apiSuffix: "/allowed-login-emails",
1295
- listRowActions
1296
- });
1297
- ```
1298
-
1299
- The row-action handler receives:
1300
-
1301
- - `action`
1302
- - `record`
1303
- - `index`
1304
- - `recordId`
1305
- - `records`
1306
- - `reload`
1307
-
1308
- Use `syntheticRows` when the page needs display rows that do not come from the CRUD list response, such as an owner row at the top of an allowlist. An array is prepended by default. Use `{ prepend, append }` when placement matters.
1309
-
1310
- ```js
1311
- const ownerRows = computed(() => owner.value
1312
- ? [
1313
- {
1314
- key: "owner",
1315
- record: {
1316
- id: owner.value.id,
1317
- email: owner.value.email,
1318
- role: "Owner",
1319
- isOwnerRow: true
1320
- }
1321
- }
1322
- ]
1323
- : []);
1324
-
1325
- const screen = useCrudListScreen({
1326
- resource: uiResource,
1327
- apiSuffix: "/allowed-login-emails",
1328
- listRowActions,
1329
- syntheticRows: ownerRows
1330
- });
1331
- ```
1332
-
1333
- Synthetic rows:
1334
-
1335
- - render in the same card/table layouts as real rows
1336
- - do not get standard Open/Edit CRUD navigation
1337
- - are excluded from bulk selection by default
1338
- - can still participate in row-action visibility/disabled logic
1339
-
1340
- Use this seam for display rows only. If a row should be persisted, it should come from the CRUD list response.
1341
-
1342
- #### Exact file checklist
1343
-
1344
- For a generated CRUD, treat this as the concrete file plan:
1345
-
1346
- - edit `src/pages/.../contacts/listFilters.js`
1347
- - create `src/pages/.../contacts/listRowActions.js` when the list needs row-level commands
1348
- - make sure the matching server route/action/repository code accepts and applies the declared query params when filters are server-backed
1349
-
1350
- If the filter contract should be shared with server code, promote it into a CRUD package module and import it from both sides:
1351
-
1352
- - create `packages/contacts/src/shared/contactListFilters.js`
1353
- - create `packages/contacts/src/server/contactListFilterContract.js` with `createCrudListFilterContract(...)`
1354
- - update `packages/contacts/src/server/registerRoutes.js` so `createCrudJsonApiRouteContracts(...)` receives `listFilterQueryValidator: contactListFilterContract.queryValidator`
1355
- - update `packages/contacts/src/server/actions.js` so `createStandardCrudListQueryValidators(...)` receives the same `listFilterQueryValidator`
1356
- - update the provider's `createJsonRestResourceScopeOptions(...)` call so it merges `searchSchema: contactListFilterContract.jsonRestSearchSchema`
1357
- - update `packages/contacts/src/server/repository.js` so the list query path passes `contactListFilterContract.toJsonRestQuery(query)` into `buildJsonRestQueryParams(...)`
1358
-
1359
- #### Client side
1360
-
1361
- Generated CRUD list pages pass the page-local filter definitions into the shared list screen:
1362
-
1363
- ```js
1364
- import { listFilters } from "./listFilters.js";
1365
-
1366
- const screen = useCrudListScreen({
1367
- resource: uiResource,
1368
- apiSuffix: "/contacts?include=pets",
1369
- listFilters,
1370
- routeQueryBlacklist: Object.freeze(["include", "cursor", "limit"])
1371
- });
1372
- ```
1373
-
1374
- ```vue
1375
- <CrudListScreen :screen="screen" />
1376
- ```
1377
-
1378
- Inside that shared screen runtime, `useCrudListFilters(...)` gives the list:
1379
-
1380
- - `filterRuntime.values`
1381
- - `filterRuntime.queryParams`
1382
- - `filterRuntime.presets`
1383
- - `filterRuntime.activeChips`
1384
- - `filterRuntime.hasActiveFilters`
1385
- - `filterRuntime.clearChip(...)`
1386
- - `filterRuntime.clearFilters()`
1387
- - `filterRuntime.toggle(...)` for flag filters
1388
- - `filterRuntime.applyPreset(...)`
1389
- - `filterRuntime.matchesPreset(...)`
1390
-
1391
- So the same runtime owns:
1392
-
1393
- - URL-synced query params
1394
- - filter chips
1395
- - reset logic
1396
- - preset application
1397
- - preset active-state matching
1398
- - small flag toggles
1399
-
1400
- For relative-date quick filters, keep the date math in runtime presets instead of page-local helper state. `resolveValues(...)` runs at preset-apply time and receives `{ values, filters, presetKey, preset }`, so the preset can derive values from the current filter state and the normalized preset metadata:
1401
-
1402
- ```js
1403
- const listFilters = useCrudListFilters(
1404
- RECEIVAL_LIST_FILTER_DEFINITIONS,
1405
- {
1406
- presets: [
1407
- {
1408
- key: "today",
1409
- label: "Today",
1410
- resolveValues({ presetKey }) {
1411
- const today = formatDateInputValue(new Date());
1412
- return {
1413
- arrivalDate: {
1414
- from: today,
1415
- to: today
1416
- }
1417
- };
1418
- }
1419
- },
1420
- {
1421
- key: "last7",
1422
- label: "Last 7 Days",
1423
- resolveValues({ values }) {
1424
- const today = formatDateInputValue(new Date());
1425
- return {
1426
- arrivalDate: {
1427
- from: shiftDateInputValue(today, -6),
1428
- to: today
1429
- }
1430
- };
1431
- }
1432
- }
1433
- ]
1434
- }
1435
- );
1436
- ```
1437
-
1438
- ```vue
1439
- <v-chip
1440
- v-for="preset in listFilters.presets"
1441
- :key="preset.key"
1442
- :variant="listFilters.matchesPreset(preset.key) ? 'flat' : 'outlined'"
1443
- @click="listFilters.applyPreset(preset.key, { mode: 'merge' })"
1444
- >
1445
- {{ preset.label }}
1446
- </v-chip>
1447
- ```
1448
-
1449
- Use `mode: "merge"` when a preset should only change one filter group, such as the arrival date range, and should not clear the rest of the page's active filters.
1450
-
1451
- `matchesPreset(...)` is strict by design. It compares the preset against the full current filter state after basic normalization, and it does **not** silently drop extra `enumMany` or `recordIdMany` values that were hydrated from the route and still appear as chips. If the URL contains `status=archived&status=bogus`, a preset for only `archived` should render as inactive until the extra `bogus` value is cleared.
1452
-
1453
- #### Server side
1454
-
1455
- Build the server contract from the same shared definitions:
1456
-
1457
- ```js
1458
- import { createCrudListFilterContract } from "@jskit-ai/crud-core/server/listFilters";
1459
- import { CONTACTS_LIST_FILTER_DEFINITIONS } from "../shared/contactListFilters.js";
1460
-
1461
- const contactListFilterContract = createCrudListFilterContract(
1462
- CONTACTS_LIST_FILTER_DEFINITIONS,
1463
- {
1464
- columns: {
1465
- status: "status",
1466
- supplierContactId: "supplier_contact_id",
1467
- arrivalDate: "arrival_datetime",
1468
- onlyArchived: "archived"
1469
- },
1470
- invalidValues: "reject"
1471
- }
1472
- );
1473
-
1474
- export { contactListFilterContract };
1475
- ```
1476
-
1477
- That one contract gives the server:
1478
-
1479
- - `queryValidator` for route/action input validation
1480
- - `jsonRestSearchSchema` for JSON REST resource registration
1481
- - `toJsonRestQuery(query)` for repository query normalization
1482
- - `applyQuery(...)` when a non-JSON REST repository still needs direct Knex filtering
1483
-
1484
- Wire the contract independently into the standard route and action query
1485
- groups:
1486
-
1487
- ```js
1488
- const {
1489
- listRouteContract
1490
- } = createCrudJsonApiRouteContracts({
1491
- resource,
1492
- listFilterQueryValidator: contactListFilterContract.queryValidator
1493
- });
1494
- ```
1495
-
1496
- ```js
1497
- input: composeSchemaDefinitions([
1498
- workspaceSlugParamsValidator,
1499
- ...createStandardCrudListQueryValidators({
1500
- resource,
1501
- listFilterQueryValidator: contactListFilterContract.queryValidator
1502
- })
1503
- ])
1504
- ```
1505
-
1506
- This does not couple route and action layers together. Each layer still owns
1507
- its validator; both consume the same explicit standard group so pagination,
1508
- search, parent filters, includes, typed sparse fieldsets, and structured
1509
- filters cannot drift.
1510
-
1511
- If `resource.contract.listFilters.queryValidator` already owns the filter
1512
- validator, call `createStandardCrudListQueryValidators({ resource })` instead.
1513
- Append a validator after the standard group only for genuinely additional,
1514
- non-filter query input. Never provide the same filter validator through both
1515
- paths.
1516
-
1517
- Do not manually reconstruct the standard list group from its individual
1518
- validators. Standard view actions likewise compose:
1519
-
1520
- ```js
1521
- input: composeSchemaDefinitions([
1522
- workspaceSlugParamsValidator,
1523
- recordIdParamsValidator,
1524
- ...createStandardCrudViewQueryValidators()
1525
- ])
1526
- ```
1527
-
1528
- Merge the JSON REST search schema when the provider registers the resource:
1529
-
1530
- ```js
1531
- await addResourceIfMissing(
1532
- api,
1533
- JSON_REST_SCOPE_NAME,
1534
- createJsonRestResourceScopeOptions(resource, {
1535
- searchSchema: contactListFilterContract.jsonRestSearchSchema,
1536
- writeSerializers: {
1537
- "datetime-utc": toDatabaseDateTimeUtc
1538
- }
1539
- })
1540
- );
1541
- ```
1542
-
1543
- Normalize the list query before building JSON REST query params:
1544
-
1545
- ```js
1546
- async function queryDocuments(query = {}, options = {}) {
1547
- return api.resources.contacts.query(
1548
- {
1549
- queryParams: buildJsonRestQueryParams(
1550
- JSON_REST_SCOPE_NAME,
1551
- contactListFilterContract.toJsonRestQuery(query)
1552
- ),
1553
- transaction: options?.trx || null,
1554
- simplified: false
1555
- },
1556
- createJsonRestContext(options?.context || null)
1557
- );
1558
- }
1559
- ```
1560
-
1561
- `enumMany` and `recordIdMany` filters stay arrays. Date and number ranges become internal JSON REST search keys, so callers keep one public query key such as `arrivalDate` while the backend receives precise lower/upper filter operations.
1562
-
1563
- Choose the invalid-value contract deliberately:
1564
-
1565
- - `createCrudListFilterContract(...)` defaults to `invalidValues: "reject"` for a strict server boundary
1566
- - set `invalidValues` explicitly when a package is choosing a non-default validation posture
1567
- - use `invalidValues: "reject"` when malformed filter values should fail validation and produce a 400-style contract error
1568
- - use `invalidValues: "discard"` when malformed filter values should be ignored and normalization should drop them
1569
- - route query validation runs before auth, so this choice changes whether malformed unauthenticated requests fail at validation or fall through to auth
1570
- - for normal HTTP CRUD handlers, route-level `discard` means the handler receives already-parsed filter values for the explicit fields you listed, so the action layer will not see those discarded bad values again later
1571
- - the filter contract is still a deliberate two-phase exception: schema parsing owns public query-field values, then `toJsonRestQuery(...)` maps those parsed values to JSON REST filter keys and SQL semantics
1572
-
1573
- #### Best practices
1574
-
1575
- - Keep client-only filters in the generated page-local `listFilters.js`. Move definitions into a CRUD package only when server code or another page needs to share them.
1576
- - Keep the filter keys identical all the way through: definition key, query param key, and repository meaning.
1577
- - Prefer `createCrudListFilterContract(...)` for server-backed structured filters so route/action validators, JSON REST search schema, and repository query normalization stay derived from one shared definition.
1578
- - Compose standard list and view query validators with
1579
- `createStandardCrudListQueryValidators(...)` and
1580
- `createStandardCrudViewQueryValidators()` rather than repeating the
1581
- individual standard validators.
1582
- - Pass a server-backed structured-filter validator through
1583
- `listFilterQueryValidator`; append validators separately only for
1584
- additional non-filter query input.
1585
- - Use `type: "presence"` for null/not-null filters such as assigned vs unassigned storage. Do not model those as custom enums plus `applyQuery(...)` overrides unless the SQL semantics are genuinely different from `whereNotNull(...)` / `whereNull(...)`.
1586
- - Use `createCrudListFilters(...)` directly only for non-JSON REST repository code that needs direct Knex filtering without JSON REST registration.
1587
- - Use `q` for free-text and explicit query params for structured filters.
1588
- - Run `jskit doctor` after wiring filters.
1589
-
1590
- ### Pattern 4: lookup-backed structured filters
1591
-
1592
- This is the next real-world step: filters like `supplierContactId`, `locationId`, or `contactId` where the user needs:
1593
-
1594
- - remote autocomplete search
1595
- - URL-synced selected ids
1596
- - readable chip labels instead of raw ids
1597
-
1598
- The right pattern is:
1599
-
1600
- 1. keep the lookup filter in `listFilters.js` or in shared definitions when server code also imports it
1601
- 2. use `useCrudListFilters(...)` for state, chips, and query params
1602
- 3. use `useCrudListFilterLookups(...)` for option loading and label resolution
1603
-
1604
- Example shared definition:
1605
-
1606
- ```js
1607
- export const RECEIVAL_LIST_FILTER_DEFINITIONS = Object.freeze({
1608
- supplierContactId: {
1609
- type: "recordIdMany",
1610
- label: "Supplier",
1611
- lookup: {
1612
- namespace: "contacts"
1613
- }
1614
- },
1615
- pollenTypeId: {
1616
- type: "recordIdMany",
1617
- label: "Pollen Type",
1618
- lookup: {
1619
- namespace: "pollen-types",
1620
- labelKey: "name"
1621
- }
1622
- }
1623
- });
1624
- ```
1625
-
1626
- #### Exact file checklist
1627
-
1628
- Lookup-backed filters do **not** change the ownership model from Pattern 3. The file plan is still:
1629
-
1630
- - keep the shared definition in `packages/receivals/src/shared/receivalListFilters.js`
1631
- - update the same server validator and repository files from Pattern 3
1632
- - update the app-owned list page or list-runtime composable so it creates both `useCrudListFilters(...)` and `useCrudListFilterLookups(...)`
1633
- - bind the lookup UI, such as `v-autocomplete`, from `filterLookups.resolveLookup(...)`
1634
-
1635
- Do **not** create a second page-local filter schema just because the UI needs remote autocomplete. The shared definition file stays the source of truth.
1636
-
1637
- #### Client side
1638
-
1639
- ```js
1640
- let filterLookups = null;
1641
-
1642
- const listFilters = useCrudListFilters(
1643
- RECEIVAL_LIST_FILTER_DEFINITIONS,
1644
- {
1645
- labelResolvers: {
1646
- supplierContactId(value) {
1647
- return filterLookups?.resolveLookupLabel("supplierContactId", value, "Supplier") || "";
1648
- }
1649
- }
1650
- }
1651
- );
1652
-
1653
- filterLookups = useCrudListFilterLookups(
1654
- RECEIVAL_LIST_FILTER_DEFINITIONS,
1655
- {
1656
- values: listFilters.values,
1657
- queryKeyPrefix: ["ui-generator", "receivals", "filters"],
1658
- placementSourcePrefix: "ui-generator.receivals.list.filters",
1659
- requestQueryParams: {
1660
- supplierContactId: { limit: 25 }
1661
- },
1662
- labelResolvers: {
1663
- supplierContactId(item = {}) {
1664
- return `${item.firstName} ${item.lastName}`.trim();
1665
- }
1666
- }
1667
- }
1668
- );
1669
-
1670
- const supplierFilterLookup = filterLookups.resolveLookup("supplierContactId");
1671
- ```
1672
-
1673
- Then bind the autocomplete:
1674
-
1675
- ```vue
1676
- <v-autocomplete
1677
- v-model="listFilters.values.supplierContactId"
1678
- :items="supplierFilterLookup.options"
1679
- :search="supplierFilterLookup.searchQuery"
1680
- :loading="supplierFilterLookup.isLoading"
1681
- item-title="label"
1682
- item-value="value"
1683
- multiple
1684
- chips
1685
- no-filter
1686
- @update:search="supplierFilterLookup.setSearch"
1687
- />
1688
- ```
1689
-
1690
- #### Why this is better than a page-local `useList()` wrapper
1691
-
1692
- - the CRUD filter state still lives in `useCrudListFilters(...)`
1693
- - the autocomplete loading logic lives in one reusable helper
1694
- - the label resolution used by filter chips and the autocomplete stays consistent
1695
- - a second screen can reuse the same pattern instead of rewriting it
1696
-
1697
- #### Best practices
1698
-
1699
- - Put lookup metadata in the shared filter definitions.
1700
- - Use `useCrudListFilterLookups(...)` for remote filter autocompletes instead of building a custom `useList()` wrapper per screen.
1701
- - Keep lookup label formatting on the client side. It is UI presentation, not repository logic.
1702
- - Keep unusual SQL semantics, such as `pending = whereNull(...)`, in the server runtime `apply` override.
1703
-
1704
- ### Pattern 5: free-text search plus structured filters together
1705
-
1706
- This is the most common real-world CRUD list.
1707
-
1708
- #### Client side
1709
-
1710
- Use both:
1711
-
1712
- - `records.searchQuery` for free-text
1713
- - `queryParams` for structured filters
1714
-
1715
- The runtime already handles both together.
1716
-
1717
- #### Server side
1718
-
1719
- Let the generic list search handle `q`, and let `createCrudListFilterContract(...)` handle the structured route/action validators, JSON REST search schema, and repository query normalization.
1720
-
1721
- #### Best practices
1722
-
1723
- - Keep free-text and structure separate.
1724
- - Preserve the current route query when linking to view/edit pages so users can return to the same filtered list state.
1725
- - Let list changes reset pagination; `useCrudList()` already does this for search and query-param changes.
1726
-
1727
- ### Pattern 6: parent-scoped child CRUD search for `addresses`
1728
-
1729
- For nested CRUDs such as the `addresses` resource from the previous chapter, parent scoping and search usually work together.
1730
-
1731
- #### Client side
1732
-
1733
- Keep the parent id in the route:
1734
-
1735
- ```text
1736
- w/[workspaceSlug]/admin/contacts/[contactId]/addresses
1737
- ```
1738
-
1739
- Then use the normal list runtime. For empty child lists, use `useCrudListParentTitle()` so the page can still resolve the parent identity.
1740
-
1741
- #### Server side
1742
-
1743
- The CRUD stack can derive parent filter keys from the resource contract via `createCrudParentFilterQueryValidator(resource)`.
1744
-
1745
- For the tutorial `addresses` table, the list search itself can stay very simple:
1746
-
1747
- ```js
1748
- const resource = Object.freeze({
1749
- searchSchema: {
1750
- id: { type: "id", actualField: "id" },
1751
- q: {
1752
- type: "string",
1753
- oneOf: ["label", "line1", "line2", "suburb", "state", "postcode"],
1754
- filterOperator: "like",
1755
- splitBy: " ",
1756
- matchAll: true
1757
- }
1758
- },
1759
- defaultSort: ["-createdAt"]
1760
- });
1761
- ```
1762
-
1763
- That keeps child-list filtering grounded in the actual resource definition instead of ad-hoc route parsing.
1764
-
1765
- #### Best practices
1766
-
1767
- - Keep parent identity in the route, not hidden component state.
1768
- - Let the resource contract define parent filter shape.
1769
- - Treat parent-scoped filtering as repository/query behavior, not as presentation logic.
1770
-
1771
- ### Pattern 7: local-only search for embedded `comments`
1772
-
1773
- Sometimes server search is unnecessary.
1774
-
1775
- This is useful for:
1776
-
1777
- - small already-loaded lists
1778
- - embedded child collections
1779
- - temporary local filtering inside a view page
1780
-
1781
- 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.
1782
-
1783
- #### Client side
1784
-
1785
- Use local search mode:
1786
-
1787
- ```js
1788
- const records = useCrudList({
1789
- resource: commentsResource,
1790
- apiSuffix: "/comments",
1791
- search: {
1792
- enabled: true,
1793
- mode: "local",
1794
- fields: ["body"]
1795
- }
1796
- });
1797
- ```
1798
-
1799
- #### Server side
1800
-
1801
- No server change is needed.
1802
-
1803
- #### Best practices
1804
-
1805
- - Use this only for small datasets or already-loaded pages.
1806
- - Local search only filters the items currently in memory.
1807
- - Do not treat local search as a replacement for real server-side search on a large paginated CRUD.
1808
-
1809
- ### Pattern 7: relation-aware search across the tutorial tables
1810
-
1811
- This is where people most often put code in the wrong place.
1812
-
1813
- Examples that still fit the tutorial's tables are:
1814
-
1815
- - "Search `addresses` by the parent contact's `full_name`"
1816
- - "Search `comments` by the parent contact's `full_name`"
1817
-
1818
- The important limitation is:
1819
-
1820
- - generic CRUD search happens in the repository query
1821
- - parent lookups or hydrated records happen later
1822
-
1823
- So a parent record being visible in the UI does **not** automatically make it searchable.
1824
-
1825
- #### Client side
1826
-
1827
- The client can still keep sending `q`, or it can expose a dedicated filter control.
1828
-
1829
- The difficult part is not the page. It is the repository query.
1830
-
1831
- #### Server side
1832
-
1833
- If you need parent-aware search, you have two main options:
1834
-
1835
- 1. Prefer a denormalized/searchable base-table column when the search is core to the feature.
1836
- 2. If denormalization is not appropriate, extend the repository query with joins, `whereExists(...)`, or other SQL in `modifyQuery(...)`.
1837
-
1838
- #### Best practices
1839
-
1840
- - Keep relation-aware search in `repository.js`, because it is a SQL concern.
1841
- - Do not try to fake relation search in the client when the dataset is paginated.
1842
- - Do not assume parent titles or hydrated child/parent records automatically become searchable.
1843
- - Prefer denormalized columns for core search paths that must stay fast and stable.
1844
-
1845
- ## The safest way to add new search behavior
1846
-
1847
- If you want to add a new search/filter use case, the safest sequence is:
1848
-
1849
- 1. Decide whether it is free-text, structured, local-only, or relation-aware.
1850
- 2. Put client state in the page or an app-owned composable.
1851
- 3. Put transport validation in `actions.js` or `registerRoutes.js`.
1852
- 4. Put SQL behavior in `repository.js`.
1853
- 5. Put cross-record business rules in `service.js` only if they are truly domain rules rather than query rules.
1854
-
1855
- That separation is what keeps CRUDs from turning into slop.
1856
-
1857
- ## A practical checklist for common changes
1858
-
1859
- ### "I added a new DB column and want it editable."
1860
-
1861
- Touch:
1862
-
1863
- - the table/migration
1864
- - `contactResource.js`
1865
- - `CrudAddEditFormFields.js`
1866
- - the relevant page/table display
1867
-
1868
- Use `scaffold-field` when it fits, then review the generated result. It patches the canonical `schema` in the shared resource file; the standard CRUD validators are derived from that schema automatically.
1869
-
1870
- ### "I want a new boolean or enum list filter."
1871
-
1872
- Touch:
1873
-
1874
- - `packages/<crud>/src/shared/<crud>ListFilters.js` and make it the only authored filter-definition module
1875
- - `packages/<crud>/src/server/<crud>ListFilterContract.js` with `createCrudListFilterContract(...)`
1876
- - `packages/<crud>/src/server/registerRoutes.js` and `packages/<crud>/src/server/actions.js` so list validators include `<crud>ListFilterContract.queryValidator`, or `packages/<crud>/src/server/listQueryValidators.js` if you extracted list-query composition there
1877
- - the provider registration so `createJsonRestResourceScopeOptions(resource, { searchSchema: <crud>ListFilterContract.jsonRestSearchSchema })` merges the JSON REST search schema
1878
- - `packages/<crud>/src/server/repository.js` so the list query calls `<crud>ListFilterContract.toJsonRestQuery(query)` before `buildJsonRestQueryParams(...)`
1879
- - the app-owned list page or list-runtime composable that calls `useCrudList(...)`
1880
-
1881
- If the filter is lookup-backed, touch that same client file again to wire `useCrudListFilterLookups(...)`.
1882
-
1883
- ### "I want a new save rule."
1884
-
1885
- Touch:
1886
-
1887
- - `service.js`
1888
- - tests for the service rule
1889
-
1890
- Do **not** start in the page unless the rule is purely visual.
1891
-
1892
- ### "I want a different permission rule."
1893
-
1894
- Touch:
1895
-
1896
- - `actions.js`
1897
- - possibly `config/roles.js`
1898
-
1899
- Do not hide permission rules inside client components.
1900
-
1901
- ### "I need a manager to see assigned records and descendants."
1902
-
1903
- Read [Row Policies](/guide/generators/row-policies).
1904
-
1905
- Touch:
1906
-
1907
- - a server-only policy module in the resource-owning package
1908
- - the owning provider's `createJsonRestResourceScopeOptions(...)` call
1909
- - a domain-owned visibility contribution seam when another package grants access
1910
- - focused pagination, count, missing-identity, child-resource, and dependency-direction tests
1911
-
1912
- Do not filter the JSON:API document in `service.js`, accept visible ids from the client, or make the resource-owning package depend on every package that can grant access.
1913
-
1914
- ## Final mental model
1915
-
1916
- A generated CRUD is not a monolith.
1917
-
1918
- It is a composition of:
1919
-
1920
- - a shared contract
1921
- - a repository
1922
- - a service
1923
- - actions
1924
- - routes
1925
- - thin page containers
1926
- - runtime composables and helpers underneath
1927
-
1928
- Once you see that structure clearly, CRUD customization becomes much easier:
1929
-
1930
- - SQL changes go in the repository
1931
- - domain rules go in the service
1932
- - transport and permission changes go in actions/routes
1933
- - presentation changes stay in the app-owned client files
1934
-
1935
- That is the line to protect as the CRUD grows.