@jskit-ai/agent-docs 0.1.283 → 0.1.284

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 (151) hide show
  1. package/guide/agent/app-extras/assistant.md +14 -2
  2. package/guide/agent/app-extras/realtime.md +38 -1
  3. package/guide/agent/app-setup/authentication.md +64 -14
  4. package/guide/agent/app-setup/console.md +8 -0
  5. package/guide/agent/app-setup/database-layer.md +23 -103
  6. package/guide/agent/app-setup/existing-application-migration.md +105 -72
  7. package/guide/agent/app-setup/multi-homing.md +94 -32
  8. package/guide/agent/app-setup/quickstart.md +1 -1
  9. package/guide/agent/app-setup/run-an-accounts-hub.md +1 -1
  10. package/guide/agent/app-setup/upgrading-jskit.md +8 -3
  11. package/guide/agent/app-setup/users.md +8 -5
  12. package/guide/agent/framework/application-operations.md +1 -1
  13. package/guide/agent/framework/crud-operations.md +63 -45
  14. package/guide/agent/framework/data-access.md +1031 -0
  15. package/guide/agent/framework/sanity-checks.md +26 -27
  16. package/guide/agent/framework/typed-features.md +114 -247
  17. package/guide/agent/index.md +2 -0
  18. package/package.json +6 -3
  19. package/patterns/feature-package/PATTERN.md +25 -17
  20. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  21. package/patterns/feature-package/example/variations/InvoiceRollupFeature.js +6 -7
  22. package/patterns/feature-package/example/variations/invoiceResource.js +15 -0
  23. package/patterns/feature-package/example/variations/invoiceRollupActions.js +4 -4
  24. package/patterns/feature-package/example/variations/ownedTableRepository.js +27 -0
  25. package/patterns/feature-package/example/variations/types.js +1 -1
  26. package/patterns/minimal-foundation/example/AGENTS.md +4 -0
  27. package/patterns/minimal-foundation/example/package.json +4 -4
  28. package/patterns/minimal-foundation/example/server.js +4 -1
  29. package/patterns/shell-foundation/example/AGENTS.md +4 -0
  30. package/patterns/shell-foundation/example/package.json +5 -5
  31. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  32. package/patterns/shell-foundation/example/server.js +4 -1
  33. package/reference/autogen/KERNEL_MAP.md +2 -1
  34. package/reference/autogen/PATTERN_INDEX.md +70 -59
  35. package/reference/autogen/README.md +1 -1
  36. package/reference/autogen/packages/agent-docs.md +7 -3
  37. package/reference/autogen/packages/agent-runtime.md +48 -11
  38. package/reference/autogen/packages/architecture-inspector.md +6 -2
  39. package/reference/autogen/packages/assistant-core.md +4 -4
  40. package/reference/autogen/packages/assistant-runtime.md +33 -13
  41. package/reference/autogen/packages/auth-provider-local-core.md +3 -1
  42. package/reference/autogen/packages/auth-provider-local-db-core.md +65 -14
  43. package/reference/autogen/packages/auth-provider-oidc-core.md +5 -1
  44. package/reference/autogen/packages/auth-provider-supabase-core.md +1 -1
  45. package/reference/autogen/packages/connectors-core.md +22 -2
  46. package/reference/autogen/packages/console-core.md +9 -5
  47. package/reference/autogen/packages/crud-core.md +38 -173
  48. package/reference/autogen/packages/database-runtime.md +8 -32
  49. package/reference/autogen/packages/http-runtime.md +3 -3
  50. package/reference/autogen/packages/http-web.md +12 -0
  51. package/reference/autogen/packages/kernel.md +25 -2
  52. package/reference/autogen/packages/oidc-issuer-core.md +4 -0
  53. package/reference/autogen/packages/payments-core.md +29 -6
  54. package/reference/autogen/packages/realtime.md +15 -6
  55. package/reference/autogen/packages/resource-core.md +30 -5
  56. package/reference/autogen/packages/resource-crud-core.md +30 -34
  57. package/reference/autogen/packages/resource-runtime.md +198 -0
  58. package/reference/autogen/packages/rewarded-core.md +16 -10
  59. package/reference/autogen/packages/shell-web.md +1 -0
  60. package/reference/autogen/packages/users-core.md +26 -14
  61. package/reference/autogen/packages/workspaces-core.md +17 -10
  62. package/reference/autogen/tooling/config-eslint.md +69 -0
  63. package/reference/autogen/tooling/jskit-catalog.md +36 -1
  64. package/reference/autogen/tooling/jskit-release.md +3 -0
  65. package/skills/jskit/SKILL.md +4 -1
  66. package/skills/jskit/references/app-operations.md +1 -1
  67. package/skills/jskit/references/crud-operations.md +62 -44
  68. package/skills/jskit/references/data-access.md +1026 -0
  69. package/skills/jskit/references/database-layer.md +411 -0
  70. package/skills/jskit/references/existing-application-migration.md +105 -72
  71. package/skills/jskit/references/heuristics/INDEX.md +6 -4
  72. package/skills/jskit/references/heuristics/crud-authoring.md +22 -14
  73. package/skills/jskit/references/heuristics/data-access.md +19 -0
  74. package/skills/jskit/references/heuristics/filters.md +6 -6
  75. package/skills/jskit/references/heuristics/row-policies.md +40 -16
  76. package/skills/jskit/references/heuristics/server-search.md +30 -42
  77. package/skills/jskit/references/heuristics/table-resource-fields.md +78 -0
  78. package/skills/jskit/references/heuristics/typed-features.md +3 -3
  79. package/skills/jskit/references/multi-homing.md +94 -32
  80. package/skills/jskit/references/pattern-index.md +70 -59
  81. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +4 -0
  82. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
  83. package/skills/jskit/references/patterns/app/minimal-foundation/example/server.js +4 -1
  84. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +4 -0
  85. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +5 -5
  86. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  87. package/skills/jskit/references/patterns/app/shell-foundation/example/server.js +4 -1
  88. package/skills/jskit/references/patterns/assistant/assistant-surface/PATTERN.md +5 -2
  89. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -2
  90. package/skills/jskit/references/patterns/auth/auth-surface/PATTERN.md +15 -0
  91. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  92. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  93. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  94. package/skills/jskit/references/patterns/crud/crud-contract/PATTERN.md +133 -0
  95. package/skills/jskit/references/patterns/crud/crud-contract/example/bookResource.js +74 -0
  96. package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/PATTERN.md +69 -37
  97. package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/packages/books/package.json +5 -4
  98. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/server/BooksFeature.js +13 -0
  99. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/shared/bookResource.js +74 -0
  100. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/shared/index.js +1 -0
  101. package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +26 -8
  102. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookEditPage.vue +2 -2
  103. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookListPage.vue +2 -2
  104. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookNewPage.vue +2 -2
  105. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookViewPage.vue +3 -3
  106. package/skills/jskit/references/patterns/database/mysql-application/PATTERN.md +9 -4
  107. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -1
  108. package/skills/jskit/references/patterns/database/mysql-application/example/scripts/prepare-database.js +8 -0
  109. package/skills/jskit/references/patterns/database/postgres-application/PATTERN.md +8 -4
  110. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -1
  111. package/skills/jskit/references/patterns/database/postgres-application/example/scripts/prepare-database.js +8 -0
  112. package/skills/jskit/references/patterns/database/table-resource/PATTERN.md +117 -0
  113. package/skills/jskit/references/patterns/database/table-resource/example/orderResource.js +25 -0
  114. package/skills/jskit/references/patterns/realtime/realtime-application/PATTERN.md +7 -0
  115. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  116. package/skills/jskit/references/patterns/rewards/google-rewarded/PATTERN.md +1 -1
  117. package/skills/jskit/references/patterns/server/feature-package/PATTERN.md +25 -17
  118. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  119. package/skills/jskit/references/patterns/server/feature-package/example/variations/InvoiceRollupFeature.js +6 -7
  120. package/skills/jskit/references/patterns/server/feature-package/example/variations/invoiceResource.js +15 -0
  121. package/skills/jskit/references/patterns/server/feature-package/example/variations/invoiceRollupActions.js +4 -4
  122. package/skills/jskit/references/patterns/server/feature-package/example/variations/ownedTableRepository.js +27 -0
  123. package/skills/jskit/references/patterns/server/feature-package/example/variations/types.js +1 -1
  124. package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +59 -10
  125. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +5 -4
  126. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/server/UsersFeature.js +7 -5
  127. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/index.js +1 -1
  128. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/userCrud.js +21 -0
  129. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +11 -5
  130. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/UsersWorkspaceFeature.js +14 -5
  131. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +52 -0
  132. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/index.js +1 -1
  133. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/userCrud.js +21 -0
  134. package/skills/jskit/references/patterns/workspaces/workspace-server/PATTERN.md +9 -9
  135. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/PATTERN.md +16 -0
  136. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/example/src/pages/home/workspaces/index.vue +2 -0
  137. package/skills/jskit/references/sanity-checks.md +27 -28
  138. package/skills/jskit/references/typed-features.md +114 -247
  139. package/patterns/feature-package/example/variations/customKnexRepository.js +0 -40
  140. package/reference/autogen/packages/json-rest-api-core.md +0 -66
  141. package/skills/jskit/references/heuristics/crud-repository-mapping.md +0 -140
  142. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/server/BooksFeature.js +0 -13
  143. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/shared/bookResource.js +0 -83
  144. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/shared/index.js +0 -1
  145. package/skills/jskit/references/patterns/crud/resource-contract/PATTERN.md +0 -155
  146. package/skills/jskit/references/patterns/crud/resource-contract/example/bookResource.js +0 -87
  147. package/skills/jskit/references/patterns/server/feature-package/example/variations/customKnexRepository.js +0 -40
  148. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/userResource.js +0 -69
  149. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/userResource.js +0 -56
  150. /package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/migrations/20260815000000_books.cjs +0 -0
  151. /package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/packages/books/src/server/types.js +0 -0
