@jskit-ai/agent-docs 0.1.284 → 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-setup/authentication.md +15 -3
- package/guide/agent/app-setup/existing-application-migration.md +29 -0
- package/guide/agent/app-setup/upgrading-jskit.md +9 -0
- package/guide/agent/framework/data-access.md +229 -53
- package/guide/agent/framework/typed-features.md +4 -2
- package/package.json +1 -1
- package/patterns/feature-package/example/booking-engine/package.json +1 -1
- package/patterns/minimal-foundation/example/package.json +4 -4
- package/patterns/shell-foundation/example/package.json +5 -5
- package/patterns/shell-foundation/example/packages/main/package.json +1 -1
- package/reference/autogen/KERNEL_MAP.md +1 -0
- package/reference/autogen/PATTERN_INDEX.md +39 -39
- package/reference/autogen/packages/auth-provider-local-db-core.md +4 -0
- package/reference/autogen/packages/crud-core.md +7 -2
- package/reference/autogen/packages/database-runtime-mysql.md +0 -26
- package/reference/autogen/packages/database-runtime.md +3 -1
- package/reference/autogen/packages/kernel.md +2 -0
- package/reference/autogen/packages/payments-core.md +0 -1
- package/reference/autogen/packages/resource-core.md +2 -0
- package/reference/autogen/packages/resource-runtime.md +23 -1
- package/reference/autogen/packages/users-core.md +7 -0
- package/reference/autogen/tooling/config-eslint.md +7 -2
- package/reference/autogen/tooling/jskit-catalog.md +14 -0
- package/reference/autogen/tooling/jskit-release.md +2 -0
- package/skills/jskit/references/data-access.md +229 -53
- package/skills/jskit/references/existing-application-migration.md +29 -0
- package/skills/jskit/references/heuristics/crud-authoring.md +4 -1
- package/skills/jskit/references/pattern-index.md +39 -39
- package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
- 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/auth/accounts-hub/example/package.json +3 -3
- package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
- package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
- package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
- package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
- package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/database/table-resource/PATTERN.md +7 -4
- package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
- package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +6 -2
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
- package/skills/jskit/references/typed-features.md +4 -2
|
@@ -244,14 +244,26 @@ error.
|
|
|
244
244
|
| Capability | Methods |
|
|
245
245
|
| --- | --- |
|
|
246
246
|
| `auth.local.accounts` | `create(account, options)`, `findById(userId, options)`, `findByEmail(email, options)`, `updatePassword(userId, passwordRecord, options)`, `updateProfile(userId, { displayName }, options)`, `markEmailVerified(userId, options)` |
|
|
247
|
-
| `auth.local.sessions` | `create(session, options)`, `update(sessionId, { purpose, expiresAt }, options)`, `revoke(sessionId, options)`, `revokeForUser(userId, { ...options, exceptSessionId })`, `deleteExpired(now, options)` |
|
|
248
|
-
| `auth.local.recovery` | `create(token, options)`, `findByTokenHash(tokenHash, options)`, `consume(tokenId, options)`, `consumeForUser(userId, options)` |
|
|
247
|
+
| `auth.local.sessions` | `create(session, options)`, `update(sessionId, { purpose, expiresAt }, { ...options, onlyIf })`, `revoke(sessionId, options)`, `revokeForUser(userId, { ...options, exceptSessionId })`, `deleteExpired(now, options)` |
|
|
248
|
+
| `auth.local.recovery` | `create(token, options)`, `findByTokenHash(tokenHash, options)`, `consume(tokenId, options)`, `consumeForUser(userId, { ...options, idPrefix })` |
|
|
249
249
|
|
|
250
250
|
`markEmailVerified` records that the account proved it owns its email address,
|
|
251
251
|
for example by using an emailed link, in the `email_verified_at` column (run
|
|
252
252
|
`npm run db:migrate`); the first proof is kept, and the account answers it as
|
|
253
253
|
`emailVerifiedAt` (empty until then). `deleteExpired` deletes the sessions that
|
|
254
|
-
expired by `now` and answers how many.
|
|
254
|
+
expired by `now` and answers how many.
|
|
255
|
+
|
|
256
|
+
A recovery token's kind is the start of its id: local sign-in's password resets
|
|
257
|
+
are `rec_…`, and an app's own kind (a sign-in link, say) takes a prefix of its
|
|
258
|
+
own, such as `magic_…`. `consumeForUser` consumes the user's open tokens of the
|
|
259
|
+
kind `idPrefix` names and no others, so a new sign-in link leaves an open
|
|
260
|
+
password reset usable, and a completed reset leaves sign-in links open.
|
|
261
|
+
`update` with `onlyIf` (`userId`, `purpose`, `revokedAt`) changes the session
|
|
262
|
+
only while it still has those values, compared in the UPDATE itself, and
|
|
263
|
+
answers null, writing nothing, when it does not: promote a sign-in link's
|
|
264
|
+
session with
|
|
265
|
+
`update(sessionId, { purpose: "normal", expiresAt }, { ...options, onlyIf: { userId, purpose: "recovery", revokedAt: null } })`
|
|
266
|
+
and treat null as a refused link. Their types are `AuthLocalAccounts`,
|
|
255
267
|
`AuthLocalSessions` and `AuthLocalRecovery` in
|
|
256
268
|
`@jskit-ai/auth-provider-local-db-core/server/types`.
|
|
257
269
|
|
|
@@ -272,6 +272,35 @@ one table at a time:
|
|
|
272
272
|
Keep each move behaviour-preserving
|
|
273
273
|
([refactors](/guide/framework/sanity-checks#refactors)).
|
|
274
274
|
|
|
275
|
+
A table keyed by several columns, or by its owner's id, gets an integer `id`
|
|
276
|
+
first ([Layer 1](/guide/framework/data-access#layer-1-the-table-resource)).
|
|
277
|
+
One new migration does it on MySQL, MariaDB and PostgreSQL: the key becomes a
|
|
278
|
+
unique index before the primary key goes, so a foreign key on its first column
|
|
279
|
+
keeps an index, and adding the `id` numbers the existing rows. `down` undoes it
|
|
280
|
+
in reverse.
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
const KEY = ["workspace_id", "day"];
|
|
284
|
+
|
|
285
|
+
export async function up(knex) {
|
|
286
|
+
await knex.schema.alterTable("day_slots", (table) => table.unique(KEY, { indexName: "day_slots_key" }));
|
|
287
|
+
await knex.schema.alterTable("day_slots", (table) => table.dropPrimary());
|
|
288
|
+
await knex.schema.alterTable("day_slots", (table) => table.increments("id"));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function down(knex) {
|
|
292
|
+
await knex.schema.alterTable("day_slots", (table) => table.dropColumn("id"));
|
|
293
|
+
await knex.schema.alterTable("day_slots", (table) => table.primary(KEY));
|
|
294
|
+
await knex.schema.alterTable("day_slots", (table) => table.dropUnique(KEY, "day_slots_key"));
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
MySQL and MariaDB commit each statement on its own, so a run that fails
|
|
299
|
+
part-way resumes as the [partial-failure
|
|
300
|
+
rules](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#directory-order-comes-before-filenames)
|
|
301
|
+
say. Code that wrote by the old key now upserts on it, with the ownership
|
|
302
|
+
column among the keys.
|
|
303
|
+
|
|
275
304
|
### Workspace invitation transaction compatibility (workspaces-core 0.1.198)
|
|
276
305
|
|
|
277
306
|
Invitation creation, acceptance and refusal now own their managed transactions:
|
|
@@ -81,6 +81,15 @@ installation, or validation fails, it restores the previous manifests,
|
|
|
81
81
|
lockfile, and installed tree before reporting the failure. Resolve the reported
|
|
82
82
|
registry or real peer problem and rerun `npm run jskit:update`.
|
|
83
83
|
|
|
84
|
+
When the install brings package migrations the application has not had, the
|
|
85
|
+
updater lists each one (`<package>: <file>`) and says to run
|
|
86
|
+
`npm run db:migrate`. Run it, on every database the application uses, before
|
|
87
|
+
the new version serves requests: a package's new version reads what its
|
|
88
|
+
migration creates, so until then those reads fail, such as every local sign-in
|
|
89
|
+
with an unknown `email_verified_at` column. Read each package's CHANGELOG
|
|
90
|
+
**Upgrading** section too, for the changes the application itself must make,
|
|
91
|
+
such as a feature to register.
|
|
92
|
+
|
|
84
93
|
## Separately composed packages
|
|
85
94
|
|
|
86
95
|
Pass each additional manifest explicitly to both commands:
|
|
@@ -46,6 +46,7 @@ through its handle's calls, and every call takes the call's `options` last:
|
|
|
46
46
|
| `lockOrCreate({ id, ...initial }, options, work)` | a keyed record locked, created first when missing, for `work` |
|
|
47
47
|
| `claim({ filters, sort, take }, txOptions)` | the next rows no other worker holds |
|
|
48
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)) |
|
|
49
50
|
| `scopedQuery(params, options)` | a read-only builder for a report ([Layer 5](#layer-5-reports-the-resource-cannot-express)) |
|
|
50
51
|
|
|
51
52
|
```js
|
|
@@ -71,7 +72,7 @@ function totalsByMonth(options) {
|
|
|
71
72
|
| owner API | The capability through which a table's owner lets other features read and write its records, such as `users.profiles`. |
|
|
72
73
|
| CRUD contract | The optional HTTP and screen surface on a table resource: `defineCrudContract()`. |
|
|
73
74
|
| CRUD feature | The feature `defineCrudFeature()` builds from a CRUD contract: its repository, service, actions, routes and screens. |
|
|
74
|
-
| ownership | Who owns a row, required in every table resource: `public`, `workspace`, `user`, `workspace_user`,
|
|
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). |
|
|
75
76
|
| row policy | A server-only rule narrowing which rows a context sees, applied to every statement, joins, includes and locks included. |
|
|
76
77
|
| scoped query | The read-only Knex builder `scopedQuery()` returns, with ownership and the row policy already applied. |
|
|
77
78
|
| system scope | Why a feature works across every workspace and user, declared with `systemScope`. |
|
|
@@ -146,6 +147,16 @@ function createReceivalsRepository({ receivals }) {
|
|
|
146
147
|
}
|
|
147
148
|
```
|
|
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
|
+
|
|
149
160
|
A package whose features own tables or declare `systemScope` lists
|
|
150
161
|
`runtime.resources` in its descriptor's `capabilities.requires`, or boot
|
|
151
162
|
refuses it; the feature never requires it itself, since it receives its
|
|
@@ -196,6 +207,37 @@ otherwise, naming both.
|
|
|
196
207
|
A table keyed by a `uuid` or `string` primary key takes its ids as text; an
|
|
197
208
|
`owns` entry's `normalizeId` is only for an id written some other way.
|
|
198
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
|
+
|
|
199
241
|
A read answers every field unless `fields` names the ones it needs (a
|
|
200
242
|
belongs-to field by its own key, such as `customerId`, or its relationship);
|
|
201
243
|
the locks (`lock`, `lockMany`, `lockWhere` and `claim`) take it in their
|
|
@@ -205,6 +247,15 @@ visible, so a hot path names its fields. A collection relation with
|
|
|
205
247
|
`hydrateOnList: false` or `hydrateOnView: false` loads its rows on that kind of
|
|
206
248
|
read only when the read includes it.
|
|
207
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
|
+
|
|
208
259
|
A `hidden: true` field (an ownership id, a bearer token, a storage key) never
|
|
209
260
|
reaches a request: no CRUD route or JSON:API document (`format: "jsonapi"`)
|
|
210
261
|
carries it, even when `fields` names it. The owner's own code reads one by
|
|
@@ -213,12 +264,21 @@ options)` or `{ ...txOptions, fields }` for a lock, which costs one more
|
|
|
213
264
|
statement per 1,000 records, scoped as the read is; a read without `fields`
|
|
214
265
|
never answers it. It may be an id, string, integer, number or boolean, and
|
|
215
266
|
naming any other kind refuses the read. The repository a CRUD feature gives
|
|
216
|
-
other features, and its own actions, refuses
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
222
282
|
never read it with `scopedQuery`.
|
|
223
283
|
|
|
224
284
|
`create` and `patch` answer the written record by default, read back through
|
|
@@ -257,8 +317,8 @@ who the call runs for as `caller`: `{ kind: "user", userId }` for a signed-in
|
|
|
257
317
|
request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
|
|
258
318
|
`{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
|
|
259
319
|
`reason`, for background work. A job names a user only as `actorId`, the
|
|
260
|
-
person it
|
|
261
|
-
anonymous request.
|
|
320
|
+
person it works for ([below](#background-work)), so never read a missing actor
|
|
321
|
+
as an anonymous request.
|
|
262
322
|
|
|
263
323
|
```js
|
|
264
324
|
// good: one predicate on the row being read
|
|
@@ -516,6 +576,16 @@ no record behind. On its own it runs work once, so work may call a provider:
|
|
|
516
576
|
two first uses of one id at once can collide on the insert, and the loser runs
|
|
517
577
|
again before its work starts.
|
|
518
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
|
+
|
|
519
589
|
A record that a person edits from a form it read earlier carries a version
|
|
520
590
|
number: the table resource names it with `lockVersionField`, an integer field
|
|
521
591
|
over a non-null integer column with `defaultTo: 0`.
|
|
@@ -577,34 +647,43 @@ async function activeDays(memberIds, options) {
|
|
|
577
647
|
resource in the code describes.
|
|
578
648
|
- `column()` names columns; `raw` takes one expression as a string literal,
|
|
579
649
|
with its values as bindings.
|
|
580
|
-
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
and
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
that
|
|
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.
|
|
597
682
|
|
|
598
683
|
```js
|
|
599
|
-
// billing's owner API: its scoped invoices as a join source, with their column names
|
|
600
|
-
async function invoiceSource(alias, options) {
|
|
601
|
-
const { query } = await invoices.scopedQuery({ alias }, options);
|
|
602
|
-
return { query, column: (field) => invoices.column(field, alias) };
|
|
603
|
-
}
|
|
604
|
-
|
|
605
684
|
// Knex: report - open tasks and billed totals per project, joined on billing's scoped invoices
|
|
606
685
|
async function projectSummary(options) {
|
|
607
|
-
const source = await billing.
|
|
686
|
+
const source = await billing.joinSource({ alias: "i" }, options);
|
|
608
687
|
const billed = source.query
|
|
609
688
|
.select({ projectId: source.column("projectId") })
|
|
610
689
|
.sum({ billed: source.column("amount") })
|
|
@@ -666,6 +745,59 @@ const { records: firstPage } = await subscriptions.query({ filters: { newsletter
|
|
|
666
745
|
|
|
667
746
|
Bad: a list read 100-row pages in a loop for each of 40 related sets.
|
|
668
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
|
+
|
|
669
801
|
## Background work
|
|
670
802
|
|
|
671
803
|
Work over the whole population runs as a background job, never inside one
|
|
@@ -689,13 +821,15 @@ const SettlementFeature = defineFeature({
|
|
|
689
821
|
`actions.execute({ actionId, input, context })` keeps the context's kind:
|
|
690
822
|
inside it, a handle call still reads as the system context or job.
|
|
691
823
|
- Work a job does for one person (a request it carries out later) passes
|
|
692
|
-
`{ actorId }` last: `forWorkspace(id, { actorId: requestedBy })`. It
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
`
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
`
|
|
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.
|
|
699
833
|
- A job finds its units with one `aggregate` grouped by the ownership field,
|
|
700
834
|
which a system context may group by (and count distinct) even when the table
|
|
701
835
|
hides it, then runs each unit scoped to it. A request's aggregate still
|
|
@@ -714,8 +848,9 @@ const SettlementFeature = defineFeature({
|
|
|
714
848
|
["workspaceId", "email"] }, { context })`, as a system context may do through
|
|
715
849
|
another feature's CRUD repository too (see [Layer
|
|
716
850
|
1](#layer-1-the-table-resource)). Never unhide it for the job.
|
|
717
|
-
- A request uses its own context. The
|
|
718
|
-
behind a console permission, with bounded output
|
|
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)).
|
|
719
854
|
- `jskit check` lists every system scope and every `systemContext` call with
|
|
720
855
|
its reason, read from the string literal at the call: a reason kept in a
|
|
721
856
|
constant reads as none.
|
|
@@ -723,6 +858,16 @@ const SettlementFeature = defineFeature({
|
|
|
723
858
|
from the request or from `forWorkspace`, `forUser` and `forWorkspaceUser`:
|
|
724
859
|
lint refuses `createSystemContext` and hand-written `scopeValues`, and a
|
|
725
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.
|
|
726
871
|
- A job that runs on a schedule is a feature whose `boot()` starts a timer and
|
|
727
872
|
whose `shutdown()` stops it; JSKIT ships no scheduler. See
|
|
728
873
|
[typed features, Owned state](/guide/framework/typed-features#owned-state).
|
|
@@ -789,11 +934,20 @@ Ask the owner: its service, or for a JSKIT table its owner API, with the same
|
|
|
789
934
|
(`users.profiles`, `users.settings`), workspaces (`workspaces`,
|
|
790
935
|
`workspaces.memberships`), local sign-in (`auth.local.accounts`,
|
|
791
936
|
`auth.local.sessions`, `auth.local.recovery`) and the console
|
|
792
|
-
(`console.settings`).
|
|
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
|
|
793
940
|
`audienceUsers({ event, options })` asks the owners and answers user ids, in the
|
|
794
941
|
event's scope: for a customer's or a public surface's event, the workspace the
|
|
795
942
|
request resolved, never one its payload names (see [Realtime](/guide/app-extras/realtime)).
|
|
796
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
|
+
|
|
797
951
|
An app that had a table resource of its own over a JSKIT table (such as
|
|
798
952
|
`users`) deletes it: its CRUD contract is built on the package's table resource
|
|
799
953
|
(users-core's `userProfileTableResource`) and served through the owner with
|
|
@@ -891,6 +1045,14 @@ test booted (`countRuntimeQueries(work)` from
|
|
|
891
1045
|
nothing never passes: "The path sent no statement at any size" means pass
|
|
892
1046
|
`knex`, or the path never reached the database.
|
|
893
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
|
+
|
|
894
1056
|
`createTestResourceRuntime({ knex })` gives a test the resource runtime its
|
|
895
1057
|
features own their tables through: `runtime.own(featureId, [resource])`
|
|
896
1058
|
answers the handles, and `runtime.declareSystemScope(featureId, reason)` the
|
|
@@ -968,19 +1130,23 @@ async function signUp(email, password) {
|
|
|
968
1130
|
|
|
969
1131
|
## Hard rules
|
|
970
1132
|
|
|
971
|
-
These are always wrong, and lint reports them as errors in all code but
|
|
972
|
-
|
|
973
|
-
|
|
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.
|
|
974
1140
|
|
|
975
1141
|
### Lint rules
|
|
976
1142
|
|
|
977
1143
|
| Rule | Reports | Section |
|
|
978
1144
|
| --- | --- | --- |
|
|
979
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) |
|
|
980
|
-
| `jskit/no-transaction-reader` | `options.trx`, `trx \|\| knex`, a `trx = knex` default, `trx` destructured from options | [Transactions](#transactions) |
|
|
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) |
|
|
981
1147
|
| `jskit/no-hand-scope` | a hand-written `where` on `workspace_id` or `workspaceId` | [Layer 1: the table resource](#layer-1-the-table-resource) |
|
|
982
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) |
|
|
983
|
-
| `jskit/no-hand-context` | a system context made with `createSystemContext`, a context's `scopeValues` written by hand | [Background work](#background-work) |
|
|
1149
|
+
| `jskit/no-hand-context` | a system context made with `createSystemContext` or `declareSystemScope`, a context's `scopeValues` written by hand | [Background work](#background-work) |
|
|
984
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) |
|
|
985
1151
|
|
|
986
1152
|
### Exceptions
|
|
@@ -1024,8 +1190,18 @@ it. Check and judge, for each file:
|
|
|
1024
1190
|
- an infrastructure exception or a feature's system scope that the app's
|
|
1025
1191
|
checked-in `jskit-data-access.json` does not list, so each new one is
|
|
1026
1192
|
reviewed: `{ "infrastructure": [{ "file", "rule" }], "systemScopes":
|
|
1027
|
-
[{ "feature", "systemScope" }] }`;
|
|
1028
|
-
- a module that requires
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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.
|
|
@@ -772,7 +772,9 @@ for anything that reads or writes the domain.**
|
|
|
772
772
|
- One handler shape: a named async function that sends what
|
|
773
773
|
`request.executeAction({ actionId: ACTION_X, input })` returns.
|
|
774
774
|
- A route that bypasses actions (a provider's callback) gets its own named
|
|
775
|
-
registrar
|
|
775
|
+
registrar and reads with `await request.callOptions()`
|
|
776
|
+
([data access](/guide/framework/data-access#layer-1-the-table-resource)).
|
|
777
|
+
Summaries are sentences.
|
|
776
778
|
- A route that repetition can abuse (sign-in, codes, sending email) declares
|
|
777
779
|
`rateLimit: { max, timeWindow }`, which the kernel enforces per client
|
|
778
780
|
address and refuses with 429, `kernel.http.rateLimited` and `Retry-After`.
|
|
@@ -1216,7 +1218,7 @@ export default [
|
|
|
1216
1218
|
`**/src/server/**`, so an existing application sees them before it lists a
|
|
1217
1219
|
package; it lists each package as it converts it. It makes the
|
|
1218
1220
|
[data-access rules](/guide/framework/data-access#hard-rules) errors in all
|
|
1219
|
-
code but
|
|
1221
|
+
code but a package's `src/client`, tests, migrations and seeds.
|
|
1220
1222
|
- `clientFeaturesConfig(globs)` holds client code to the same rules; see
|
|
1221
1223
|
[client features](/guide/framework/client-features#running-the-checks).
|
|
1222
1224
|
|
package/package.json
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@local/main": "0.1.0",
|
|
36
36
|
"@fastify/static": "^10.1.3",
|
|
37
|
-
"@jskit-ai/kernel": "0.2.
|
|
37
|
+
"@jskit-ai/kernel": "0.2.15",
|
|
38
38
|
"@jskit-ai/material-web": "0.1.3",
|
|
39
39
|
"@tanstack/vue-query": "^5.101.0",
|
|
40
40
|
"fastify": "^5.12.7",
|
|
@@ -44,13 +44,13 @@
|
|
|
44
44
|
"vue": "^3.5.44",
|
|
45
45
|
"vue-router": "^5.3.1",
|
|
46
46
|
"vuetify": "^4.3.0",
|
|
47
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
47
|
+
"@jskit-ai/http-runtime": "0.2.15",
|
|
48
48
|
"@fastify/fast-json-stringify-compiler": "^5.1.0",
|
|
49
49
|
"@vue/devtools-api": "^8.2.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@jskit-ai/config-eslint": "0.1.
|
|
53
|
-
"@jskit-ai/jskit-catalog": "0.2.
|
|
52
|
+
"@jskit-ai/config-eslint": "0.1.248",
|
|
53
|
+
"@jskit-ai/jskit-catalog": "0.2.37",
|
|
54
54
|
"@playwright/test": "1.61.1",
|
|
55
55
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
56
56
|
"eslint": "^10.8.0",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@local/main": "0.1.0",
|
|
36
36
|
"@fastify/static": "^10.1.3",
|
|
37
|
-
"@jskit-ai/kernel": "0.2.
|
|
37
|
+
"@jskit-ai/kernel": "0.2.15",
|
|
38
38
|
"@jskit-ai/material-web": "0.1.3",
|
|
39
39
|
"@tanstack/vue-query": "^5.101.0",
|
|
40
40
|
"fastify": "^5.12.7",
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"vue": "^3.5.44",
|
|
45
45
|
"vue-router": "^5.3.1",
|
|
46
46
|
"vuetify": "^4.3.0",
|
|
47
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
48
|
-
"@jskit-ai/shell-web": "0.2.
|
|
47
|
+
"@jskit-ai/http-runtime": "0.2.15",
|
|
48
|
+
"@jskit-ai/shell-web": "0.2.18",
|
|
49
49
|
"@fastify/fast-json-stringify-compiler": "^5.1.0",
|
|
50
50
|
"@vue/devtools-api": "^8.2.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@jskit-ai/config-eslint": "0.1.
|
|
54
|
-
"@jskit-ai/jskit-catalog": "0.2.
|
|
53
|
+
"@jskit-ai/config-eslint": "0.1.248",
|
|
54
|
+
"@jskit-ai/jskit-catalog": "0.2.37",
|
|
55
55
|
"@playwright/test": "1.61.1",
|
|
56
56
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
57
57
|
"eslint": "^10.8.0",
|
|
@@ -373,6 +373,7 @@ Exports
|
|
|
373
373
|
### `capabilities/defineProvider.js`
|
|
374
374
|
Exports
|
|
375
375
|
- `ARCHITECTURE_ID_PATTERN`
|
|
376
|
+
- `capabilityKey(value)`
|
|
376
377
|
- `defineProvider({ id, requires = {}, optional = {}, provides = {}, hints = {}, setup, boot = null, shutdown = null } = {})`
|
|
377
378
|
- `normalizeArchitectureId(value, label)`
|
|
378
379
|
- `type ProviderDefinition`
|