@jskit-ai/agent-docs 0.1.292 → 0.1.294

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 (62) hide show
  1. package/guide/agent/app-extras/realtime.md +14 -4
  2. package/guide/agent/app-setup/authentication.md +19 -8
  3. package/guide/agent/app-setup/multi-homing.md +31 -3
  4. package/guide/agent/app-setup/upgrading-jskit.md +4 -0
  5. package/guide/agent/framework/client-features.md +54 -1
  6. package/guide/agent/framework/crud-operations.md +7 -0
  7. package/guide/agent/framework/data-access.md +75 -9
  8. package/guide/agent/framework/sanity-checks.md +4 -4
  9. package/guide/agent/framework/typed-features.md +45 -5
  10. package/package.json +1 -1
  11. package/patterns/feature-package/example/booking-engine/package.json +2 -2
  12. package/patterns/minimal-foundation/example/AGENTS.md +5 -1
  13. package/patterns/minimal-foundation/example/package.json +5 -5
  14. package/patterns/shell-foundation/example/AGENTS.md +5 -1
  15. package/patterns/shell-foundation/example/package.json +6 -6
  16. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  17. package/reference/autogen/PATTERN_INDEX.md +38 -38
  18. package/reference/autogen/packages/agent.md +4 -2
  19. package/reference/autogen/packages/assistant-core.md +1 -1
  20. package/reference/autogen/packages/assistant-runtime.md +1 -0
  21. package/reference/autogen/packages/crud-core.md +2 -0
  22. package/reference/autogen/packages/http-web.md +9 -6
  23. package/reference/autogen/packages/kernel.md +7 -1
  24. package/reference/autogen/packages/realtime.md +1 -0
  25. package/reference/autogen/packages/resource-runtime.md +1 -0
  26. package/reference/autogen/packages/users-core.md +3 -0
  27. package/reference/autogen/packages/workspaces-core.md +19 -4
  28. package/reference/autogen/packages/workspaces-web.md +2 -1
  29. package/reference/autogen/tooling/config-eslint.md +44 -1
  30. package/reference/autogen/tooling/jskit-catalog.md +35 -13
  31. package/skills/jskit/references/client-features.md +54 -1
  32. package/skills/jskit/references/crud-operations.md +7 -0
  33. package/skills/jskit/references/data-access.md +75 -9
  34. package/skills/jskit/references/heuristics/INDEX.md +1 -1
  35. package/skills/jskit/references/heuristics/data-access.md +1 -0
  36. package/skills/jskit/references/heuristics/filters.md +14 -0
  37. package/skills/jskit/references/heuristics/server-search.md +2 -0
  38. package/skills/jskit/references/multi-homing.md +31 -3
  39. package/skills/jskit/references/pattern-index.md +38 -38
  40. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +5 -1
  41. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +5 -5
  42. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +5 -1
  43. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +6 -6
  44. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  45. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
  46. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  47. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  48. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  49. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
  50. package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +12 -1
  51. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
  52. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
  53. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  54. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +2 -2
  55. package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +7 -0
  56. package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +7 -3
  57. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
  58. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
  59. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +69 -17
  60. package/skills/jskit/references/patterns/workspaces/workspace-server/PATTERN.md +9 -0
  61. package/skills/jskit/references/sanity-checks.md +4 -4
  62. package/skills/jskit/references/typed-features.md +45 -5
@@ -219,6 +219,25 @@ Local functions
219
219
  - `typeKey(text, file, imports)`
220
220
  - `commentTypes(text)`
221
221
 
222
+ ### `plugin/rules/narrowInQuery.js`
223
+ Exports
224
+ - `narrowInQuery`
225
+ Local functions
226
+ - `literalProperty(object, key)`
227
+ - `paramsLiteral(node)`
228
+ - `objectLiteral(sourceCode, node)`
229
+ - `literalNumber(object, key)`
230
+ - `isOneMore(node)`
231
+ - `pageBound(sourceCode, params)`
232
+ - `builderChain(node)`
233
+ - `limitCount(end, back)`
234
+ - `isScopedBuilder(sourceCode, identifier)`
235
+ - `readOf(sourceCode, call)`
236
+ - `isRowsMember(expression)`
237
+ - `useOf(expression)`
238
+ - `repeatsPerItem(node, readNode)`
239
+ - `statementLine(node)`
240
+
222
241
  ### `plugin/rules/noDirectKnex.js`