@@ -0,0 +1,1031 @@
1
+ <!-- Generated by `npm run agent-docs:build` from `packages/agent-docs/site/guide/framework/data-access.md`. Do not edit manually. -->
2
+
3
+ ---
4
+ title: Data access
5
+ description: Read and write an application's tables through their table resources, the one door that applies ownership, row policies and validation.
6
+ ---
7
+
8
+ # Data access
9
+
10
+ Read this before writing code that reads or writes a database table, with or
11
+ without screens. [Typed features](/guide/framework/typed-features) covers how
12
+ the code around it is shaped; this page covers how it reaches the data.
13
+
14
+ ## The resource is the only door
15
+
16
+ Every table has exactly one **table resource**. Only the feature that owns the
17
+ table reads or writes it, through the resource's handle. Ownership, the row
18
+ policy, validation, ownership stamping, audits and change events run only on
19
+ that path. Knex reaches the table past all of them, and putting it in a file
20
+ named "repository" protects nothing.
21
+
22
+ Bad: a repository raised a version counter on a record id it was handed, with
23
+ no workspace predicate, so one workspace could change another workspace's
24
+ booking.
25
+
26
+ Good: the owner's repository calls `bookings.patch(id, { version }, options)`,
27
+ which changes the row only when the caller's scope owns it.
28
+
29
+ ## The handle's calls
30
+
31
+ One table, one table resource, one owner feature. The owner reaches its table
32
+ through its handle's calls, and every call takes the call's `options` last:
33
+
34
+ | Call | Does |
35
+ | --- | --- |
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 |
38
+ | `get(id, params, options)` | one record |
39
+ | `aggregate({ measures, groupBy, filters, sort }, options)` | totals, grouped by fields or date buckets |
40
+ | `create(data, options)`, `findOrCreate({ id, ...data }, options)` | one record; a keyed one, created once |
41
+ | `patch(id, changes, options)`, `delete(id, options)` | one record |
42
+ | `createMany(rows, options)` | many records, or an upsert with `onConflict` |
43
+ | `patchMany(rows, options)` | many records, each with its own changes |
44
+ | `patchWhere(filters, changes, options)`, `deleteWhere(filters, options)` | every matching row |
45
+ | `lock(id, txOptions)`, `lockMany(ids, txOptions)`, `lockWhere(filters, txOptions)` | rows locked until the transaction ends |
46
+ | `lockOrCreate({ id, ...initial }, options, work)` | a keyed record locked, created first when missing, for `work` |
47
+ | `claim({ filters, sort, take }, txOptions)` | the next rows no other worker holds |
48
+ | `withTransaction(options, work)` | one transaction; `work` gets its `txOptions` |
49
+ | `scopedQuery(params, options)` | a read-only builder for a report ([Layer 5](#layer-5-reports-the-resource-cannot-express)) |
50
+
51
+ ```js
52
+ // How many invoices of each status the workspace issued each month, and their total.
53
+ function totalsByMonth(options) {
54
+ return invoices.aggregate({
55
+ groupBy: ["status", { field: "issuedAt", bucket: "month", as: "month" }],
56
+ measures: { invoices: { count: "*" }, amount: { sum: "amount" } },
57
+ sort: ["month"]
58
+ }, options);
59
+ }
60
+ ```
61
+
62
+ ## Words
63
+
64
+ | Word | Meaning |
65
+ | --- | --- |
66
+ | table resource | The one description of one table: fields and their columns, ownership, relationships, search and lock version. `defineTableResource()`, shared with the client. |
67
+ | owner feature | The feature whose `owns` lists the table resource. It is the only feature that reads or writes the table. |
68
+ | handle | What the owner gets for each resource it owns. Every method applies the scope of the call's `options.context`. |
69
+ | repository | The owner's module that holds every query, built on its handles. Its methods have domain names. A plain CRUD feature's repository is its table's handle itself. |
70
+ | service | Orchestration. It calls repositories (its own, or another feature's through that feature's capability: an owner API, or a CRUD feature's `repository`) and other features' services. It never builds a query (no Knex, no `scopedQuery`) and never reaches another feature's table any other way. |
71
+ | owner API | The capability through which a table's owner lets other features read and write its records, such as `users.profiles`. |
72
+ | CRUD contract | The optional HTTP and screen surface on a table resource: `defineCrudContract()`. |
73
+ | CRUD feature | The feature `defineCrudFeature()` builds from a CRUD contract: its repository, service, actions, routes and screens. |
74
+ | ownership | Who owns a row, required in every table resource: `public`, `workspace`, `user`, `workspace_user`, or `workspace_or_shared` (a workspace's own rows, or rows shared with every workspace when the owner is empty). |
75
+ | row policy | A server-only rule narrowing which rows a context sees, applied to every statement, joins, includes and locks included. |
76
+ | scoped query | The read-only Knex builder `scopedQuery()` returns, with ownership and the row policy already applied. |
77
+ | system scope | Why a feature works across every workspace and user, declared with `systemScope`. |
78
+ | system context | The context of that work, from `systemContext(reason)`; `forWorkspace`, `forUser` and `forWorkspaceUser` narrow it. |
79
+ | `txOptions` | What `withTransaction(options, work)` hands `work`: only the call's context and its transaction, `{ context, trx }`, passed to every call inside. |
80
+ | record | What a handle answers: `{ id, ...fields }`, relationships as ids, included records nested. |
81
+
82
+ ## Layer 1: the table resource
83
+
84
+ Every table your app owns has one table resource. The feature that owns the
85
+ table lists it in `owns`, and only that feature's setup receives its handle.
86
+ Every read and write goes through the handle. On every statement the handle
87
+ adds the ownership predicate and the row policy, validates what you write and
88
+ stamps the workspace or user on new rows. A table whose schema stores
89
+ `createdAt` and `updatedAt` as `dateTime` fields gets them stamped by every
90
+ write: both on a create (`createMany` too), `updatedAt` on every change
91
+ (`patch`, `patchMany`, `patchWhere`, an upsert merge, which keeps the stored
92
+ `createdAt`). The stamps replace what any other caller sends, and a CRUD
93
+ contract offers them as output only; only a system context (an import, a
94
+ migration, a seed) sets its own. Pass the call's `options` (its context and
95
+ transaction) to every handle call; a call without a context throws instead of
96
+ guessing.
97
+
98
+ ```js
99
+ // shared/receivalResource.js: the table's facts, shared with the client
100
+ const receivalResource = defineTableResource({
101
+ name: "receivals",
102
+ tableName: "receivals",
103
+ ownership: "workspace",
104
+ schema: {
105
+ status: { type: "string", required: true, search: true },
106
+ arrivedAt: { type: "dateTime", nullable: true }
107
+ }
108
+ });
109
+
110
+ // server/ReceivalsFeature.js: the owner
111
+ const ReceivalsFeature = defineFeature({
112
+ id: "receivals",
113
+ owns: [receivalResource, { resource: receivalNoteResource, rowPolicy: authorOrManager }],
114
+ provides: { receivals: "receivals" },
115
+ setup: ({ resources }) => ({ receivals: createReceivalsService(createReceivalsRepository(resources)) })
116
+ });
117
+
118
+ /**
119
+ * server/receivalsRepository.js: every query, by domain name.
120
+ * @param {{ receivals: import("@jskit-ai/resource-runtime/server/types").ResourceHandle }} handles
121
+ */
122
+ function createReceivalsRepository({ receivals }) {
123
+ /**
124
+ * One page of the open receivals.
125
+ * @param {{ size: number, after?: string }} page
126
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceCallOptions} options
127
+ */
128
+ function listOpen(page, options) {
129
+ return receivals.query({ filters: { status: "open" }, page }, options);
130
+ }
131
+
132
+ /**
133
+ * @param {string} id
134
+ * @param {string} arrivedAt
135
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceCallOptions} options
136
+ */
137
+ function markArrived(id, arrivedAt, options) {
138
+ return receivals.patch(id, { status: "arrived", arrivedAt }, options);
139
+ }
140
+
141
+ return Object.freeze({
142
+ withTransaction: receivals.withTransaction,
143
+ listOpen,
144
+ markArrived
145
+ });
146
+ }
147
+ ```
148
+
149
+ A package whose features own tables or declare `systemScope` lists
150
+ `runtime.resources` in its descriptor's `capabilities.requires`, or boot
151
+ refuses it; the feature never requires it itself, since it receives its
152
+ handles.
153
+
154
+ Reads take `(params, options)` and writes `(data, options)`: `get(id, {},
155
+ options)`. A call's settings ride in its options, added on the call they are
156
+ for and spread from the options the method received: `{ ...options, missing:
157
+ "skip" }`, `{ ...options, returning: "id" }`. A method passes on only the
158
+ options it received, never a setting it added for one of its own calls, and
159
+ another table gets only `{ context, trx }`: `passedOn(options)` from
160
+ `@jskit-ai/resource-runtime/server`. An owner API, called with another
161
+ feature's options, hands each of its calls `passedOn(options)` plus the
162
+ settings that call needs, never the caller's options whole. `withTransaction`
163
+ hands its work only the context and the transaction, and so does a handle to
164
+ its hooks and its own nested calls.
165
+
166
+ `txOptions` is `{ context, trx }` and nothing else: every other key of the
167
+ method's `options`, the application's own included (a flag that skips an
168
+ email, a `limit`, a per-request cache), is gone inside the work. Read what the
169
+ work needs from `options` itself, never from `txOptions`. A method that wraps
170
+ a write on its own table in `withTransaction` passes that write `{ ...options,
171
+ ...txOptions }`, or the caller's `returning`, `missing` or `format` never
172
+ reaches it and the method answers something its caller did not ask for.
173
+ Another table's call still gets `txOptions`:
174
+
175
+ ```js
176
+ const addLines = (rows, options) => lines.withTransaction(options, async (txOptions) => {
177
+ const added = await lines.createMany(rows, { ...options, ...txOptions });
178
+ await orders.patch(rows[0].orderId, { linesChangedAt: new Date() }, txOptions);
179
+ return added;
180
+ });
181
+ ```
182
+
183
+ A filter key is one of the resource's `search: true` fields or a
184
+ `searchSchema` entry. `id` is every handle's own filter (a resource never
185
+ declares an `id` search entry) and takes one id or a list; an empty list
186
+ matches no row. Another list of values needs an entry with `filterOperator:
187
+ "in"`. A `null` value matches the rows where the field is null: `filters: {
188
+ archivedAt: null }`.
189
+
190
+ A filter on another table's field (`searchSchema: { supplierName: { type:
191
+ "string", actualField: "suppliers.name" } }`) needs that field declared
192
+ `indexed: true` on its own table resource, with a database index on its column,
193
+ even when the join is on the other table's id; `own()` refuses it at startup
194
+ otherwise, naming both.
195
+
196
+ A table keyed by a `uuid` or `string` primary key takes its ids as text; an
197
+ `owns` entry's `normalizeId` is only for an id written some other way.
198
+
199
+ A read answers every field unless `fields` names the ones it needs (a
200
+ belongs-to field by its own key, such as `customerId`, or its relationship);
201
+ the locks (`lock`, `lockMany`, `lockWhere` and `claim`) take it in their
202
+ options, `{ ...txOptions, fields }`.
203
+ Each belongs-to field a read answers costs a check that its related record is
204
+ visible, so a hot path names its fields. A collection relation with
205
+ `hydrateOnList: false` or `hydrateOnView: false` loads its rows on that kind of
206
+ read only when the read includes it.
207
+
208
+ A `hidden: true` field (an ownership id, a bearer token, a storage key) never
209
+ reaches a request: no CRUD route or JSON:API document (`format: "jsonapi"`)
210
+ carries it, even when `fields` names it. The owner's own code reads one by
211
+ naming it in a handle read's `fields`, `get(id, { fields: ["publicToken"] },
212
+ options)` or `{ ...txOptions, fields }` for a lock, which costs one more
213
+ statement per 1,000 records, scoped as the read is; a read without `fields`
214
+ never answers it. It may be an id, string, integer, number or boolean, and
215
+ naming any other kind refuses the read. The repository a CRUD feature gives
216
+ other features, and its own actions, refuses a read naming a hidden field, and
217
+ any `scopedQuery()` (its builder carries every column), and every method it
218
+ holds, `decorateRepository`'s included, answers without the hidden fields of the
219
+ tables its owner owns, unless the call runs in a system context. So the owner
220
+ checks a token in a method `decorateRepository` adds, with the handle it
221
+ receives, and answers only the outcome. Never unhide a field to read it, and
222
+ never read it with `scopedQuery`.
223
+
224
+ `create` and `patch` answer the written record by default, read back through
225
+ the row policy. Server code that does not use it passes `returning: "id"` or
226
+ `"none"`, which skips that read; `delete` answers the id. One rule covers a
227
+ record that is missing or outside the caller's scope: `get`, `patch`, `delete`
228
+ and `lock` throw json-rest-api's not-found error (a 404), and with
229
+ `missing: "skip"` they answer `null` instead, without failing the caller's
230
+ transaction. A row keyed by an id the code computes (a fingerprint, a
231
+ conversation id) is read or created once with `findOrCreate({ id,
232
+ ...attributes }, options)`, outside the transaction that then locks it: inside
233
+ one, losing the race to create it fails the whole transaction. Compute such an
234
+ id from the owner too (the workspace or user id in the fingerprint): an id
235
+ another scope's row holds answers a missing record. That answer still says the
236
+ id is taken, so `findOrCreate` and `lockOrCreate` take only ids the server
237
+ computes or generates, never an id a request supplies.
238
+
239
+ A write that the caller's row policy then hides fails, because its read-back
240
+ finds nothing. The `create` or `patch` answers json-rest-api's 404
241
+ (`REST_API_RESOURCE`, "Resource not found") and its transaction rolls back. A
242
+ staff member who sees only the bookings they run cannot book a colleague this
243
+ way. Decide which way the rule goes. Either the policy lets the writer see
244
+ what they may write, or the action refuses the write before it runs, with a
245
+ 403 or 422 that says why.
246
+
247
+ You never write `where workspace_id = ?`, so you cannot forget it. The row
248
+ policy and write hooks go in the `owns` entry on the server, never in the
249
+ shared definition, which ships to the browser. Another feature's contribution
250
+ to a row policy can only narrow it. A row policy adds one predicate correlated
251
+ with the row being read, on indexed columns, and reads the owner's other
252
+ tables through `ownTable(name)`, a read-only subquery, never by table name. It
253
+ never loads everything the subject was ever linked to into an `IN` list, and
254
+ never calls another owner's check once per item: it runs on every read, write
255
+ read-back and lookup, so every request pays what it costs. A policy is told
256
+ who the call runs for as `caller`: `{ kind: "user", userId }` for a signed-in
257
+ request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
258
+ `{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
259
+ `reason`, for background work. A job names a user only as `actorId`, the
260
+ person it was derived for (below), so never read a missing actor as an
261
+ anonymous request.
262
+
263
+ ```js
264
+ // good: one predicate on the row being read
265
+ function noticeVisibilityPolicy({ query, caller, column, value, ownTable }) {
266
+ if (caller.kind !== "user") {
267
+ return caller.kind !== "anonymous";
268
+ }
269
+ query.where(column("recipientId"), value("recipientId", caller.userId)).whereExists(
270
+ ownTable("sources").select(1).whereColumn("sources.id", column("sourceId")).where("sources.status", "published")
271
+ );
272
+ return true;
273
+ }
274
+
275
+ // bad: every source the recipient was ever notified about, checked one by one
276
+ async function noticeVisibilityPolicy({ query, caller, context, column, value }) {
277
+ const readable = [];
278
+ for (const sourceId of await noticeHistory.listSourceIds(caller.userId)) {
279
+ if (await sources.canRead(sourceId, context)) readable.push(sourceId);
280
+ }
281
+ query.whereIn(column("sourceId"), readable.map((id) => value("sourceId", id)));
282
+ return true;
283
+ }
284
+ ```
285
+
286
+ A rule that depends on the caller's role reads `context.permissions` with
287
+ `hasPermission(context.permissions, "bookings.view-all")` from
288
+ `@jskit-ai/kernel/shared/support`, never with `includes()`. A role may hold
289
+ `*` (an owner does) or `bookings.*`, and `includes()` misses both, so the
290
+ owner would see only the rows a narrower rule leaves.
291
+
292
+ ```js
293
+ import { hasPermission } from "@jskit-ai/kernel/shared/support";
294
+
295
+ // good: staff see the bookings they run; bookings.view-all, the owner's * included, sees them all
296
+ function staffSeeTheirOwnBookings({ query, caller, context, column, value }) {
297
+ if (caller.kind !== "user") {
298
+ return caller.kind !== "anonymous";
299
+ }
300
+ if (hasPermission(context.permissions, "bookings.view-all")) {
301
+ return true;
302
+ }
303
+ query.where(column("staffUserId"), value("staffUserId", caller.userId));
304
+ return true;
305
+ }
306
+
307
+ // bad: an owner holding "*" sees only the bookings they run
308
+ if (context.permissions.includes("bookings.view-all")) return true;
309
+ ```
310
+
311
+ A policy trusts `caller`. In a workspace request it also trusts what the
312
+ server resolved from the route's workspace slug and the caller's membership
313
+ before any handle ran:
314
+ - `context.workspace.id`;
315
+ - `context.membership.roleSid`;
316
+ - `context.permissions`, the role's permissions in that workspace.
317
+
318
+ On a surface whose access policy sets `requireWorkspaceMembership: false`, a
319
+ caller who is not a member has `context.membership` null and
320
+ `context.permissions` empty. Never decide on a value from the request's input.
321
+
322
+ A row policy hides a row everywhere, also as the target of a visible record's
323
+ link: an order whose supplier the reader cannot see reads `supplierId: null`.
324
+ A form that saves the order back sends that null, and the write keeps the
325
+ stored link: a null for a link whose target exists but is hidden from the
326
+ writer counts as unchanged. Send that null, never the hidden id: a write naming
327
+ a target the writer cannot see is refused as a missing target, since accepting
328
+ the right hidden id would tell the writer which record is linked. The link is
329
+ still unreadable to that reader, so a row policy says who may see a row at all;
330
+ narrowing what one screen lists is a filter on that screen's read.
331
+
332
+ ```js
333
+ // bad: a row policy to list only the suppliers a user added; every order whose
334
+ // supplier someone else added now reads supplierId: null for that user
335
+ owns: [{ resource: supplierResource, rowPolicy: addedByActor }]
336
+
337
+ // good: the screen's read filters; orders keep their suppliers
338
+ function listAddedByMe(params, options) {
339
+ return suppliers.query({ ...params, filters: { ...params.filters, addedBy: resolveActorId(options.context) } }, options);
340
+ }
341
+ ```
342
+
343
+ ## Layer 2: the CRUD surface
344
+
345
+ When people list, view, create, edit or delete the rows over HTTP, add a CRUD
346
+ contract to the same table resource. It says which operations exist and which
347
+ fields each one takes and returns; `defineCrudFeature` builds the standard
348
+ repository, service, actions, routes and screens from it. A table without
349
+ screens skips this layer and still uses Layer 1. A table never gets a second
350
+ resource for its CRUD. [CRUD operations](/guide/framework/crud-operations)
351
+ covers this layer in depth.
352
+
353
+ ```js
354
+ const receivalCrud = defineCrudContract(receivalResource, {
355
+ namespace: "receivals",
356
+ apiAccess: "authenticated",
357
+ operations: ["list", "view", "create", "patch"],
358
+ fields: {
359
+ status: { output: "required", create: "required", patch: "optional" },
360
+ arrivedAt: { output: "optional" }
361
+ }
362
+ });
363
+ ```
364
+
365
+ A contract shows and accepts only the fields it names, and so does an included
366
+ record: it shows its id, its relation's `labelKey`, and only what the including
367
+ contract lists, a record it links to only when the list names that
368
+ relationship, at any depth. Another table's contract never shapes it, since
369
+ that contract may be served to another surface or behind another permission:
370
+
371
+ ```js
372
+ fields: {
373
+ ownerId: { output: { required: false, includeFields: ["displayName"] } },
374
+ // ?include=pet,pet.breed shows the pet's name and its breed's name
375
+ petId: { output: { required: false, includeFields: ["name", { breed: ["name"] }] } }
376
+ }
377
+ ```
378
+ A `decorateService` that also needs the record it wrote (for an audit or a
379
+ history row) reads it from its own answer, `simplifyJsonApiResult(answer)`
380
+ from `@jskit-ai/http-runtime/shared`, never with a second read; a relationship
381
+ whose field key is not its name plus `Id` passes
382
+ `{ lookupFieldMap: { owner: "ownerUserId" } }`.
383
+ A second surface on the same table (a customer's view beside the staff's) is
384
+ a second CRUD contract on the same resource, never a second resource, with
385
+ its own namespace. One plain feature owns the table and provides its handle.
386
+ Each surface is a `defineCrudFeature` that serves its contract through that
387
+ handle with `ownerRepository`. A CRUD feature that owns the table provides
388
+ `{ repository, crud, service }` rather than a handle, so it cannot be another
389
+ one's `ownerRepository`.
390
+
391
+ ```js
392
+ const ServicesFeature = defineFeature({
393
+ id: "app.services",
394
+ owns: [serviceResource],
395
+ provides: { services: "app.services" },
396
+ setup: ({ resources }) => ({ services: resources.services })
397
+ });
398
+ const ServiceAdminFeature = defineCrudFeature({
399
+ crud: serviceAdminCrud,
400
+ surface: "admin",
401
+ ownerRepository: "app.services",
402
+ scope: WORKSPACE_CRUD_SCOPE,
403
+ relativePath: "/services"
404
+ });
405
+ const ServiceCatalogFeature = defineCrudFeature({
406
+ crud: serviceCatalogCrud,
407
+ surface: "app",
408
+ ownerRepository: "app.services",
409
+ scope: WORKSPACE_CRUD_SCOPE,
410
+ relativePath: "/service-catalog"
411
+ });
412
+ ```
413
+
414
+ `WORKSPACE_CRUD_SCOPE` is from
415
+ `@jskit-ai/workspaces-core/server/support/workspaceCrud`; the two contracts'
416
+ namespaces differ because `crud.contracts` refuses two contracts under one
417
+ namespace. A field
418
+ fragment several resources spread into their schemas carries only the field:
419
+ each contract that shows it states its operations in its own `fields`.
420
+
421
+ ## Layer 3: many rows at once
422
+
423
+ When one action creates, changes or deletes many rows, use the handle's
424
+ many-row operations: `createMany`, `patchMany`, `patchWhere` and `deleteWhere`.
425
+ Every filter takes `id`, one id or a list, so `patchWhere({ id: ids },
426
+ changes, options)` and `deleteWhere({ id: ids }, options)` change a known
427
+ list. Each checks every row against the resource first, reads which of the
428
+ rows the caller may touch, then writes each chunk with one statement, in one
429
+ transaction. Audits and change events run once for the batch, so the
430
+ statements are the same for 10 rows as for 200. `patchWhere` and
431
+ `deleteWhere` check the scope and filters on each row once they hold its lock,
432
+ so a row gone meanwhile, or changed so that it no longer matches, is skipped,
433
+ and they take no `missing`. That makes them a compare-and-set on their filters
434
+ on every database: of two writers whose filters both matched a row (open to
435
+ booked), only one writes it, and the other's answer says so
436
+ (`returning: "ids"` lists only the rows it wrote). The one that waited for the
437
+ other's lock leaves the row out; SQLite gets there by failing the second writer
438
+ with `SQLITE_BUSY`, and MariaDB with `innodb_snapshot_isolation` fails the
439
+ waiter with errno 1020 ("Record has changed since last read"), which a write
440
+ that owns its transaction runs again. For one row,
441
+ `patch(id, changes, { ...options, onlyIf: { status: "open" } })` does the same.
442
+ A filter on another table (a search field whose `actualField` reaches a
443
+ related table) is refused on MySQL and MariaDB (`RestApiValidationError`, rule
444
+ `selection_cross_table`), even when it matches no row. A search entry's own
445
+ `applyFilter` that reads another table through a subquery is not detected, and
446
+ is no compare-and-set there either. Filter on the row's own columns instead, for example
447
+ on the link after locking its targets with `lockMany` in the same transaction
448
+ (Layer 4), or move the value onto the row itself for `onlyIf`, or use a
449
+ `claim`. A many-row write answers
450
+ nothing unless `returning` asks: `"ids"`, `"records"`, or `"count"`, which
451
+ reads nothing back.
452
+
453
+ Bad: a batch intake patched each record through the resource: 705 statements
454
+ for 10 records, 11,915 for 200. Written with `patchMany`, it sends the same
455
+ number at both sizes.
456
+
457
+ An upsert (`createMany(rows, { ...options, onConflict })`) on an owned table
458
+ names the ownership field among its conflict keys, or it throws: otherwise it
459
+ could update or hide another workspace's row. `merge` is refused under a row
460
+ policy; use `ignore`, then `patchMany`. A table with no owners and no row
461
+ policy, keyed by a natural id such as an email, may key the upsert on the id
462
+ alone: `onConflict: { keys: ["id"], action: "merge" }`, with an id on every
463
+ row, creates the record or replaces the fields given in one statement. Of two
464
+ upserts at once the later stays, so a counter that must count both is created
465
+ plainly and its duplicate retried instead. Inserting and catching the
466
+ duplicate works only outside a transaction: inside one, the failed insert
467
+ fails the whole transaction, so use an upsert there.
468
+
469
+ A transaction whose insert loses a race for a unique key (another request with
470
+ the same idempotency key committed first) runs once more:
471
+ `retryLostInsertRace(options, () => repository.withTransaction(options, work))`
472
+ from `@jskit-ai/database-runtime/shared`. The database reports the duplicate
473
+ only once the winner has committed, so the second run's lookup by the key
474
+ finds the winner's row. A second duplicate is final, and inside a caller's
475
+ transaction nothing runs again: the caller's transaction has failed, and the
476
+ caller's own retry runs the whole of it. Never write this retry by hand.
477
+
478
+ ```js
479
+ // A repeated payment request answers the first one's record.
480
+ const recordPayment = (input, options) => retryLostInsertRace(options, () => payments.withTransaction(options, async (txOptions) => {
481
+ const [paid] = await payments.queryAll({ filters: { requestKey: input.requestKey } }, txOptions);
482
+ return paid ?? payments.create(input, txOptions);
483
+ }));
484
+ ```
485
+
486
+ ## Layer 4: locks
487
+
488
+ When two requests could change the same rows at once (allocating stock,
489
+ numbering documents, moving items into a batch), lock the rows first, inside
490
+ the transaction: `lock(id, txOptions)`, `lockMany(ids, txOptions)` or
491
+ `lockWhere(filters, txOptions)`. The handle locks only rows the caller may
492
+ see, in id order, so nobody locks another workspace's rows: a missing or
493
+ hidden id throws not-found, as for `get`, or with `missing: "skip"` is left
494
+ out. A check-then-write that must not race (is the drier empty? then load it)
495
+ locks the record first, so another lock or write of it waits until the
496
+ transaction ends. Locks cover existing rows only: number with a unique index
497
+ or a counter row taken with `lock(id)`. Every lock needs a transaction; SQLite
498
+ runs one transaction at a time, so there is no row lock to take. Take locks in
499
+ one order on every path, a parent before its children.
500
+
501
+ On MySQL and MariaDB a locking read locks every row it examines, so two rules
502
+ apply there. Lock ids taken from your own scoped reads, never a list straight
503
+ from a request: every id listed is locked, a hidden one too, before it is left
504
+ out. And `lockWhere` needs an index that starts with the ownership columns (on
505
+ a table with no owners, with a column the filters compare); without one it
506
+ refuses with `RestApiBulkWriteUnsupportedError` naming the index to add in a
507
+ migration.
508
+
509
+ A worker taking the next job uses `claim({ filters, sort, take }, txOptions)`,
510
+ which skips rows another worker holds. A record that serializes work on a key
511
+ and may not exist yet (an account's balance, a connection to a provider), on a
512
+ table with no owners, takes `lockOrCreate({ id, ...initial }, options, work)`:
513
+ it locks the record, creating it from the row first when it is missing, and
514
+ runs `work(record, txOptions)` in the same transaction, so failed work leaves
515
+ no record behind. On its own it runs work once, so work may call a provider:
516
+ two first uses of one id at once can collide on the insert, and the loser runs
517
+ again before its work starts.
518
+
519
+ A record that a person edits from a form it read earlier carries a version
520
+ number: the table resource names it with `lockVersionField`, an integer field
521
+ over a non-null integer column with `defaultTo: 0`.
522
+ `patch(id, changes, { ...options, lockVersion })` then locks the row, applies
523
+ `changes` only while it still holds that version, and advances it. A record
524
+ whose version moved on rejects with a 409 coded
525
+ `crud.document.lockVersionConflict` ("This record changed. Reload and try
526
+ again.") and nothing is written; a missing or hidden record follows the
527
+ missing-row rule. The changes leave the version field out. Every update
528
+ advances the version, a plain `patch`, `patchMany`, `patchWhere` or upsert
529
+ merge too, so a form that read version 3 and saves after a background job's
530
+ plain patch of the same record (now version 4) gets the 409 and reloads, even
531
+ though the job changed other fields. `patch(id, changes, { ...options, onlyIf:
532
+ { status: "open" } })` is the same check on stored values, answering `null`
533
+ when they differ.
534
+
535
+ ## Layer 5: reports the resource cannot express
536
+
537
+ Some reads are reports, not records: totals by status and month, the newest
538
+ row per group, distinct active days. Try `aggregate` (counts, sums, buckets by
539
+ day, week, month or year) and an include with `strategy: "window"` (newest per
540
+ group) first. A window include is declared on the table resource's
541
+ relationship, never in the query, and read with `include: ["snapshots"]`:
542
+
543
+ ```js
544
+ relationships: {
545
+ snapshots: { type: "hasMany", target: "snapshots", foreignKey: "booking_id", include: { strategy: "window", limit: 1, orderBy: ["-revision"] } }
546
+ }
547
+ ```
548
+
549
+ When neither can express it, the owner's repository builds it from
550
+ `scopedQuery()`, the table's builder with ownership and the row policy already
551
+ applied:
552
+
553
+ ```js
554
+ // Knex: report - the days each member was active, counted per member
555
+ async function activeDays(memberIds, options) {
556
+ const { query } = await visits.scopedQuery({}, options);
557
+ return query
558
+ .select({ memberId: visits.column("memberId") })
559
+ .countDistinct({ days: visits.raw("DATE(??)", [visits.column("visitedAt")]) })
560
+ .whereIn(visits.column("memberId"), memberIds)
561
+ .groupBy(visits.column("memberId"));
562
+ }
563
+ ```
564
+
565
+ - Each report is one named method in its owner's repository file
566
+ (`*Repository.js`, `repository.js`, or a file under `repositories/`;
567
+ `jskit/knex-reason` refuses `scopedQuery()` anywhere else), under
568
+ `// Knex: <kind> - <reason>`. The kinds:
569
+ - `totals`: sums or counts `aggregate` cannot group;
570
+ - `report`: rows shaped across tables;
571
+ - `newest-per-group`: one row per group, such as each customer's latest
572
+ visit;
573
+ - `subquery`: a scoped set another query joins;
574
+ - `hot-path`: a measured path the handle makes too slow, with its JSKIT
575
+ ticket (`// Knex: hot-path #123 - <reason>`);
576
+ - `runtime-tables`: tables people define while the app runs, which no table
577
+ resource in the code describes.
578
+ - `column()` names columns; `raw` takes one expression as a string literal,
579
+ with its values as bindings.
580
+ - The builder only reads, and only its own table is scoped. A `.join()` of a
581
+ table by name, or a table named inside `raw()`, reads that table's rows past
582
+ its owner and row policy. Nothing at run time stops it: `jskit/no-direct-knex`
583
+ and `jskit/sql-literal` flag it, and `expectScoped` catches it only when the
584
+ test seeds another owner's rows the join matches. Join another table only as
585
+ that table's scoped subquery.
586
+ - When a report needs another owner's rows side by side, that owner adds a
587
+ method answering its scoped rows as a subquery with its own column names,
588
+ and the report joins it in one statement. The method answers
589
+ `{ query, column }` from its `scopedQuery({ alias }, options)`, with
590
+ `column(field)` naming its columns under that alias, wrapped, since a
591
+ builder is thenable and an async function would run it. The report shapes
592
+ that builder (select, group), names it with `.as()` and joins it, so every
593
+ owner's scope and row policy stay applied. The report lives with the main
594
+ table's owner, or in a feature that requires both. A join source carries
595
+ every column, hidden ones too, so an owner answers one only to a feature
596
+ that may read them.
597
+
598
+ ```js
599
+ // billing's owner API: its scoped invoices as a join source, with their column names
600
+ async function invoiceSource(alias, options) {
601
+ const { query } = await invoices.scopedQuery({ alias }, options);
602
+ return { query, column: (field) => invoices.column(field, alias) };
603
+ }
604
+
605
+ // Knex: report - open tasks and billed totals per project, joined on billing's scoped invoices
606
+ async function projectSummary(options) {
607
+ const source = await billing.invoiceSource("i", options);
608
+ const billed = source.query
609
+ .select({ projectId: source.column("projectId") })
610
+ .sum({ billed: source.column("amount") })
611
+ .groupBy(source.column("projectId"))
612
+ .as("b");
613
+ const { query } = await tasks.scopedQuery({ alias: "t" }, options);
614
+ const projectId = tasks.column("projectId", "t");
615
+ return query
616
+ .leftJoin(billed, "b.projectId", projectId)
617
+ .select({ projectId })
618
+ .count({ open: tasks.column("id", "t") })
619
+ .max({ billed: "b.billed" })
620
+ .groupBy(projectId);
621
+ }
622
+ ```
623
+
624
+ - When each owner can answer on its own (keys in, grouped totals out), the
625
+ report is a feature of its own that calls each owner's method and merges the
626
+ answers in memory: one statement per owner, never a call per row.
627
+ - Give each report a scope test and a flat-query test (see [Tests](#tests)).
628
+
629
+ ## Reads shaped for the screen
630
+
631
+ - A list reads one page with `query`; it needs `page.size`, at most the
632
+ resource's `queryMaxLimit`. A scrolling list passes the previous page's
633
+ `page.next` as `page.after`; numbered pages, or restoring a known page, pass
634
+ `page.number` (from 1) and answer `page: { size, number, hasMore, next }`.
635
+ `page.number` reads with OFFSET, which slows as the rows grow, so anything
636
+ that grows pages with `page.after`. Each page is one statement, with no
637
+ count: never walk pages to reach one.
638
+ - A screen list never uses `queryAll`.
639
+ - Related rows come by id list (`filters: { id: ids }`, or an `in` filter on
640
+ another field) or `include`, grouped in memory. Never a query per row, however
641
+ the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
642
+ item).
643
+ - `queryAll` reads every matching row, in cursor pages that skip and repeat
644
+ none. Use it for id lists, one parent's rows and jobs: a read its callers
645
+ treat as complete, which a `query` would silently cut at one page. In a
646
+ request it stops past the request row limit (10,000 rows) with a 400 coded
647
+ `RESOURCE_ROW_LIMIT`.
648
+
649
+ ```js
650
+ // good: one page of invoices, then all of that page's lines in one read
651
+ const { records, page: next } = await invoices.query({ sort: ["-issuedAt"], page }, options);
652
+ const lines = await invoiceLines.queryAll({ filters: { invoiceIds: records.map((invoice) => invoice.id) } }, options);
653
+ const linesByInvoice = Map.groupBy(lines, (line) => line.invoiceId);
654
+
655
+ // bad: one more read, and its row policy, for every invoice
656
+ for (const invoice of records) {
657
+ invoice.lines = await invoiceLines.queryAll({ filters: { invoiceIds: [invoice.id] } }, options);
658
+ }
659
+
660
+ // good: a delivery job reads every active subscriber
661
+ const subscribers = await subscriptions.queryAll({ filters: { newsletterId, status: "active" } }, options);
662
+
663
+ // bad: the first page only, whatever its size
664
+ const { records: firstPage } = await subscriptions.query({ filters: { newsletterId, status: "active" }, page: { size: 20 } }, options);
665
+ ```
666
+
667
+ Bad: a list read 100-row pages in a loop for each of 40 related sets.
668
+
669
+ ## Background work
670
+
671
+ Work over the whole population runs as a background job, never inside one
672
+ person's request. Its feature declares why with `systemScope`, and its setup
673
+ receives `systemContext(reason)`, which names its reason at every call:
674
+
675
+ ```js
676
+ const SettlementFeature = defineFeature({
677
+ id: "leagues.settlement",
678
+ owns: [cohortResource, memberResource],
679
+ systemScope: "settle finished weekly leagues for every user",
680
+ setup: ({ resources, systemContext }) => ({ settlement: createSettlement({ resources, systemContext }) })
681
+ });
682
+ ```
683
+
684
+ - Prefer `systemContext(reason).forWorkspace(id)`, `.forUser(id)` or
685
+ `.forWorkspaceUser(workspaceId, userId)`: each workspace or user is then one
686
+ unit of work, scoped as usual, in a transaction of its own. An event it
687
+ publishes carries that workspace's or user's scope, as a request there would,
688
+ so it reaches that audience, never everyone. An action it runs with
689
+ `actions.execute({ actionId, input, context })` keeps the context's kind:
690
+ inside it, a handle call still reads as the system context or job.
691
+ - Work a job does for one person (a request it carries out later) passes
692
+ `{ actorId }` last: `forWorkspace(id, { actorId: requestedBy })`. It is for
693
+ attribution only: audit fields read it with `resolveAuditActorId(context)`
694
+ (the signed-in user, else the person a job acts for) and the row policy as
695
+ `caller.actorId`. It never changes which rows the job reaches and signs no
696
+ one in: `resolveActorId` and every `authenticated` check still see no one.
697
+ Write audit fields with `resolveAuditActorId`, and decide access with
698
+ `resolveActorId`.
699
+ - A job finds its units with one `aggregate` grouped by the ownership field,
700
+ which a system context may group by (and count distinct) even when the table
701
+ hides it, then runs each unit scoped to it. A request's aggregate still
702
+ refuses a hidden field:
703
+
704
+ ```js
705
+ async function settleDue(options) {
706
+ const due = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" } }, options);
707
+ for (const { workspaceId } of due) {
708
+ await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
709
+ }
710
+ }
711
+ ```
712
+ - A job that must learn each row's owner across workspaces names the hidden
713
+ ownership field in its read's `fields`, `queryAll({ filters, fields:
714
+ ["workspaceId", "email"] }, { context })`, as a system context may do through
715
+ another feature's CRUD repository too (see [Layer
716
+ 1](#layer-1-the-table-resource)). Never unhide it for the job.
717
+ - A request uses its own context. The only exception is a console report
718
+ behind a console permission, with bounded output.
719
+ - `jskit check` lists every system scope and every `systemContext` call with
720
+ its reason, read from the string literal at the call: a reason kept in a
721
+ constant reads as none.
722
+ - A system context comes only from `systemScope`, and a context's scope only
723
+ from the request or from `forWorkspace`, `forUser` and `forWorkspaceUser`:
724
+ lint refuses `createSystemContext` and hand-written `scopeValues`, and a
725
+ context naming two workspaces or two users throws.
726
+ - A job that runs on a schedule is a feature whose `boot()` starts a timer and
727
+ whose `shutdown()` stops it; JSKIT ships no scheduler. See
728
+ [typed features, Owned state](/guide/framework/typed-features#owned-state).
729
+ Every server runs the timer, so a run takes its rows with `claim()`.
730
+
731
+ Bad: a job read every record of every workspace inside a request path, every
732
+ hour.
733
+
734
+ ## Transactions
735
+
736
+ Open a transaction with the repository's `withTransaction(options, work)` and
737
+ pass the `txOptions` it gives `work` to every call inside, spread over the
738
+ method's own `options` for the write the caller's settings are for. Never read the
739
+ transaction out of `options` by hand. A handle refuses every other
740
+ transaction, a raw Knex one or a savepoint, for reads as for writes. Inside an
741
+ open transaction it joins it; otherwise it opens one and runs events and other
742
+ after-effects after commit. Any failed write inside it fails the whole
743
+ transaction.
744
+
745
+ A deadlock victim's transaction (MySQL and MariaDB errno 1213, PostgreSQL
746
+ `40P01`), and one MariaDB fails as "Record has changed since last read" (errno
747
+ 1020, with `innodb_snapshot_isolation` on), runs its whole work again, up to
748
+ three more times, so the work must
749
+ be safe to repeat: an HTTP call or an email goes after commit, or into an
750
+ outbox row written inside it. Work that cannot be repeated passes `{ ...options, retry: false }`, and work that must
751
+ commit before its caller goes on passes `{ ...options, outermost: true }`.
752
+ Code never asks whether it runs in a transaction: `withTransaction` joins the
753
+ caller's, `outermost` refuses one, and `lock`, `lockMany`, `lockWhere` and
754
+ `claim` refuse to run outside one; `lockOrCreate` opens its own (and retries a
755
+ lost insert race only then).
756
+
757
+ When the work throws, the transaction rolls back and the caller gets the
758
+ work's own error, with its status and code, reporting `transactionOutcome:
759
+ "rolledBack"`, so `retryLostInsertRace` (Layer 3) also runs again work that
760
+ turned the duplicate into its own 409. A failed write inside the work, or a failed commit, reaches
761
+ the caller as json-rest-api's `RestApiWriteError` with the transaction's
762
+ outcome. It carries the failure's own `code` and `status` (a driver's
763
+ `ER_CHECKREAD`, a not-found's 404), and `REST_API_WRITE` only when the failure
764
+ has no code. Classify an error by its `code` or `status`,
765
+ never with `instanceof`: a second copy of a package makes a second class.
766
+
767
+ A database error never reaches a client. Its message carries the statement
768
+ and its values, so JSKIT's HTTP handler and the assistant's stream answer one,
769
+ thrown as it is or as a wrapper's cause, with a stand-in
770
+ (`hideDatabaseError` in `@jskit-ai/kernel/server/runtime`) and log it in full:
771
+ 409 `kernel.database.concurrentWrite` when the database refused the transaction for a concurrent one
772
+ (a deadlock or lock wait whose retries ran out, a row changed since the
773
+ transaction's snapshot, a serialization failure), which the client may send
774
+ again, else 500. To give a duplicate key or another database refusal its own
775
+ answer, throw an `AppError` (a `ConflictError`) with the driver's error as its
776
+ `cause`: an `AppError` keeps its answer whatever it wraps.
777
+
778
+ ```js
779
+ const allocate = (input, options) => repository.withTransaction(options, async (txOptions) => {
780
+ const invoices = await repository.lockOpenInvoices(input.invoiceIds, txOptions);
781
+ return repository.recordAllocations(allocationsFor(invoices, input.amount), txOptions);
782
+ });
783
+ ```
784
+
785
+ ## Tables you don't own
786
+
787
+ Ask the owner: its service, or for a JSKIT table its owner API, with the same
788
+ `options`. JSKIT's packages expose their tables this way: users
789
+ (`users.profiles`, `users.settings`), workspaces (`workspaces`,
790
+ `workspaces.memberships`), local sign-in (`auth.local.accounts`,
791
+ `auth.local.sessions`, `auth.local.recovery`) and the console
792
+ (`console.settings`). A realtime audience reaches its users the same way: its
793
+ `audienceUsers({ event, options })` asks the owners and answers user ids, in the
794
+ event's scope: for a customer's or a public surface's event, the workspace the
795
+ request resolved, never one its payload names (see [Realtime](/guide/app-extras/realtime)).
796
+
797
+ An app that had a table resource of its own over a JSKIT table (such as
798
+ `users`) deletes it: its CRUD contract is built on the package's table resource
799
+ (users-core's `userProfileTableResource`) and served through the owner with
800
+ `defineCrudFeature({ crud, ownerRepository: "users.profiles" })` (the
801
+ `users/user-administration-server` pattern). The app's own facts about a
802
+ person (a staff role, a preference) live in the app's own table keyed by the
803
+ user id, owned by the app with ownership `user` or `workspace_user`, never as
804
+ new columns on `users`.
805
+
806
+ A surface over a table you don't own that may show only some of its rows (a
807
+ people picker limited to those who share a workspace with the caller) asks the
808
+ owner for exactly that read, as one owner API method that narrows with one
809
+ predicate over the owner's own tables. It never contributes the rule to the
810
+ owner's row policy, which narrows every read of the table (sign-in's too), and
811
+ never passes an id list of everyone the caller may see: an id list is fine for
812
+ the rows one screen shows, not for a rule over thousands of people. An owner
813
+ without such a method gets one added at the owner; until then the surface uses
814
+ the owner's own listing for that scope, such as
815
+ `workspaces.memberships.listActiveByWorkspaceId(workspaceId, options)`, which
816
+ reads the members with their users in one statement.
817
+
818
+ Bad: an app wrote sign-in sessions and memberships with Knex; the framework's
819
+ own checks and audits never ran.
820
+
821
+ ## Two features that need each other
822
+
823
+ When a parent's owner and its children's owners each need the other (the
824
+ parent's service reads its children, and a child's write must advance the
825
+ parent's version), split what both need into a feature of its own, so
826
+ capabilities point one way:
827
+
828
+ - The parent table's owner keeps the table and provides a small owner API,
829
+ such as `versions.advance(id, txOptions)`, and requires none of the
830
+ children.
831
+ - Each child's owner requires that API and calls it in its own write's
832
+ transaction, from its repository's write method. An `owns` entry's
833
+ `afterWrite` hook cannot do it: it is declared with the table, before any
834
+ capability arrives.
835
+ - The aggregate that reads or changes them together (the parent with its
836
+ children) is a third feature that requires both.
837
+
838
+ A version that moves on with no field changed is the parent's
839
+ `lockVersionField`: `lockMany(ids, txOptions)` locks the parents, and
840
+ `patchMany(ids.map((id) => ({ id })), { ...txOptions, returning: "none" })`
841
+ advances each by one, in one statement, whatever the field is called. An
842
+ advance is an update, so it stamps the parent's `updatedAt` too: the parent as
843
+ a whole changed, and a reader that compares either one sees it. A condition
844
+ named with no value, such as `lockVersion: record.lockVersion` on a field
845
+ called `version`, is refused (`options.lockVersion is undefined`), never run
846
+ without it.
847
+
848
+ ## Check and judge
849
+
850
+ Before calling data work done, apply the
851
+ [sanity checks](/guide/framework/sanity-checks#query-counts-and-batches) with
852
+ JSKIT's tools: `expectFlatQueries` for statements that grow with rows,
853
+ `expectScoped` for every read and write path, and a look at each `// Knex:`
854
+ method, which `aggregate` or an include might do instead.
855
+
856
+ ## Tests
857
+
858
+ Each data path gets two one-line tests on the real test database, with the
859
+ helpers from `@jskit-ai/resource-runtime/test`:
860
+
861
+ ```js
862
+ import { expectEveryTableOwned, expectFlatQueries, expectScoped, startTestFeatures } from "@jskit-ai/resource-runtime/test";
863
+
864
+ test("allocation stays in its workspace", () => expectScoped({ environment: process.env, handles: [invoices, payments], systemContext, owners: [workspaceA, workspaceB], seed: seedInvoices, run: (owner) => billing.allocate(input, owner) }));
865
+ test("allocation is flat", () => expectFlatQueries({ environment: process.env, knex, seed: seedInvoices, run: (seeded) => billing.allocate(inputFor(seeded), seeded.options) }));
866
+ ```
867
+
868
+ `expectFlatQueries` (from `@jskit-ai/resource-runtime/test`) seeds each size,
869
+ counts the statements the path sends, and fails when a larger size sends more,
870
+ with the counts and the statements repeated most; the later chunks a many-row
871
+ write reports are allowed. `expectScoped` runs the path as owner A before and
872
+ after seeding owner B and fails when the answer changes, so it catches a report
873
+ that sums, counts or joins B's rows, not only one that returns them; it also
874
+ fails when the path changed or removed a row of B's. The path runs twice, so it
875
+ answers the same for the same rows: a write answers what repeats, such as the
876
+ ids it changed, not a fresh id or time.
877
+
878
+ An owner is whatever `seed` and `run` receive. On a workspace path it is a
879
+ signed-in member's options, built as the request pipeline builds them:
880
+ `{ context: { actor: { id: userId }, workspace: { id: workspaceId }, permissions } }`,
881
+ where `permissions` holds the member's role permissions. Seed with a job's
882
+ context, `{ context: systemContext(reason).forWorkspace(workspaceId) }`. A row
883
+ policy sees that context as `caller.kind` `"job"`, never as the member.
884
+
885
+ A data test never skips: without `TEST_DB_NAME` it fails
886
+ (`requireTestDatabase(process.env)`). Give `expectFlatQueries` the test's
887
+ `knex` whenever the features run on it (`startTestFeatures`,
888
+ `createTestResourceRuntime`). Without `knex` it counts through a server the
889
+ test booted (`countRuntimeQueries(work)` from
890
+ `@jskit-ai/database-runtime/server/testDatabases`). A check that measures
891
+ nothing never passes: "The path sent no statement at any size" means pass
892
+ `knex`, or the path never reached the database.
893
+
894
+ `createTestResourceRuntime({ knex })` gives a test the resource runtime its
895
+ features own their tables through: `runtime.own(featureId, [resource])`
896
+ answers the handles, and `runtime.declareSystemScope(featureId, reason)` the
897
+ `systemContext` function a test reads every owner's rows with. One more test
898
+ proves every table has an owner:
899
+ `expectEveryTableOwned({ knex, runtime, infrastructureTables, environment })`
900
+ fails, naming them, when the migrated test database holds a table no feature
901
+ owns and no package keeps as infrastructure. JSKIT's packages export their
902
+ infrastructure tables for it: `LOCAL_AUTH_INFRASTRUCTURE_TABLES`
903
+ (`@jskit-ai/auth-provider-local-db-core/server/lib/index`),
904
+ `OIDC_CLIENT_INFRASTRUCTURE_TABLES` (`@jskit-ai/auth-provider-oidc-core/server/lib/index`)
905
+ and `OIDC_ISSUER_INFRASTRUCTURE_TABLES` (`@jskit-ai/oidc-issuer-core/server`).
906
+ `runtime` knows only the features the test started, so start every owner of a
907
+ migrated table, JSKIT's own included, and pass `runtime: resources` from
908
+ `startTestFeatures`. Local sign-in's tables belong to
909
+ `AuthLocalDatabaseBackendProvider`
910
+ (`@jskit-ai/auth-provider-local-db-core/server/providers/AuthLocalDatabaseBackendProvider`,
911
+ given `inputs: { "runtime.database": { knex } }`).
912
+ `LOCAL_AUTH_INFRASTRUCTURE_TABLES` lists only the identities table it keeps
913
+ outside resources.
914
+
915
+ A test of a feature and the owner APIs it requires starts them as the server
916
+ does, without HTTP: `startTestFeatures({ knex, features, requires, inputs,
917
+ createTables })` orders the features by their requires, owns their tables
918
+ through a test resource runtime, runs every boot (so `crud.contracts` is
919
+ sealed) and answers `{ capabilities, resources, stop }`. JSKIT's owners start
920
+ beside yours: `UsersExtensionsProvider` and `UsersIdentityProvider` give
921
+ `users.profiles`, and `WorkspaceRecordsFeature`
922
+ (`@jskit-ai/workspaces-core/test/WorkspaceRecordsFeature`) gives `workspaces`
923
+ and `workspaces.memberships` without the workspace routes. Never import another
924
+ package's repositories to build its owner API by hand.
925
+
926
+ A `defineCrudFeature` also requires `crud.contracts` and `runtime.http`, which
927
+ an application's server supplies. In a test, add `CrudContractsProvider`
928
+ (`@jskit-ai/crud-core/server/crudContracts`) to `features` and give
929
+ `inputs: { "runtime.http": { router: { register() {} } } }`. The routes are
930
+ then registered nowhere, and the test calls the feature's `service` and
931
+ `repository` directly.
932
+
933
+ ```js
934
+ const { capabilities, stop } = await startTestFeatures({
935
+ knex,
936
+ features: [RosterFeature, WorkspaceRecordsFeature, UsersIdentityProvider, UsersExtensionsProvider],
937
+ requires: { roster: "roster" }
938
+ });
939
+ t.after(stop);
940
+ ```
941
+
942
+ A test of the routes themselves boots the application's server and signs
943
+ people in over `app.inject`. Keep one cookie jar per person and send the
944
+ session's `csrfToken` as `csrf-token` with every write. The session answer
945
+ carries no user id, so the test finds each person by email
946
+ (`users.profiles.findByEmail(email)`).
947
+
948
+ ```js
949
+ const app = await createServer({ runtimeEnv: { ...resolveRuntimeEnv(), AUTH_LOCAL_EMAIL_CONFIRMATION: "false" } });
950
+
951
+ async function signUp(email, password) {
952
+ const cookies = new Map();
953
+ const send = async (options) => {
954
+ const cookie = [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
955
+ const response = await app.inject({ ...options, headers: { ...options.headers, cookie } });
956
+ for (const header of [response.headers["set-cookie"] ?? []].flat()) {
957
+ const [name, ...value] = header.split(";")[0].split("=");
958
+ cookies.set(name.trim(), value.join("="));
959
+ }
960
+ return response;
961
+ };
962
+ const csrf = async () => (await send({ method: "GET", url: "/api/session" })).json().csrfToken;
963
+ await send({ method: "POST", url: "/api/register", headers: { "csrf-token": await csrf() }, payload: { email, password } });
964
+ await send({ method: "POST", url: "/api/login", headers: { "csrf-token": await csrf() }, payload: { email, password } });
965
+ return send;
966
+ }
967
+ ```
968
+
969
+ ## Hard rules
970
+
971
+ These are always wrong, and lint reports them as errors in all code but the
972
+ client's, wherever it sits; tests, migrations and seeds are outside them.
973
+ `eslint-disable` cannot turn them off.
974
+
975
+ ### Lint rules
976
+
977
+ | Rule | Reports | Section |
978
+ | --- | --- | --- |
979
+ | `jskit/no-direct-knex` | a table reached by name with Knex, a `.knex` read or `knex` destructured, a transaction opened by hand | [The resource is the only door](#the-resource-is-the-only-door) |
980
+ | `jskit/no-transaction-reader` | `options.trx`, `trx \|\| knex`, a `trx = knex` default, `trx` destructured from options | [Transactions](#transactions) |
981
+ | `jskit/no-hand-scope` | a hand-written `where` on `workspace_id` or `workspaceId` | [Layer 1: the table resource](#layer-1-the-table-resource) |
982
+ | `jskit/sql-literal` | raw SQL built from strings or templates instead of one literal with bindings | [Layer 5: reports the resource cannot express](#layer-5-reports-the-resource-cannot-express) |
983
+ | `jskit/no-hand-context` | a system context made with `createSystemContext`, a context's `scopeValues` written by hand | [Background work](#background-work) |
984
+ | `jskit/knex-reason` | a `scopedQuery()` report outside a named repository method under `// Knex: <kind> - <reason>` | [Layer 5: reports the resource cannot express](#layer-5-reports-the-resource-cannot-express) |
985
+
986
+ ### Exceptions
987
+
988
+ A module whose job is data access itself (a database runtime, a migration
989
+ runner, test-database setup, an operator's data import or cutover tool, a
990
+ database console that runs the person's own SQL) may opt out of
991
+ `jskit/no-direct-knex`, `jskit/no-transaction-reader`, `jskit/no-hand-scope`
992
+ and `jskit/sql-literal` as a whole, with one line per rule before its first
993
+ line of code:
994
+
995
+ ```js
996
+ // readable-check: allow no-direct-knex - infrastructure: the test databases' snapshot and restore
997
+ ```
998
+
999
+ In a file excused from `jskit/no-direct-knex`, raw SQL runs on Knex itself,
1000
+ so `jskit/sql-literal` lets it hold a statement; it is still one literal with
1001
+ its values as bindings. Only a module that runs SQL it is given by design (a
1002
+ console's query runner) opts out of `jskit/sql-literal` itself, in that one
1003
+ header line with its reason; never with a per-line disable. `jskit check`
1004
+ lists every such file for review. `jskit/no-hand-context` and
1005
+ `jskit/knex-reason` take no exception.
1006
+
1007
+ A data import or cutover tool (loading another system's records into the app's
1008
+ tables before go-live, say) is the one application module that commonly takes
1009
+ it. Check and judge, for each file:
1010
+
1011
+ - it runs only as an operator's command; nothing a request can reach imports
1012
+ it;
1013
+ - it bypasses ownership and row policies on purpose, so every read and write
1014
+ names its workspace explicitly;
1015
+ - it writes in one transaction per batch, so a failed batch leaves nothing
1016
+ half-loaded;
1017
+ - its statements per row are still measured, as any write path's are: a batch
1018
+ of 200 rows costs about what a batch of 10 does.
1019
+
1020
+ `jskit check` also fails:
1021
+
1022
+ - a table with two table resources;
1023
+ - a system scope or `systemContext` call without a reason;
1024
+ - an infrastructure exception or a feature's system scope that the app's
1025
+ checked-in `jskit-data-access.json` does not list, so each new one is
1026
+ reviewed: `{ "infrastructure": [{ "file", "rule" }], "systemScopes":
1027
+ [{ "feature", "systemScope" }] }`;
1028
+ - a module that requires `runtime.database` or `runtime.resources` without
1029
+ the infrastructure exception: features own their tables with `owns`;
1030
+ - an `eslint-disable` that names a data-access rule, or no rule, outside
1031
+ client code.