@jskit-ai/agent-docs 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/guide/agent/app-extras/assistant.md +2 -2
  2. package/guide/agent/app-setup/initial-scaffolding.md +1 -3
  3. package/guide/agent/app-setup/multi-homing.md +27 -4
  4. package/guide/agent/app-setup/working-with-the-jskit-cli.md +1 -0
  5. package/guide/agent/generators/advanced-cruds.md +206 -112
  6. package/guide/agent/generators/crud-generators.md +26 -0
  7. package/guide/agent/index.md +1 -1
  8. package/package.json +1 -1
  9. package/patterns/INDEX.md +1 -1
  10. package/patterns/client-requests.md +3 -0
  11. package/patterns/crud-repository-mapping.md +36 -35
  12. package/patterns/filters.md +8 -8
  13. package/reference/autogen/KERNEL_MAP.md +94 -40
  14. package/reference/autogen/README.md +3 -0
  15. package/reference/autogen/packages/assistant-core.md +0 -12
  16. package/reference/autogen/packages/assistant-runtime.md +11 -3
  17. package/reference/autogen/packages/auth-core.md +34 -23
  18. package/reference/autogen/packages/auth-provider-supabase-core.md +4 -7
  19. package/reference/autogen/packages/auth-web.md +3 -0
  20. package/reference/autogen/packages/console-core.md +6 -29
  21. package/reference/autogen/packages/crud-core.md +34 -30
  22. package/reference/autogen/packages/crud-server-generator.md +28 -83
  23. package/reference/autogen/packages/crud-ui-generator.md +12 -7
  24. package/reference/autogen/packages/http-runtime.md +171 -21
  25. package/reference/autogen/packages/json-rest-api-core.md +54 -0
  26. package/reference/autogen/packages/kernel.md +118 -55
  27. package/reference/autogen/packages/realtime.md +1 -0
  28. package/reference/autogen/packages/resource-core.md +35 -0
  29. package/reference/autogen/packages/resource-crud-core.md +51 -0
  30. package/reference/autogen/packages/shell-web.md +31 -0
  31. package/reference/autogen/packages/users-core.md +34 -77
  32. package/reference/autogen/packages/users-web.md +29 -20
  33. package/reference/autogen/packages/workspaces-core.md +44 -76
  34. package/reference/autogen/packages/workspaces-web.md +1 -3
  35. package/reference/autogen/tooling/jskit-cli.md +6 -5
  36. package/workflow/feature-delivery.md +2 -1
@@ -181,7 +181,7 @@ For example:
181
181
  require: "all",
182
182
  permissions: ["workspace.members.invite"]
183
183
  },