223
242
  Exports
224
243
  - `noDirectKnex`
@@ -248,7 +267,9 @@ Exports
248
267
  Local functions
249
268
  - `topLevelFunctions(program)`
250
269
  - `isNamedNoOp(fn)`
251
- - `topLevelHelpers(program, text)`
270
+ - `inside(range, [start, end])`
271
+ - `componentClosures(scopeManager, setupRanges)`
272
+ - `topLevelHelpers(program, text, isComponentClosure)`
252
273
  - `comparisonRoot(file, packageDirectory)`
253
274
  - `compareCopies(left, right)`
254
275
 
@@ -370,6 +391,28 @@ Exports
370
391
  Local functions
371
392
  - `positionalCollaboratorDefaults(fn, visitorKeys)`
372
393
 
394
+ ### `plugin/rules/searchOnServer.js`
395
+ Exports
396
+ - `searchOnServer`
397
+ Local functions
398
+ - `isSearchName(name)`
399
+ - `attribute(element, name, directive = "")`
400
+
401
+ ### `plugin/rules/selectionField.js`
402
+ Exports
403
+ - `selectionField`
404
+ Local functions
405
+ - `kebabName(rawName)`
406
+ - `isWrittenList(node)`
407
+ - `isWrittenValue(node)`
408
+ - `computedResult(getter)`
409
+ - `isFixedValue(node, { freezing, computing })`
410
+ - `fixedBindings(program)`
411
+ - `propAttribute(element, name)`
412
+ - `bindsObject(element)`
413
+ - `rootIdentifier(expression)`
414
+ - `hasFixedItems(attribute, bindings)`
415
+
373
416
  ### `plugin/rules/sqlLiteral.js`
374
417
  Exports
375
418
  - `sqlLiteral`
@@ -71,14 +71,35 @@ Exports
71
71
  - `ROOT`
72
72
  - `lintIgnores({ governed, config })`
73
73
  - `sourceFiles(root)`
74
+ - `vueFiles(root)`
74
75
  Local functions
75
76
  - `skipsDirectory(name)`
76
- - `walk(root, directory, files)`
77
+ - `walk(root, directory, files, pattern = SOURCE_FILE)`
77
78
  - `frameworkPackages(root)`
78
79
  - `appEslint(root)`
80
+ - `configIgnoredBy(root, { eslint, ESLint })`
79
81
  - `lintConfigs(root, files)`
80
82
  - `skippedPaths(files, skipped)`
81
83
 
84
+ ### `scripts/data-access-source.mjs`
85
+ Exports
86
+ - `assignedVariable(masked, offset)`
87
+ - `blank(text)`
88
+ - `closingOffset(masked, start)`
89
+ - `enclosingObject(masked, offset)`
90
+ - `identifierAt(code, offset)`
91
+ - `keyValue(code, masked, keys, key)`
92
+ - `nextCodeOffset(text, offset)`
93
+ - `objectArgument(masked, callOffset, matchLength)`
94
+ - `objectAt(masked, offset)`
95
+ - `objectKeys(masked, start, end)`
96
+ - `readSourceFile(source)`
97
+ - `readValue(code, masked, offset)`
98
+ - `topLevelKeys(masked, start, end, names)`
99
+ Local functions
100
+ - `maskStrings(code)`
101
+ - `constantValue(code, name)`
102
+
82
103
  ### `scripts/data-access.mjs`
83
104
  Exports
84
105
  - `checkDataAccess({ projectRoot = process.cwd() } = {})`
@@ -86,21 +107,14 @@ Exports
86
107
  - `readTableResourceSources({ projectRoot = process.cwd() } = {})`
87
108
  Local functions
88
109
  - `escapeRegExp(text)`
89
- - `blank(text)`
90
- - `maskStrings(code)`
91
- - `closingOffset(masked, start)`
92
- - `nextCodeOffset(text, offset)`
93
- - `constantValue(code, name)`
94
- - `readValue(code, masked, offset)`
95
- - `topLevelKeys(masked, start, end, names)`
96
- - `objectArgument(masked, callOffset, matchLength)`
97
- - `keyValue(code, masked, keys, key)`
98
- - `readSourceFile(source)`
99
- - `objectKeys(masked, start, end)`
100
- - `objectAt(masked, offset)`
101
110
  - `readSchemaFields(code, masked, keys, ownership)`
