@jskit-ai/agent-docs 0.1.292 → 0.1.294

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 (62) hide show
  1. package/guide/agent/app-extras/realtime.md +14 -4
  2. package/guide/agent/app-setup/authentication.md +19 -8
  3. package/guide/agent/app-setup/multi-homing.md +31 -3
  4. package/guide/agent/app-setup/upgrading-jskit.md +4 -0
  5. package/guide/agent/framework/client-features.md +54 -1
  6. package/guide/agent/framework/crud-operations.md +7 -0
  7. package/guide/agent/framework/data-access.md +75 -9
  8. package/guide/agent/framework/sanity-checks.md +4 -4
  9. package/guide/agent/framework/typed-features.md +45 -5
  10. package/package.json +1 -1
  11. package/patterns/feature-package/example/booking-engine/package.json +2 -2
  12. package/patterns/minimal-foundation/example/AGENTS.md +5 -1
  13. package/patterns/minimal-foundation/example/package.json +5 -5
  14. package/patterns/shell-foundation/example/AGENTS.md +5 -1
  15. package/patterns/shell-foundation/example/package.json +6 -6
  16. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  17. package/reference/autogen/PATTERN_INDEX.md +38 -38
  18. package/reference/autogen/packages/agent.md +4 -2
  19. package/reference/autogen/packages/assistant-core.md +1 -1
  20. package/reference/autogen/packages/assistant-runtime.md +1 -0
  21. package/reference/autogen/packages/crud-core.md +2 -0
  22. package/reference/autogen/packages/http-web.md +9 -6
  23. package/reference/autogen/packages/kernel.md +7 -1
  24. package/reference/autogen/packages/realtime.md +1 -0
  25. package/reference/autogen/packages/resource-runtime.md +1 -0
  26. package/reference/autogen/packages/users-core.md +3 -0
  27. package/reference/autogen/packages/workspaces-core.md +19 -4
  28. package/reference/autogen/packages/workspaces-web.md +2 -1
  29. package/reference/autogen/tooling/config-eslint.md +44 -1
  30. package/reference/autogen/tooling/jskit-catalog.md +35 -13
  31. package/skills/jskit/references/client-features.md +54 -1
  32. package/skills/jskit/references/crud-operations.md +7 -0
  33. package/skills/jskit/references/data-access.md +75 -9
  34. package/skills/jskit/references/heuristics/INDEX.md +1 -1
  35. package/skills/jskit/references/heuristics/data-access.md +1 -0
  36. package/skills/jskit/references/heuristics/filters.md +14 -0
  37. package/skills/jskit/references/heuristics/server-search.md +2 -0
  38. package/skills/jskit/references/multi-homing.md +31 -3
  39. package/skills/jskit/references/pattern-index.md +38 -38
  40. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +5 -1
  41. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +5 -5
  42. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +5 -1
  43. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +6 -6
  44. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  45. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -3
  46. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  47. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  48. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  49. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +3 -3
  50. package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +12 -1
  51. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -2
  52. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -2
  53. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  54. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +2 -2
  55. package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +7 -0
  56. package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +7 -3
  57. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +3 -3
  58. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +6 -6
  59. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +69 -17
  60. package/skills/jskit/references/patterns/workspaces/workspace-server/PATTERN.md +9 -0
  61. package/skills/jskit/references/sanity-checks.md +4 -4
  62. package/skills/jskit/references/typed-features.md +45 -5
@@ -38,9 +38,13 @@ variant also names `visibility: "workspace"`: its routes are a workspace's,
38
38
  so they resolve the caller's workspace and check its `crud.users.*`
39
39
  permissions. The `users` table is public, every user's, so the variant also
40
40
  narrows the repository to the workspace's members with `decorateRepository`
41
- (`src/server/workspaceMembersOnly.js`): a list reads only the active members
42
- from `workspaces.memberships`' `activeMemberIds`, and anyone else is a missing
43
- record, so a workspace admin never learns who else has an account. The narrowed
41
+ (`src/server/workspaceMembersOnly.js`): a list reads only the active members,
42
+ and anyone else is a missing record, so a workspace admin never learns who else
43
+ has an account. Each page is one report over users.profiles' and
44
+ `workspaces.memberships`' join sources: the users the list's filters match that
45
+ hold an active membership, in the users list's order, a page at a time, so it
46
+ never reads the workspace's memberships whole; a view reads the one membership
47
+ with `findByWorkspaceIdAndUserId`. The narrowed
44
48
  repository answers only `query`, `get` and `pageLimits`: users.profiles' other
