@jskit-ai/agent-docs 0.1.284 → 0.1.286

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/guide/agent/app-setup/authentication.md +15 -3
  2. package/guide/agent/app-setup/existing-application-migration.md +29 -0
  3. package/guide/agent/app-setup/upgrading-jskit.md +9 -0
  4. package/guide/agent/framework/data-access.md +230 -53
  5. package/guide/agent/framework/typed-features.md +4 -2
  6. package/package.json +1 -1
  7. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  8. package/patterns/minimal-foundation/example/package.json +4 -4
  9. package/patterns/shell-foundation/example/package.json +5 -5
  10. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  11. package/reference/autogen/KERNEL_MAP.md +1 -0
  12. package/reference/autogen/PATTERN_INDEX.md +39 -39
  13. package/reference/autogen/packages/auth-provider-local-db-core.md +4 -0
  14. package/reference/autogen/packages/crud-core.md +7 -2
  15. package/reference/autogen/packages/database-runtime-mysql.md +0 -26
  16. package/reference/autogen/packages/database-runtime.md +3 -1
  17. package/reference/autogen/packages/kernel.md +2 -0
  18. package/reference/autogen/packages/payments-core.md +0 -1
  19. package/reference/autogen/packages/resource-core.md +2 -0
  20. package/reference/autogen/packages/resource-runtime.md +23 -1
  21. package/reference/autogen/packages/users-core.md +7 -0
  22. package/reference/autogen/tooling/config-eslint.md +7 -2
  23. package/reference/autogen/tooling/jskit-catalog.md +14 -0
  24. package/reference/autogen/tooling/jskit-release.md +2 -0
  25. package/skills/jskit/references/data-access.md +230 -53
  26. package/skills/jskit/references/existing-application-migration.md +29 -0
  27. package/skills/jskit/references/heuristics/crud-authoring.md +4 -1
  28. package/skills/jskit/references/pattern-index.md +39 -39
  29. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
  30. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +5 -5
  31. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  32. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
  33. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  34. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  35. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  36. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
  37. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
  38. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
  39. package/skills/jskit/references/patterns/database/table-resource/PATTERN.md +7 -4
  40. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  41. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  42. package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +6 -2
  43. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
  44. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
  45. package/skills/jskit/references/typed-features.md +4 -2
@@ -70,7 +70,10 @@ Local functions
70
70
  Exports
71
71
  - `checkDataAccess({ projectRoot = process.cwd() } = {})`
72
72
  - `formatDataAccessReport(result)`
73
+ - `readTableResourceSources({ projectRoot = process.cwd() } = {})`
73
74
  Local functions
75
+ - `escapeRegExp(text)`
76
+ - `skipsDirectory(name)`
74
77
  - `blank(text)`
75
78
  - `maskStrings(code)`
76
79
  - `closingOffset(masked, start)`
@@ -90,14 +93,21 @@ Local functions
90
93
  - `ownsSystemContextName(source, masked)`
91
94
  - `readSystemScopes(file, { source, code, masked, position })`
92
95
  - `readInfrastructureExcuses(file, source)`
96
+ - `isDataAccessRule(rule)`
97
+ - `blockComments(source)`
93
98
  - `readLintDisables(file, source, position)`
94
99
  - `readRuntimeCapabilities(file, { code, masked, position })`
100
+ - `isError(setting)`
101
+ - `appEslint(root)`
102
+ - `checkLintConfig(root, files, baseline, report)`
103
+ - `checkCapabilities(baseline, uses, report)`
95
104
  - `readBaseline(root, report)`
96
105
  - `checkBaseline(baseline, excuses, scopes, report)`
97
106
  - `walk(root, directory, files)`
98
107
  - `frameworkPackages(root)`
99
108
  - `checkTables(resources, report)`
100
109
  - `checkSystemScopes(scopes, report)`
110
+ - `sourceFiles(root)`
101
111
 
102
112
  ### `scripts/files.mjs`
103
113
  Exports
@@ -312,6 +322,7 @@ Local functions
312
322
  - `alignLockManifestRecord(lockRecord, manifest, localPackageNames)`
313
323
  - `preparePackageLockForInstall(projectRoot, manifests, packageLockPath)`
314
324
  - `findImports(root, packageIds)`
325
+ - `installedPackageMigrations(root)`
315
326
 
316
327
  ### `scripts/test-budget.mjs`
317
328
  Exports
@@ -334,9 +345,12 @@ Local functions
334
345
  ### `scripts/test-databases.mjs`
335
346
  Exports
