@jskit-ai/agent-docs 0.1.131 → 0.1.133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/guide/agent/app-extras/assistant.md +29 -605
- package/guide/agent/app-extras/mobile-capacitor.md +29 -362
- package/guide/agent/app-extras/realtime.md +29 -277
- package/guide/agent/app-setup/a-more-interesting-shell.md +44 -815
- package/guide/agent/app-setup/authentication.md +43 -1073
- package/guide/agent/app-setup/console.md +26 -298
- package/guide/agent/app-setup/database-layer.md +110 -790
- package/guide/agent/app-setup/existing-application-migration.md +273 -0
- package/guide/agent/app-setup/initial-scaffolding.md +50 -784
- package/guide/agent/app-setup/multi-homing.md +39 -712
- package/guide/agent/app-setup/quickstart.md +43 -179
- package/guide/agent/app-setup/users.md +34 -353
- package/guide/agent/index.md +17 -23
- package/package.json +2 -2
- package/patterns/INDEX.md +7 -7
- package/patterns/child-cruds.md +3 -3
- package/patterns/client-requests.md +6 -6
- package/patterns/crud-authoring.md +94 -0
- package/patterns/crud-links.md +1 -1
- package/patterns/feature-package/PATTERN.md +108 -0
- package/patterns/feature-package/example/booking-engine/package.json +48 -0
- package/patterns/feature-package/example/booking-engine/src/server/BookingEngineProvider.js +33 -0
- package/patterns/feature-package/example/booking-engine/src/server/actions.js +26 -0
- package/patterns/feature-package/example/booking-engine/src/server/inputSchemas.js +19 -0
- package/patterns/feature-package/example/variations/AvailabilityEngineProvider.js +31 -0
- package/patterns/feature-package/example/variations/InvoiceRollupProvider.js +36 -0
- package/patterns/feature-package/example/variations/customKnexRepository.js +34 -0
- package/patterns/feature-package/example/variations/orchestratorService.js +23 -0
- package/patterns/filters.md +8 -8
- package/patterns/live-actions.md +5 -18
- package/patterns/minimal-foundation/PATTERN.md +98 -0
- package/patterns/minimal-foundation/example/.nvmrc +1 -0
- package/patterns/minimal-foundation/example/AGENTS.md +17 -0
- package/patterns/minimal-foundation/example/Procfile +2 -0
- package/patterns/minimal-foundation/example/app.json +14 -0
- package/patterns/minimal-foundation/example/bin/develop.js +71 -0
- package/patterns/minimal-foundation/example/bin/server.js +8 -0
- package/patterns/minimal-foundation/example/config/public.js +40 -0
- package/patterns/minimal-foundation/example/config/server.js +1 -0
- package/patterns/minimal-foundation/example/config/surfaceAccessPolicies.js +3 -0
- package/patterns/minimal-foundation/example/eslint.config.mjs +19 -0
- package/patterns/minimal-foundation/example/favicon.svg +7 -0
- package/patterns/minimal-foundation/example/gitignore +9 -0
- package/patterns/minimal-foundation/example/index.html +13 -0
- package/patterns/minimal-foundation/example/jsconfig.json +8 -0
- package/patterns/minimal-foundation/example/package.json +57 -0
- package/patterns/minimal-foundation/example/packages/main/package.json +42 -0
- package/patterns/minimal-foundation/example/packages/main/src/shared/index.js +10 -0
- package/patterns/minimal-foundation/example/packages/main/src/shared/schemas/index.js +22 -0
- package/patterns/minimal-foundation/example/playwright.config.mjs +31 -0
- package/patterns/minimal-foundation/example/server/lib/runtimeEnv.js +45 -0
- package/patterns/minimal-foundation/example/server/lib/surfaceRuntime.js +10 -0
- package/patterns/minimal-foundation/example/server.js +195 -0
- package/patterns/minimal-foundation/example/src/App.vue +13 -0
- package/patterns/minimal-foundation/example/src/main.js +85 -0
- package/patterns/minimal-foundation/example/src/pages/home/index.vue +48 -0
- package/patterns/minimal-foundation/example/src/pages/home.vue +13 -0
- package/patterns/minimal-foundation/example/src/views/NotFound.vue +13 -0
- package/patterns/minimal-foundation/example/tests/client/smoke.vitest.js +7 -0
- package/patterns/minimal-foundation/example/tests/e2e/base-shell.spec.ts +23 -0
- package/patterns/minimal-foundation/example/tests/server/smoke.test.js +16 -0
- package/patterns/minimal-foundation/example/vite.config.mjs +81 -0
- package/patterns/page-scaffolding.md +20 -17
- package/patterns/placements.md +17 -15
- package/patterns/row-policies.md +4 -5
- package/patterns/server-search.md +3 -3
- package/patterns/shell-foundation/PATTERN.md +104 -0
- package/patterns/shell-foundation/example/.nvmrc +1 -0
- package/patterns/shell-foundation/example/AGENTS.md +17 -0
- package/patterns/shell-foundation/example/Procfile +2 -0
- package/patterns/shell-foundation/example/app.json +14 -0
- package/patterns/shell-foundation/example/bin/develop.js +71 -0
- package/patterns/shell-foundation/example/bin/server.js +8 -0
- package/patterns/shell-foundation/example/config/public.js +40 -0
- package/patterns/shell-foundation/example/config/server.js +1 -0
- package/patterns/shell-foundation/example/config/surfaceAccessPolicies.js +3 -0
- package/patterns/shell-foundation/example/eslint.config.mjs +19 -0
- package/patterns/shell-foundation/example/favicon.svg +7 -0
- package/patterns/shell-foundation/example/gitignore +9 -0
- package/patterns/shell-foundation/example/index.html +13 -0
- package/patterns/shell-foundation/example/jsconfig.json +8 -0
- package/patterns/shell-foundation/example/package.json +59 -0
- package/patterns/shell-foundation/example/packages/main/package.json +56 -0
- package/patterns/shell-foundation/example/packages/main/src/client/index.js +9 -0
- package/patterns/shell-foundation/example/packages/main/src/client/providers/MainClientProvider.js +18 -0
- package/patterns/shell-foundation/example/packages/main/src/shared/index.js +10 -0
- package/patterns/shell-foundation/example/packages/main/src/shared/schemas/index.js +22 -0
- package/patterns/shell-foundation/example/playwright.config.mjs +31 -0
- package/patterns/shell-foundation/example/server/lib/runtimeEnv.js +45 -0
- package/patterns/shell-foundation/example/server/lib/surfaceRuntime.js +10 -0
- package/patterns/shell-foundation/example/server.js +195 -0
- package/patterns/shell-foundation/example/src/App.vue +11 -0
- package/patterns/shell-foundation/example/src/components/ShellLayout.vue +12 -0
- package/patterns/shell-foundation/example/src/components/menus/MenuLinkItem.vue +30 -0
- package/patterns/shell-foundation/example/src/components/menus/SurfaceAwareMenuLinkItem.vue +42 -0
- package/patterns/shell-foundation/example/src/components/menus/TabLinkItem.vue +42 -0
- package/patterns/shell-foundation/example/src/error.js +19 -0
- package/patterns/shell-foundation/example/src/main.js +85 -0
- package/patterns/shell-foundation/example/src/pages/home/index.vue +116 -0
- package/patterns/shell-foundation/example/src/pages/home/settings/general/index.vue +40 -0
- package/patterns/shell-foundation/example/src/pages/home/settings/index.vue +7 -0
- package/patterns/shell-foundation/example/src/pages/home/settings.vue +109 -0
- package/patterns/shell-foundation/example/src/pages/home.vue +20 -0
- package/patterns/shell-foundation/example/src/placement.js +56 -0
- package/patterns/shell-foundation/example/src/placementTopology.js +149 -0
- package/patterns/shell-foundation/example/src/views/NotFound.vue +13 -0
- package/patterns/shell-foundation/example/tests/client/smoke.vitest.js +7 -0
- package/patterns/shell-foundation/example/tests/e2e/adaptive-shell.spec.ts +10 -0
- package/patterns/shell-foundation/example/tests/e2e/base-shell.spec.ts +23 -0
- package/patterns/shell-foundation/example/tests/server/smoke.test.js +16 -0
- package/patterns/shell-foundation/example/vite.config.mjs +81 -0
- package/patterns/ui-contract.md +56 -0
- package/patterns/ui-testing.md +10 -12
- package/reference/autogen/KERNEL_MAP.md +29 -107
- package/reference/autogen/PATTERN_INDEX.md +230 -0
- package/reference/autogen/README.md +4 -8
- package/reference/autogen/packages/agent-docs.md +259 -0
- package/reference/autogen/packages/assistant-core.md +3 -3
- package/reference/autogen/packages/assistant-runtime.md +32 -17
- package/reference/autogen/packages/auth-core.md +31 -33
- package/reference/autogen/packages/auth-provider-local-core.md +4 -12
- package/reference/autogen/packages/auth-provider-local-db-core.md +4 -4
- package/reference/autogen/packages/auth-provider-supabase-core.md +14 -18
- package/reference/autogen/packages/auth-web.md +42 -22
- package/reference/autogen/packages/console-core.md +8 -25
- package/reference/autogen/packages/console-web.md +5 -5
- package/reference/autogen/packages/crud-core.md +61 -17
- package/reference/autogen/packages/database-runtime-mysql.md +12 -2
- package/reference/autogen/packages/database-runtime-postgres.md +12 -2
- package/reference/autogen/packages/database-runtime.md +26 -25
- package/reference/autogen/packages/google-rewarded-core.md +19 -104
- package/reference/autogen/packages/http-runtime.md +4 -8
- package/reference/autogen/packages/http-web.md +32 -0
- package/reference/autogen/packages/json-rest-api-core.md +4 -6
- package/reference/autogen/packages/kernel.md +109 -390
- package/reference/autogen/packages/mobile-capacitor.md +2 -13
- package/reference/autogen/packages/realtime.md +29 -26
- package/reference/autogen/packages/resource-crud-core.md +6 -0
- package/reference/autogen/packages/shell-web.md +69 -54
- package/reference/autogen/packages/storage-runtime.md +3 -3
- package/reference/autogen/packages/uploads-image-web.md +0 -1
- package/reference/autogen/packages/uploads-runtime.md +3 -3
- package/reference/autogen/packages/users-core.md +45 -90
- package/reference/autogen/packages/users-web.md +5 -7
- package/reference/autogen/packages/workspaces-core.md +53 -74
- package/reference/autogen/packages/workspaces-web.md +15 -16
- package/reference/autogen/tooling/jskit-catalog.md +34 -0
- package/reference/autogen/tooling/testUtils.md +4 -4
- package/skills/jskit/SKILL.md +38 -31
- package/skills/jskit/references/app-operations.md +68 -53
- package/skills/jskit/references/crud-operations.md +58 -106
- package/skills/jskit/references/material-3.md +1 -1
- package/skills/jskit/references/ui-operations.md +41 -44
- package/templates/app/AGENTS.md +7 -3
- package/guide/agent/app-setup/upgrade-beta-1-to-final.md +0 -252
- package/guide/agent/app-setup/working-with-the-jskit-cli.md +0 -325
- package/guide/agent/generators/advanced-cruds.md +0 -1935
- package/guide/agent/generators/crud-generators.md +0 -948
- package/guide/agent/generators/intro.md +0 -65
- package/guide/agent/generators/row-policies.md +0 -537
- package/guide/agent/generators/ui-generators.md +0 -690
- package/patterns/crud-scaffolding.md +0 -198
- package/patterns/generated-ui-contract-tracking.md +0 -66
- package/reference/autogen/packages/assistant.md +0 -68
- package/reference/autogen/packages/crud-server-generator.md +0 -215
- package/reference/autogen/packages/crud-ui-generator.md +0 -192
- package/reference/autogen/packages/feature-server-generator.md +0 -65
- package/reference/autogen/packages/ui-generator.md +0 -127
- package/reference/autogen/tooling/create-app.md +0 -317
- package/reference/autogen/tooling/jskit-cli.md +0 -933
- 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.
|