45
49
  methods (`findByEmail`, `countUsers`, the writes) read or write every user, so
46
50
  they never pass through it.
@@ -10,9 +10,9 @@
10
10
  "./shared": "./src/shared/index.js"
11
11
  },
12
12
  "dependencies": {
13
- "@jskit-ai/crud-core": "0.2.22",
14
- "@jskit-ai/resource-crud-core": "0.1.208",
15
- "@jskit-ai/users-core": "0.2.23"
13
+ "@jskit-ai/crud-core": "0.2.24",
14
+ "@jskit-ai/resource-crud-core": "0.1.210",
15
+ "@jskit-ai/users-core": "0.2.25"
16
16
  },
17
17
  "jskit": {
18
18
  "kind": "runtime",
@@ -10,14 +10,14 @@
10
10
  "./shared": "./src/shared/index.js"
11
11
  },
12
12
  "dependencies": {
13
- "@jskit-ai/crud-core": "0.2.22",
14
- "@jskit-ai/resource-crud-core": "0.1.208",
15
- "@jskit-ai/users-core": "0.2.23",
16
- "@jskit-ai/workspaces-core": "0.2.23"
13
+ "@jskit-ai/crud-core": "0.2.24",
14
+ "@jskit-ai/resource-crud-core": "0.1.210",
15
+ "@jskit-ai/users-core": "0.2.25",
16
+ "@jskit-ai/workspaces-core": "0.2.25"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/kernel": "0.2.17",
20
- "@jskit-ai/resource-runtime": "0.2.19"
19
+ "@jskit-ai/kernel": "0.2.18",
20
+ "@jskit-ai/resource-runtime": "0.2.21"
21
21
  },