336
347
  - `DATABASES_OPTION`
348
+ - `foreignKeyCheckFor(cwd)`
337
349
  - `testDatabaseRunFor(count, cwd = process.cwd())`
338
350
  Local functions
339
351
  - `withOwnEnvironment(load)`
352
+ - `exportedTableResources(namespace, isTableResource)`
353
+ - `keyText({ tableName, columns, referencedTableName, referencedColumns })`
340
354
 
341
355
  ### `scripts/test-file-stop.mjs`
342
356
  Exports
@@ -84,6 +84,8 @@ Local functions
84
84
  - `git(repoRoot, args)`
85
85
  - `commitAll(repoRoot, message)`
86
86
  - `releaseCommitMessage(entries)`
87
+ - `catalogPins(record, entry)`
88
+ - `waitUntilServedAsLatest(pins, inspection, { attempts = Number(process.env.JSKIT_RELEASE_SERVE_ATTEMPTS || 20), delayMs = Number(process.env.JSKIT_RELEASE_RETRY_DELAY_MS || 30_000) } = {})`
87
89
  - `publishRelease(records, { cwd: repoRoot, registry })`
88
90
  - `publishCommand(options)`
89
91
  - `forgetPinnedLockEntries(repoRoot, names)`
@@ -41,6 +41,7 @@ through its handle's calls, and every call takes the call's `options` last:
41
41
  | `lockOrCreate({ id, ...initial }, options, work)` | a keyed record locked, created first when missing, for `work` |
42
42
  | `claim({ filters, sort, take }, txOptions)` | the next rows no other worker holds |
43
43
  | `withTransaction(options, work)` | one transaction; `work` gets its `txOptions` |
