@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
@@ -39,8 +39,6 @@ Exports
39
39
  - `createRepository({ workspaceInvites, workspaces })`
40
40
  - `normalizeInviteRecord(payload)`
41
41
  - `normalizeInviteWithWorkspace(payload = {})`
42
- Local functions
43
- - `normalizeInvitePatchPayload(payload = {})`
44
42
 
45
43
  ### `src/server/common/repositories/workspaceMembershipsRepository.js`
46
44
  Exports
@@ -66,6 +66,19 @@ Local functions
66
66
  - `buildCatalog({ repoRoot, packagesRoot, outputPath })`
67
67
  - `main()`
68
68
 
69
+ ### `scripts/data-access-files.mjs`
70
+ Exports
71
+ - `ROOT`
72
+ - `lintIgnores({ governed, config })`
73
+ - `sourceFiles(root)`
74
+ Local functions
75
+ - `skipsDirectory(name)`
76
+ - `walk(root, directory, files)`
77
+ - `frameworkPackages(root)`
78
+ - `appEslint(root)`
79
+ - `lintConfigs(root, files)`
80
+ - `skippedPaths(files, skipped)`
81
+
69
82
  ### `scripts/data-access.mjs`
70
83
  Exports
71
84
  - `checkDataAccess({ projectRoot = process.cwd() } = {})`
@@ -73,7 +86,6 @@ Exports
73
86
  - `readTableResourceSources({ projectRoot = process.cwd() } = {})`
74
87
  Local functions
75
88
  - `escapeRegExp(text)`
76
- - `skipsDirectory(name)`
77
89
  - `blank(text)`
78
90
  - `maskStrings(code)`
79
91
  - `closingOffset(masked, start)`
@@ -98,16 +110,12 @@ Local functions
98
110
  - `readLintDisables(file, source, position)`
99
111
  - `readRuntimeCapabilities(file, { code, masked, position })`
100
112
  - `isError(setting)`
101
- - `appEslint(root)`
102
- - `checkLintConfig(root, files, baseline, report)`
113
+ - `checkLintConfig({ configs, error }, baseline, report)`
103
114
  - `checkCapabilities(baseline, uses, report)`
104
115
  - `readBaseline(root, report)`
105
116
  - `checkBaseline(baseline, excuses, scopes, report)`
106
- - `walk(root, directory, files)`
107
- - `frameworkPackages(root)`
108
117
  - `checkTables(resources, report)`
109
118
  - `checkSystemScopes(scopes, report)`
110
- - `sourceFiles(root)`
111
119
 
112
120
  ### `scripts/files.mjs`
113
121
  Exports
@@ -188,9 +196,8 @@ Local functions
188
196
  ### `scripts/material3.mjs`
189
197
  Exports
190
198
  - `MATERIAL3_RULES`
191
- - `checkMaterial3({ projectRoot = process.cwd(), paths = [], navigationOnly = false } = {})`
199
+ - `checkMaterial3({ projectRoot = process.cwd(), paths = [] } = {})`
192
200
  - `formatMaterial3Report(result)`
193
- - `formatNavigationReport(result)`
194
201
  Local functions
195
202
  - `hexPattern()`
196
203
  - `channelColour()`
@@ -201,8 +208,8 @@ Local functions
201
208
  - `isFrameworkPackage(projectRoot, directory)`
202
209
  - `walk(projectRoot, directory, files, wholeApp = false)`
203
210
  - `collectFiles(projectRoot, paths)`
204
- - `createReporter(file, source, { navigationOnly = false } = {})`
205
- - `checkCode(code, offset, { report, themed })`
211
+ - `createReporter(file, source)`
212
+ - `checkCode(code, offset, context)`
206
213
  - `checkScript(code, offset, context)`
207
214
  - `checkCss(css, offset, context)`
208
215
  - `checkDeclaration(declaration, context)`
@@ -386,10 +393,23 @@ Local functions
386
393
  - `seconds(ms)`
387
394
  - `readTestTimeBounds(cwd)`
388
395
 
396
+ ### `scripts/ui-components.mjs`
397
+ Exports
398
+ - `checkRemovedProps(tags, script, removedProps, report)`
399
+ - `checkVuetifyByNameAttribute(tag, entry, context)`
400
+ - `checkVuetifyByNameCode(code, offset, context)`
401
+ - `readRemovedProps(root)`
402
+ - `readVuetifyComponents(root)`
403
+ Local functions
404
+ - `readExportNames(file, names = new Set(), seen = new Set())`
405
+ - `readComponentImports(script)`
406
+ - `reportByName({ report, vuetifyComponents }, offset, name, how)`
407
+
389
408
  ### `scripts/verify-packages.mjs`
