@jskit-ai/agent-docs 0.1.290 → 0.1.292

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 (37) hide show
  1. package/guide/agent/app-setup/existing-application-migration.md +1 -1
  2. package/guide/agent/framework/data-access.md +309 -22
  3. package/guide/agent/framework/material-3.md +40 -39
  4. package/guide/agent/framework/sanity-checks.md +19 -12
  5. package/package.json +1 -1
  6. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  7. package/patterns/minimal-foundation/example/package.json +4 -4
  8. package/patterns/shell-foundation/example/package.json +5 -5
  9. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  10. package/reference/autogen/PATTERN_INDEX.md +38 -38
  11. package/reference/autogen/packages/agent-runtime.md +4 -0
  12. package/reference/autogen/packages/crud-core.md +1 -1
  13. package/reference/autogen/packages/kernel.md +1 -1
  14. package/reference/autogen/packages/resource-core.md +1 -0
  15. package/reference/autogen/packages/resource-runtime.md +39 -3
  16. package/reference/autogen/packages/workspaces-core.md +0 -2
  17. package/reference/autogen/tooling/jskit-catalog.md +30 -10
  18. package/skills/jskit/references/data-access.md +309 -22
  19. package/skills/jskit/references/existing-application-migration.md +1 -1
  20. package/skills/jskit/references/heuristics/ui-testing.md +6 -3
  21. package/skills/jskit/references/material-3.md +40 -39
  22. package/skills/jskit/references/pattern-index.md +38 -38
  23. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
  24. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +5 -5
  25. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  26. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
  27. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  28. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  29. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  30. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
  31. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
  32. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
  33. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  34. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  35. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
  36. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
  37. package/skills/jskit/references/sanity-checks.md +19 -12
@@ -213,7 +213,7 @@ write goes through that table's handle
213
213
  | a field's `default:` | `defaultTo:` (`default` is refused, since json-rest-api never read it) |
214
214
  | a `createdAt` or `updatedAt` default of now | nothing: drop it, since every handle write stamps them |
215
215
  | a CRUD contract that showed every column | a contract shows only its `fields`, so list every field the API showed |
216
- | reading every row | `queryAll`, which refuses above 10,000 rows, so page with `query()` |
216
+ | reading every row | `queryAll`, which refuses above 10,000 rows in a request; a job over more reads `queryPages()` a page at a time |
217
217
  | an app's own wrappers around old calls | the handle method they wrapped, then delete the wrapper |
218
218
 
219
219
  Handles answer records, `{ id, ...fields }`, with a belongs-to field as the
@@ -34,9 +34,10 @@ through its handle's calls, and every call takes the call's `options` last:
34
34
  | Call | Does |
35
35
  | --- | --- |
36
36
  | `query({ filters, sort, page, include }, options)` | one page: `{ records, page }` |
37
- | `queryAll({ filters, sort, include }, options)` | every matching row, for jobs, id lists and one parent's rows |
37
+ | `queryAll({ filters, sort, include, take }, options)` | every matching row, or the first `take`, for jobs, id lists and one parent's rows |
38
+ | `queryPages({ filters, sort, include }, options)` | every matching row, a page at a time, for work over many rows |
38
39
  | `get(id, params, options)` | one record |
39
- | `aggregate({ measures, groupBy, filters, sort }, options)` | totals, grouped by fields or date buckets |
40
+ | `aggregate({ measures, groupBy, filters, sort, page }, options)` | totals, grouped by fields or date buckets |
40
41
  | `create(data, options)`, `findOrCreate({ id, ...data }, options)` | one record; a keyed one, created once |
41
42
  | `patch(id, changes, options)`, `delete(id, options)` | one record |
42
43
  | `createMany(rows, options)` | many records, or an upsert with `onConflict` |
@@ -60,6 +61,14 @@ function totalsByMonth(options) {
60
61
  }