184
- inputValidator: {
184
+ input: {
185
185
  schema: {
186
186
  type: "object",
187
187
  properties: {
@@ -191,7 +191,7 @@ For example:
191
191
  additionalProperties: false
192
192
  }
193
193
  },
194
- outputValidator: {
194
+ output: {
195
195
  schema: {
196
196
  type: "object",
197
197
  properties: {
@@ -576,8 +576,6 @@ The core of that startup path looks like this:
576
576
  ```js
577
577
  async function createServer() {
578
578
  const app = Fastify({ logger: true });
579
- registerTypeBoxFormats();
580
- app.setValidatorCompiler(TypeBoxValidatorCompiler);
581
579
 
582
580
  app.get("/api/health", async () => {
583
581
  return {
@@ -610,7 +608,7 @@ async function createServer() {
610
608
  }
611
609
  ```
612
610
 
613
- The health route is built in, but the more important idea is that the server is already prepared to validate HTTP input, load the JSKIT provider runtime from the app itself, and constrain requests by surface.
611
+ The health route is built in, but the more important idea is that the server is already prepared to validate HTTP input with Fastify's normal JSON Schema path, load the JSKIT provider runtime from the app itself, and constrain requests by surface.
614
612
 
615
613
  You will also notice `config/server.js`. In the base shell it is intentionally almost empty. It is there to reserve a clear place for server-side configuration as backend features are added, without pretending the starter app already has server behavior it does not yet need.
616
614
 
@@ -591,12 +591,12 @@ registerProfileSyncLifecycleContributor(app, "workspaces.core.profileSyncLifecyc
591
591
  return Object.freeze({
592
592
  contributorId: "workspaces.core.profileSync",
593
593
  order: 100,
594
- async afterIdentityProfileSynced({ profile, created, options } = {}) {
595
- if (!created || !profile || typeof workspaceService?.provisionWorkspaceForNewUser !== "function") {
594
+ async afterIdentityProfileSynced({ profile, options } = {}) {
595
+ if (!profile || typeof workspaceService?.ensureProvisionedWorkspaceForAuthenticatedUser !== "function") {
596
596
  return;
597
597
  }
598
598
 
599
- await workspaceService.provisionWorkspaceForNewUser(profile, options);
599
+ await workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser(profile, options);
600
600
  }
601
601
  });
602
602
  });
@@ -604,7 +604,9 @@ registerProfileSyncLifecycleContributor(app, "workspaces.core.profileSyncLifecyc
604
604
 
605
605
  That means the workspace package does not need to patch auth directly to learn that a user was added. It listens through the `users-core` lifecycle registry instead.
606
606
 
607
- For this chapter's `tenancyMode = "personal"` setup, that contributor now does real work. When a brand-new JSKIT user is synchronized from auth, `workspaces-core` uses the lifecycle seam to provision that user's first personal workspace automatically.
607
+ For this chapter's `tenancyMode = "personal"` setup, that contributor now does real work. When an authenticated JSKIT user is synchronized from auth, `workspaces-core` ensures that user's personal workspace exists.
608
+
609
+ That detail matters for the retrofit path described earlier in this chapter. If the app started on `none`, then later switched to `personal`, the first sign-in after that switch still needs to backfill the personal workspace for the already-existing user record. The lifecycle contributor handles that because the workspace provision step is idempotent.
608
610
 
609
611
  - `users-core` owns the "user was synchronized" lifecycle
610
612
  - `workspaces-core` subscribes to that lifecycle through the registry
@@ -612,6 +614,27 @@ For this chapter's `tenancyMode = "personal"` setup, that contributor now does r
612
614
 
613
615
  So even in this chapter, the package boundary is already correct: users owns user creation/sync, and workspaces reacts through an extension point.
614
616
 
617
+ ### Workspace auth context comes from the auth policy resolver registry
618
+
619
+ There is a second registry seam in this chapter that matters just as much for the runtime model.
620
+
621
+ Workspace-aware routes do not hard-code workspace lookup inside the auth plugin itself. Instead, `workspaces-core` registers an auth policy context resolver contribution, and `auth-core` composes the registered resolvers at request time.
622
+
623
+ The important consequence is:
624
+
625
+ - `auth-core` stays generic
626
+ - `workspaces-core` contributes workspace-specific context
627
+ - the request only resolves workspace membership and permissions when a route actually asks for auth context or permissions
628
+
629
+ So the flow is:
630
+
631
+ 1. a workspace-aware route declares auth and, when needed, permission requirements
632
+ 2. the auth policy runtime authenticates the actor
633
+ 3. the composed auth policy context resolver asks the workspace layer for the current workspace context
634
+ 4. the request gains normalized `workspace`, `membership`, and `permissions` values
635
+
636
+ That is why the workspace package does not need to patch the auth plugin directly, and it is also why this setup scales better than a one-off global hook. It uses the same tagged-registry pattern as other JSKIT extension seams, but for request-time auth context instead of bootstrap payloads or lifecycle events.
637
+
615
638
  ### Why this chapter is the real routing pivot
616
639
 
617
640
  Earlier chapters added features inside a flat top-level app.
@@ -568,6 +568,7 @@ So `update` is for reapplying package-owned managed changes, not for generic dep
568
568
  The best guide example is the tenancy-mode recovery path from the multi-homing chapter. If you installed `workspaces-core` or `workspaces-web` while the app was still on `tenancyMode = "none"`, then later changed the app to `personal`, the missing gated scaffold does not backfill automatically. The recovery path is:
569
569
 
570
570
  ```bash
571
+ npx jskit update package users-core
571
572
  npx jskit update package workspaces-core
572
573
  npx jskit update package workspaces-web
573
574
  ```
@@ -211,9 +211,7 @@ packages/contacts/
211
211
  package.json
212
212
  package.descriptor.mjs
213
213
  src/server/ContactsProvider.js
214
- src/server/actionIds.js
215
214
  src/server/actions.js
216
- src/server/listConfig.js
217
215
  src/server/registerRoutes.js
218
216
  src/server/repository.js
219
217
  src/server/service.js
@@ -265,15 +263,61 @@ If you come from an ORM stack, this is the key adjustment:
265
263
  The resource file owns:
266
264
 
267
265
  - 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`
266
+ - the canonical `schema`
267
+ - `searchSchema`
268
+ - `defaultSort`
269
+ - `autofilter`
271
270
  - lookup contract configuration
272
- - messages and realtime event declarations
273
- - field metadata when column overrides or richer metadata are needed
271
+ - messages
272
+ - field metadata, including which fields participate in `output`, `create`, `replace`, and `patch`
274
273
 
275
274
  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
275
 
276
+ For a standard CRUD resource like `contacts`, the authored file is intentionally compact. It uses `defineCrudResource(...)` from `@jskit-ai/resource-crud-core`:
277
+
278
+ ```js
279
+ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
280
+
281
+ const resource = defineCrudResource({
282
+ namespace: "contacts",
283
+ tableName: "contacts",
284
+ schema: {
285
+ name: {
286
+ type: "string",
287
+ maxLength: 190,
288
+ required: true,
289
+ search: true,
290
+ operations: {
291
+ output: { required: true },
292
+ create: { required: true },
293
+ patch: { required: false }
294
+ }
295
+ }
296
+ },
297
+ searchSchema: {
298
+ id: { type: "id", actualField: "id" }
299
+ },
300
+ defaultSort: ["-createdAt"],
301
+ autofilter: "workspace",
302
+ messages: {
303
+ saveSuccess: "Record saved."
304
+ },
305
+ contract: {
306
+ lookup: {
307
+ containerKey: "lookups"
308
+ }
309
+ }
310
+ });
311
+ ```
312
+
313
+ `defineCrudResource(...)` derives the standard CRUD operation contracts once at module load time and exposes them on `resource.operations`. That means:
314
+
315
+ - you author the canonical resource shape once
316
+ - JSKIT derives the standard `list` / `view` / `create` / `replace` / `patch` / `delete` contracts
317
+ - routes, actions, client code, and generators can keep reading `resource.operations.*` without each resource file repeating that boilerplate
318
+
319
+ 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.
320
+
277
321
  ### `src/shared/index.js`
278
322
 
279
323
  This is just the shared package barrel. It re-exports the resource contract and shared symbols.
@@ -292,22 +336,6 @@ It owns:
292
336
 
293
337
  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
338
 
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
339
  ### `src/server/actions.js`
312
340
 
313
341
  This is the action contract boundary.
@@ -344,33 +372,6 @@ In other words:
344
372
 
345
373
  Those are related, but not the same concern.
346
374
 
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
375
  ### `src/server/repository.js`
375
376
 
376
377
  This is the data-access layer.
@@ -656,6 +657,44 @@ const paths = usePaths();
656
657
  const reportsApiPath = computed(() => paths.api("/reports"));
657
658
  ```
658
659
 
660
+ Use `requestQueryParams` when a runtime needs endpoint query parameters. Do not put query strings into `apiUrlTemplate`; URL templates are for path shape only.
661
+
662
+ For routed CRUD edit forms, pass request query params through `addEditOptions`:
663
+
664
+ ```js
665
+ const formRuntime = useCrudAddEdit({
666
+ resource: uiResource,
667
+ operationName: "patch",
668
+ formFields: UI_EDIT_FORM_FIELDS,
669
+ addEditOptions: {
670
+ apiUrlTemplate: "/products/:productId",
671
+ requestQueryParams: {
672
+ include: "serviceId,bookingSteps,bookingSteps.requiredRoleId"
673
+ }
674
+ }
675
+ });
676
+ ```
677
+
678
+ `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:
679
+
680
+ ```js
681
+ const formRuntime = useCrudAddEdit({
682
+ resource: uiResource,
683
+ operationName: "patch",
684
+ formFields: UI_EDIT_FORM_FIELDS,
685
+ addEditOptions: {
686
+ apiUrlTemplate: "/products/:productId",
687
+ requestQueryParams({ recordId, model }) {
688
+ return {
689
+ include: "serviceId,bookingSteps,bookingSteps.requiredRoleId"
690
+ };
691
+ }
692
+ }
693
+ });
694
+ ```
695
+
696
+ 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.
697
+
659
698
  The safe mental model is:
660
699
 
661
700
  - do not raw `fetch(...)` for normal app work
@@ -663,6 +702,7 @@ The safe mental model is:
663
702
  - use the operation/runtime composable that matches the UI interaction
664
703
  - drop to `usersWebHttpClient.request(...)` only for exceptional low-level cases
665
704
  - use `usePaths().api(...)` when you need a custom scoped API path and the higher-level runtime does not already resolve it for you
705
+ - keep `apiUrlTemplate` path-only and put endpoint query strings in `requestQueryParams`
666
706
 
667
707
  ### `_components/CrudAddEditForm.vue`
668
708
 
@@ -703,7 +743,7 @@ A generated CRUD works because several layers cooperate:
703
743
  4. the route executes an action from `actions.js`
704
744
  5. the action delegates to the service in `service.js`
705
745
  6. the service calls the repository in `repository.js`
706
- 7. the repository uses `crud-core` helpers and the resource contract to talk to the database
746
+ 7. the repository uses the shared resource contract, `crud-core` helpers, and the internal JSON:API host to talk to the database
707
747
  8. the response comes back through validators and is rendered by the page
708
748
 
709
749
  That is why the generated route files are mostly containers: they are the outermost layer of a larger pipeline.
@@ -714,10 +754,10 @@ Use this rule of thumb when deciding where to edit:
714
754
 
715
755
  | Need | Primary owner | Why |
716
756
  | --- | --- | --- |
717
- | Change API/input/output contract | `contactResource.js` and `actions.js` | This is where operation shape and validators live |
757
+ | 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 |
718
758
  | Change route path or HTTP transport | `registerRoutes.js` | This is the HTTP layer |
719
759
  | Change permissions or channels | `actions.js` | This is the action contract boundary |
720
- | Change default ordering, limits, or searchable DB columns | `listConfig.js` | This is list runtime configuration |
760
+ | 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 |
721
761
  | Change SQL, joins, parent filters, or advanced search | `repository.js` | This is the data-access layer |
722
762
  | Add cross-record or domain rules on save/delete | `service.js` | This is business logic |
723
763
  | Change page layout and display behavior | the route pages and app-owned composables | This is presentation |
@@ -788,8 +828,8 @@ The client runtime debounces the search input, writes the query string to `q`, a
788
828
  The generic CRUD stack already understands `q`.
789
829
 
790
830
  - `listSearchQueryValidator` reads and normalizes the `q` query param
791
- - the repository applies search to `list.searchColumns`
792
- - if `searchColumns` is not configured, CRUD falls back to a derived set of searchable string columns from the resource output schema
831
+ - the repository applies search through `resource.searchSchema.q`
832
+ - 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
793
833
 
794
834
  For the tutorial `contacts` table, that usually means the columns behind:
795
835
 
@@ -800,27 +840,31 @@ For the tutorial `contacts` table, that usually means the columns behind:
800
840
 
801
841
  #### Best practices
802
842
 
803
- - Once the UX is stable, set explicit `searchColumns` instead of relying on the fallback.
804
- - Keep search focused on the columns users actually expect.
805
- - Remember that `searchColumns` are database columns.
843
+ - Once the UX is stable, edit `contactResource.js` and set `resource.searchSchema.q.oneOf` explicitly instead of relying on the initial generated defaults.
844
+ - Keep search focused on the fields users actually expect.
845
+ - Remember that these are JSON:API/internal resource search keys, not a free-form UI concern.
806
846
 
807
- ### Pattern 2: explicit `contacts` search columns
847
+ ### Pattern 2: explicit `contacts` search fields
808
848
 
809
- This is the first thing to do when the fallback search becomes too broad or too accidental.
849
+ This is the first thing to do when the generated `q` search becomes too broad or too accidental.
810
850
 
811
851
  #### Server side
812
852
 
813
- Set the searchable columns explicitly in `listConfig.js`:
853
+ Set the searchable fields explicitly in `contactResource.js`:
814
854
 
815
855
  ```js
816
- const LIST_CONFIG = Object.freeze({
817
- searchColumns: ["full_name", "email", "phone"],
818
- orderBy: [
819
- {
820
- column: "created_at",
821
- direction: "desc"
856
+ const resource = Object.freeze({
857
+ searchSchema: {
858
+ id: { type: "id", actualField: "id" },
859
+ q: {
860
+ type: "string",
861
+ oneOf: ["fullName", "email", "phone"],
862
+ filterOperator: "like",
863
+ splitBy: " ",
864
+ matchAll: true
822
865
  }
823
- ]
866
+ },
867
+ defaultSort: ["-createdAt"]
824
868
  });
825
869
  ```
826
870
 
@@ -841,32 +885,58 @@ Do not treat the output schema as if it also defined database storage.
841
885
  In JSKIT CRUD:
842
886
 
843
887
  - the schema defines the API contract
844
- - `resource.fieldMeta` defines storage mapping
888
+ - field definitions may also carry storage/lookup/ui metadata
845
889
  - the repository runtime owns computed SQL projections
846
890
 
847
891
  Use these rules:
848
892
 
849
- - for explicit DB column overrides, use `repository.column`
893
+ - for explicit DB column overrides, use `actualField`
850
894
  - standard writable `date-time` fields are serialized automatically during CRUD writes
851
- - use `repository.writeSerializer` only for non-default DB write serialization
852
- - for computed output fields, use `repository.storage: "virtual"`
895
+ - use `storage.writeSerializer` only for non-default DB write serialization
896
+ - for computed output fields, use `storage: { virtual: true }`
853
897
  - do not put computed fields in create/patch write schemas
854
898
 
855
899
  Example field metadata:
856
900
 
857
901
  ```js
858
- RESOURCE_FIELD_META.push({
859
- key: "createdAt",
860
- repository: {
861
- column: "created_at"
862
- }
863
- });
864
-
865
- RESOURCE_FIELD_META.push({
866
- key: "remainingBatchWeight",
867
- repository: {
868
- storage: "virtual"
869
- }
902
+ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
903
+
904
+ const resource = defineCrudResource({
905
+ namespace: "receivals",
906
+ tableName: "receivals",
907
+ schema: {
908
+ createdAt: {
909
+ type: "dateTime",
910
+ required: true,
911
+ actualField: "created_at",
912
+ operations: {
913
+ output: { required: true }
914
+ }
915
+ },
916
+ arrivalDatetime: {
917
+ type: "dateTime",
918
+ required: true,
919
+ storage: {
920
+ writeSerializer: "datetime-utc"
921
+ },
922
+ operations: {
923
+ output: { required: true },
924
+ create: { required: true },
925
+ patch: { required: false }
926
+ }
927
+ },
928
+ remainingBatchWeight: {
929
+ type: "number",
930
+ required: true,
931
+ storage: {
932
+ virtual: true
933
+ },
934
+ operations: {
935
+ output: { required: true }
936
+ }
937
+ }
938
+ },
939
+ crudOperations: ["list", "view", "create", "patch"]
870
940
  });
871
941
  ```
872
942
 
@@ -894,7 +964,7 @@ Once registered there:
894
964
  - generic CRUD `listByIds`
895
965
  - generic CRUD `listByForeignIds`
896
966
 
897
- 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 `repository.writeSerializer` for non-default cases only.
967
+ 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.
898
968
 
899
969
  all pick up the projection automatically, so you should not hand-patch `clearSelect()` / re-select logic into each method.
900
970
 
@@ -921,7 +991,7 @@ Instead:
921
991
 
922
992
  1. define the filters once in a shared CRUD-package module
923
993
  2. build the server runtime from that definition with `createCrudListFilters(...)`
924
- 3. choose the route/action invalid-value contract explicitly with `createQueryValidator({ invalidValues: "reject" | "discard" })`
994
+ 3. choose the route/action invalid-value contract explicitly on each structured filter field with `createCrudListFilterQueryField(...)`
925
995
  4. build the client runtime from that same definition with `useCrudListFilters(...)`
926
996
 
927
997
  For example, a shared filter-definition module can look like this:
@@ -948,7 +1018,7 @@ export const CONTACTS_LIST_FILTER_DEFINITIONS = Object.freeze({
948
1018
  For a generated CRUD, treat this as the concrete file plan:
949
1019
 
950
1020
  - create `packages/contacts/src/shared/contactListFilters.js`
951
- - update `packages/contacts/src/server/registerRoutes.js` so the list route query validator includes an explicit `createQueryValidator({ invalidValues: ... })` choice, unless you already extracted list-query composition into `packages/contacts/src/server/listQueryValidators.js`
1021
+ - update `packages/contacts/src/server/registerRoutes.js` so the list route query validator lists structured filter params explicitly with `createCrudListFilterQueryField(...)`, unless you already extracted list-query composition into `packages/contacts/src/server/listQueryValidators.js`
952
1022
  - update `packages/contacts/src/server/actions.js` so the list action input validator includes that same explicit contract choice
953
1023
  - update `packages/contacts/src/server/repository.js` so the list query path builds the `createCrudListFilters(...)` runtime and calls `applyQuery(...)`
954
1024
  - update the app-owned list page or list-runtime composable, usually under `src/pages/.../contacts/` or `src/composables/...`, so it builds `useCrudListFilters(...)`, passes `queryParams` into `useCrudList(...)`, and renders chips / reset behavior from that runtime
@@ -1075,46 +1145,66 @@ const contactsListFiltersRuntime = createCrudListFilters(
1075
1145
  );
1076
1146
  ```
1077
1147
 
1078
- There is no default query-validator mode and no `runtime.queryValidator` alias. Create the validator that matches the contract you want at that route or action boundary.
1148
+ There is no default query-validator mode or route-runtime alias. Keep the filter runtime for repository semantics, but author route/action query params explicitly.
1079
1149
 
1080
1150
  Strict contract example:
1081
1151
 
1082
1152
  ```js
1083
- const contactsListFiltersQueryValidator = contactsListFiltersRuntime.createQueryValidator({
1084
- invalidValues: "reject" // malformed filter values should fail validation and return 400
1085
- });
1153
+ const contactsListFilters = CONTACTS_LIST_FILTER_DEFINITIONS;
1154
+
1155
+ const contactsListFiltersQueryValidator = {
1156
+ schema: createCrudListFilterQuerySchema({
1157
+ status: createCrudListFilterQueryField(contactsListFilters.status, {
1158
+ invalidValues: "reject"
1159
+ }),
1160
+ arrivalDate: createCrudListFilterQueryField(contactsListFilters.arrivalDate, {
1161
+ invalidValues: "reject"
1162
+ })
1163
+ }),
1164
+ mode: "patch"
1165
+ };
1086
1166
  ```
1087
1167
 
1088
1168
  Lenient contract example:
1089
1169
 
1090
1170
  ```js
1091
- const contactsListFiltersQueryValidator = contactsListFiltersRuntime.createQueryValidator({
1092
- invalidValues: "discard" // malformed filter values should be ignored and dropped by normalize()
1093
- });
1171
+ const contactsListFiltersQueryValidator = {
1172
+ schema: createCrudListFilterQuerySchema({
1173
+ status: createCrudListFilterQueryField(CONTACTS_LIST_FILTER_DEFINITIONS.status, {
1174
+ invalidValues: "discard"
1175
+ }),
1176
+ arrivalDate: createCrudListFilterQueryField(CONTACTS_LIST_FILTER_DEFINITIONS.arrivalDate, {
1177
+ invalidValues: "discard"
1178
+ })
1179
+ }),
1180
+ mode: "patch"
1181
+ };
1094
1182
  ```
1095
1183
 
1096
- Wire the runtime into the list validator and the repository:
1184
+ Wire the explicit query contract into the list validator and the repository:
1097
1185
 
1098
1186
  ```js
1099
- queryValidator: [
1187
+ const listRouteQueryValidator = composeSchemaDefinitions([
1100
1188
  listCursorPaginationQueryValidator,
1101
1189
  listSearchQueryValidator,
1102
1190
  contactsListFiltersQueryValidator,
1103
1191
  listParentFilterQueryValidator,
1104
1192
  lookupIncludeQueryValidator
1105
- ]
1193
+ ], {
1194
+ mode: "patch"
1195
+ });
1106
1196
  ```
1107
1197
 
1108
- Use that same `contactsListFiltersQueryValidator` anywhere else the list query is validated, such as the list action input validator if your CRUD package validates query shape at both the route and action boundaries.
1198
+ Use that same explicit `contactsListFiltersQueryValidator` anywhere else the list query is validated, such as the composed list action input validator if your CRUD package validates query shape at both the route and action boundaries.
1109
1199
 
1110
1200
  Choose the invalid-value contract deliberately:
1111
1201
 
1112
- - there is no default mode and no fallback alias, so every route or action that validates structured filters must call `createQueryValidator({ invalidValues: ... })` explicitly
1202
+ - there is no default mode and no fallback alias, so every structured filter field must choose `invalidValues: "reject"` or `invalidValues: "discard"` explicitly
1113
1203
  - use `invalidValues: "reject"` when malformed filter values should fail validation and produce a 400-style contract error
1114
1204
  - use `invalidValues: "discard"` when malformed filter values should be ignored and normalization should drop them
1115
1205
  - route query validation runs before auth, so this choice changes whether malformed unauthenticated requests fail at validation or fall through to auth
1116
- - for normal HTTP CRUD handlers, route-level `discard` means the handler receives already-normalized query input, so the action layer will not see those discarded bad values again later
1117
- - `jskit doctor` flags `createQueryValidator(...)` calls that do not spell out `invalidValues` directly at the call site
1206
+ - 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
1207
+ - the filter runtime is still a deliberate two-phase exception: schema parsing owns query-field values, then `applyQuery(...)` maps those parsed values back through filter keys and SQL semantics
1118
1208
 
1119
1209
  ```js
1120
1210
  async function list(query = {}, callOptions = {}) {
@@ -1130,11 +1220,11 @@ async function list(query = {}, callOptions = {}) {
1130
1220
 
1131
1221
  - Put the filter definitions in the CRUD package, not the page. Both server and client need them.
1132
1222
  - Keep the filter keys identical all the way through: definition key, query param key, and repository meaning.
1133
- - Do not expect a default `runtime.queryValidator` to exist. Every structured-filter validator must be created explicitly with `createQueryValidator({ invalidValues: ... })`.
1223
+ - Prefer `createCrudListFilterQueryField(...)` plus `createCrudListFilterQuerySchema(...)` at route/action boundaries so accepted structured-filter params stay visible in app code.
1134
1224
  - 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(...)`.
1135
1225
  - Use `createCrudListFilters(...)` unless the list semantics are truly unusual.
1136
1226
  - Use `q` for free-text and explicit query params for structured filters.
1137
- - Run `jskit doctor` after wiring filters. It flags inline/local filter-definition objects passed into `useCrudListFilters(...)` or `createCrudListFilters(...)`, and it flags `createQueryValidator(...)` calls that hide the invalid-value policy.
1227
+ - Run `jskit doctor` after wiring filters. It flags inline/local filter-definition objects passed into `useCrudListFilters(...)` or `createCrudListFilters(...)`.
1138
1228
 
1139
1229
  ### Pattern 4: lookup-backed structured filters
1140
1230
 
@@ -1294,14 +1384,18 @@ The CRUD stack can derive parent filter keys from the resource contract via `cre
1294
1384
  For the tutorial `addresses` table, the list search itself can stay very simple:
1295
1385
 
1296
1386
  ```js
1297
- const LIST_CONFIG = Object.freeze({
1298
- searchColumns: ["label", "line_1", "line_2", "suburb", "state", "postcode"],
1299
- orderBy: [
1300
- {
1301
- column: "created_at",
1302
- direction: "desc"
1387
+ const resource = Object.freeze({
1388
+ searchSchema: {
1389
+ id: { type: "id", actualField: "id" },
1390
+ q: {
1391
+ type: "string",
1392
+ oneOf: ["label", "line1", "line2", "suburb", "state", "postcode"],
1393
+ filterOperator: "like",
1394
+ splitBy: " ",
1395
+ matchAll: true
1303
1396
  }
1304
- ]
1397
+ },
1398
+ defaultSort: ["-createdAt"]
1305
1399
  });
1306
1400
  ```
1307
1401
 
@@ -1410,14 +1504,14 @@ Touch:
1410
1504
  - `CrudAddEditFormFields.js`
1411
1505
  - the relevant page/table display
1412
1506
 
1413
- Use `scaffold-field` when it fits, then review the generated result.
1507
+ 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.
1414
1508
 
1415
1509
  ### "I want a new boolean or enum list filter."
1416
1510
 
1417
1511
  Touch:
1418
1512
 
1419
1513
  - `packages/<crud>/src/shared/<crud>ListFilters.js` and make it the only authored filter-definition module
1420
- - `packages/<crud>/src/server/registerRoutes.js` and `packages/<crud>/src/server/actions.js` so the list validator includes an explicit `createQueryValidator({ invalidValues: ... })` choice, or `packages/<crud>/src/server/listQueryValidators.js` if you extracted list-query composition there
1514
+ - `packages/<crud>/src/server/registerRoutes.js` and `packages/<crud>/src/server/actions.js` so the list validator lists structured filter params explicitly with `createCrudListFilterQueryField(...)`, or `packages/<crud>/src/server/listQueryValidators.js` if you extracted list-query composition there
1421
1515
  - `packages/<crud>/src/server/repository.js` so the list query applies the runtime
1422
1516
  - the app-owned list page or list-runtime composable that calls `useCrudList(...)`
1423
1517
 
@@ -284,6 +284,30 @@ That file is the shared CRUD contract. The UI generator reads it to decide:
284
284
 
285
285
  So even though the server scaffold writes many files, the resource file is the bridge between the server and UI halves.
286
286
 
287
+ ### One install boundary to remember
288
+
289
+ `crud-server-generator scaffold` also adds a new local app package dependency such as:
290
+
291
+ ```text
292
+ @local/contacts
293
+ ```
294
+
295
+ So before you build or run the app again, install that new local package:
296
+
297
+ ```bash
298
+ npm install
299
+ ```
300
+
301
+ If you are verifying the guide against a local JSKIT checkout and have already been using local package links, rerun:
302
+
303
+ ```bash
304
+ npm run devlinks
305
+ ```
306
+
307
+ The same rule applies after later server scaffolds such as `addresses` and `comments`. The UI generator can still read the generated resource file directly, but the app runtime needs the local package install boundary to be completed before the CRUD can boot normally.
308
+
309
+ For standard CRUDs, that file is now intentionally compact. It uses `defineCrudResource(...)` from `@jskit-ai/resource-crud-core`, authors the canonical `schema` / `searchSchema` / `defaultSort` / `autofilter` shape once, and lets JSKIT derive the standard CRUD operation contracts from it.
310
+
287
311
  ### Step 3: scaffold the UI
288
312
 
289
313
  Once the resource file exists, generate the UI route tree:
@@ -566,6 +590,8 @@ Use this when:
566
590
 
567
591
  It is a maintenance tool, not the first step in the workflow.
568
592
 
593
+ It patches the canonical `schema` inside the shared resource file. That matters because the standard CRUD validators are derived from that canonical schema; you are no longer maintaining separate authored `create` / `patch` / `view` validator blocks by hand.
594
+
569
595
  ## Summary
570
596
 
571
597
  The important thing is not memorizing commands. It is learning the three shapes:
@@ -16,8 +16,8 @@ It starts with the smallest scaffold, then explains how to work with the JSKIT C
16
16
  - [Authentication](/guide/app-setup/authentication)
17
17
  - [Database Layer](/guide/app-setup/database-layer)
18
18
  - [Users](/guide/app-setup/users)
19
- - [Console](/guide/app-setup/console)
20
19
  - [Multi-homing](/guide/app-setup/multi-homing)
20
+ - [Console](/guide/app-setup/console)
21
21
 
22
22
  ### App Extras
23
23