390
409
  Exports
391
410
  - `main()`
392
411
  - `validateApiSummarySurfaces(packageJson)`
412
+ - `validateRemovedProps(packageJson)`
393
413
  - `validateCapabilityClosure(packages, { builtinCapabilities = BUILTIN_CAPABILITIES } = {})`
394
414
  - `validateMigrations(packageRecord, migrationOwners)`
395
415
  - `validateProviderExport(value, label)`
@@ -29,9 +29,10 @@ through its handle's calls, and every call takes the call's `options` last:
29
29
  | Call | Does |
30
30
  | --- | --- |
31
31
  | `query({ filters, sort, page, include }, options)` | one page: `{ records, page }` |
32
- | `queryAll({ filters, sort, include }, options)` | every matching row, for jobs, id lists and one parent's rows |
32
+ | `queryAll({ filters, sort, include, take }, options)` | every matching row, or the first `take`, for jobs, id lists and one parent's rows |
33
+ | `queryPages({ filters, sort, include }, options)` | every matching row, a page at a time, for work over many rows |
33
34
  | `get(id, params, options)` | one record |
34
- | `aggregate({ measures, groupBy, filters, sort }, options)` | totals, grouped by fields or date buckets |
35
+ | `aggregate({ measures, groupBy, filters, sort, page }, options)` | totals, grouped by fields or date buckets |
35
36
  | `create(data, options)`, `findOrCreate({ id, ...data }, options)` | one record; a keyed one, created once |
36
37
  | `patch(id, changes, options)`, `delete(id, options)` | one record |
37
38
  | `createMany(rows, options)` | many records, or an upsert with `onConflict` |
@@ -55,6 +56,14 @@ function totalsByMonth(options) {
55
56
  }