61
62
  ```
62
63
 
64
+ A grouped `aggregate` answers its groups from one statement, at most
65
+ `pageLimits().request` of them (the resource's `queryMaxLimit`). When there
66
+ are more it throws a 400 coded `RESOURCE_GROUP_LIMIT` instead of answering
67
+ part of them. Narrow the filters, or read the groups in pages, `page: { size,
68
+ number }` with `number` from 1, until a page answers fewer than `size` rows.
69
+ Page numbers are offsets, so read every page before work that changes what
70
+ the filters match.
71
+
63
72
  ## Words
64
73
 
65
74
  | Word | Meaning |
@@ -157,6 +166,44 @@ whole number exactly, so json-rest-api answers those digits as text rather
157
166
  than a rounded number. A value that can grow that large gets a decimal field
158
167
  (`precision` and `scale`), whose digits stay text.
159
168
 
169
+ A `type: "blob"` field holds bytes the process reads and writes whole, so it
170
+ names `maxBytes`, the most bytes a value holds, and its column holds that much
171
+ too: MySQL's and MariaDB's `BLOB` holds 64 KiB and `MEDIUMBLOB` 16 MiB, while
172
+ PostgreSQL's `bytea` and SQLite's `BLOB` hold more than any limit you would
173
+ name. A blob takes no `search`, `indexed`, `unique`, `sortable`, `defaultTo` or
174
+ `belongsTo`. A write takes a Buffer or a Uint8Array of at most `maxBytes`, or
175
+ null, and refuses anything else with a validation error on the field (422)
176
+ before any statement, a transaction's or a lock's included. A read leaves a
177
+ blob out unless its `fields` names it, and answers it as a Buffer, holding at
178
+ most 16 MiB of the blobs it names, or one record's: `get(id, { fields:
179
+ ["image"] }, options)` and `lock()` read one record's; a page of `query()` and
180
+ the ids of `lockMany()` are refused past the records whose `maxBytes` fit 16
181
+ MiB, and each page of `queryPages()` holds no more. `queryAll()`, `lockWhere()`
182
+ and `claim()` read every matching row at once, so they refuse to name a blob,
183
+ and no read names an included table's blob, since an include holds any number
184
+ of records: read it through that table's handle. A join source carries no
185
+ blob. JSON holds no binary, so a JSON:API read and a CRUD contract refuse a
186
+ blob, and a route of its own serves one as the response body. A file people upload (a photo, a
187
+ document) goes to storage through `runtime.uploads` and the table keeps its
188
+ key; a blob is for the few bytes the record itself needs, such as a thumbnail
189
+ or a signature.
190
+
191
+ ```js
192
+ const signatureResource = defineTableResource({
193
+ name: "signatures",
194
+ tableName: "signatures",
195
+ ownership: "workspace",
196
+ schema: {
197
+ signedBy: { type: "string", required: true },
198
+ // MEDIUMBLOB on MySQL and MariaDB
199
+ image: { type: "blob", maxBytes: 256 * 1024, required: true }
200
+ }
201
+ });
202
+
203
+ // One signature's image, read where a route answers it.
204
+ const imageOf = async (id, options) => (await signatures.get(id, { fields: ["image"] }, options)).image;
205
+ ```
206
+
160
207
  A package whose features own tables or declare `systemScope` lists
161
208
  `runtime.resources` in its descriptor's `capabilities.requires`, or boot
162
209
  refuses it; the feature never requires it itself, since it receives its
@@ -202,7 +249,74 @@ A filter on another table's field (`searchSchema: { supplierName: { type:
202
249
  "string", actualField: "suppliers.name" } }`) needs that field declared
203
250
  `indexed: true` on its own table resource, with a database index on its column,
204
251
  even when the join is on the other table's id; `own()` refuses it at startup
205
- otherwise, naming both.
252
+ otherwise, naming both. That `indexed: true` is the other table's owner letting
253
+ filters search the field. The filter reads that table as the table's own read
254
+ in the caller's scope: its ownership, its owner's row policy and the policies
255
+ contributed to it decide which of its rows can match, in `query`, `queryAll`,
256
+ `queryPages` and `aggregate` alike, and each row matches once, through a
257
+ has-many path too (suppliers filtered by their items' names). `own()` also
258
+ refuses two filters no scope guards: one that reads a `system` table from a
259
+ table that is not `system`, since a request would read it, and one on a field
260
+ the other table hides, which would tell a caller which rows hold a value it
261
+ may not read. For either, the other table's owner answers the ids it matches
262
+ through its owner API, and the filter takes the list: `filters: { supplierIds }`.
263
+
264
+ A filter that asks whether a row has, or lacks, another owner's rows
265
+ (projects with an open task, projects with no task) reads that owner's join
266
+ source. The `owns` entry's `searchSchema` names each join source by an alias,
267
+ with a function answering its owner from the feature's `requires` or its own
268
+ handles (`owned`), and `applyFilter` gets `joinSource(alias)`: that owner's
269
+ `{ query, column }` for the caller's scope, as a report's join source
270
+ ([Layer 5](#layer-5-reports-the-resource-cannot-express)), which it ties to
271
+ the row with `whereColumn` inside `whereExists` or `whereNotExists`:
272
+
273
+ ```js
274
+ // the projects feature's owns entry; the feature requires the tasks owner as `tasks`
275
+ {
276
+ resource: projectResource,
277
+ searchSchema: {
278
+ hasOpenTask: {
279
+ type: "boolean",
280
+ joinSources: { k: ({ requires }) => requires.tasks },
281
+ applyFilter(query, wanted, { column, joinSource }) {
282
+ const task = joinSource("k");
283
+ const openTasks = task.query
284
+ .select(task.column("id"))
285
+ .whereColumn(task.column("projectId"), column("id"))
286
+ .where(task.column("status"), "open");
287
+ if (wanted) {
288
+ query.whereExists(openTasks);
289
+ } else {
290
+ query.whereNotExists(openTasks);
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ ```
297
+
298
+ The handle builds the join sources a read's filters use just before the read,
299
+ in the call's own context and transaction, so `query`, `queryAll`,
300
+ `queryPages`, `aggregate` and `joinSource` take the filter in their one
301
+ statement, and the other owner's ownership, row policy and hidden fields
302
+ decide which of its rows count (`column()` refuses a hidden field). A filter
303
+ that prefers one match and falls back on another when no row of the table has
304
+ the first (the projects named like the term, or, when none is, those with a
305
+ task in that status) names the feature's own handle for its own table,
306
+ `({ owned }) => owned.projects`. Only a read through a handle builds join
307
+ sources. `own()` refuses, at startup, `joinSources` that is not a record of
308
+ functions under aliases, such a filter with `oneOf` or without `applyFilter`,
309
+ and `joinSources` in the table resource's own `searchSchema`, which names no
310
+ feature to answer it.
311
+
312
+ An app switches to such a filter where a search resolves another owner's
313
+ matches first: a repository that reads the other owner's ids (an id report on
314
+ its `scopedQuery()`, or that owner's `queryAll` matched in memory) and then
315
+ filters `id` by the list, or a filter that was a `whereRaw` with `EXISTS
316
+ (SELECT ... FROM ...)`, which `jskit/sql-literal` refuses. The filter moves
317
+ into the `owns` entry with the other owner as a join source, the code that
318
+ resolved ids goes, and the read is one statement however many rows match,
319
+ where the id list grew with the matches.
206
320
 
207
321
  A table keyed by a `uuid` or `string` primary key takes its ids as text; an
208
322
  `owns` entry's `normalizeId` is only for an id written some other way.
@@ -318,7 +432,11 @@ request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
318
432
  `{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
319
433
  `reason`, for background work. A job names a user only as `actorId`, the
320
434
  person it works for ([below](#background-work)), so never read a missing actor
321
- as an anonymous request.
435
+ as an anonymous request. The owner's policy also receives the capabilities its
436
+ feature requires (and its optional ones), by local name, as `requires`, the
437
+ same ones its setup receives: a policy that needs another feature's answer
438
+ declares that feature's capability in `requires` and reads `requires.<name>`.
439
+ It runs on every read, so what it asks there must be cheap.
322
440
 
323
441
  ```js
324
442
  // good: one predicate on the row being read
@@ -492,8 +610,10 @@ Every filter takes `id`, one id or a list, so `patchWhere({ id: ids },
492
610
  changes, options)` and `deleteWhere({ id: ids }, options)` change a known
493
611
  list. Each checks every row against the resource first, reads which of the
494
612
  rows the caller may touch, then writes each chunk with one statement, in one
495
- transaction. Audits and change events run once for the batch, so the
496
- statements are the same for 10 rows as for 200. `patchWhere` and
613
+ transaction. A chunk is 500 rows unless the call passes `chunkSize`, which is
614
+ at least 100: a smaller chunk would send a statement every few rows, as a loop
615
+ does, and is refused before any statement. Audits and change events run once
616
+ for the batch, so the statements are the same for 10 rows as for 200. `patchWhere` and
497
617
  `deleteWhere` check the scope and filters on each row once they hold its lock,
498
618
  so a row gone meanwhile, or changed so that it no longer matches, is skipped,
499
619
  and they take no `missing`. That makes them a compare-and-set on their filters
@@ -506,8 +626,9 @@ waiter with errno 1020 ("Record has changed since last read"), which a write
506
626
  that owns its transaction runs again. For one row,
507
627
  `patch(id, changes, { ...options, onlyIf: { status: "open" } })` does the same.
508
628
  A filter on another table (a search field whose `actualField` reaches a
509
- related table) is refused on MySQL and MariaDB (`RestApiValidationError`, rule
510
- `selection_cross_table`), even when it matches no row. A search entry's own
629
+ related table, or one that reads a join source) is refused on MySQL and
630
+ MariaDB (`RestApiValidationError`, rule `selection_cross_table`), even when it
631
+ matches no row; `lockWhere` and `claim` refuse it the same way. A search entry's own
511
632
  `applyFilter` that reads another table through a subquery is not detected, and
512
633
  is no compare-and-set there either. Filter on the row's own columns instead, for example
513
634
  on the link after locking its targets with `lockMany` in the same transaction
@@ -564,6 +685,45 @@ or a counter row taken with `lock(id)`. Every lock needs a transaction; SQLite
564
685
  runs one transaction at a time, so there is no row lock to take. Take locks in
565
686
  one order on every path, a parent before its children.
566
687
 
688
+ A lock answers the rows it locks, with the fields `fields` names. Its
689
+ `include` names belongs-to relationships (`include: ["customer"]`, or
690
+ `"batch.yard"` a level further): after its own rows, the lock locks the
691
+ records they name through each related table's own handle (its scope, its row
692
+ policy, its hidden fields), in id order, each related table once however many
693
+ rows name it, and nests them as a read's include does; a link to a record the
694
+ caller may not see reads as in a read, its belongs-to field null. Those are locks too, so on MySQL and MariaDB
695
+ they find a record committed after the transaction's first plain read, which a
696
+ plain read misses. They come after the rows, a child before the parent it
697
+ names, so a path that locks the parent first and then its children (to add one)
698
+ takes the other order: the database ends a deadlock between the two by failing
699
+ one transaction, which `withTransaction` runs again, and a path that runs
700
+ often keeps one order on both sides. A collection (`include: ["lines"]`) is
701
+ refused before any statement: lock a parent's children with their own
702
+ table's `lockWhere` in the same transaction.
703
+
704
+ Every other read after the lock is a plain read. On PostgreSQL every statement
705
+ sees what had committed when it began, so that read is current. On MySQL and
706
+ MariaDB a plain read inside a transaction sees the snapshot of the
707
+ transaction's first plain read, and a lock that waited does not move it. So
708
+ take the lock before any plain read in the transaction: the reads after it then
709
+ see what the lock's previous holder committed. A related row the work decides
710
+ on (is this seat already booked?) is locked itself, through the lock's
711
+ `include` or a lock of its own, or read after a lock that every writer of it
712
+ takes first.
713
+
714
+ ```js
715
+ // good: the lock is the transaction's first read, so the bookings read after it are current
716
+ const reserve = (seatId, options) => seats.withTransaction(options, async (txOptions) => {
717
+ await seats.lock(seatId, { ...txOptions, fields: ["status"] });
718
+ const taken = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
719
+ return taken.length > 0 ? null : bookings.create({ seatId }, { ...txOptions, returning: "id" });
720
+ });
721
+
722
+ // bad: the read before the lock fixed the snapshot, so a booking committed while the lock waited is missed
723
+ const free = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
724
+ await seats.lock(seatId, txOptions);
725
+ ```
726
+
567
727
  On MySQL and MariaDB a locking read locks every row it examines, so two rules
568
728
  apply there. Lock ids taken from your own scoped reads, never a list straight
569
729
  from a request: every id listed is locked, a hidden one too, before it is left
@@ -676,8 +836,10 @@ async function activeDays(memberIds, options) {
676
836
  other features, answers `{ query, column }`: the caller's scoped rows under
677
837
  that alias (ownership, the row policy and the filters applied) with the
678
838
  columns a read answers and the ownership fields (`workspaceId`, `userId`),
679
- hidden or not, as join keys; never another hidden column. `column(field)`
680
- names each under the alias. The builder comes wrapped, since a builder is
839
+ hidden or not, as join keys; never another hidden column, and never a blob,
840
+ so a report's `select("k.*")` holds no unbounded bytes. `column(field)`
841
+ names each under the alias. A report on the owner's own `scopedQuery()`
842
+ names the columns it selects, never a blob's. The builder comes wrapped, since a builder is
681
843
  thenable and an async function would run it. The report shapes it (select,
682
844
  group), names it with `.as()` and joins it, so every owner's scope and row
683
845
  policy stay applied. The report lives with the main table's owner, or in a
@@ -707,6 +869,54 @@ async function activeDays(memberIds, options) {
707
869
  }
708
870
  ```
709
871
 
872
+ - A report that asks which of its rows have, or lack, another owner's rows
873
+ puts that owner's join source in `whereExists` or `whereNotExists`, tied to
874
+ its own row with `whereColumn`; one that counts them joins the source grouped,
875
+ as `projectSummary` does. Rows matched by a related row's value ("items
876
+ whose supplier is named Acme") need no report: a cross-table search filter
877
+ answers them in a plain read ([Layer 1](#layer-1-the-table-resource)). Nor
878
+ does a question that only narrows a plain read's rows (has an open task, has
879
+ no task): a search filter that reads the other owner's join source answers
880
+ it in that read, with its page, sort and includes as usual (Layer 1).
881
+
882
+ ```js
883
+ // Knex: report - the caller's projects with no task the caller sees
884
+ async function idleProjects(options) {
885
+ const task = await tasks.joinSource({ alias: "k" }, options);
886
+ const { query } = await projects.scopedQuery({ alias: "p" }, options);
887
+ const projectId = projects.column("id", "p");
888
+ return query
889
+ .whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), projectId))
890
+ .select({ id: projectId, name: projects.column("name", "p") });
891
+ }
892
+ ```
893
+
894
+ - A report in a feature that owns none of its tables (a console's, over
895
+ several owners) roots at the main owner's `joinSource()` instead of a
896
+ `scopedQuery()`, and ties the other owners' join sources to it the same way,
897
+ in one statement, every owner's scope and row policy applied.
898
+
899
+ ```js
900
+ // Knex: report - the caller's projects with no task the caller sees, for a console that owns neither table
901
+ async function idleProjects(options) {
902
+ const project = await projects.joinSource({ alias: "p" }, options);
903
+ const task = await tasks.joinSource({ alias: "k" }, options);
904
+ return project.query
905
+ .whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), project.column("id")))
906
+ .select({ name: project.column("name") });
907
+ }
908
+ ```
909
+
910
+ - An app switches a report it kept as a raw-SQL exception (another owner's
911
+ table joined by name, or a subquery in `raw()`, in a file under an
912
+ infrastructure exception, see [Exceptions](#exceptions)) to these: the
913
+ report's feature requires each owner whose rows it reads, puts each owner's
914
+ join source where the table stood (`whereExists`, `whereNotExists`, or
915
+ joined grouped for counts and sums), roots at its own table's
916
+ `scopedQuery()` or at the main owner's `joinSource()`, and the file drops
917
+ its `readable-check: allow` lines and its `jskit-data-access.json` entry.
918
+ When an owner is a JSKIT package, its owner API answers `joinSource`
919
+ ([Tables you don't own](#tables-you-dont-own)).
710
920
  - When each owner can answer on its own (keys in, grouped totals out), the
711
921
  report is a feature of its own that calls each owner's method and merges the
712
922
  answers in memory: one statement per owner, never a call per row.
@@ -726,11 +936,31 @@ async function activeDays(memberIds, options) {
726
936
  another field) or `include`, grouped in memory. Never a query per row, however
727
937
  the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
728
938
  item).
729
- - `queryAll` reads every matching row, in cursor pages that skip and repeat
730
- none. Use it for id lists, one parent's rows and jobs: a read its callers
939
+ - `queryAll` reads every matching row, in cursor pages that each start after
940
+ the sort values of the page before, so no row is skipped or read twice
941
+ unless a write moves a row's sort value (the id, by default) past the page
942
+ while the read runs. Use it for id lists, one parent's rows and jobs: a read its callers
731
943
  treat as complete, which a `query` would silently cut at one page. In a
732
- request it stops past the request row limit (10,000 rows) with a 400 coded
733
- `RESOURCE_ROW_LIMIT`.
944
+ request it stops past the request row limit (`pageLimits().requestRows`,
945
+ 10,000 rows) with a 400 coded `RESOURCE_ROW_LIMIT`, and never answers part
946
+ of the rows. A job reads without that limit and holds every row in memory.
947
+ `queryAll({ ..., take: 500 })` answers only the first 500 rows in its sort
948
+ order and reads no row past them, the last page no larger than the rows still
949
+ wanted. `take` is then the read's bound, so a request may name more than the
950
+ request row limit and never gets `RESOURCE_ROW_LIMIT`: name the bound the
951
+ work can hold, never one from the request unchecked.
952
+ - Work over many rows reads `queryPages` and handles one page at a time:
953
+ `for await (const records of handle.queryPages(params, options))`. Each
954
+ page is one statement of up to `pageLimits().server` rows (fewer when its
955
+ fields name blobs, so it holds at most 16 MiB of them), plus the reads of
956
+ its includes and of the hidden fields it names, and starts after the sort
957
+ values of the last row of the page before. So the work may change what the
958
+ filters match as it goes, and no row still to come is skipped or read twice,
959
+ as long as it leaves the sort fields' values alone (the id, by default): a
960
+ row whose sort value moves past the page is read again or not at all. In a
961
+ request it throws at the same row limit, before the page that passes it.
962
+ `expectFlatQueries` allows each later page's own statements, as it allows
963
+ a many-row write's later chunks; work the path does for each page counts.
734
964
 
735
965
  ```js
736
966
  // good: one page of invoices, then all of that page's lines in one read
@@ -746,6 +976,11 @@ for (const invoice of records) {
746
976
  // good: a delivery job reads every active subscriber
747
977
  const subscribers = await subscriptions.queryAll({ filters: { newsletterId, status: "active" } }, options);
748
978
 
979
+ // good: a job over a table of any size marks each page as it reads it
980
+ for await (const records of reminders.queryPages({ filters: { status: "due" } }, txOptions)) {
981
+ await reminders.patchWhere({ id: records.map((reminder) => reminder.id) }, { status: "sent" }, txOptions);
982
+ }
983
+
749
984
  // bad: the first page only, whatever its size
750
985
  const { records: firstPage } = await subscriptions.query({ filters: { newsletterId, status: "active" }, page: { size: 20 } }, options);
751
986
  ```
@@ -837,14 +1072,21 @@ const SettlementFeature = defineFeature({
837
1072
  request's `caller.userId`, so a job for a person sees what that person may
838
1073
  see, and a job for no one its whole scope. Work on one person's rows only
839
1074
  takes their scope (`forUser`, `forWorkspaceUser`) or filters its reads.
840
- - A job finds its units with one `aggregate` grouped by the ownership field,
1075
+ - A job finds its units with an `aggregate` grouped by the ownership field,
841
1076
  which a system context may group by (and count distinct) even when the table
842
- hides it, then runs each unit scoped to it. A request's aggregate still
843
- refuses a hidden field:
1077
+ hides it, then runs each unit scoped to it. It reads the units in pages, all
1078
+ of them before the first runs, since settling a unit changes what the
1079
+ filters match. A request's aggregate still refuses a hidden field:
844
1080
 
845
1081
  ```js
846
1082
  async function settleDue(options) {
847
- const due = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" } }, options);
1083
+ const size = cohorts.pageLimits().request;
1084
+ const due = [];
1085
+ for (let number = 1; ; number += 1) {
1086
+ const page = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
1087
+ due.push(...page);
1088
+ if (page.length < size) break;
1089
+ }
848
1090
  for (const { workspaceId } of due) {
849
1091
  await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
850
1092
  }
@@ -953,7 +1195,10 @@ participants) reads them with `users.profiles.listByIds(ids, options)`: every
953
1195
  profile in one call, however many ids, never `query` pages of 100 walked by
954
1196
  hand or one `findById` per person. `users.settings.listByUserIds(ids, options)`
955
1197
  does the same for their settings, and `workspaces.listByIds(ids, options)` for
956
- workspaces.
1198
+ workspaces. A report that reads people, their settings, workspaces or
1199
+ memberships beside its own rows joins `joinSource({ alias, filters },
1200
+ options)` of `users.profiles`, `users.settings`, `workspaces` or
1201
+ `workspaces.memberships` ([Layer 5](#layer-5-reports-the-resource-cannot-express)).
957
1202
 
958
1203
  An app that had a table resource of its own over a JSKIT table (such as
959
1204
  `users`) deletes it: its CRUD contract is built on the package's table resource
@@ -1006,6 +1251,38 @@ named with no value, such as `lockVersion: record.lockVersion` on a field
1006
1251
  called `version`, is refused (`options.lockVersion is undefined`), never run
1007
1252
  without it.
1008
1253
 
1254
+ ### Capability cycles
1255
+
1256
+ A capability cycle is a ring of requires: feature A requires a capability that
1257
+ feature B provides while B, directly or through others, requires one that A
1258
+ provides. An `optional` require closes a ring as a required one does. The
1259
+ kernel sets features up in the order their requires give, so it cannot start a
1260
+ ring: boot fails before any setup, naming it,
1261
+ `Provider capability cycle detected: children -> parents -> children.`
1262
+ `startTestFeatures` orders features the same way, so a feature test meets the
1263
+ ring before the server does. The same ring between packages, each listing the
1264
+ other in its `package.json` dependencies (as importing the other's shared
1265
+ table resource needs), fails as
1266
+ `Package dependency cycle detected: @local/jobs -> @local/checklists -> @local/jobs`.
1267
+
1268
+ Data access makes them easy to write: a parent's owner reads its children
1269
+ through their owners while each child's owner checks or advances the parent.
1270
+ Keep every require pointing one way:
1271
+
1272
+ - The table nearest the root requires none of the owners below it. It provides
1273
+ a small owner API, and the owners below require that.
1274
+ - Work that reads several owners together (a parent with its children, a
1275
+ report across them) is a feature of its own that requires each of them, as
1276
+ above and in [Layer 5](#layer-5-reports-the-resource-cannot-express).
1277
+ - A package imports another package's shared table resource only when that
1278
+ package does not depend on it back. When two packages need each other's
1279
+ definitions, move those table resources into a package of their own that
1280
+ holds only shared definitions and depends on neither.
1281
+ - Never break a ring with a value handed over later (a setter, a capability
1282
+ looked up at call time): a call's destination then depends on what ran
1283
+ before. A setter kept until the split carries typed features' `cycle:`
1284
+ exception ([Optional tokens](/guide/framework/typed-features#optional-tokens)).
1285
+
1009
1286
  ## Check and judge
1010
1287
 
1011
1288
  Before calling data work done, apply the
@@ -1029,7 +1306,11 @@ test("allocation is flat", () => expectFlatQueries({ environment: process.env, k
1029
1306
  `expectFlatQueries` (from `@jskit-ai/resource-runtime/test`) seeds each size,
1030
1307
  counts the statements the path sends, and fails when a larger size sends more,
1031
1308
  with the counts and the statements repeated most; the later chunks a many-row
1032
- write reports are allowed. `expectScoped` runs the path as owner A before and
1309
+ write reports, and the statements of each later page `queryPages()` reads, are
1310
+ allowed. It counts only the path's own statements, those
1311
+ `run` and the async work it starts send, so another test or a background loop
1312
+ using the same Knex meanwhile changes nothing; work a loop started earlier does
1313
+ on the path's behalf is not counted either. `expectScoped` runs the path as owner A before and
1033
1314
  after seeding owner B and fails when the answer changes, so it catches a report
1034
1315
  that sums, counts or joins B's rows, not only one that returns them; it also
1035
1316
  fails when the path changed or removed a row of B's. The path runs twice, so it
@@ -1210,5 +1491,11 @@ it. Check and judge, for each file:
1210
1491
  `/* eslint ... */` config that sets one, outside client code;
1211
1492
  - an app file that the app's ESLint config lints without every data-access
1212
1493
  rule as an error (`"jskit/no-direct-knex": "off"`, `dataAccessConfig(files,
1213
- "off")`), or server code (`src/server`, `src/shared`, `server/`) that it
1214
- ignores, until `infrastructure` lists that file and rule.
1494
+ "off")`), or server code that it ignores, until `infrastructure` lists
1495
+ that file and rule. Server code is what the base config lints, at the app's
1496
+ root or a package's: its files (`server.js`), `src/` (an app's whole `src/`,
1497
+ `src/jobs/` with it), `server/`, `scripts/`, `bin/` and `config/`.
1498
+
1499
+ It reads none of what the app's ESLint config ignores outside server code,
1500
+ such as a vendored copy of old code kept beside the app, since no lint reads
1501
+ it either, and lists each directory and file it skipped.
@@ -184,9 +184,9 @@ Exactly this, and nothing more:
184
184
  `createVuetify(createMaterialVuetifyOptions(theme))` with
185
185
  `createMaterialVuetifyOptions` from `@jskit-ai/material-web/client`, importing
186
186
  `@jskit-ai/material-web/client/material.css`. JSKIT generates the light and
187
- dark schemes from the seed at startup (Material Color Utilities
188
- `SchemeTonalSpot`, every role in kebab-case such as `surface-container-low`,
189
- plus success, warning and info with their containers), with
187
+ dark schemes from the seed at startup (every role in kebab-case such as
188
+ `surface-container-low`, plus success, warning and info with their
189
+ containers), with
190
190
  `defaultTheme: "system"` so the person's light, dark or system preference
191
191
  applies. The `md3` blueprint and JSKIT's Material 3 Vuetify defaults live in
192
192
  material-web; an app keeps no defaults file and never hand-picks a colour. The
@@ -210,6 +210,12 @@ Exactly this, and nothing more:
210
210
  Symbols has no brand marks (Google, GitHub, a payment network): a brand's
211
211
  logo is the brand's own artwork, a content image or SVG used as its owner
212
212
  publishes it, not an icon alias.
213
+ - **Components:** a Vuetify component chosen at run time is imported and
214
+ passed: `import { VDialog } from "vuetify/components"`, then `:is="VDialog"`
215
+ or `h(VDialog)`. A JSKIT app registers none globally, so one named as a
216
+ string (`resolveComponent("v-dialog")`, `is="v-dialog"`) renders nothing
217
+ (`ui/vuetify-by-name`). A prop a JSKIT package lists as removed (its
218
+ `jskit.metadata.ui.removedProps` and CHANGELOG) fails `ui/removed-prop`.
213
219
 
214
220
  ## Exact sizes
215
221
 
@@ -260,14 +266,9 @@ dialog, chip or FAB.
260
266
  file, such as a CSV export), `disabled`, `loading` or `color` (`error` for a
261
267
  destructive one); attributes such as `target` reach its button. On compact
262
268
  widths the bar shows the single `primary` action (a form's Save) and puts
263
- every other action in its "More actions" menu, where `overflow` actions
264
- always are. The bar's own items (top-right placements such as tools, a
265
- notification bell and the account avatar) stay while they fit beside the
266
- whole title; those that don't follow the actions into the menu, farthest
267
- from the corner first, and the item nearest the corner stays unless the
268
- `primary` action takes its place. An item keeps running while it is in the
269
- menu, closed or open, so its live count, its panel or a floating button it
270
- shows elsewhere on the page stay. Medium and wider bars show them all.
269
+ the other actions, and the top-right items that don't fit beside the title,
270
+ in its "More actions" menu, where `overflow` actions always are; medium and
271
+ wider bars show them all.
271
272
  - **Window size classes:** compact under 600 (navigation bar), medium 600–839
272
273
  (navigation rail), expanded 840–1199 and large from 1200 (drawer), read from
273
274
  `useShellWindowSizeClass()`
@@ -280,12 +281,17 @@ dialog, chip or FAB.
280
281
  starter registers it as `local.main.ui.section-tab-link-item`), and the page
281
282
  draws one tab row, `display: flex; overflow-x: auto` over a 1dp
282
283
  `outline-variant` bottom border. `ShellTabLinkItem` is the navigation bar's
283
- destination; placed in a page it draws a section tab.
284
+ destination; placed in a page it draws a section tab. material.css draws an
285
+ app's own `v-bottom-navigation` the same way, keeping its colour props.
284
286
 
285
287
  ## Colour
286
288
 
287
289
  - Use semantic roles through Vuetify props, classes and
288
290
  `rgb(var(--v-theme-<role>))`; never a raw colour value in a component.
291
+ - Text in an `on-*` role uses its class or a `color` prop
292
+ (`class="text-on-surface-variant"`, `<v-icon color="on-surface-variant">`);
293
+ an app colour's is `class="on-partner"`, which `bg-partner` already sets.
294
+ Never lower text emphasis with an opacity or `text-medium-emphasis`.
289
295
  - Status chips use `color="success"` (or `warning`, `info`, `error`) or the
290
296
  container roles (`success-container` with `on-success-container`), and each
291
297
  status meaning has its own hue. Never communicate status, selection, validation, or permission through
@@ -318,9 +324,11 @@ dialog, chip or FAB.
318
324
  actions in the bar's overflow menu, never in the content; the record's
319
325
  next workflow step is the one filled button, shown in context rather than a
320
326
  row of mostly disabled actions.
321
- - **Form:** the bar's close (X) cancels and Save is its filled action. Fields
322
- use `hideDetails: "auto"`. Nothing moves under a finger: a multi-select chip
323
- field keeps its input width when it loses focus. A pick-one-of-N choice is a
327
+ - **Form:** the bar's close (X) cancels and Save is its filled action. A
328
+ field keeps its message line (no `hide-details`, no margin under it), so an
329
+ error appears in place; a field outside a form (search, filter) sets
330
+ `hide-details`. Nothing moves under a finger: a multi-select
331
+ chip field keeps its input width when it loses focus. A pick-one-of-N choice is a
324
332
  segmented button (`v-btn-toggle`, drawn by JSKIT, with 12dp segment sides as
325
333
  in any `v-btn-group`) with `role="radiogroup"` and
326
334
  `role="radio"`/`aria-checked` buttons.
@@ -354,11 +362,9 @@ dialog, chip or FAB.
354
362
  field. A user-triggered command uses JSKIT's shared action
355
363
  feedback/snackbar path. Never insert a transient command-error alert above
356
364
  page content where it shifts the working layout.
357
- - A snackbar never covers the FAB or the actions it reports on. `ShellErrorHost`
358
- centres it above the navigation bar on compact windows and starts it at the
359
- content's start edge, at most half its width, on wider ones. A FAB takes
360
- layout room (`<v-fab app layout>`, as `ShellPrimaryAction`'s does), so the
361
- snackbar shows above it.
365
+ - A snackbar never covers the FAB or the actions it reports on: `ShellErrorHost`
366
+ places it, and a FAB takes layout room (`<v-fab app layout>`, as
367
+ `ShellPrimaryAction`'s does), so the snackbar shows above it.
362
368
 
363
369
  ## Adaptive layout
364
370
 
@@ -374,16 +380,14 @@ Two commands read application source (no server, no browser) and print each
374
380
  finding with its file, line, rule and the section of this page:
375
381
 
376
382
  ```bash
377
- npx jskit check # in every app's verify: fails on any nav/* rule
378
- npx jskit check m3 # the whole app: src/ and packages/
383
+ npx jskit check # in every app's verify: fails on any nav/*, m3/* or ui/* finding
384
+ npx jskit check m3 # these rules alone, the whole app: src/ and packages/
379
385
  npx jskit check m3 src/pages/home/books # a section
380
386
  npx jskit check m3 $(git diff --name-only --diff-filter=d main) # changed files
381
387
  ```
382
388
 
383
- Plain `jskit check` fails on the [navigation rules](#forbidden-in-app-code).
384
- `jskit check m3` lists those and the visual rules below. The visual rules are a
385
- report until every JSKIT app has been converted to them; the JSKIT release after
386
- that makes plain `jskit check`, and so every app's verify, fail on them too:
389
+ Both fail on the [navigation rules](#forbidden-in-app-code) and on these;
390
+ nothing excuses a `ui/*` finding:
387
391
 
388
392
  | Rule | Finds |
389
393
  |---|---|
@@ -393,12 +397,12 @@ that makes plain `jskit check`, and so every app's verify, fail on them too:
393
397
  | `m3/outlined-card` | `variant="outlined"` or `border` on a card or sheet |
394
398
  | `m3/mdi-icon` | `mdi-*` icon strings and `@mdi/js` imports |
395
399
  | `m3/fixed-button-height` | `height`/`min-height` on buttons, in props, styles or CSS |
400
+ | `ui/vuetify-by-name` | a Vuetify component named as a string for Vue to resolve |
401
+ | `ui/removed-prop` | a prop the installed JSKIT package lists as removed from that component |
396
402
 
397
403
  Tests, fixtures, server code and migrations are not checked. A clean check is
398
- necessary, not sufficient: it cannot see layout, spacing or contrast. A
399
- [UI pass](./ui-pass.md) is the guard for those, because it looks at every
400
- affected screen at three widths in light and dark; an editor that runs one
401
- automatically after each plan keeps that guard on every change.
404
+ necessary, not sufficient: layout, spacing and contrast need a
405
+ [UI pass](./ui-pass.md), every affected screen at three widths in light and dark.
402
406
 
403
407
  ### Exceptions
404
408
 
@@ -407,16 +411,13 @@ Take the approved route first; none needs an exception:
407
411
  - **Brand or partner colours** are theme custom colours, used by name:
408
412
  `colors: { partner: "#0A7D3E" }` in `src/theme/index.js`, then
409
413
  `<v-chip color="partner">`. A hex string is harmonized toward the seed; a
410
- colour that must keep its hue is `{ value: "#F5C518", blend: false }`
411
- (Material Theme Builder's Blend switched off), with the same four roles.
414
+ colour that must keep its hue is `{ value: "#F5C518", blend: false }`, with
415
+ the same four roles.
412
416
  - **Content colours**, colours that are content rather than UI, keep one
413
- value in both schemes: an identity palette a person picks and the server
414
- stores, avatar and illustration artwork, brand marks (a language's
415
- file-type tint), fixed chart series, QR codes, colours written into
416
- exported files. They are named exports of plain hex values in
417
- `src/theme/palettes.js`, used as they are, never for UI chrome (a theme
418
- colour is toned per scheme, so it can't keep an identity the same).
419
- Components read them through bindings:
417
+ value in both schemes: an identity palette a person picks, artwork, brand
418
+ marks, fixed chart series, QR codes, colours in exported files. They are
419
+ named exports of plain hex values in `src/theme/palettes.js`, used as they
420
+ are, never for UI chrome. Components read them through bindings:
420
421
 
421
422
  ```vue
422
423
  <script setup>