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