102
111
  - `readTableResources(file, { code, masked, position })`
112
+ - `readSearchQ(masked, keys)`
103
113
  - `readCrudContracts({ code, masked })`
114
+ - `readSearches(file, { code, masked, position })`
115
+ - `vueScripts(source)`
116
+ - `resourceNameFromNamespace(namespace)`
117
+ - `checkSearchQ(resources, contracts, searches, report)`
104
118
  - `unnamedFields(resources, contracts)`
105
119
  - `ownsSystemContextName(source, masked)`
106
120
  - `readSystemScopes(file, { source, code, masked, position })`
@@ -166,6 +180,14 @@ Local functions
166
180
  - `runI18n({ subcommand, locale, projectRoot })`
167
181
  - `readCatalog()`
168
182
 
183
+ ### `scripts/manifest-capabilities.mjs`
184
+ Exports
185
+ - `checkManifestCapabilities({ projectRoot, manifests })`
186
+ - `formatManifestCapabilitiesReport(result)`
187
+ Local functions
188
+ - `capabilityLine(contents, capabilityId = "")`
189
+ - `kernelPlatform(manifestPath, root)`
190
+
169
191
  ### `scripts/material3-source.mjs`
170
192
  Exports
171
193
  - `DEFAULT_IMPORT`
@@ -384,6 +384,29 @@ const emit = defineEmits(["close"]);
384
384
  emit("closed");
385
385
  ```
386
386
 
387
+ ### Selection fields
388
+
389
+ **A select, autocomplete or combobox whose options load from the server, or
390
+ whose value is a record id, is `SelectField` from
391
+ `@jskit-ai/http-web/client/components/SelectField`, with `loading` bound while
392
+ the options load.** A raw `v-select`, `v-autocomplete` or `v-combobox` takes
393
+ fixed options only: a list written out in the template, a top-level `const`
394
+ holding one (as written, frozen, or `computed()` over one written out), or an
395
+ import from a `shared` module. The model's name decides nothing.
396
+
397
+ Why: Vuetify titles a value missing from `items` with the value itself, so a
398
+ stored id such as `53` shows until the options arrive; `SelectField` hides it
399
+ while `loading` is true.
400
+
401
+ ```vue
402
+ <!-- good -->
403
+ <SelectField v-model="form.serviceId" :items="services.items" :loading="services.loading" />
404
+ <v-select v-model="form.size" :items="SIZE_OPTIONS" />
405
+
406
+ <!-- bad: a stored id shows while the services load -->
407
+ <v-select v-model="form.serviceId" :items="services.items" />
408
+ ```
409
+
387
410
  ### Presenting scripts
388
411
 
389
412
  **A component's `<script>` presents: props, emits, element refs, display
@@ -491,6 +514,33 @@ let attachmentClientSequence = 0;
491
514
  let pendingDraft = "";
492
515
  ```
493
516
 