44
+ | `changeHorizon(options)` | PostgreSQL's snapshot horizon, where a read of what changed since starts ([What changed since](#what-changed-since)) |
44
45
  | `scopedQuery(params, options)` | a read-only builder for a report ([Layer 5](#layer-5-reports-the-resource-cannot-express)) |
45
46
 
46
47
  ```js
@@ -66,7 +67,7 @@ function totalsByMonth(options) {
66
67
  | owner API | The capability through which a table's owner lets other features read and write its records, such as `users.profiles`. |
67
68
  | CRUD contract | The optional HTTP and screen surface on a table resource: `defineCrudContract()`. |
68
69
  | CRUD feature | The feature `defineCrudFeature()` builds from a CRUD contract: its repository, service, actions, routes and screens. |
69
- | ownership | Who owns a row, required in every table resource: `public`, `workspace`, `user`, `workspace_user`, or `workspace_or_shared` (a workspace's own rows, or rows shared with every workspace when the owner is empty). |
70
+ | 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). |
70
71
  | row policy | A server-only rule narrowing which rows a context sees, applied to every statement, joins, includes and locks included. |
71
72
  | scoped query | The read-only Knex builder `scopedQuery()` returns, with ownership and the row policy already applied. |
72
73
  | system scope | Why a feature works across every workspace and user, declared with `systemScope`. |
@@ -141,6 +142,16 @@ function createReceivalsRepository({ receivals }) {
141
142
  }
142
143
  ```
143
144
 
145
+ A field's type says what its column holds, not the column's SQL type. A
146
+ whole-number column that is not an id (a counter, a byte count, a position),
147
+ BIGINT included, is `type: "integer"`. Every handle read answers it as a
148
+ number on MySQL, MariaDB and PostgreSQL alike, although both drivers read
149
+ BIGINT as text. A column that holds another row's id is `type: "id"` and
150
+ reads as text, as ids do. Past 2^53 − 1 a JavaScript number cannot hold a
151
+ whole number exactly, so json-rest-api answers those digits as text rather
152
+ than a rounded number. A value that can grow that large gets a decimal field
153
+ (`precision` and `scale`), whose digits stay text.
154
+
144
155
  A package whose features own tables or declare `systemScope` lists
145
156
  `runtime.resources` in its descriptor's `capabilities.requires`, or boot
146
157
  refuses it; the feature never requires it itself, since it receives its
@@ -191,6 +202,37 @@ otherwise, naming both.
191
202
  A table keyed by a `uuid` or `string` primary key takes its ids as text; an
192
203
  `owns` entry's `normalizeId` is only for an id written some other way.
193
204
 
205
+ A handle finds every row by one `id`. A table whose rows another key
206
+ identifies (a workspace and a day, or one row per user keyed by the user's id)
207
+ still gets an integer `id` primary key, and keeps that key as a unique index
208
+ on exactly the columns its upsert names, the ownership column among them. The
209
+ code reads by the key with a filter and writes by it with an upsert,
210
+ `onConflict: { keys: ["workspaceId", "day"] }`
211
+ ([Layer 3](#layer-3-many-rows-at-once)). The [port
212
+ guide](existing-application-migration.md#moving-hand-written-sql-behind-resources)
213
+ has the migration for a table that exists already.
214
+
215
+ A route that reads or writes without an action (a provider's callback, a
216
+ download it streams) takes its options from `await request.callOptions()`: the
217
+ context the same request's action would get, its user, workspace and route
218
+ scope resolved by the same contributors. No action's permission runs, so the
219
+ route's own `auth` and `permission` are its gate. Never build a request's
220
+ context by hand.
221
+
222
+ A `workspace_or_shared` table's shared rows (no workspace) belong to every
223
+ workspace, so no request or job writes one: a create stamps the caller's
224
+ workspace, a patch or delete of a shared row is refused, and many-row writes
225
+ and locks leave shared rows out. When a request may change one (a permission
226
+ says so, or the row is the person's own), the owner reads the row in the
227
+ request's own context, which proves the caller reaches it, decides in code
228
+ whether this caller may change it, and only then writes it in a system
229
+ context, `systemContext(reason)` itself (one `forUser` or its kin derived is
230
+ not a system context), naming the empty owner: `create({ ...fields,
231
+ workspaceId: null }, …)`, or `patchWhere({ id: [id], workspaceId: null },
232
+ changes, …)` and `deleteWhere` with the same filters, which never reach a
233
+ workspace's row. `{ ...txOptions, context: systemContext(reason) }` joins
234
+ the request's transaction.
235
+
194
236
  A read answers every field unless `fields` names the ones it needs (a
195
237
  belongs-to field by its own key, such as `customerId`, or its relationship);
196
238
  the locks (`lock`, `lockMany`, `lockWhere` and `claim`) take it in their
@@ -200,6 +242,15 @@ visible, so a hot path names its fields. A collection relation with
200
242
  `hydrateOnList: false` or `hydrateOnView: false` loads its rows on that kind of
201
243
  read only when the read includes it.
202
244
 
245
+ `foreignKeys` declares the table's real database foreign keys, in json-rest-api's
246
+ shape: `foreignKeys: [{ columns: ["customerId"], referencedTableName: "customers" }]`,
247
+ `columns` naming schema fields and `referencedColumns` the other table's columns
248
+ when they are not its `id`. json-rest-api relies on a declared key to skip work,
249
+ so a declaration the database lacks changes results: before any test runs,
250
+ `jskit test --databases` compares every declaration with the test database's keys,
251
+ fails on one the database lacks and lists the keys of your tables that nothing
252
+ declares.
253
+
203
254
  A `hidden: true` field (an ownership id, a bearer token, a storage key) never
204
255
  reaches a request: no CRUD route or JSON:API document (`format: "jsonapi"`)
205
256
  carries it, even when `fields` names it. The owner's own code reads one by
@@ -208,12 +259,21 @@ options)` or `{ ...txOptions, fields }` for a lock, which costs one more
208
259
  statement per 1,000 records, scoped as the read is; a read without `fields`
209
260
  never answers it. It may be an id, string, integer, number or boolean, and
210
261
  naming any other kind refuses the read. The repository a CRUD feature gives
211
- other features, and its own actions, refuses a read naming a hidden field, and
212
- any `scopedQuery()` (its builder carries every column), and every method it
213
- holds, `decorateRepository`'s included, answers without the hidden fields of the
214
- tables its owner owns, unless the call runs in a system context. So the owner
215
- checks a token in a method `decorateRepository` adds, with the handle it
216
- receives, and answers only the outcome. Never unhide a field to read it, and
262
+ other features, and its own actions, refuses any call whose `fields` names a
263
+ hidden field, whatever the method (`decorateRepository`'s included), and any
264
+ `scopedQuery()` or answered builder other than a `joinSource()`'s (either can
265
+ select every column), and every method it holds answers without the hidden
266
+ fields of the tables its owner owns, unless the call runs in a system context.
267
+ It tells hidden fields by name, so a feature whose tables hide a field in one
268
+ and show it in another fails to start: hide it in both, or rename one. So the
269
+ owner checks a token in a method `decorateRepository` adds, with the handle it
270
+ receives, and answers only the outcome. Work in another feature that needs the
271
+ value itself (a key it signs with, an address it sends to) calls a method the
272
+ owner's `decorateService` adds: that receives the repository before the guard,
273
+ so it reads the field, named in `fields`, in the caller's own context, with no
274
+ system context. A test of such a path asserts the value the other feature uses,
275
+ not only that the call succeeded: a hidden field left out of a record reads as
276
+ `undefined`, not as an error. Never unhide a field to read it, and
217
277
  never read it with `scopedQuery`.
218
278
 
219
279
  `create` and `patch` answer the written record by default, read back through
@@ -252,8 +312,8 @@ who the call runs for as `caller`: `{ kind: "user", userId }` for a signed-in
252
312
  request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
253
313
  `{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
254
314
  `reason`, for background work. A job names a user only as `actorId`, the
255
- person it was derived for (below), so never read a missing actor as an
256
- anonymous request.
315
+ person it works for ([below](#background-work)), so never read a missing actor
316
+ as an anonymous request.
257
317
 
258
318
  ```js
259
319
  // good: one predicate on the row being read
@@ -511,6 +571,16 @@ no record behind. On its own it runs work once, so work may call a provider:
511
571
  two first uses of one id at once can collide on the insert, and the loser runs
512
572
  again before its work starts.
513
573
 
574
+ Every lock takes `FOR UPDATE`, as `patchWhere` does for the rows it selects,
575
+ which also keeps out another transaction's insert of a row that references the
576
+ locked one (a foreign key's check). Where work locks a parent only to change
577
+ its own fields while children may be added meanwhile (a balance others post
578
+ entries to), pass `lockMode: "noKeyUpdate"` to `lock`, `lockMany`,
579
+ `lockWhere`, `claim`, `lockOrCreate` or `patchWhere`: on PostgreSQL it
580
+ is `FOR NO KEY UPDATE`, which still keeps every other lock and write of the row
581
+ out but lets those inserts through; MySQL, MariaDB and SQLite lock as for the
582
+ default `"update"`. Any other value throws a `TypeError` before anything runs.
583
+
514
584
  A record that a person edits from a form it read earlier carries a version
515
585
  number: the table resource names it with `lockVersionField`, an integer field
516
586
  over a non-null integer column with `defaultTo: 0`.
@@ -572,34 +642,44 @@ async function activeDays(memberIds, options) {
572
642
  resource in the code describes.
573
643
  - `column()` names columns; `raw` takes one expression as a string literal,
574
644
  with its values as bindings.
575
- - The builder only reads, and only its own table is scoped. A `.join()` of a
576
- table by name, or a table named inside `raw()`, reads that table's rows past
577
- its owner and row policy. Nothing at run time stops it: `jskit/no-direct-knex`
578
- and `jskit/sql-literal` flag it, and `expectScoped` catches it only when the
579
- test seeds another owner's rows the join matches. Join another table only as
580
- that table's scoped subquery.
581
- - When a report needs another owner's rows side by side, that owner adds a
582
- method answering its scoped rows as a subquery with its own column names,
583
- and the report joins it in one statement. The method answers
584
- `{ query, column }` from its `scopedQuery({ alias }, options)`, with
585
- `column(field)` naming its columns under that alias, wrapped, since a
586
- builder is thenable and an async function would run it. The report shapes
587
- that builder (select, group), names it with `.as()` and joins it, so every
588
- owner's scope and row policy stay applied. The report lives with the main
589
- table's owner, or in a feature that requires both. A join source carries
590
- every column, hidden ones too, so an owner answers one only to a feature
591
- that may read them.
645
+ - Its rows are the driver's, with no field types applied: a BIGINT column
646
+ (ids and counters alike) and a `COUNT` read as text on MySQL and MariaDB
647
+ (the runtime's `bigNumberStrings`) and on PostgreSQL (int8). The method
648
+ turns the counts and totals it answers into numbers, such as `Number(days)`
649
+ for `activeDays` above.
650
+ - The builder only reads, and only its own table is scoped. A `.join()` or
651
+ `.from()` of a table by name, or a table named inside `raw()`, reads that
652
+ table's rows past its owner and row policy, and so does a subquery callback
653
+ (`whereIn("id", (sub) => sub.from(...))`), whose builder is Knex's own. Nothing
654
+ at run time stops it: `jskit/no-direct-knex` and `jskit/sql-literal` flag it
655
+ (a name as text, an alias object such as `{ p: "projects" }`, a variable
656
+ whose name says table or name, a const holding one, either branch of a `?:`
657
+ or `??`, any `fromRaw()` or `joinRaw()`, and in a subquery callback any
658
+ variable but a scoped subquery or one destructured from a call, such as
659
+ `const { query } = await lots.scopedQuery(...)`), and `expectScoped` catches it
660
+ only when the test seeds another owner's rows the join matches. Join another
661
+ table only as that table's scoped subquery.
662
+ - When a report needs another owner's rows side by side, it joins that
663
+ table's join source in one statement. `joinSource({ alias, filters },
664
+ options)` on the owner's handle, or on the repository a CRUD feature gives
665
+ other features, answers `{ query, column }`: the caller's scoped rows under
666
+ that alias (ownership, the row policy and the filters applied) with the
667
+ columns a read answers and the ownership fields (`workspaceId`, `userId`),
668
+ hidden or not, as join keys; never another hidden column. `column(field)`
669
+ names each under the alias. The builder comes wrapped, since a builder is
670
+ thenable and an async function would run it. The report shapes it (select,
671
+ group), names it with `.as()` and joins it, so every owner's scope and row
672
+ policy stay applied. The report lives with the main table's owner, or in a
673
+ feature that requires both. Never hand-build a join source on `scopedQuery()`: its
674
+ builder carries every column, so a CRUD feature's repository refuses
675
+ `scopedQuery()`, and any method answering a builder other than a
676
+ `joinSource()`'s, outside a system context. A report that needs another
677
+ owner's hidden value asks that owner for a method answering what it needs.
592
678
 
593
679
  ```js
594
- // billing's owner API: its scoped invoices as a join source, with their column names
595
- async function invoiceSource(alias, options) {
596
- const { query } = await invoices.scopedQuery({ alias }, options);
597
- return { query, column: (field) => invoices.column(field, alias) };
598
- }
599
-
600
680
  // Knex: report - open tasks and billed totals per project, joined on billing's scoped invoices
601
681
  async function projectSummary(options) {
602
- const source = await billing.invoiceSource("i", options);
682
+ const source = await billing.joinSource({ alias: "i" }, options);
603
683
  const billed = source.query
604
684
  .select({ projectId: source.column("projectId") })
605
685
  .sum({ billed: source.column("amount") })
@@ -661,6 +741,59 @@ const { records: firstPage } = await subscriptions.query({ filters: { newsletter
661
741
 
662
742
  Bad: a list read 100-row pages in a loop for each of 40 related sets.
663
743
 
744
+ ## What changed since
745
+
746
+ A page that catches up on a chat, a watch that acts on changes and a sync to
747
+ another system each read what changed since they last read. "Past the highest
748
+ id seen" misses a row whose id was taken before its transaction committed, and
749
+ every change to an older row; "`updatedAt` after the last read" misses a
750
+ transaction that committed after a later one. On PostgreSQL a change stamp and
751
+ the change horizon miss neither:
752
+
753
+ - The table stamps each row with the transaction that last wrote it, an `xid8`
754
+ column a trigger sets on every insert and update, whatever the write sent,
755
+ with an index that starts with the ownership columns:
756
+
757
+ ```js
758
+ // migrations/20261108120000_messages_changed.cjs
759
+ exports.up = async function up(knex) {
760
+ await knex.raw("alter table messages add column changed xid8 not null default pg_current_xact_id()");
761
+ await knex.raw("create function messages_changed() returns trigger language plpgsql as $$ begin new.changed := pg_current_xact_id(); return new; end $$");
762
+ await knex.raw("create trigger messages_changed before insert or update on messages for each row execute function messages_changed()");
763
+ await knex.raw("create index messages_changed_ix on messages (workspace_id, changed)");
764
+ };
765
+ ```
766
+ - The table resource reads it as an `xid8` field, which nothing writes, and
767
+ filters on it:
768
+
769
+ ```js
770
+ schema: { /* ... */ changed: { type: "xid8", nullable: true } },
771
+ searchSchema: {
772
+ changedSince: { type: "xid8", actualField: "changed", filterOperator: ">=" },
773
+ changedBefore: { type: "xid8", actualField: "changed", filterOperator: "<" }
774
+ }
775
+ ```
776
+ - The reader takes `changeHorizon(options)` before it reads and answers it as
777
+ the next cursor. Every transaction below the horizon had finished, so the
778
+ next read, `changedSince: cursor`, finds everything this read could not see.
779
+ A row can come twice, so the reader merges by id. A job that keeps its cursor
780
+ in a row reads the window `{ changedSince: cursor, changedBefore: horizon }`
781
+ and stores the horizon as the new cursor in the same transaction.
782
+
783
+ ```js
784
+ async function catchUp(conversationId, since, options) {
785
+ const cursor = await messages.changeHorizon(options);
786
+ const changed = await messages.queryAll({ filters: { conversationId, changedSince: since } }, options);
787
+ return { messages: changed, cursor };
788
+ }
789
+ ```
790
+
791
+ The horizon is the server's: a long transaction anywhere holds it back, so a
792
+ read repeats more rows, never misses one. MySQL, MariaDB and SQLite stamp no
793
+ row with its transaction: `changeHorizon` throws `UnsupportedDatabaseError`
794
+ there, and an `xid8` field is refused. A reader there takes the rows to handle
795
+ with `claim()`, marking each done in the same transaction.
796
+
664
797
  ## Background work
665
798
 
666
799
  Work over the whole population runs as a background job, never inside one
@@ -684,13 +817,15 @@ const SettlementFeature = defineFeature({
684
817
  `actions.execute({ actionId, input, context })` keeps the context's kind:
685
818
  inside it, a handle call still reads as the system context or job.
686
819
  - Work a job does for one person (a request it carries out later) passes
687
- `{ actorId }` last: `forWorkspace(id, { actorId: requestedBy })`. It is for
688
- attribution only: audit fields read it with `resolveAuditActorId(context)`
689
- (the signed-in user, else the person a job acts for) and the row policy as
690
- `caller.actorId`. It never changes which rows the job reaches and signs no
691
- one in: `resolveActorId` and every `authenticated` check still see no one.
692
- Write audit fields with `resolveAuditActorId`, and decide access with
693
- `resolveActorId`.
820
+ `{ actorId }` last: `forWorkspace(id, { actorId: requestedBy })`. It says
821
+ whom the work is for and never sets the scope, which stays the workspace or
822
+ user the job was derived for, and it signs no one in: `resolveActorId` and
823
+ every `authenticated` check still see no one. Audit fields read it with
824
+ `resolveAuditActorId(context)`. A row policy is told it as `caller.actorId`,
825
+ and one that holds people to their own rows reads it as it reads a
826
+ request's `caller.userId`, so a job for a person sees what that person may
827
+ see, and a job for no one its whole scope. Work on one person's rows only
828
+ takes their scope (`forUser`, `forWorkspaceUser`) or filters its reads.
694
829
  - A job finds its units with one `aggregate` grouped by the ownership field,
695
830
  which a system context may group by (and count distinct) even when the table
696
831
  hides it, then runs each unit scoped to it. A request's aggregate still
@@ -709,8 +844,9 @@ const SettlementFeature = defineFeature({
709
844
  ["workspaceId", "email"] }, { context })`, as a system context may do through
710
845
  another feature's CRUD repository too (see [Layer
711
846
  1](#layer-1-the-table-resource)). Never unhide it for the job.
712
- - A request uses its own context. The only exception is a console report
713
- behind a console permission, with bounded output.
847
+ - A request uses its own context. The exceptions are a console report
848
+ behind a console permission, with bounded output, and the write of a shared
849
+ row it has proved it reaches ([Layer 1](#layer-1-the-table-resource)).
714
850
  - `jskit check` lists every system scope and every `systemContext` call with
715
851
  its reason, read from the string literal at the call: a reason kept in a
716
852
  constant reads as none.
@@ -718,6 +854,16 @@ const SettlementFeature = defineFeature({
718
854
  from the request or from `forWorkspace`, `forUser` and `forWorkspaceUser`:
719
855
  lint refuses `createSystemContext` and hand-written `scopeValues`, and a
720
856
  context naming two workspaces or two users throws.
857
+ - A table only the feature's own background work reads and writes, whose rows
858
+ belong to no workspace or user (an agent's runs, a server's leases, a queue
859
+ keyed by something other than an owner), takes ownership `system`. Every call
860
+ then needs the owner's `systemContext(reason)`: a request's context, a
861
+ `forWorkspace`, `forUser` or `forWorkspaceUser` job and no context at all
862
+ throw `SystemContextRequiredError` before any statement, since on such a
863
+ table they would reach every workspace's rows. Requests
864
+ reach the rows only through the owner's own methods, which decide what each
865
+ caller may see, and `defineCrudFeature` refuses a `system` table. A table
866
+ whose rows do belong to a workspace or user names that ownership instead.
721
867
  - A job that runs on a schedule is a feature whose `boot()` starts a timer and
722
868
  whose `shutdown()` stops it; JSKIT ships no scheduler. See
723
869
  [typed features, Owned state](typed-features.md#owned-state).
@@ -784,11 +930,20 @@ Ask the owner: its service, or for a JSKIT table its owner API, with the same
784
930
  (`users.profiles`, `users.settings`), workspaces (`workspaces`,
785
931
  `workspaces.memberships`), local sign-in (`auth.local.accounts`,
786
932
  `auth.local.sessions`, `auth.local.recovery`) and the console
787
- (`console.settings`). A realtime audience reaches its users the same way: its
933
+ (`console.settings`). Local sign-in's owner APIs work in its system context,
934
+ so `jskit check` wants a reviewed reason for each module that requires one
935
+ (see [Hard rules](#hard-rules)). A realtime audience reaches its users the same way: its
788
936
  `audienceUsers({ event, options })` asks the owners and answers user ids, in the
789
937
  event's scope: for a customer's or a public surface's event, the workspace the
790
938
  request resolved, never one its payload names (see [Realtime](/guide/app-extras/realtime)).
791
939
 
940
+ Code that names people by id (a conversation's members, a group's
941
+ participants) reads them with `users.profiles.listByIds(ids, options)`: every
942
+ profile in one call, however many ids, never `query` pages of 100 walked by
943
+ hand or one `findById` per person. `users.settings.listByUserIds(ids, options)`
944
+ does the same for their settings, and `workspaces.listByIds(ids, options)` for
945
+ workspaces.
946
+
792
947
  An app that had a table resource of its own over a JSKIT table (such as
793
948
  `users`) deletes it: its CRUD contract is built on the package's table resource
794
949
  (users-core's `userProfileTableResource`) and served through the owner with
@@ -886,6 +1041,14 @@ test booted (`countRuntimeQueries(work)` from
886
1041
  nothing never passes: "The path sent no statement at any size" means pass
887
1042
  `knex`, or the path never reached the database.
888
1043
 
1044
+ A package's own data test that creates its tables in the test takes its Knex
1045
+ from `createPackageTestKnex(t, { environment: process.env })` (from
1046
+ `@jskit-ai/resource-runtime/test`): an in-memory SQLite database, or on a
1047
+ `DB_CLIENT=pg` run a schema of the test's own in `TEST_DB_NAME`, dropped after
1048
+ it. The same file then proves its locks, many-row writes and upserts on
1049
+ PostgreSQL too; compare counts with `Number()` (PostgreSQL answers them as
1050
+ strings) and ids with `String()`.
1051
+
889
1052
  `createTestResourceRuntime({ knex })` gives a test the resource runtime its
890
1053
  features own their tables through: `runtime.own(featureId, [resource])`
891
1054
  answers the handles, and `runtime.declareSystemScope(featureId, reason)` the
@@ -963,19 +1126,23 @@ async function signUp(email, password) {
963
1126
 
964
1127
  ## Hard rules
965
1128
 
966
- These are always wrong, and lint reports them as errors in all code but the
967
- client's, wherever it sits; tests, migrations and seeds are outside them.
968
- `eslint-disable` cannot turn them off.
1129
+ These are always wrong, and lint reports them as errors in all code but a
1130
+ package's client (`src/client`), the app's own `src/` included, since server
1131
+ code sits there too. Tests, migrations and seeds (a `test/`, `migrations/` or
1132
+ `seeds/` folder at the root of the app or of a package, or a `seed*` script in
1133
+ its `scripts/`, `bin/` or root) are outside them; such a folder deeper in
1134
+ server code is server code. Neither `eslint-disable`, an inline
1135
+ `/* eslint ... */` config nor the app's ESLint config can turn them off.
969
1136
 
970
1137
  ### Lint rules
971
1138
 
972
1139
  | Rule | Reports | Section |
973
1140
  | --- | --- | --- |
974
1141
  | `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) |
975
- | `jskit/no-transaction-reader` | `options.trx`, `trx \|\| knex`, a `trx = knex` default, `trx` destructured from options | [Transactions](#transactions) |
1142
+ | `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) |
976
1143
  | `jskit/no-hand-scope` | a hand-written `where` on `workspace_id` or `workspaceId` | [Layer 1: the table resource](#layer-1-the-table-resource) |
977
1144
  | `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) |
978
- | `jskit/no-hand-context` | a system context made with `createSystemContext`, a context's `scopeValues` written by hand | [Background work](#background-work) |
1145
+ | `jskit/no-hand-context` | a system context made with `createSystemContext` or `declareSystemScope`, a context's `scopeValues` written by hand | [Background work](#background-work) |
979
1146
  | `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) |
980
1147
 
981
1148
  ### Exceptions
@@ -1019,8 +1186,18 @@ it. Check and judge, for each file:
1019
1186
  - an infrastructure exception or a feature's system scope that the app's
1020
1187
  checked-in `jskit-data-access.json` does not list, so each new one is
1021
1188
  reviewed: `{ "infrastructure": [{ "file", "rule" }], "systemScopes":
1022
- [{ "feature", "systemScope" }] }`;
1023
- - a module that requires `runtime.database` or `runtime.resources` without
1024
- the infrastructure exception: features own their tables with `owns`;
1025
- - an `eslint-disable` that names a data-access rule, or no rule, outside
1026
- client code.
1189
+ [{ "feature", "systemScope" }, { "file", "capability", "systemScope" }] }`;
1190
+ - a module that requires a capability handing out rows past their owners:
1191
+ `runtime.database` or `runtime.resources` without the infrastructure
1192
+ exception (features own their tables with `owns`), and
1193
+ `realtime.audience-scope`, `auth.local.accounts`, `auth.local.sessions`,
1194
+ `auth.local.recovery`, `connectors.store`, `assistant.repositories` or
1195
+ `payments.store` until `systemScopes` lists that file and capability with
1196
+ its reason, in any spelling the kernel resolves (`Runtime.Resources`); a
1197
+ feature's own id counts only where it provides that capability;
1198
+ - an `eslint-disable` that names a data-access rule, or no rule, or an inline
1199
+ `/* eslint ... */` config that sets one, outside client code;
1200
+ - an app file that the app's ESLint config lints without every data-access
1201
+ rule as an error (`"jskit/no-direct-knex": "off"`, `dataAccessConfig(files,
1202
+ "off")`), or server code (`src/server`, `src/shared`, `server/`) that it
1203
+ ignores, until `infrastructure` lists that file and rule.
@@ -267,6 +267,35 @@ one table at a time:
267
267
  Keep each move behaviour-preserving
268
268
  ([refactors](sanity-checks.md#refactors)).
269
269
 
270
+ A table keyed by several columns, or by its owner's id, gets an integer `id`
271
+ first ([Layer 1](data-access.md#layer-1-the-table-resource)).
272
+ One new migration does it on MySQL, MariaDB and PostgreSQL: the key becomes a
273
+ unique index before the primary key goes, so a foreign key on its first column
274
+ keeps an index, and adding the `id` numbers the existing rows. `down` undoes it
275
+ in reverse.
276
+
277
+ ```js
278
+ const KEY = ["workspace_id", "day"];
279
+
280
+ export async function up(knex) {
281
+ await knex.schema.alterTable("day_slots", (table) => table.unique(KEY, { indexName: "day_slots_key" }));
282
+ await knex.schema.alterTable("day_slots", (table) => table.dropPrimary());
283
+ await knex.schema.alterTable("day_slots", (table) => table.increments("id"));
284
+ }
285
+
286
+ export async function down(knex) {
287
+ await knex.schema.alterTable("day_slots", (table) => table.dropColumn("id"));
288
+ await knex.schema.alterTable("day_slots", (table) => table.primary(KEY));
289
+ await knex.schema.alterTable("day_slots", (table) => table.dropUnique(KEY, "day_slots_key"));
290
+ }
291
+ ```
292
+
293
+ MySQL and MariaDB commit each statement on its own, so a run that fails
294
+ part-way resumes as the [partial-failure
295
+ rules](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#directory-order-comes-before-filenames)
296
+ say. Code that wrote by the old key now upserts on it, with the ownership
297
+ column among the keys.
298
+
270
299
  ### Workspace invitation transaction compatibility (workspaces-core 0.1.198)
271
300
 
272
301
  Invitation creation, acceptance and refusal now own their managed transactions:
@@ -54,7 +54,10 @@ The `crud/crud-contract` and `database/table-resource` patterns list what each e
54
54
 
55
55
  Normal CRUD tables use one non-null single-column primary key. Foreign keys are
56
56
  single-column and target compatible keys. Composite unique indexes may enforce
57
- business rules but do not become framework identities.
57
+ business rules but do not become framework identities: a table keyed by several
58
+ columns, or by its owner's id, gets an integer `id` and keeps that key as a
59
+ unique index, read with a filter and written with an upsert
60
+ ([Layer 1](../data-access.md#layer-1-the-table-resource)).
58
61
 
59
62
  ## Schema evolution
60
63