@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.
- package/guide/agent/app-extras/realtime.md +14 -4
- package/guide/agent/app-setup/authentication.md +19 -8
- package/guide/agent/app-setup/multi-homing.md +31 -3
- package/guide/agent/app-setup/upgrading-jskit.md +4 -0
- package/guide/agent/framework/client-features.md +54 -1
- package/guide/agent/framework/crud-operations.md +7 -0
- package/guide/agent/framework/data-access.md +75 -9
- package/guide/agent/framework/sanity-checks.md +4 -4
- package/guide/agent/framework/typed-features.md +45 -5
- package/package.json +1 -1
- package/patterns/feature-package/example/booking-engine/package.json +2 -2
- package/patterns/minimal-foundation/example/AGENTS.md +5 -1
- package/patterns/minimal-foundation/example/package.json +5 -5
- package/patterns/shell-foundation/example/AGENTS.md +5 -1
- package/patterns/shell-foundation/example/package.json +6 -6
- package/patterns/shell-foundation/example/packages/main/package.json +1 -1
- package/reference/autogen/PATTERN_INDEX.md +38 -38
- package/reference/autogen/packages/agent.md +4 -2
- package/reference/autogen/packages/assistant-core.md +1 -1
- package/reference/autogen/packages/assistant-runtime.md +1 -0
- package/reference/autogen/packages/crud-core.md +2 -0
- package/reference/autogen/packages/http-web.md +9 -6
- package/reference/autogen/packages/kernel.md +7 -1
- package/reference/autogen/packages/realtime.md +1 -0
- package/reference/autogen/packages/resource-runtime.md +1 -0
- package/reference/autogen/packages/users-core.md +3 -0
- package/reference/autogen/packages/workspaces-core.md +19 -4
- package/reference/autogen/packages/workspaces-web.md +2 -1
- package/reference/autogen/tooling/config-eslint.md +44 -1
- package/reference/autogen/tooling/jskit-catalog.md +35 -13
- package/skills/jskit/references/client-features.md +54 -1
- package/skills/jskit/references/crud-operations.md +7 -0
- package/skills/jskit/references/data-access.md +75 -9
- package/skills/jskit/references/heuristics/INDEX.md +1 -1
- package/skills/jskit/references/heuristics/data-access.md +1 -0
- package/skills/jskit/references/heuristics/filters.md +14 -0
- package/skills/jskit/references/heuristics/server-search.md +2 -0
- package/skills/jskit/references/multi-homing.md +31 -3
- package/skills/jskit/references/pattern-index.md +38 -38
- package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +5 -1
- package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +5 -5
- package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +5 -1
- package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +6 -6
- package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
- package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
- package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
- package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
- package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
- package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
- package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +12 -1
- package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +2 -2
- package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +7 -0
- package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +7 -3
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +69 -17
- package/skills/jskit/references/patterns/workspaces/workspace-server/PATTERN.md +9 -0
- package/skills/jskit/references/sanity-checks.md +4 -4
- 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
|
-
- `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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; ;
|
|
1081
|
-
const page = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
|
|
1082
|
-
due.push(...
|
|
1083
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|