22
22
  "jskit": {
23
23
  "kind": "runtime",
@@ -1,43 +1,95 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
2
  import { passedOn, resolveWorkspaceScopeValue } from "@jskit-ai/resource-runtime/server";
3
3
 
4
+ // The page size json-rest-api reads for a list that names none.
5
+ const DEFAULT_PAGE_SIZE = 20;
6
+
7
+ // A page's cursor: the display name, email and id of its last user, the users list's order, as base64url JSON.
8
+ function encodeCursor(row) {
9
+ return Buffer.from(JSON.stringify([row.displayName, row.email, String(row.id)])).toString("base64url");
10
+ }
11
+
12
+ function decodeCursor(cursor) {
13
+ try {
14
+ const [displayName, email, id] = JSON.parse(Buffer.from(String(cursor), "base64url").toString("utf8"));
15
+ if ([displayName, email, id].every((value) => typeof value === "string")) {
16
+ return { displayName, email, id };
17
+ }
18
+ } catch {
19
+ // Not a cursor this list answered: refused below.
20
+ }
21
+ throw new AppError(400, "This list's cursor is not one it answered.", { code: "users.list.cursorInvalid" });
22
+ }
23
+
4
24
  /**
5
25
  * users-core's users repository narrowed to the caller's workspace: a list reads only the workspace's active members,
6
26
  * and anyone else is a missing record, so a workspace admin never learns who else has an account. The users table is
7
- * public, so its handle alone would list every user; the narrowing reads the workspace's memberships first. It answers
8
- * only the reads it narrows (query and get, with pageLimits): the rest of users.profiles, such as findByEmail or
9
- * countUsers, never passes through this feature's repository, so nothing reaches users past the narrowing through it.
10
- * @param {import("@jskit-ai/crud-core/server/types").CrudRepository} repository
11
- * @param {import("@jskit-ai/workspaces-core/server/types").WorkspaceMembershipsRepository} memberships
27
+ * public, so its handle alone would list every user. A page is one report over the users the list's filters match
28
+ * that hold an active membership in the workspace, in the users list's order, so it reads a page of rows however many
29
+ * members the workspace has; the users repository then reads that page's users as the CRUD contract answers them. It
30
+ * answers only the reads it narrows (query and get, with pageLimits): the rest of users.profiles, such as findByEmail
31
+ * or countUsers, never passes through this feature's repository, so nothing reaches users past the narrowing through it.
32
+ * @param {import("@jskit-ai/crud-core/server/types").CrudRepository & Pick<import("@jskit-ai/users-core/server/types").UsersProfiles, "joinSource">} repository
33
+ * @param {import("@jskit-ai/workspaces-core/server/types").WorkspacesMemberships} memberships
12
34
  */
13
35
  function workspaceMembersOnly(repository, memberships) {
14
- // The caller's workspace's active members; none when the context names no workspace. The memberships get only the
15
- // context and transaction: the list's own settings (its format, missing) are for the users read.
16
- function memberIds(options) {
17
- return memberships.activeMemberIds(resolveWorkspaceScopeValue(options.context), passedOn(options));
36
+ // Knex: report - one page of the users the list's filters match that are active members of the workspace, in the users list's order
37
+ async function memberPage(workspaceId, filters, { size, after }, options) {
38
+ const user = await repository.joinSource({ alias: "u", ...(filters === undefined ? {} : { filters }) }, options);
39
+ const member = await memberships.joinSource({ alias: "m", filters: { workspace: workspaceId, status: "active" } }, options);
40
+ const [shown, mail, key] = [user.column("displayName"), user.column("email"), user.column("id")];
41
+ const query = user.query
42
+ .whereExists(member.query.select(member.column("id")).whereColumn(member.column("userId"), key))
43
+ .select({ id: key, displayName: shown, email: mail })
44
+ .orderBy([shown, mail, key])
45
+ .limit(size + 1);
46
+ if (after !== undefined) {
47
+ const last = decodeCursor(after);
48
+ query.where((later) => later.where(shown, ">", last.displayName)
49
+ .orWhere((tie) => tie.where(shown, last.displayName).where(mail, ">", last.email))
50
+ .orWhere((tie) => tie.where(shown, last.displayName).where(mail, last.email).where(key, ">", last.id)));
51
+ }
52
+ const rows = await query;
53
+ return { ids: rows.slice(0, size).map((row) => String(row.id)), next: rows.length > size ? encodeCursor(rows[size - 1]) : null };
18
54
  }
19
55
 
20
56
  /**
21
- * One page of the workspace's members the params select.
57
+ * One page of the workspace's members the params select: the report picks the page's users, the users repository
58
+ * reads them, and the page's cursor is the report's.
22
59
  * @param {Record<string, any>} params
23
60
  * @param {{ context?: object }} options
24
61
  */
25
62
  async function query(params, options) {
26
- const members = await memberIds(options);
27
- const requested = params.filters?.id;
28
- // An empty list matches no row: a workspace with no members lists nobody.
29
- const id = requested === undefined ? members : [requested].flat().map(String).filter((memberId) => members.includes(memberId));
30
- return repository.query({ ...params, filters: { ...params.filters, id } }, options);
63
+ const workspaceId = resolveWorkspaceScopeValue(options.context);
64
+ const size = Number(params.page?.size ?? DEFAULT_PAGE_SIZE);
65
+ const after = params.page?.after;
66
+ // A context with no workspace has no members: the page is empty.
67
+ const page = workspaceId
68
+ ? await memberPage(workspaceId, params.filters, { size: Math.min(size, repository.pageLimits().request), after }, passedOn(options))
69
+ : { ids: [], next: null };
70
+ // An empty list matches no row. A size over the page limit throws here, as the users list's own read does.
71
+ const result = await repository.query({ ...params, filters: { id: page.ids }, page: { size } }, options);
72
+ const hasMore = page.next !== null;
73
+ if (options.format !== "jsonapi") {
74
+ return { records: result.records, page: { size, hasMore, next: page.next } };
75
+ }
76
+ // The users read's own links would name its id list; the page's cursor is the report's.
77
+ const { links: _links, ...document } = result;
78
+ return {
79
+ ...document,
80
+ meta: { ...document.meta, pagination: { pageSize: size, hasMore, ...(hasMore ? { cursor: { next: page.next } } : {}) } }
81
+ };
31
82
  }
32
83
 
33
84
  /**
34
- * A member of the workspace, or a missing record for anyone else.
85
+ * A member of the workspace, or a missing record for anyone else. It reads the one membership.
35
86
  * @param {unknown} id
36
87
  * @param {Record<string, any>} params
37
88
  * @param {{ context?: object, missing?: "skip" }} options
38
89
  */
39
90
  async function get(id, params, options) {
40
- if ((await memberIds(options)).includes(String(id))) {
91
+ const membership = await memberships.findByWorkspaceIdAndUserId(resolveWorkspaceScopeValue(options.context), id, passedOn(options));
92
+ if (membership?.status === "active") {
41
93
  return repository.get(id, params, options);
42
94
  }
43
95
  if (options.missing === "skip") {
@@ -74,6 +74,15 @@ external effects out of it. Without the capability, acceptance activates the
74
74
  membership only. Allow ordinary invitations; reject stale, revoked, conflicting
75
75
  or unexpectedly missing application associations.
76
76
 
77
+ Provide the optional `workspaces.workspace-created-participant` capability the
78
+ same way to write application records for every new workspace. The
79
+ `workspace.workspaces.create` action and personal workspace provisioning invoke
80
+ `fn({ workspace, owner }, txOptions)` after writing the workspace, its owner's
81
+ membership and its settings, in the creation's uncommitted transaction (the
82
+ caller's when it passes one). Pass `txOptions` on and propagate failures: a
83
+ failure rolls the whole creation back. An existing personal workspace does not
84
+ call it.
85
+
77
86
  Call `members.prepareInvite(workspace, actor, payload, txOptions)` inside
78
87
  `repositories.workspaceInvites.withTransaction(options, async (txOptions) => …)`
79
88
  or another resource handle's `withTransaction`. It returns
@@ -67,13 +67,13 @@ expects the relationship id or an unselected field never gets it.
67
67
  ### Every row
68
68
 
69
69
  - A handle's `query()` answers one page and refuses a missing or oversized
70
- `page.size`, where json-rest-api would cut the list silently. A read its
71
- callers treat as complete uses `queryAll()`, or the missing rows vanish from
72
- every picker and delivery built on it
70
+ `page.size`, where json-rest-api would cut the list silently. A delivery
71
+ reaching every row uses `queryAll()`; a picker reads a page searched with
72
+ `q` and its selected values by `id`
73
73
  ([Reads](data-access.md#reads-shaped-for-the-screen)).
74
74
  - A grouped `aggregate()` answers every group or throws
75
75
  `RESOURCE_GROUP_LIMIT`; past `pageLimits().request` groups, page it with
76
- `page: { size, number }`
76
+ `page: { size, number }` (answers `{ rows, page }`)
77
77
  ([The handle's calls](data-access.md#the-handle-s-calls)).
78
78
  - A lookup by a unique key matches at most one row: `get(id)`, or a `query()`
79
79
  of page size 1.
@@ -76,6 +76,31 @@ setup: ({ resources }) => ({ invoices: { list: (options) => resources.invoices.q
76
76
  actions: ({ invoices }) => [{ id: "invoices.list", execute: () => invoices.list() }]
77
77
  ```
78
78
 
79
+ ### Package manifest
80
+
81
+ **A package's manifest, `jskit.capabilities` in its `package.json`, lists
82
+ exactly what its features provide, and every capability they require beyond
83
+ the kernel's own (`runtime.actions`, `runtime.events`, `runtime.http`,
84
+ `runtime.bootstrap`) and those the package provides itself.** Optional
85
+ capabilities are not required. A requirement no feature has stays only when
86
+ the package needs it at install time, such as the database its migrations
87
+ need.
88
+
89
+ Why: start-up resolves every package's requirements from the manifests, and
90
+ refuses a manifest that disagrees with the package's features. Plain
91
+ `jskit check` loads each application package's features (the server providers
92
+ `jskit.runtime.server.providers` declares) and reports the drift before that
93
+ (`manifest/capabilities`), each capability to add or remove; it lists the
94
+ requirements no feature has for review.
95
+
96
+ ```jsonc
97
+ // good: BooksFeature provides app.books and requires crud.contracts and runtime.actions
98
+ "capabilities": { "provides": ["app.books"], "requires": ["crud.contracts"] }
99
+
100
+ // bad: app.books missing from provides, crud.contracts from requires
101
+ "capabilities": { "provides": ["app.book"], "requires": [] }
102
+ ```
103
+
79
104
  ### Capability types
80
105
 
81
106
  **Each provided token has one `@typedef` in its package's `src/server/types.js`,
@@ -622,9 +647,9 @@ Why: a value with two defaults has two meanings, and a mode flag hides a
622
647
  second method inside the first.
623
648
 
624
649
  ```js
625
- // good
626
- async function list(filters, options = {}) {
627
- return invoices.queryAll({ filters }, options);
650
+ // good: one page, as a list shows it
651
+ async function list(filters, page, options = {}) {
652
+ return invoices.query({ filters, page }, options);
628
653
  }
629
654
 
630
655
  // bad
@@ -691,6 +716,11 @@ way (is this user an operator) is a member of its owner's capability.**
691
716
  meaning of 0 and false before replacing a copy.
692
717
  - A helper two packages of an application use lives in its shared package,
693
718
  never inside one of them.
719
+ - A component's closure over its own state is not a helper: a function of at
720
+ most two statements in `<script setup>` whose every outside name is a
721
+ binding `<script setup>` declares (not an import or a global). Two pages
722
+ that reload their own resources the same way keep their own `reload()`;
723
+ sharing it would take a module that receives each page's state.
694
724
 
695
725
  Why: the kernel's helpers fix one meaning of empty, and a copy brings its own.
696
726
 
@@ -741,6 +771,13 @@ handle's `queryAll(params, options)`. A `query()` names its page size, and
741
771
  answers one page.** [Reads shaped for the screen](data-access.md#reads-shaped-for-the-screen)
742
772
  gives the rules and examples.
743
773
 
774
+ ### Narrow in the query
775
+
776
+ **A read asks for exactly the rows the code uses: its filter, sort, limit,
777
+ existence check and count go in the read, never in code over the rows it
778
+ answered.** [Narrow in the query](data-access.md#narrow-in-the-query)
779
+ gives the rules and examples.
780
+
744
781
  ### Lists read related rows in batches
745
782
 
746
783
  **A read that builds a list fetches its related rows in batches, an id-list
@@ -1170,8 +1207,9 @@ Why: a comment that narrates history is wrong the day after it is written.
1170
1207
 
1171
1208
  `npm run lint` runs ESLint with `@jskit-ai/config-eslint`, and `npm run
1172
1209
  verify` runs `jskit check` (the package graph, navigation, [data
1173
- access](data-access.md#exceptions) and [test
1174
- runs](#test-run-rules)), lint, the tests and the build. The foundations'
1210
+ access](data-access.md#exceptions), [test
1211
+ runs](#test-run-rules) and [package manifests](#package-manifest)), lint, the
1212
+ tests and the build. The foundations'
1175
1213
  `eslint.config.mjs` already holds a new
1176
1214
  application's code to every check:
1177
1215
 
@@ -1241,6 +1279,7 @@ Every finding ends with a link to the section of this page that explains it.
1241
1279
  | `jskit/app-error-code` | an `AppError`, `DomainError`, `ConflictError` or `NotFoundError` (or a subclass the module declares) without its own code, and one code naming two sentences in a module | [Refusals carry their own code](#refusals-carry-their-own-code) |
1242
1280
  | `jskit/no-patched-error` | `Object.assign(new Error(…), { statusCode })`, or an error builder's result given its status afterwards | [One construction per error](#one-construction-per-error) |
1243
1281
  | `jskit/no-test-only-export` | an export whose name says only tests use it (`…ForTests`, `__reset…`; `__testables` aside) | [No test-only exports](#no-test-only-exports) |
1282
+ | `jskit/narrow-in-query` | `.filter()`, `.find()`, `.some()`, `.every()`, `.slice()`, a lone `.length` or a loop that skips with `continue`, over the rows a read in the same function answered and uses no other way | [Narrow in the query](#narrow-in-the-query) |
1244
1283
  | `jskit/one-property-per-line` | two properties on one line in a returned `Object.freeze({ … })` or a `requires` / `optional` / `provides` map (`eslint --fix` splits them) | [Formatter](#formatter) |
1245
1284
  | `jskit/jsdoc-description-inside` | a `//` description directly above a `/** */` block | [Comments](#comments) |
1246
1285
  | `jskit/allow-comment-form` | a `readable-check: allow` reason without a prefix from the table below, or one that cites tests or the state of a project | [Exceptions](#exceptions) |
@@ -1292,6 +1331,7 @@ if (typeof buildSerializer !== "function") {
1292
1331
  | `filled by <repository>; convert with its supplier` | keeps a setter that another repository's code calls |
1293
1332
  | `cross-repository slot <token>` | keeps a probe for a token another repository supplies |
1294
1333
  | `shared resource:` | keeps client module state that every instance on the page shares on purpose ([client features](client-features.md#module-state)) |
1334
+ | `every row: <why>` | code needs every row a read answers and narrows them for its own use, such as a read the data itself keeps small (a conversation's few summaries) whose oldest ones it deletes (`jskit/narrow-in-query`) |
1295
1335
  | `infrastructure: <what the module is>` | excuses a whole file whose job is data access itself from `jskit/no-direct-knex` or `jskit/no-transaction-reader`, one line per rule before its first line of code ([data access](data-access.md#exceptions)) |
1296
1336
 
1297
1337
  One kind of probe gets one prefix and one wording everywhere, and a bare