56
57
  ```
57
58
 
59
+ A grouped `aggregate` answers its groups from one statement, at most
60
+ `pageLimits().request` of them (the resource's `queryMaxLimit`). When there
61
+ are more it throws a 400 coded `RESOURCE_GROUP_LIMIT` instead of answering
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.
64
+ Page numbers are offsets, so read every page before work that changes what
65
+ the filters match.
66
+
58
67
  ## Words
59
68
 
60
69
  | Word | Meaning |
@@ -152,6 +161,44 @@ whole number exactly, so json-rest-api answers those digits as text rather
152
161
  than a rounded number. A value that can grow that large gets a decimal field
153
162
  (`precision` and `scale`), whose digits stay text.
154
163
 
164
+ A `type: "blob"` field holds bytes the process reads and writes whole, so it
165
+ names `maxBytes`, the most bytes a value holds, and its column holds that much
166
+ too: MySQL's and MariaDB's `BLOB` holds 64 KiB and `MEDIUMBLOB` 16 MiB, while
167
+ PostgreSQL's `bytea` and SQLite's `BLOB` hold more than any limit you would
168
+ name. A blob takes no `search`, `indexed`, `unique`, `sortable`, `defaultTo` or
169
+ `belongsTo`. A write takes a Buffer or a Uint8Array of at most `maxBytes`, or
170
+ null, and refuses anything else with a validation error on the field (422)
171
+ before any statement, a transaction's or a lock's included. A read leaves a
172
+ blob out unless its `fields` names it, and answers it as a Buffer, holding at
173
+ most 16 MiB of the blobs it names, or one record's: `get(id, { fields:
174
+ ["image"] }, options)` and `lock()` read one record's; a page of `query()` and
175
+ the ids of `lockMany()` are refused past the records whose `maxBytes` fit 16
176
+ MiB, and each page of `queryPages()` holds no more. `queryAll()`, `lockWhere()`
177
+ and `claim()` read every matching row at once, so they refuse to name a blob,
178
+ and no read names an included table's blob, since an include holds any number
179
+ of records: read it through that table's handle. A join source carries no
180
+ blob. JSON holds no binary, so a JSON:API read and a CRUD contract refuse a
181
+ blob, and a route of its own serves one as the response body. A file people upload (a photo, a
182
+ document) goes to storage through `runtime.uploads` and the table keeps its
183
+ key; a blob is for the few bytes the record itself needs, such as a thumbnail
184
+ or a signature.
185
+
186
+ ```js
187
+ const signatureResource = defineTableResource({
188
+ name: "signatures",
189
+ tableName: "signatures",
190
+ ownership: "workspace",
191
+ schema: {
192
+ signedBy: { type: "string", required: true },
193
+ // MEDIUMBLOB on MySQL and MariaDB
194
+ image: { type: "blob", maxBytes: 256 * 1024, required: true }
195
+ }
196
+ });
197
+
198
+ // One signature's image, read where a route answers it.
199
+ const imageOf = async (id, options) => (await signatures.get(id, { fields: ["image"] }, options)).image;
200
+ ```
201
+
155
202
  A package whose features own tables or declare `systemScope` lists
156
203
  `runtime.resources` in its descriptor's `capabilities.requires`, or boot
157
204
  refuses it; the feature never requires it itself, since it receives its
@@ -197,7 +244,74 @@ A filter on another table's field (`searchSchema: { supplierName: { type:
197
244
  "string", actualField: "suppliers.name" } }`) needs that field declared
198
245
  `indexed: true` on its own table resource, with a database index on its column,
199
246
  even when the join is on the other table's id; `own()` refuses it at startup
200
- otherwise, naming both.
247
+ otherwise, naming both. That `indexed: true` is the other table's owner letting
248
+ filters search the field. The filter reads that table as the table's own read
249
+ in the caller's scope: its ownership, its owner's row policy and the policies
250
+ contributed to it decide which of its rows can match, in `query`, `queryAll`,
251
+ `queryPages` and `aggregate` alike, and each row matches once, through a
252
+ has-many path too (suppliers filtered by their items' names). `own()` also
253
+ refuses two filters no scope guards: one that reads a `system` table from a
254
+ table that is not `system`, since a request would read it, and one on a field
255
+ the other table hides, which would tell a caller which rows hold a value it
256
+ may not read. For either, the other table's owner answers the ids it matches
257
+ through its owner API, and the filter takes the list: `filters: { supplierIds }`.
258
+
259
+ A filter that asks whether a row has, or lacks, another owner's rows
260
+ (projects with an open task, projects with no task) reads that owner's join
261
+ source. The `owns` entry's `searchSchema` names each join source by an alias,
262
+ with a function answering its owner from the feature's `requires` or its own
263
+ handles (`owned`), and `applyFilter` gets `joinSource(alias)`: that owner's
264
+ `{ query, column }` for the caller's scope, as a report's join source
265
+ ([Layer 5](#layer-5-reports-the-resource-cannot-express)), which it ties to
266
+ the row with `whereColumn` inside `whereExists` or `whereNotExists`:
267
+
268
+ ```js
269
+ // the projects feature's owns entry; the feature requires the tasks owner as `tasks`
270
+ {
271
+ resource: projectResource,
272
+ searchSchema: {
273
+ hasOpenTask: {
274
+ type: "boolean",
275
+ joinSources: { k: ({ requires }) => requires.tasks },
276
+ applyFilter(query, wanted, { column, joinSource }) {
277
+ const task = joinSource("k");
278
+ const openTasks = task.query
279
+ .select(task.column("id"))
280
+ .whereColumn(task.column("projectId"), column("id"))
281
+ .where(task.column("status"), "open");
282
+ if (wanted) {
283
+ query.whereExists(openTasks);
284
+ } else {
285
+ query.whereNotExists(openTasks);
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ ```
292
+
293
+ The handle builds the join sources a read's filters use just before the read,
294
+ in the call's own context and transaction, so `query`, `queryAll`,
295
+ `queryPages`, `aggregate` and `joinSource` take the filter in their one
296
+ statement, and the other owner's ownership, row policy and hidden fields
297
+ decide which of its rows count (`column()` refuses a hidden field). A filter
298
+ that prefers one match and falls back on another when no row of the table has
299
+ the first (the projects named like the term, or, when none is, those with a
300
+ task in that status) names the feature's own handle for its own table,
301
+ `({ owned }) => owned.projects`. Only a read through a handle builds join
302
+ sources. `own()` refuses, at startup, `joinSources` that is not a record of
303
+ functions under aliases, such a filter with `oneOf` or without `applyFilter`,
304
+ and `joinSources` in the table resource's own `searchSchema`, which names no
305
+ feature to answer it.
306
+
307
+ An app switches to such a filter where a search resolves another owner's
308
+ matches first: a repository that reads the other owner's ids (an id report on
309
+ its `scopedQuery()`, or that owner's `queryAll` matched in memory) and then
310
+ filters `id` by the list, or a filter that was a `whereRaw` with `EXISTS
311
+ (SELECT ... FROM ...)`, which `jskit/sql-literal` refuses. The filter moves
312
+ into the `owns` entry with the other owner as a join source, the code that
313
+ resolved ids goes, and the read is one statement however many rows match,
314
+ where the id list grew with the matches.
201
315
 
202
316
  A table keyed by a `uuid` or `string` primary key takes its ids as text; an
203
317
  `owns` entry's `normalizeId` is only for an id written some other way.
@@ -313,7 +427,11 @@ request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
313
427
  `{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
314
428
  `reason`, for background work. A job names a user only as `actorId`, the
315
429
  person it works for ([below](#background-work)), so never read a missing actor
316
- as an anonymous request.
430
+ as an anonymous request. The owner's policy also receives the capabilities its
431
+ feature requires (and its optional ones), by local name, as `requires`, the
432
+ same ones its setup receives: a policy that needs another feature's answer
433
+ declares that feature's capability in `requires` and reads `requires.<name>`.
434
+ It runs on every read, so what it asks there must be cheap.
317
435
 
318
436
  ```js
319
437
  // good: one predicate on the row being read
@@ -487,8 +605,10 @@ Every filter takes `id`, one id or a list, so `patchWhere({ id: ids },
487
605
  changes, options)` and `deleteWhere({ id: ids }, options)` change a known
488
606
  list. Each checks every row against the resource first, reads which of the
489
607
  rows the caller may touch, then writes each chunk with one statement, in one
490
- transaction. Audits and change events run once for the batch, so the
491
- statements are the same for 10 rows as for 200. `patchWhere` and
608
+ transaction. A chunk is 500 rows unless the call passes `chunkSize`, which is
609
+ at least 100: a smaller chunk would send a statement every few rows, as a loop
610
+ does, and is refused before any statement. Audits and change events run once
611
+ for the batch, so the statements are the same for 10 rows as for 200. `patchWhere` and
492
612
  `deleteWhere` check the scope and filters on each row once they hold its lock,
493
613
  so a row gone meanwhile, or changed so that it no longer matches, is skipped,
494
614
  and they take no `missing`. That makes them a compare-and-set on their filters
@@ -501,8 +621,9 @@ waiter with errno 1020 ("Record has changed since last read"), which a write
501
621
  that owns its transaction runs again. For one row,
502
622
  `patch(id, changes, { ...options, onlyIf: { status: "open" } })` does the same.
503
623
  A filter on another table (a search field whose `actualField` reaches a
504
- related table) is refused on MySQL and MariaDB (`RestApiValidationError`, rule
505
- `selection_cross_table`), even when it matches no row. A search entry's own
624
+ related table, or one that reads a join source) is refused on MySQL and
625
+ MariaDB (`RestApiValidationError`, rule `selection_cross_table`), even when it
626
+ matches no row; `lockWhere` and `claim` refuse it the same way. A search entry's own
506
627
  `applyFilter` that reads another table through a subquery is not detected, and
507
628
  is no compare-and-set there either. Filter on the row's own columns instead, for example
508
629
  on the link after locking its targets with `lockMany` in the same transaction
@@ -559,6 +680,45 @@ or a counter row taken with `lock(id)`. Every lock needs a transaction; SQLite
559
680
  runs one transaction at a time, so there is no row lock to take. Take locks in
560
681
  one order on every path, a parent before its children.
561
682
 
683
+ A lock answers the rows it locks, with the fields `fields` names. Its
684
+ `include` names belongs-to relationships (`include: ["customer"]`, or
685
+ `"batch.yard"` a level further): after its own rows, the lock locks the
686
+ records they name through each related table's own handle (its scope, its row
687
+ policy, its hidden fields), in id order, each related table once however many
688
+ rows name it, and nests them as a read's include does; a link to a record the
689
+ caller may not see reads as in a read, its belongs-to field null. Those are locks too, so on MySQL and MariaDB
690
+ they find a record committed after the transaction's first plain read, which a
691
+ plain read misses. They come after the rows, a child before the parent it
692
+ names, so a path that locks the parent first and then its children (to add one)
693
+ takes the other order: the database ends a deadlock between the two by failing
694
+ one transaction, which `withTransaction` runs again, and a path that runs
695
+ often keeps one order on both sides. A collection (`include: ["lines"]`) is
696
+ refused before any statement: lock a parent's children with their own
697
+ table's `lockWhere` in the same transaction.
698
+
699
+ Every other read after the lock is a plain read. On PostgreSQL every statement
700
+ sees what had committed when it began, so that read is current. On MySQL and
701
+ MariaDB a plain read inside a transaction sees the snapshot of the
702
+ transaction's first plain read, and a lock that waited does not move it. So
703
+ take the lock before any plain read in the transaction: the reads after it then
704
+ see what the lock's previous holder committed. A related row the work decides
705
+ on (is this seat already booked?) is locked itself, through the lock's
706
+ `include` or a lock of its own, or read after a lock that every writer of it
707
+ takes first.
708
+
709
+ ```js
710
+ // good: the lock is the transaction's first read, so the bookings read after it are current
711
+ const reserve = (seatId, options) => seats.withTransaction(options, async (txOptions) => {
712
+ 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
+ });
716
+
717
+ // 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);
719
+ await seats.lock(seatId, txOptions);
720
+ ```
721
+
562
722
  On MySQL and MariaDB a locking read locks every row it examines, so two rules
563
723
  apply there. Lock ids taken from your own scoped reads, never a list straight
564
724
  from a request: every id listed is locked, a hidden one too, before it is left
@@ -671,8 +831,10 @@ async function activeDays(memberIds, options) {
671
831
  other features, answers `{ query, column }`: the caller's scoped rows under
672
832
  that alias (ownership, the row policy and the filters applied) with the
673
833
  columns a read answers and the ownership fields (`workspaceId`, `userId`),
674
- hidden or not, as join keys; never another hidden column. `column(field)`
675
- names each under the alias. The builder comes wrapped, since a builder is
834
+ hidden or not, as join keys; never another hidden column, and never a blob,
835
+ so a report's `select("k.*")` holds no unbounded bytes. `column(field)`
836
+ names each under the alias. A report on the owner's own `scopedQuery()`
837
+ names the columns it selects, never a blob's. The builder comes wrapped, since a builder is
676
838
  thenable and an async function would run it. The report shapes it (select,
677
839
  group), names it with `.as()` and joins it, so every owner's scope and row
678
840
  policy stay applied. The report lives with the main table's owner, or in a
@@ -702,6 +864,54 @@ async function activeDays(memberIds, options) {
702
864
  }
703
865
  ```
704
866
 
867
+ - A report that asks which of its rows have, or lack, another owner's rows
868
+ puts that owner's join source in `whereExists` or `whereNotExists`, tied to
869
+ its own row with `whereColumn`; one that counts them joins the source grouped,
870
+ as `projectSummary` does. Rows matched by a related row's value ("items
871
+ whose supplier is named Acme") need no report: a cross-table search filter
872
+ answers them in a plain read ([Layer 1](#layer-1-the-table-resource)). Nor
873
+ does a question that only narrows a plain read's rows (has an open task, has
874
+ no task): a search filter that reads the other owner's join source answers
875
+ it in that read, with its page, sort and includes as usual (Layer 1).
876
+
877
+ ```js
878
+ // Knex: report - the caller's projects with no task the caller sees
879
+ async function idleProjects(options) {
880
+ const task = await tasks.joinSource({ alias: "k" }, options);
881
+ const { query } = await projects.scopedQuery({ alias: "p" }, options);
882
+ const projectId = projects.column("id", "p");
883
+ return query
884
+ .whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), projectId))
885
+ .select({ id: projectId, name: projects.column("name", "p") });
886
+ }
887
+ ```
888
+
889
+ - A report in a feature that owns none of its tables (a console's, over
890
+ several owners) roots at the main owner's `joinSource()` instead of a
891
+ `scopedQuery()`, and ties the other owners' join sources to it the same way,
892
+ in one statement, every owner's scope and row policy applied.
893
+
894
+ ```js
895
+ // Knex: report - the caller's projects with no task the caller sees, for a console that owns neither table
896
+ async function idleProjects(options) {
897
+ const project = await projects.joinSource({ alias: "p" }, options);
898
+ const task = await tasks.joinSource({ alias: "k" }, options);
899
+ return project.query
900
+ .whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), project.column("id")))
901
+ .select({ name: project.column("name") });
902
+ }
903
+ ```
904
+
905
+ - An app switches a report it kept as a raw-SQL exception (another owner's
906
+ table joined by name, or a subquery in `raw()`, in a file under an
907
+ infrastructure exception, see [Exceptions](#exceptions)) to these: the
908
+ report's feature requires each owner whose rows it reads, puts each owner's
909
+ join source where the table stood (`whereExists`, `whereNotExists`, or
910
+ joined grouped for counts and sums), roots at its own table's
911
+ `scopedQuery()` or at the main owner's `joinSource()`, and the file drops
912
+ its `readable-check: allow` lines and its `jskit-data-access.json` entry.
913
+ When an owner is a JSKIT package, its owner API answers `joinSource`
914
+ ([Tables you don't own](#tables-you-dont-own)).
705
915
  - When each owner can answer on its own (keys in, grouped totals out), the
706
916
  report is a feature of its own that calls each owner's method and merges the
707
917
  answers in memory: one statement per owner, never a call per row.
@@ -721,11 +931,31 @@ async function activeDays(memberIds, options) {
721
931
  another field) or `include`, grouped in memory. Never a query per row, however
722
932
  the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
723
933
  item).
724
- - `queryAll` reads every matching row, in cursor pages that skip and repeat
725
- none. Use it for id lists, one parent's rows and jobs: a read its callers
934
+ - `queryAll` reads every matching row, in cursor pages that each start after
935
+ the sort values of the page before, so no row is skipped or read twice
936
+ unless a write moves a row's sort value (the id, by default) past the page
937
+ while the read runs. Use it for id lists, one parent's rows and jobs: a read its callers
726
938
  treat as complete, which a `query` would silently cut at one page. In a
727
- request it stops past the request row limit (10,000 rows) with a 400 coded
728
- `RESOURCE_ROW_LIMIT`.
939
+ request it stops past the request row limit (`pageLimits().requestRows`,
940
+ 10,000 rows) with a 400 coded `RESOURCE_ROW_LIMIT`, and never answers part
941
+ of the rows. A job reads without that limit and holds every row in memory.
942
+ `queryAll({ ..., take: 500 })` answers only the first 500 rows in its sort
943
+ order and reads no row past them, the last page no larger than the rows still
944
+ wanted. `take` is then the read's bound, so a request may name more than the
945
+ request row limit and never gets `RESOURCE_ROW_LIMIT`: name the bound the
946
+ work can hold, never one from the request unchecked.
947
+ - Work over many rows reads `queryPages` and handles one page at a time:
948
+ `for await (const records of handle.queryPages(params, options))`. Each
949
+ page is one statement of up to `pageLimits().server` rows (fewer when its
950
+ fields name blobs, so it holds at most 16 MiB of them), plus the reads of
951
+ its includes and of the hidden fields it names, and starts after the sort
952
+ values of the last row of the page before. So the work may change what the
953
+ filters match as it goes, and no row still to come is skipped or read twice,
954
+ as long as it leaves the sort fields' values alone (the id, by default): a
955
+ row whose sort value moves past the page is read again or not at all. In a
956
+ request it throws at the same row limit, before the page that passes it.
957
+ `expectFlatQueries` allows each later page's own statements, as it allows
958
+ a many-row write's later chunks; work the path does for each page counts.
729
959
 
730
960
  ```js
731
961
  // good: one page of invoices, then all of that page's lines in one read
@@ -741,6 +971,11 @@ for (const invoice of records) {
741
971
  // good: a delivery job reads every active subscriber
742
972
  const subscribers = await subscriptions.queryAll({ filters: { newsletterId, status: "active" } }, options);
743
973
 
974
+ // good: a job over a table of any size marks each page as it reads it
975
+ for await (const records of reminders.queryPages({ filters: { status: "due" } }, txOptions)) {
976
+ await reminders.patchWhere({ id: records.map((reminder) => reminder.id) }, { status: "sent" }, txOptions);
977
+ }
978
+
744
979
  // bad: the first page only, whatever its size
745
980
  const { records: firstPage } = await subscriptions.query({ filters: { newsletterId, status: "active" }, page: { size: 20 } }, options);
746
981
  ```
@@ -832,14 +1067,21 @@ const SettlementFeature = defineFeature({
832
1067
  request's `caller.userId`, so a job for a person sees what that person may
833
1068
  see, and a job for no one its whole scope. Work on one person's rows only
834
1069
  takes their scope (`forUser`, `forWorkspaceUser`) or filters its reads.
835
- - A job finds its units with one `aggregate` grouped by the ownership field,
1070
+ - A job finds its units with an `aggregate` grouped by the ownership field,
836
1071
  which a system context may group by (and count distinct) even when the table
837
- hides it, then runs each unit scoped to it. A request's aggregate still
838
- refuses a hidden field:
1072
+ hides it, then runs each unit scoped to it. It reads the units in pages, all
1073
+ of them before the first runs, since settling a unit changes what the
1074
+ filters match. A request's aggregate still refuses a hidden field:
839
1075
 
840
1076
  ```js
841
1077
  async function settleDue(options) {
842
- const due = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" } }, options);
1078
+ const size = cohorts.pageLimits().request;
1079
+ 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;
1084
+ }
843
1085
  for (const { workspaceId } of due) {
844
1086
  await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
845
1087
  }
@@ -948,7 +1190,10 @@ participants) reads them with `users.profiles.listByIds(ids, options)`: every
948
1190
  profile in one call, however many ids, never `query` pages of 100 walked by
949
1191
  hand or one `findById` per person. `users.settings.listByUserIds(ids, options)`
950
1192
  does the same for their settings, and `workspaces.listByIds(ids, options)` for
951
- workspaces.
1193
+ workspaces. A report that reads people, their settings, workspaces or
1194
+ memberships beside its own rows joins `joinSource({ alias, filters },
1195
+ options)` of `users.profiles`, `users.settings`, `workspaces` or
1196
+ `workspaces.memberships` ([Layer 5](#layer-5-reports-the-resource-cannot-express)).
952
1197
 
953
1198
  An app that had a table resource of its own over a JSKIT table (such as
954
1199
  `users`) deletes it: its CRUD contract is built on the package's table resource
@@ -1001,6 +1246,38 @@ named with no value, such as `lockVersion: record.lockVersion` on a field
1001
1246
  called `version`, is refused (`options.lockVersion is undefined`), never run
1002
1247
  without it.
1003
1248
 
1249
+ ### Capability cycles
1250
+
1251
+ A capability cycle is a ring of requires: feature A requires a capability that
1252
+ feature B provides while B, directly or through others, requires one that A
1253
+ provides. An `optional` require closes a ring as a required one does. The
1254
+ kernel sets features up in the order their requires give, so it cannot start a
1255
+ ring: boot fails before any setup, naming it,
1256
+ `Provider capability cycle detected: children -> parents -> children.`
1257
+ `startTestFeatures` orders features the same way, so a feature test meets the
1258
+ ring before the server does. The same ring between packages, each listing the
1259
+ other in its `package.json` dependencies (as importing the other's shared
1260
+ table resource needs), fails as
1261
+ `Package dependency cycle detected: @local/jobs -> @local/checklists -> @local/jobs`.
1262
+
1263
+ Data access makes them easy to write: a parent's owner reads its children
1264
+ through their owners while each child's owner checks or advances the parent.
1265
+ Keep every require pointing one way:
1266
+
1267
+ - The table nearest the root requires none of the owners below it. It provides
1268
+ a small owner API, and the owners below require that.
1269
+ - Work that reads several owners together (a parent with its children, a
1270
+ report across them) is a feature of its own that requires each of them, as
1271
+ above and in [Layer 5](#layer-5-reports-the-resource-cannot-express).
1272
+ - A package imports another package's shared table resource only when that
1273
+ package does not depend on it back. When two packages need each other's
1274
+ definitions, move those table resources into a package of their own that
1275
+ holds only shared definitions and depends on neither.
1276
+ - Never break a ring with a value handed over later (a setter, a capability
1277
+ looked up at call time): a call's destination then depends on what ran
1278
+ before. A setter kept until the split carries typed features' `cycle:`
1279
+ exception ([Optional tokens](typed-features.md#optional-tokens)).
1280
+
1004
1281
  ## Check and judge
1005
1282
 
1006
1283
  Before calling data work done, apply the
@@ -1024,7 +1301,11 @@ test("allocation is flat", () => expectFlatQueries({ environment: process.env, k
1024
1301
  `expectFlatQueries` (from `@jskit-ai/resource-runtime/test`) seeds each size,
1025
1302
  counts the statements the path sends, and fails when a larger size sends more,
1026
1303
  with the counts and the statements repeated most; the later chunks a many-row
1027
- write reports are allowed. `expectScoped` runs the path as owner A before and
1304
+ write reports, and the statements of each later page `queryPages()` reads, are
1305
+ allowed. It counts only the path's own statements, those
1306
+ `run` and the async work it starts send, so another test or a background loop
1307
+ using the same Knex meanwhile changes nothing; work a loop started earlier does
1308
+ on the path's behalf is not counted either. `expectScoped` runs the path as owner A before and
1028
1309
  after seeding owner B and fails when the answer changes, so it catches a report
1029
1310
  that sums, counts or joins B's rows, not only one that returns them; it also
1030
1311
  fails when the path changed or removed a row of B's. The path runs twice, so it
@@ -1205,5 +1486,11 @@ it. Check and judge, for each file:
1205
1486
  `/* eslint ... */` config that sets one, outside client code;
1206
1487
  - an app file that the app's ESLint config lints without every data-access
1207
1488
  rule as an error (`"jskit/no-direct-knex": "off"`, `dataAccessConfig(files,
1208
- "off")`), or server code (`src/server`, `src/shared`, `server/`) that it
1209
- ignores, until `infrastructure` lists that file and rule.
1489
+ "off")`), or server code that it ignores, until `infrastructure` lists
1490
+ that file and rule. Server code is what the base config lints, at the app's
1491
+ root or a package's: its files (`server.js`), `src/` (an app's whole `src/`,
1492
+ `src/jobs/` with it), `server/`, `scripts/`, `bin/` and `config/`.
1493
+
1494
+ It reads none of what the app's ESLint config ignores outside server code,
1495
+ such as a vendored copy of old code kept beside the app, since no lint reads
1496
+ it either, and lists each directory and file it skipped.
@@ -208,7 +208,7 @@ write goes through that table's handle
208
208
  | a field's `default:` | `defaultTo:` (`default` is refused, since json-rest-api never read it) |
209
209
  | a `createdAt` or `updatedAt` default of now | nothing: drop it, since every handle write stamps them |
210
210
  | a CRUD contract that showed every column | a contract shows only its `fields`, so list every field the API showed |
211
- | reading every row | `queryAll`, which refuses above 10,000 rows, so page with `query()` |
211
+ | reading every row | `queryAll`, which refuses above 10,000 rows in a request; a job over more reads `queryPages()` a page at a time |
212
212
  | an app's own wrappers around old calls | the handle method they wrapped, then delete the wrapper |
213
213
 
214
214
  Handles answer records, `{ id, ...fields }`, with a belongs-to field as the
@@ -11,7 +11,9 @@ Use when:
11
11
 
12
12
  Rules:
13
13
 
14
- - Any chunk that adds or changes user-facing UI must include a Playwright flow that exercises the changed behavior before the chunk is done.
14
+ - Any chunk that adds or changes user-facing UI proves the changed behavior with an automated test before the chunk is done, at the cheapest level that can: a component's content, states and conditional display in a client component test (Vitest); only what needs a real browser (navigation, a flow across screens, layout at the standard widths, focus, the real sign-in) in Playwright.
15
+ - Put a new browser check in the existing spec that already reaches that screen with the data it needs. Add a new spec only for a new flow, using the app's shared server, sign-in and fixture setup. A browser test that pays its own server start, sign-in or seeding to check one card, label or state is not acceptable: every run pays for it for as long as it exists. Move that check to a component test or an existing spec, and report the spec's time and the suite's time before and after.
16
+ - Never wait out the app's own delays (request retries, debounce, polling) in real time, and never change the scenario to skip them: install Playwright's `page.clock` before the page loads and advance it with `page.clock.runFor(...)`, or use Vitest's fake timers in a component test. Change the scenario only when another test already proves the skipped path.
15
17
  - Pattern and framework UI changes must be checked at compact phone, tablet-ish medium, and expanded desktop widths.
16
18
  - Check horizontal overflow, clipped or invisible text, duplicate navigation, and broken route placement at every standard viewport. On compact screens, also check screen tap targets under 48 px; medium and expanded layouts may use their documented denser controls.
17
19
  - Apps with `shell-web` installed should start from `tests/e2e/adaptive-shell.spec.ts` and extend it with feature-specific assertions.
@@ -186,7 +188,8 @@ browser check with a full lint, build, or test sweep; follow its verification po
186
188
 
187
189
  Do not mark the chunk done if:
188
190
 
189
- - the feature changed user-facing UI but no Playwright flow ran
190
- - the Playwright flow skipped the changed behavior itself
191
+ - the feature changed user-facing UI but no test at the right level proves it
192
+ - the test skipped the changed behavior itself
193
+ - a new browser test pays its own setup to check what a component test or an existing spec could
191
194
  - a managed environment was bypassed by installing another browser
192
195
  - an authenticated flow was left untested without clearly reporting the missing session-bootstrap seam