@jskit-ai/agent-docs 0.1.293 → 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.
- package/guide/agent/app-extras/realtime.md +14 -4
- package/guide/agent/app-setup/multi-homing.md +12 -3
- package/guide/agent/framework/client-features.md +28 -0
- package/guide/agent/framework/crud-operations.md +7 -0
- package/guide/agent/framework/data-access.md +75 -9
- package/guide/agent/framework/sanity-checks.md +4 -4
- package/guide/agent/framework/typed-features.md +12 -3
- package/package.json +1 -1
- package/patterns/minimal-foundation/example/package.json +2 -2
- package/patterns/shell-foundation/example/package.json +3 -3
- package/reference/autogen/PATTERN_INDEX.md +35 -35
- package/reference/autogen/packages/agent.md +4 -2
- package/reference/autogen/packages/assistant-core.md +1 -1
- package/reference/autogen/packages/assistant-runtime.md +1 -0
- package/reference/autogen/packages/crud-core.md +2 -0
- package/reference/autogen/packages/http-web.md +9 -6
- package/reference/autogen/packages/realtime.md +1 -0
- package/reference/autogen/packages/resource-runtime.md +1 -0
- package/reference/autogen/packages/users-core.md +3 -0
- package/reference/autogen/packages/workspaces-core.md +11 -2
- package/reference/autogen/packages/workspaces-web.md +1 -1
- package/reference/autogen/tooling/config-eslint.md +26 -0
- package/reference/autogen/tooling/jskit-catalog.md +27 -13
- package/skills/jskit/references/client-features.md +28 -0
- package/skills/jskit/references/crud-operations.md +7 -0
- package/skills/jskit/references/data-access.md +75 -9
- package/skills/jskit/references/heuristics/INDEX.md +1 -1
- package/skills/jskit/references/heuristics/data-access.md +1 -0
- package/skills/jskit/references/heuristics/filters.md +14 -0
- package/skills/jskit/references/heuristics/server-search.md +2 -0
- package/skills/jskit/references/multi-homing.md +12 -3
- package/skills/jskit/references/pattern-index.md +35 -35
- package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +2 -2
- package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +3 -3
- package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +1 -1
- 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 +2 -2
- package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/package.json +2 -2
- package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +9 -0
- package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
- package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +7 -3
- 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 +5 -5
- package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +69 -17
- package/skills/jskit/references/sanity-checks.md +4 -4
- package/skills/jskit/references/typed-features.md +12 -3
|
@@ -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
|
-
|
|
43
|
-
|
|
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.
|
|
14
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
15
|
-
"@jskit-ai/users-core": "0.2.
|
|
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.
|
|
14
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
15
|
-
"@jskit-ai/users-core": "0.2.
|
|
16
|
-
"@jskit-ai/workspaces-core": "0.2.
|
|
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
19
|
"@jskit-ai/kernel": "0.2.18",
|
|
20
|
-
"@jskit-ai/resource-runtime": "0.2.
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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") {
|
|
@@ -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
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
@@ -647,9 +647,9 @@ Why: a value with two defaults has two meanings, and a mode flag hides a
|
|
|
647
647
|
second method inside the first.
|
|
648
648
|
|
|
649
649
|
```js
|
|
650
|
-
// good
|
|
651
|
-
async function list(filters, options = {}) {
|
|
652
|
-
return invoices.
|
|
650
|
+
// good: one page, as a list shows it
|
|
651
|
+
async function list(filters, page, options = {}) {
|
|
652
|
+
return invoices.query({ filters, page }, options);
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
// bad
|
|
@@ -771,6 +771,13 @@ handle's `queryAll(params, options)`. A `query()` names its page size, and
|
|
|
771
771
|
answers one page.** [Reads shaped for the screen](data-access.md#reads-shaped-for-the-screen)
|
|
772
772
|
gives the rules and examples.
|
|
773
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
|
+
|
|
774
781
|
### Lists read related rows in batches
|
|
775
782
|
|
|
776
783
|
**A read that builds a list fetches its related rows in batches, an id-list
|
|
@@ -1272,6 +1279,7 @@ Every finding ends with a link to the section of this page that explains it.
|
|
|
1272
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) |
|
|
1273
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) |
|
|
1274
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) |
|
|
1275
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) |
|
|
1276
1284
|
| `jskit/jsdoc-description-inside` | a `//` description directly above a `/** */` block | [Comments](#comments) |
|
|
1277
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) |
|
|
@@ -1323,6 +1331,7 @@ if (typeof buildSerializer !== "function") {
|
|
|
1323
1331
|
| `filled by <repository>; convert with its supplier` | keeps a setter that another repository's code calls |
|
|
1324
1332
|
| `cross-repository slot <token>` | keeps a probe for a token another repository supplies |
|
|
1325
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`) |
|
|
1326
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)) |
|
|
1327
1336
|
|
|
1328
1337
|
One kind of probe gets one prefix and one wording everywhere, and a bare
|