517
+ ### Search on the server
518
+
519
+ **A search over a list's records runs on the server: the list's search
520
+ (`useList({ search: { enabled: true } })`) sends what the person types as `q`,
521
+ and the table resource defines `searchSchema.q`. An autocomplete over a list's
522
+ items or a lookup's options takes `no-filter` and sends the typed text with
523
+ `@update:search` (a lookup's `setLookupSearch`).**
524
+
525
+ Why: a list holds the page it loaded, so a search over its items misses every
526
+ row past that page.
527
+
528
+ ```vue
529
+ <!-- good: the server searches every record -->
530
+ <SelectField v-model="formState.serviceId" searchable no-filter
531
+ :search="resolveLookupSearch('serviceId')"
532
+ @update:search="setLookupSearch('serviceId', $event)"
533
+ :items="resolveLookupItems('serviceId', { selectedValue: formState.serviceId })" />
534
+
535
+ <!-- bad: Vuetify searches only the options already loaded -->
536
+ <v-autocomplete v-model="serviceId" :items="services.items" />
537
+ ```
538
+
539
+ ```js
540
+ // bad: a search over the page the list loaded
541
+ const visible = computed(() => list.items.filter((service) => service.name.includes(search.value)));
542
+ ```
543
+
494
544
  ## Helpers and sizes
495
545
 
496
546
  ### One helper per package
@@ -558,6 +608,8 @@ client-only rules below. The server's action and error rules do not apply.
558
608
  | `jskit/no-frozen-vue-state` | `Object.freeze` over refs, computeds or reactive state | [Fixed result shape](#fixed-result-shape) |
559
609
  | `jskit/released-listeners` | a listener, interval, observer or subscription the file starts and never releases | [Released listeners](#released-listeners) |
560
610
  | `jskit/no-module-state` | a module-level `let`, `ref()` or `reactive()` | [Module state](#module-state) |
611
+ | `jskit/selection-field` | a `v-select`, `v-autocomplete` or `v-combobox` whose items are not fixed, and a `SelectField` whose items are not fixed that binds no `loading` | [Selection fields](#selection-fields) |
612
+ | `jskit/search-on-server` | a `computed()` that filters a list composable's items by a search ref; an autocomplete over a list's items or a lookup's options without `no-filter` or an `update:search` handler | [Search on the server](#search-on-the-server) |
561
613
  | `vue/require-prop-types`, `vue/require-default-prop`, `vue/require-valid-default-prop`, `vue/no-required-prop-with-default` | a prop without a type, optional without a default, or both | [Collaborator props](#collaborator-props) |
562
614
  | `vue/require-explicit-emits` | an emitted event the component does not declare | [Declared emits](#declared-emits) |
563
615
 
@@ -578,10 +630,11 @@ Simplicity, effect order and presenting scripts are checked by reading.
578
630
  Client exceptions use the
579
631
  [typed features exceptions](typed-features.md#exceptions): a
580
632
  comment on the line before the finding, naming the rule or the finding, with a
581
- reason prefix. Client code adds three placements:
633
+ reason prefix. Client code adds four placements:
582
634
 
583
635
  | Placement | The exception |
584
636
  | --- | --- |
585
637
  | `// readable-check: allow module-state - shared resource: <what the page shares>` | module state every instance on the page shares on purpose |
586
638
  | `<!-- readable-check: allow optional-call - optional operation: <what lacks it> -->` above an element | that element's bindings call an optional operation |
639
+ | `<!-- readable-check: allow selection-field - data: <where the options come from> -->` above an element | a selection whose options are in memory and whose value is no record id |
587
640
  | `// readable-check: allow method-probe - input validation: <the shapes>` above a function | the probes of a function whose one job is normalizing an external value of several shapes |
@@ -250,6 +250,13 @@ mapping in the resource; do not add page-local filtering or duplicate request
250
250
  code. The full Books source example maps `q` to title, author and notes; narrow
251
251
  it when adapting the example to fewer fields.
252
252
 
253
+ Every CRUD list also takes `id`, one record id or a list of them
254
+ (`filter[id]` on the JSON:API transport), and answers only those of its rows,
255
+ in the caller's scope. A picker whose selected values are not on the page it
256
+ loaded reads them this way, all in one request, instead of showing their raw
257
+ ids or reading pages until it finds them; `useCrudListFilterLookups()` does it
258
+ for a lookup filter's chips and options.
259
+
253
260
  The browser page's route query and internal CRUD query use plain `q`. The
254
261
  JSON:API HTTP transport uses `filter[q]`. Shared CRUD clients encode and decode
255
262
  this automatically. Raw HTTP/API tests must send the transport key themselves:
@@ -60,7 +60,9 @@ A grouped `aggregate` answers its groups from one statement, at most
60
60
  `pageLimits().request` of them (the resource's `queryMaxLimit`). When there
61
61
  are more it throws a 400 coded `RESOURCE_GROUP_LIMIT` instead of answering
62
62
  part of them. Narrow the filters, or read the groups in pages, `page: { size,
63
- number }` with `number` from 1, until a page answers fewer than `size` rows.
63
+ number }` with `number` from 1. A page answers as a `query` page does, `{ rows,
64
+ page: { size, number, hasMore, next } }`, never a bare array that could pass
65
+ for every group: read the next while `page.hasMore`.
64
66
  Page numbers are offsets, so read every page before work that changes what
65
67
  the filters match.
66
68
 
@@ -710,12 +712,12 @@ takes first.
710
712
  // good: the lock is the transaction's first read, so the bookings read after it are current
711
713
  const reserve = (seatId, options) => seats.withTransaction(options, async (txOptions) => {
712
714
  await seats.lock(seatId, { ...txOptions, fields: ["status"] });
713
- const taken = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
714
- return taken.length > 0 ? null : bookings.create({ seatId }, { ...txOptions, returning: "id" });
715
+ const [taken] = await bookings.queryAll({ filters: { seatIds: [seatId] }, take: 1 }, txOptions);
716
+ return taken ? null : bookings.create({ seatId }, { ...txOptions, returning: "id" });
715
717
  });
716
718
 
717
719
  // bad: the read before the lock fixed the snapshot, so a booking committed while the lock waited is missed
718
- const free = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
720
+ const [booked] = await bookings.queryAll({ filters: { seatIds: [seatId] }, take: 1 }, txOptions);
719
721
  await seats.lock(seatId, txOptions);
720
722
  ```
721
723
 
@@ -926,7 +928,11 @@ async function activeDays(memberIds, options) {
926
928
  `page.number` reads with OFFSET, which slows as the rows grow, so anything
927
929
  that grows pages with `page.after`. Each page is one statement, with no
928
930
  count: never walk pages to reach one.
929
- - A screen list never uses `queryAll`.
931
+ - A screen list never uses `queryAll`, and neither does a picker: it reads
932
+ one page searched on the server as the person types (`q`), narrowed in the
933
+ query by what it depends on (the parent record, `active`), and reads its
934
+ selected values past that page by id. Reading every row for a dropdown, or
935
+ narrowing a page in code, either grows with the table or misses rows.
930
936
  - Related rows come by id list (`filters: { id: ids }`, or an `in` filter on
931
937
  another field) or `include`, grouped in memory. Never a query per row, however
932
938
  the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
@@ -982,6 +988,66 @@ const { records: firstPage } = await subscriptions.query({ filters: { newsletter
982
988
 
983
989
  Bad: a list read 100-row pages in a loop for each of 40 related sets.
984
990
 
991
+ ## Narrow in the query
992
+
993
+ A read asks the store for exactly the rows the code uses. The filter, sort,
994
+ limit, existence check and count go in the read, never in code over the rows
995
+ it answered: a read of every row then narrowed is slow, and a page or a `take`
996
+ then narrowed is also wrong, since the row wanted can sit past it.
997
+
998
+ - Filter with the resource's searchSchema: a field
999
+ (`filters: { status: "open" }`), an id list (`filters: { id: ids }`, or an
1000
+ `in` filter on another field), or `q` for typed text, whose `oneOf` names the
1001
+ fields it searches.
1002
+ - One row: a unique filter with `take: 1` or `page: { size: 1 }`, which also
1003
+ answers whether one exists. A count: `aggregate()`.
1004
+ - Never filter, find, slice or count the rows a read answered, nor skip some
1005
+ in a loop. Mapping them and grouping them by a key (`Map.groupBy`) narrow
1006
+ nothing, and neither does code that needs every row it read: rows handed on
1007
+ whole (returned, passed, stored, mapped or walked in full) with a subset
1008
+ derived beside them, and rows matched against other items in a loop or a
1009
+ callback (an in-memory join, one read instead of one per item).
1010
+ - A lookup field's picker and a list's Search box send what the person types
1011
+ as `q` to the server, which searches every row; `useList` does with
1012
+ `search: { enabled: true }`. The table resource defines `searchSchema.q`.
1013
+ Never search the page a list loaded in the browser: it misses every row past
1014
+ it.
1015
+ - A CRUD list refuses a query key it does not take with a 400
1016
+ (`crud.query.undeclaredKeys`). A list filter is declared in the feature's
1017
+ `listFilterQueryValidator` and the resource's searchSchema.
1018
+ - Prove a capped read with a test that seeds past the cap and makes the row
1019
+ the cap leaves out (the oldest, for a newest-first read) the one that
1020
+ matches: the read that asks for it finds it; a read narrowed in code does
1021
+ not.
1022
+
1023
+ ```js
1024
+ // good: the store answers one module's sections
1025
+ const { records } = await sections.query({ filters: { moduleId }, sort: ["-createdAt"], page: { size: 50 } }, options);
1026
+
1027
+ // bad: the newest 200 sections of every module, then one module's kept in code
1028
+ const { records: recent } = await sections.query({ sort: ["-createdAt"], page: { size: 200 } }, options);
1029
+ const moduleSections = recent.filter((section) => section.moduleId === moduleId);
1030
+
1031
+ // good: one row says who holds a unique username
1032
+ const [holder] = await profiles.queryAll({ filters: { usernames: [username] }, take: 1 }, options);
1033
+ ```
1034
+
1035
+ `jskit/narrow-in-query` reports `.filter()`, `.find()`, `.some()`,
1036
+ `.every()`, `.slice()`, a lone `.length` and a loop that skips rows with
1037
+ `continue` over the rows a read answered in the same function, unless the
1038
+ code also uses those rows whole, runs the narrowing once per item of
1039
+ something else, read at most one row, or slices a read that asked for one row
1040
+ more than it keeps (`limit(size + 1)`) to learn whether more follow.
1041
+ `jskit/search-on-server` reports a client search over a list's loaded items.
1042
+ `jskit check` fails a lookup field or a CRUD list screen whose app table
1043
+ resource has no `searchSchema.q`, on the resource or its owns entry
1044
+ (`data/search-q`). Other code that needs every row a read answers says why on
1045
+ the line above the read or the narrowing:
1046
+
1047
+ ```js
1048
+ // readable-check: allow narrow-in-query - every row: a conversation holds at most keep + 1 summaries, since each save deletes the older ones
1049
+ ```
1050
+
985
1051
  ## What changed since
986
1052
 
987
1053
  A page that catches up on a chat, a watch that acts on changes and a sync to
@@ -1077,10 +1143,10 @@ const SettlementFeature = defineFeature({
1077
1143
  async function settleDue(options) {
1078
1144
  const size = cohorts.pageLimits().request;
1079
1145
  const due = [];
1080
- for (let number = 1; ; number += 1) {
1081
- const page = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
1082
- due.push(...page);
1083
- if (page.length < size) break;
1146
+ for (let number = 1; number; ) {
1147
+ const { rows, page } = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
1148
+ due.push(...rows);
1149
+ number = page.next;
1084
1150
  }
1085
1151
  for (const { workspaceId } of due) {
1086
1152
  await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
@@ -38,7 +38,7 @@ How to use it:
38
38
  - [ui-contract.md](./ui-contract.md)
39
39
  - filter, filters, search facets, chips, date range, enum filter, lookup filter, `useCrudListFilters`, `createCrudListFilters`
40
40
  - [filters.md](./filters.md)
41
- - `searchSchema`, `search: true`, `applyFilter`, server search, query validators, backend filters, internal JSON REST filters
41
+ - `searchSchema`, `search: true`, `applyFilter`, server search, query validators, backend filters, internal JSON REST filters, picker search, `q`, filtering rows in code, `jskit/narrow-in-query`, `jskit/search-on-server`, `data/search-q`, `crud.query.undeclaredKeys`
42
42
  - [server-search.md](./server-search.md)
43
43
  - `actualField`, `storage.column`, `storage.writeSerializer`, `storage.virtual`, `queryFields`, computed field, virtual field, projection, `defaultTo`, datetime write serialization, temporalPrecision, json-rest-schema dates
44
44
  - [table-resource-fields.md](./table-resource-fields.md)
@@ -13,6 +13,7 @@ Pick the layer ([Data access](../data-access.md)):
13
13
  - Two requests could change the same rows: `lockMany` inside `withTransaction`, rows in id order.
14
14
  - Work inside `withTransaction`: `txOptions` is only `{ context, trx }`, so a key the work needs, the app's own included, comes from the method's `options`, and a write on the method's own table gets `{ ...options, ...txOptions }` so the caller's `returning`, `missing` or `format` reaches it.
15
15
  - A change only while a row still holds a value (open to booked, once): `patch` with `onlyIf`, or `patchWhere` with that value in its filters (a compare-and-set on every database, so of two concurrent writers one wins); a filter on a related table is refused on MySQL and MariaDB (`selection_cross_table`), so filter on the row's own columns, after locking the related rows with `lockMany`, or use `claim`.
16
+ - Only some of the rows, one row, whether one exists: in the read (a searchSchema filter, `take: 1` or a page of size 1), never `.filter()`, `.find()`, `.some()`, `.slice()` or `.length` over the rows it answered ([Narrow in the query](../data-access.md#narrow-in-the-query)).
16
17
  - Counts, sums, buckets: `aggregate`; newest per group: a window include; anything else: one `scopedQuery()` method under `// Knex: <kind> - <reason>`.
17
18
  - Every workspace or user at once: a job with `systemScope`, finding its units with an `aggregate` grouped by the ownership field and looping over them, never rows.
18
19
  - A table another feature owns: its service or owner API, never its table. A report that joins it joins the `{ query, column }` its owner API answers from `scopedQuery({ alias })`, as a subquery: one statement, both scopes kept.
@@ -132,6 +132,20 @@ Use `useCrudListFilterLookups(...)` when:
132
132
  - the UI needs remote autocomplete search
133
133
  - chips should show readable labels instead of raw ids
134
134
 
135
+ A lookup filter's options are one server page, searched on the server as the
136
+ person types (`setSearch`, sent as `q`):
137
+ - the page keeps the server's order while more rows remain (`hasMore`), and is
138
+ sorted by label only once every row is loaded; `loadMore()` (or
139
+ `loadMoreLookup(key)`) reads the next page, so never present the first page
140
+ as the complete list
141
+ - a selected value past the loaded page (from the URL or a bookmark) is read by
142
+ id, every such value in one request, so its chip shows its label
143
+ - the options load when something shows them (the open filter sheet's
144
+ control, or a search), not with every list page
145
+ - a lookup narrowed by another value (only active suppliers, only one
146
+ region's) passes it in `requestQueryParams[filterKey]`, which the lookup's
147
+ list must accept as a filter
148
+
135
149
  Use built-in `presence` when:
136
150
  - a filter means null vs not-null, such as assigned vs unassigned storage
137
151
  - the UI wants custom labels like "Assigned" / "Unassigned" but the transport contract can stay `present` / `missing`
@@ -243,6 +243,8 @@ owns: [{ resource: contactResource, rowPolicy: ownContactsOrShared }]
243
243
  - Put visibility in the owner's row policy, never in a search key or route query validation.
244
244
  - Do not expose a query key publicly just because the service maps to it internally.
245
245
  - Do not rely on route validators alone; a public key still needs a matching backend search definition.
246
+ - A CRUD list refuses a query key its route does not declare with a 400 (`crud.query.undeclaredKeys`), so an undeclared filter fails loudly instead of answering every row.
247
+ - Never narrow the rows a read answered in code, and never search a list's loaded page in the browser: the filter goes in the read, and a picker or Search box sends `q` ([Narrow in the query](../data-access.md#narrow-in-the-query); `jskit/narrow-in-query`, `jskit/search-on-server`, and `jskit check`'s `data/search-q` for a lookup or list screen whose resource has no `searchSchema.q`).
246
248
 
247
249
  ## Quick Rule Of Thumb
248
250
 
@@ -196,12 +196,21 @@ method takes the call's `options` last and joins the transaction of the
196
196
  | Capability | Methods |
197
197
  | --- | --- |
198
198
  | `workspaces` | `findById(id, options)`, `findBySlug(slug, options)` (a deleted workspace is not found, though its slug stays taken), `listSlugsIn`, `findPersonalByOwnerUserId`, `listForUserId(userId, options)`, `insert`, `updateById`, `lock(workspaceId, options)` |
199
- | `workspaces.memberships` | `findByWorkspaceIdAndUserId`, `ensureOwnerMembership`, `upsertMembership(workspaceId, userId, patch, options)`, `addMember({ workspaceId, userId, roleSid }, options)`, `activeMemberIds(workspaceId, options)`, `listActiveByWorkspaceId`, `findActiveByWorkspaceIdAndUserId`, `listActiveWorkspaceIdsByUserId`, `listActiveWorkspaceIdsByUserIds` |
199
+ | `workspaces.memberships` | `findByWorkspaceIdAndUserId`, `ensureOwnerMembership`, `upsertMembership(workspaceId, userId, patch, options)`, `addMember({ workspaceId, userId, roleSid }, options)`, `activeMemberIds(workspaceId, options)`, `listActiveByWorkspaceId`, `findActiveByWorkspaceIdAndUserId`, `listActiveWorkspaceIdsByUserId`, `listActiveWorkspaceIdsByUserIds`, `listActiveWorkspaceIdsByUserIdsAmong(userIds, workspaceIds, options)`, `joinSource({ alias, filters }, options)` |
200
200
 
201
201
  `addMember` makes the user an active member with the role (`member` when none
202
202
  is given), adding the membership or reactivating it. `activeMemberIds` answers
203
- the active members' user ids at the same cost however many members there
204
- are, without including their profiles. `lock` takes
203
+ every active member's user id, without their profiles: it reads all of the
204
+ workspace's active memberships, so its cost grows with the workspace, and so
205
+ do `listActiveByWorkspaceId`'s and
206
+ `workspaces.services.members.listMembers(workspace, options)`'s. Use them where
207
+ the work needs every member, such as a job, a delivery's recipients or a
208
+ realtime audience. A screen or list reads a page instead:
209
+ `workspaces.services.members.listMembersPage(workspace, { cursor, limit }, options)`
210
+ answers one page of active members by display name (`limit`, 20 when none)
211
+ with the `nextCursor` that asks for the next, null on the last, and an app's
212
+ own list of members pages them in SQL with one report over the memberships'
213
+ `joinSource`. `lock` takes
205
214
  the workspace's row lock until the caller's transaction ends, so work that must
206
215
  not interleave for one workspace takes turns; it runs only inside a
207
216
  transaction, given its `txOptions`, and answers `{ id }`, or `null` when there
@@ -336,6 +345,7 @@ authorization and role checks. Do not pass arbitrary HTTP input as options.
336
345
  | `workspaces.services.members.sendInvite(workspace, actor, prepared, options)` | Refuses to run inside a transaction: it delivers after the preparation's transaction commits. Reloads the committed pending invitation and workspace display data, validates expiry and token correspondence, and sends that invitation without replacement. |
337
346
  | `workspaces.services.members.createInvite(workspace, actor, payload, options)` | Owns preparation and commit, then calls `sendInvite`, and answers with `sendInvite`'s result as JSON:API data (`returnJsonApiData`, read it from `.value`), as the invite action sends it. Refuses to run inside a transaction. |
338
347
  | `workspaces.invite-acceptance-participant` (optional capability) | One application function receiving `({ invite, user }, options)` inside the acceptance transaction. Without it, acceptance activates the membership only. |
348
+ | `workspaces.workspace-created-participant` (optional capability) | One application function receiving `({ workspace, owner }, options)` inside a new workspace's creation transaction. Without it, creation writes the workspace, its owner's membership and its settings only. |
339
349
 
340
350
  `sendInvite` accepts `{ createdInviteId, inviteTokenPreview }` from preparation or
341
351
  only `{ createdInviteId }` for a later attempt. The latter uses JSKIT's existing
@@ -471,6 +481,24 @@ Replacement can create a new pending invitation after acceptance while retaining
471
481
  the accepted invitation. Keep callbacks short: invitation changes in one workspace
472
482
  serialize. Deadlocks/serialization failures propagate without replaying callbacks.
473
483
 
484
+ ### Workspace creation participation
485
+
486
+ Provide the optional `workspaces.workspace-created-participant` capability the
487
+ same way, from a small application feature, to write application records for
488
+ every new workspace in the same transaction. Both creation paths call
489
+ `fn({ workspace, owner }, txOptions)`: the `workspace.workspaces.create` action
490
+ (`createWorkspaceForAuthenticatedUser`) and a personal workspace's provisioning
491
+ (`ensurePersonalWorkspaceForUser`, which joins its caller's transaction, such as
492
+ a first sign-in's profile sync). It runs after the workspace, its owner's
493
+ membership and its settings are written, so it can read them with `txOptions`.
494
+ `workspace` and `owner` are shallow frozen. Pass `txOptions` to every write and
495
+ let failures propagate: a failure rolls back the whole creation, workspace,
496
+ membership, settings and the participant's own writes, and the caller receives
497
+ the failure itself (an `AppError` answers as that refusal). Keep external effects
498
+ out of it. An existing personal workspace is not created again and does not call
499
+ it, and a create that loses its slug to the same owner's identical create answers
500
+ that workspace without calling it a second time.
501
+
474
502
  ### Compatibility and verification
475
503
 
476
504
  Since `@jskit-ai/workspaces-core@0.1.198`, transactional `createInvite` callers