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