@jskit-ai/agent-docs 0.1.290 → 0.1.291
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/existing-application-migration.md +1 -1
- package/guide/agent/framework/data-access.md +200 -19
- package/guide/agent/framework/material-3.md +40 -39
- package/guide/agent/framework/sanity-checks.md +15 -12
- 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/PATTERN_INDEX.md +38 -38
- package/reference/autogen/packages/agent-runtime.md +4 -0
- package/reference/autogen/packages/kernel.md +1 -1
- package/reference/autogen/packages/resource-core.md +1 -0
- package/reference/autogen/packages/resource-runtime.md +27 -0
- package/reference/autogen/packages/workspaces-core.md +0 -2
- package/reference/autogen/tooling/jskit-catalog.md +30 -10
- package/skills/jskit/references/data-access.md +200 -19
- package/skills/jskit/references/existing-application-migration.md +1 -1
- package/skills/jskit/references/material-3.md +40 -39
- package/skills/jskit/references/pattern-index.md +38 -38
- 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/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/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/sanity-checks.md +15 -12
|
@@ -213,7 +213,7 @@ write goes through that table's handle
|
|
|
213
213
|
| a field's `default:` | `defaultTo:` (`default` is refused, since json-rest-api never read it) |
|
|
214
214
|
| a `createdAt` or `updatedAt` default of now | nothing: drop it, since every handle write stamps them |
|
|
215
215
|
| a CRUD contract that showed every column | a contract shows only its `fields`, so list every field the API showed |
|
|
216
|
-
| reading every row | `queryAll`, which refuses above 10,000 rows
|
|
216
|
+
| reading every row | `queryAll`, which refuses above 10,000 rows in a request; a job over more reads `queryPages()` a page at a time |
|
|
217
217
|
| an app's own wrappers around old calls | the handle method they wrapped, then delete the wrapper |
|
|
218
218
|
|
|
219
219
|
Handles answer records, `{ id, ...fields }`, with a belongs-to field as the
|
|
@@ -35,8 +35,9 @@ through its handle's calls, and every call takes the call's `options` last:
|
|
|
35
35
|
| --- | --- |
|
|
36
36
|
| `query({ filters, sort, page, include }, options)` | one page: `{ records, page }` |
|
|
37
37
|
| `queryAll({ filters, sort, include }, options)` | every matching row, for jobs, id lists and one parent's rows |
|
|
38
|
+
| `queryPages({ filters, sort, include }, options)` | every matching row, a page at a time, for work over many rows |
|
|
38
39
|
| `get(id, params, options)` | one record |
|
|
39
|
-
| `aggregate({ measures, groupBy, filters, sort }, options)` | totals, grouped by fields or date buckets |
|
|
40
|
+
| `aggregate({ measures, groupBy, filters, sort, page }, options)` | totals, grouped by fields or date buckets |
|
|
40
41
|
| `create(data, options)`, `findOrCreate({ id, ...data }, options)` | one record; a keyed one, created once |
|
|
41
42
|
| `patch(id, changes, options)`, `delete(id, options)` | one record |
|
|
42
43
|
| `createMany(rows, options)` | many records, or an upsert with `onConflict` |
|
|
@@ -60,6 +61,14 @@ function totalsByMonth(options) {
|
|
|
60
61
|
}
|
|
61
62
|
```
|
|
62
63
|
|
|
64
|
+
A grouped `aggregate` answers its groups from one statement, at most
|
|
65
|
+
`pageLimits().request` of them (the resource's `queryMaxLimit`). When there
|
|
66
|
+
are more it throws a 400 coded `RESOURCE_GROUP_LIMIT` instead of answering
|
|
67
|
+
part of them. Narrow the filters, or read the groups in pages, `page: { size,
|
|
68
|
+
number }` with `number` from 1, until a page answers fewer than `size` rows.
|
|
69
|
+
Page numbers are offsets, so read every page before work that changes what
|
|
70
|
+
the filters match.
|
|
71
|
+
|
|
63
72
|
## Words
|
|
64
73
|
|
|
65
74
|
| Word | Meaning |
|
|
@@ -157,6 +166,44 @@ whole number exactly, so json-rest-api answers those digits as text rather
|
|
|
157
166
|
than a rounded number. A value that can grow that large gets a decimal field
|
|
158
167
|
(`precision` and `scale`), whose digits stay text.
|
|
159
168
|
|
|
169
|
+
A `type: "blob"` field holds bytes the process reads and writes whole, so it
|
|
170
|
+
names `maxBytes`, the most bytes a value holds, and its column holds that much
|
|
171
|
+
too: MySQL's and MariaDB's `BLOB` holds 64 KiB and `MEDIUMBLOB` 16 MiB, while
|
|
172
|
+
PostgreSQL's `bytea` and SQLite's `BLOB` hold more than any limit you would
|
|
173
|
+
name. A blob takes no `search`, `indexed`, `unique`, `sortable`, `defaultTo` or
|
|
174
|
+
`belongsTo`. A write takes a Buffer or a Uint8Array of at most `maxBytes`, or
|
|
175
|
+
null, and refuses anything else with a validation error on the field (422)
|
|
176
|
+
before any statement, a transaction's or a lock's included. A read leaves a
|
|
177
|
+
blob out unless its `fields` names it, and answers it as a Buffer, holding at
|
|
178
|
+
most 16 MiB of the blobs it names, or one record's: `get(id, { fields:
|
|
179
|
+
["image"] }, options)` and `lock()` read one record's; a page of `query()` and
|
|
180
|
+
the ids of `lockMany()` are refused past the records whose `maxBytes` fit 16
|
|
181
|
+
MiB, and each page of `queryPages()` holds no more. `queryAll()`, `lockWhere()`
|
|
182
|
+
and `claim()` read every matching row at once, so they refuse to name a blob,
|
|
183
|
+
and no read names an included table's blob, since an include holds any number
|
|
184
|
+
of records: read it through that table's handle. A join source carries no
|
|
185
|
+
blob. JSON holds no binary, so a JSON:API read and a CRUD contract refuse a
|
|
186
|
+
blob, and a route of its own serves one as the response body. A file people upload (a photo, a
|
|
187
|
+
document) goes to storage through `runtime.uploads` and the table keeps its
|
|
188
|
+
key; a blob is for the few bytes the record itself needs, such as a thumbnail
|
|
189
|
+
or a signature.
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
const signatureResource = defineTableResource({
|
|
193
|
+
name: "signatures",
|
|
194
|
+
tableName: "signatures",
|
|
195
|
+
ownership: "workspace",
|
|
196
|
+
schema: {
|
|
197
|
+
signedBy: { type: "string", required: true },
|
|
198
|
+
// MEDIUMBLOB on MySQL and MariaDB
|
|
199
|
+
image: { type: "blob", maxBytes: 256 * 1024, required: true }
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// One signature's image, read where a route answers it.
|
|
204
|
+
const imageOf = async (id, options) => (await signatures.get(id, { fields: ["image"] }, options)).image;
|
|
205
|
+
```
|
|
206
|
+
|
|
160
207
|
A package whose features own tables or declare `systemScope` lists
|
|
161
208
|
`runtime.resources` in its descriptor's `capabilities.requires`, or boot
|
|
162
209
|
refuses it; the feature never requires it itself, since it receives its
|
|
@@ -202,7 +249,17 @@ A filter on another table's field (`searchSchema: { supplierName: { type:
|
|
|
202
249
|
"string", actualField: "suppliers.name" } }`) needs that field declared
|
|
203
250
|
`indexed: true` on its own table resource, with a database index on its column,
|
|
204
251
|
even when the join is on the other table's id; `own()` refuses it at startup
|
|
205
|
-
otherwise, naming both.
|
|
252
|
+
otherwise, naming both. That `indexed: true` is the other table's owner letting
|
|
253
|
+
filters search the field. The filter reads that table as the table's own read
|
|
254
|
+
in the caller's scope: its ownership, its owner's row policy and the policies
|
|
255
|
+
contributed to it decide which of its rows can match, in `query`, `queryAll`,
|
|
256
|
+
`queryPages` and `aggregate` alike, and each row matches once, through a
|
|
257
|
+
has-many path too (suppliers filtered by their items' names). `own()` also
|
|
258
|
+
refuses two filters no scope guards: one that reads a `system` table from a
|
|
259
|
+
table that is not `system`, since a request would read it, and one on a field
|
|
260
|
+
the other table hides, which would tell a caller which rows hold a value it
|
|
261
|
+
may not read. For either, the other table's owner answers the ids it matches
|
|
262
|
+
through its owner API, and the filter takes the list: `filters: { supplierIds }`.
|
|
206
263
|
|
|
207
264
|
A table keyed by a `uuid` or `string` primary key takes its ids as text; an
|
|
208
265
|
`owns` entry's `normalizeId` is only for an id written some other way.
|
|
@@ -318,7 +375,11 @@ request, `{ kind: "anonymous" }` for one by no one, and `{ kind: "system" }` or
|
|
|
318
375
|
`{ kind: "job" }` (a context `forWorkspace` and its kin derived), each with its
|
|
319
376
|
`reason`, for background work. A job names a user only as `actorId`, the
|
|
320
377
|
person it works for ([below](#background-work)), so never read a missing actor
|
|
321
|
-
as an anonymous request.
|
|
378
|
+
as an anonymous request. The owner's policy also receives the capabilities its
|
|
379
|
+
feature requires (and its optional ones), by local name, as `requires`, the
|
|
380
|
+
same ones its setup receives: a policy that needs another feature's answer
|
|
381
|
+
declares that feature's capability in `requires` and reads `requires.<name>`.
|
|
382
|
+
It runs on every read, so what it asks there must be cheap.
|
|
322
383
|
|
|
323
384
|
```js
|
|
324
385
|
// good: one predicate on the row being read
|
|
@@ -492,8 +553,10 @@ Every filter takes `id`, one id or a list, so `patchWhere({ id: ids },
|
|
|
492
553
|
changes, options)` and `deleteWhere({ id: ids }, options)` change a known
|
|
493
554
|
list. Each checks every row against the resource first, reads which of the
|
|
494
555
|
rows the caller may touch, then writes each chunk with one statement, in one
|
|
495
|
-
transaction.
|
|
496
|
-
|
|
556
|
+
transaction. A chunk is 500 rows unless the call passes `chunkSize`, which is
|
|
557
|
+
at least 100: a smaller chunk would send a statement every few rows, as a loop
|
|
558
|
+
does, and is refused before any statement. Audits and change events run once
|
|
559
|
+
for the batch, so the statements are the same for 10 rows as for 200. `patchWhere` and
|
|
497
560
|
`deleteWhere` check the scope and filters on each row once they hold its lock,
|
|
498
561
|
so a row gone meanwhile, or changed so that it no longer matches, is skipped,
|
|
499
562
|
and they take no `missing`. That makes them a compare-and-set on their filters
|
|
@@ -564,6 +627,31 @@ or a counter row taken with `lock(id)`. Every lock needs a transaction; SQLite
|
|
|
564
627
|
runs one transaction at a time, so there is no row lock to take. Take locks in
|
|
565
628
|
one order on every path, a parent before its children.
|
|
566
629
|
|
|
630
|
+
A lock answers only the rows it locks, with the fields `fields` names, and
|
|
631
|
+
refuses `include` before any statement. Read the locked rows' related records
|
|
632
|
+
after the lock, in the same transaction: `queryAll({ filters: { id: ids },
|
|
633
|
+
include }, txOptions)`, or the related table's read by id list. On PostgreSQL
|
|
634
|
+
every statement sees what had committed when it began, so that read is current.
|
|
635
|
+
On MySQL and MariaDB a plain read inside a transaction sees the snapshot of the
|
|
636
|
+
transaction's first plain read, and a lock that waited does not move it. So
|
|
637
|
+
take the lock before any plain read in the transaction: the reads after it then
|
|
638
|
+
see what the lock's previous holder committed. A related row the work decides
|
|
639
|
+
on (is this seat already booked?) is locked itself, or read after a lock
|
|
640
|
+
that every writer of it takes first.
|
|
641
|
+
|
|
642
|
+
```js
|
|
643
|
+
// good: the lock is the transaction's first read, so the bookings read after it are current
|
|
644
|
+
const reserve = (seatId, options) => seats.withTransaction(options, async (txOptions) => {
|
|
645
|
+
await seats.lock(seatId, { ...txOptions, fields: ["status"] });
|
|
646
|
+
const taken = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
|
|
647
|
+
return taken.length > 0 ? null : bookings.create({ seatId }, { ...txOptions, returning: "id" });
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// bad: the read before the lock fixed the snapshot, so a booking committed while the lock waited is missed
|
|
651
|
+
const free = await bookings.queryAll({ filters: { seatIds: [seatId] } }, txOptions);
|
|
652
|
+
await seats.lock(seatId, txOptions);
|
|
653
|
+
```
|
|
654
|
+
|
|
567
655
|
On MySQL and MariaDB a locking read locks every row it examines, so two rules
|
|
568
656
|
apply there. Lock ids taken from your own scoped reads, never a list straight
|
|
569
657
|
from a request: every id listed is locked, a hidden one too, before it is left
|
|
@@ -676,8 +764,10 @@ async function activeDays(memberIds, options) {
|
|
|
676
764
|
other features, answers `{ query, column }`: the caller's scoped rows under
|
|
677
765
|
that alias (ownership, the row policy and the filters applied) with the
|
|
678
766
|
columns a read answers and the ownership fields (`workspaceId`, `userId`),
|
|
679
|
-
hidden or not, as join keys; never another hidden column
|
|
680
|
-
|
|
767
|
+
hidden or not, as join keys; never another hidden column, and never a blob,
|
|
768
|
+
so a report's `select("k.*")` holds no unbounded bytes. `column(field)`
|
|
769
|
+
names each under the alias. A report on the owner's own `scopedQuery()`
|
|
770
|
+
names the columns it selects, never a blob's. The builder comes wrapped, since a builder is
|
|
681
771
|
thenable and an async function would run it. The report shapes it (select,
|
|
682
772
|
group), names it with `.as()` and joins it, so every owner's scope and row
|
|
683
773
|
policy stay applied. The report lives with the main table's owner, or in a
|
|
@@ -707,6 +797,25 @@ async function activeDays(memberIds, options) {
|
|
|
707
797
|
}
|
|
708
798
|
```
|
|
709
799
|
|
|
800
|
+
- A report that asks which of its rows have, or lack, another owner's rows
|
|
801
|
+
puts that owner's join source in `whereExists` or `whereNotExists`, tied to
|
|
802
|
+
its own row with `whereColumn`; one that counts them joins the source grouped,
|
|
803
|
+
as `projectSummary` does. Rows matched by a related row's value ("items
|
|
804
|
+
whose supplier is named Acme") need no report: a cross-table search filter
|
|
805
|
+
answers them in a plain read ([Layer 1](#layer-1-the-table-resource)).
|
|
806
|
+
|
|
807
|
+
```js
|
|
808
|
+
// Knex: report - the caller's projects with no task the caller sees
|
|
809
|
+
async function idleProjects(options) {
|
|
810
|
+
const task = await tasks.joinSource({ alias: "k" }, options);
|
|
811
|
+
const { query } = await projects.scopedQuery({ alias: "p" }, options);
|
|
812
|
+
const projectId = projects.column("id", "p");
|
|
813
|
+
return query
|
|
814
|
+
.whereNotExists(task.query.select(task.column("id")).whereColumn(task.column("projectId"), projectId))
|
|
815
|
+
.select({ id: projectId, name: projects.column("name", "p") });
|
|
816
|
+
}
|
|
817
|
+
```
|
|
818
|
+
|
|
710
819
|
- When each owner can answer on its own (keys in, grouped totals out), the
|
|
711
820
|
report is a feature of its own that calls each owner's method and merges the
|
|
712
821
|
answers in memory: one statement per owner, never a call per row.
|
|
@@ -726,11 +835,26 @@ async function activeDays(memberIds, options) {
|
|
|
726
835
|
another field) or `include`, grouped in memory. Never a query per row, however
|
|
727
836
|
the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
|
|
728
837
|
item).
|
|
729
|
-
- `queryAll` reads every matching row, in cursor pages that
|
|
730
|
-
|
|
838
|
+
- `queryAll` reads every matching row, in cursor pages that each start after
|
|
839
|
+
the sort values of the page before, so no row is skipped or read twice
|
|
840
|
+
unless a write moves a row's sort value (the id, by default) past the page
|
|
841
|
+
while the read runs. Use it for id lists, one parent's rows and jobs: a read its callers
|
|
731
842
|
treat as complete, which a `query` would silently cut at one page. In a
|
|
732
|
-
request it stops past the request row limit (
|
|
733
|
-
`RESOURCE_ROW_LIMIT
|
|
843
|
+
request it stops past the request row limit (`pageLimits().requestRows`,
|
|
844
|
+
10,000 rows) with a 400 coded `RESOURCE_ROW_LIMIT`, and never answers part
|
|
845
|
+
of the rows. A job reads without that limit and holds every row in memory.
|
|
846
|
+
- Work over many rows reads `queryPages` and handles one page at a time:
|
|
847
|
+
`for await (const records of handle.queryPages(params, options))`. Each
|
|
848
|
+
page is one statement of up to `pageLimits().server` rows (fewer when its
|
|
849
|
+
fields name blobs, so it holds at most 16 MiB of them), plus the reads of
|
|
850
|
+
its includes and of the hidden fields it names, and starts after the sort
|
|
851
|
+
values of the last row of the page before. So the work may change what the
|
|
852
|
+
filters match as it goes, and no row still to come is skipped or read twice,
|
|
853
|
+
as long as it leaves the sort fields' values alone (the id, by default): a
|
|
854
|
+
row whose sort value moves past the page is read again or not at all. In a
|
|
855
|
+
request it throws at the same row limit, before the page that passes it.
|
|
856
|
+
`expectFlatQueries` allows each later page's own statements, as it allows
|
|
857
|
+
a many-row write's later chunks; work the path does for each page counts.
|
|
734
858
|
|
|
735
859
|
```js
|
|
736
860
|
// good: one page of invoices, then all of that page's lines in one read
|
|
@@ -746,6 +870,11 @@ for (const invoice of records) {
|
|
|
746
870
|
// good: a delivery job reads every active subscriber
|
|
747
871
|
const subscribers = await subscriptions.queryAll({ filters: { newsletterId, status: "active" } }, options);
|
|
748
872
|
|
|
873
|
+
// good: a job over a table of any size marks each page as it reads it
|
|
874
|
+
for await (const records of reminders.queryPages({ filters: { status: "due" } }, txOptions)) {
|
|
875
|
+
await reminders.patchWhere({ id: records.map((reminder) => reminder.id) }, { status: "sent" }, txOptions);
|
|
876
|
+
}
|
|
877
|
+
|
|
749
878
|
// bad: the first page only, whatever its size
|
|
750
879
|
const { records: firstPage } = await subscriptions.query({ filters: { newsletterId, status: "active" }, page: { size: 20 } }, options);
|
|
751
880
|
```
|
|
@@ -837,14 +966,21 @@ const SettlementFeature = defineFeature({
|
|
|
837
966
|
request's `caller.userId`, so a job for a person sees what that person may
|
|
838
967
|
see, and a job for no one its whole scope. Work on one person's rows only
|
|
839
968
|
takes their scope (`forUser`, `forWorkspaceUser`) or filters its reads.
|
|
840
|
-
- A job finds its units with
|
|
969
|
+
- A job finds its units with an `aggregate` grouped by the ownership field,
|
|
841
970
|
which a system context may group by (and count distinct) even when the table
|
|
842
|
-
hides it, then runs each unit scoped to it.
|
|
843
|
-
|
|
971
|
+
hides it, then runs each unit scoped to it. It reads the units in pages, all
|
|
972
|
+
of them before the first runs, since settling a unit changes what the
|
|
973
|
+
filters match. A request's aggregate still refuses a hidden field:
|
|
844
974
|
|
|
845
975
|
```js
|
|
846
976
|
async function settleDue(options) {
|
|
847
|
-
const
|
|
977
|
+
const size = cohorts.pageLimits().request;
|
|
978
|
+
const due = [];
|
|
979
|
+
for (let number = 1; ; number += 1) {
|
|
980
|
+
const page = await cohorts.aggregate({ measures: { cohorts: { count: "id" } }, groupBy: ["workspaceId"], filters: { status: "finished" }, page: { size, number } }, options);
|
|
981
|
+
due.push(...page);
|
|
982
|
+
if (page.length < size) break;
|
|
983
|
+
}
|
|
848
984
|
for (const { workspaceId } of due) {
|
|
849
985
|
await settleWorkspace({ context: systemContext("settle finished weekly leagues for every user").forWorkspace(workspaceId) });
|
|
850
986
|
}
|
|
@@ -953,7 +1089,10 @@ participants) reads them with `users.profiles.listByIds(ids, options)`: every
|
|
|
953
1089
|
profile in one call, however many ids, never `query` pages of 100 walked by
|
|
954
1090
|
hand or one `findById` per person. `users.settings.listByUserIds(ids, options)`
|
|
955
1091
|
does the same for their settings, and `workspaces.listByIds(ids, options)` for
|
|
956
|
-
workspaces.
|
|
1092
|
+
workspaces. A report that reads people, their settings, workspaces or
|
|
1093
|
+
memberships beside its own rows joins `joinSource({ alias, filters },
|
|
1094
|
+
options)` of `users.profiles`, `users.settings`, `workspaces` or
|
|
1095
|
+
`workspaces.memberships` ([Layer 5](#layer-5-reports-the-resource-cannot-express)).
|
|
957
1096
|
|
|
958
1097
|
An app that had a table resource of its own over a JSKIT table (such as
|
|
959
1098
|
`users`) deletes it: its CRUD contract is built on the package's table resource
|
|
@@ -1006,6 +1145,38 @@ named with no value, such as `lockVersion: record.lockVersion` on a field
|
|
|
1006
1145
|
called `version`, is refused (`options.lockVersion is undefined`), never run
|
|
1007
1146
|
without it.
|
|
1008
1147
|
|
|
1148
|
+
### Capability cycles
|
|
1149
|
+
|
|
1150
|
+
A capability cycle is a ring of requires: feature A requires a capability that
|
|
1151
|
+
feature B provides while B, directly or through others, requires one that A
|
|
1152
|
+
provides. An `optional` require closes a ring as a required one does. The
|
|
1153
|
+
kernel sets features up in the order their requires give, so it cannot start a
|
|
1154
|
+
ring: boot fails before any setup, naming it,
|
|
1155
|
+
`Provider capability cycle detected: children -> parents -> children.`
|
|
1156
|
+
`startTestFeatures` orders features the same way, so a feature test meets the
|
|
1157
|
+
ring before the server does. The same ring between packages, each listing the
|
|
1158
|
+
other in its `package.json` dependencies (as importing the other's shared
|
|
1159
|
+
table resource needs), fails as
|
|
1160
|
+
`Package dependency cycle detected: @local/jobs -> @local/checklists -> @local/jobs`.
|
|
1161
|
+
|
|
1162
|
+
Data access makes them easy to write: a parent's owner reads its children
|
|
1163
|
+
through their owners while each child's owner checks or advances the parent.
|
|
1164
|
+
Keep every require pointing one way:
|
|
1165
|
+
|
|
1166
|
+
- The table nearest the root requires none of the owners below it. It provides
|
|
1167
|
+
a small owner API, and the owners below require that.
|
|
1168
|
+
- Work that reads several owners together (a parent with its children, a
|
|
1169
|
+
report across them) is a feature of its own that requires each of them, as
|
|
1170
|
+
above and in [Layer 5](#layer-5-reports-the-resource-cannot-express).
|
|
1171
|
+
- A package imports another package's shared table resource only when that
|
|
1172
|
+
package does not depend on it back. When two packages need each other's
|
|
1173
|
+
definitions, move those table resources into a package of their own that
|
|
1174
|
+
holds only shared definitions and depends on neither.
|
|
1175
|
+
- Never break a ring with a value handed over later (a setter, a capability
|
|
1176
|
+
looked up at call time): a call's destination then depends on what ran
|
|
1177
|
+
before. A setter kept until the split carries typed features' `cycle:`
|
|
1178
|
+
exception ([Optional tokens](/guide/framework/typed-features#optional-tokens)).
|
|
1179
|
+
|
|
1009
1180
|
## Check and judge
|
|
1010
1181
|
|
|
1011
1182
|
Before calling data work done, apply the
|
|
@@ -1029,7 +1200,11 @@ test("allocation is flat", () => expectFlatQueries({ environment: process.env, k
|
|
|
1029
1200
|
`expectFlatQueries` (from `@jskit-ai/resource-runtime/test`) seeds each size,
|
|
1030
1201
|
counts the statements the path sends, and fails when a larger size sends more,
|
|
1031
1202
|
with the counts and the statements repeated most; the later chunks a many-row
|
|
1032
|
-
write reports
|
|
1203
|
+
write reports, and the statements of each later page `queryPages()` reads, are
|
|
1204
|
+
allowed. It counts only the path's own statements, those
|
|
1205
|
+
`run` and the async work it starts send, so another test or a background loop
|
|
1206
|
+
using the same Knex meanwhile changes nothing; work a loop started earlier does
|
|
1207
|
+
on the path's behalf is not counted either. `expectScoped` runs the path as owner A before and
|
|
1033
1208
|
after seeding owner B and fails when the answer changes, so it catches a report
|
|
1034
1209
|
that sums, counts or joins B's rows, not only one that returns them; it also
|
|
1035
1210
|
fails when the path changed or removed a row of B's. The path runs twice, so it
|
|
@@ -1210,5 +1385,11 @@ it. Check and judge, for each file:
|
|
|
1210
1385
|
`/* eslint ... */` config that sets one, outside client code;
|
|
1211
1386
|
- an app file that the app's ESLint config lints without every data-access
|
|
1212
1387
|
rule as an error (`"jskit/no-direct-knex": "off"`, `dataAccessConfig(files,
|
|
1213
|
-
"off")`), or server code
|
|
1214
|
-
|
|
1388
|
+
"off")`), or server code that it ignores, until `infrastructure` lists
|
|
1389
|
+
that file and rule. Server code is what the base config lints, at the app's
|
|
1390
|
+
root or a package's: its files (`server.js`), `src/` (an app's whole `src/`,
|
|
1391
|
+
`src/jobs/` with it), `server/`, `scripts/`, `bin/` and `config/`.
|
|
1392
|
+
|
|
1393
|
+
It reads none of what the app's ESLint config ignores outside server code,
|
|
1394
|
+
such as a vendored copy of old code kept beside the app, since no lint reads
|
|
1395
|
+
it either, and lists each directory and file it skipped.
|
|
@@ -184,9 +184,9 @@ Exactly this, and nothing more:
|
|
|
184
184
|
`createVuetify(createMaterialVuetifyOptions(theme))` with
|
|
185
185
|
`createMaterialVuetifyOptions` from `@jskit-ai/material-web/client`, importing
|
|
186
186
|
`@jskit-ai/material-web/client/material.css`. JSKIT generates the light and
|
|
187
|
-
dark schemes from the seed at startup (
|
|
188
|
-
`
|
|
189
|
-
|
|
187
|
+
dark schemes from the seed at startup (every role in kebab-case such as
|
|
188
|
+
`surface-container-low`, plus success, warning and info with their
|
|
189
|
+
containers), with
|
|
190
190
|
`defaultTheme: "system"` so the person's light, dark or system preference
|
|
191
191
|
applies. The `md3` blueprint and JSKIT's Material 3 Vuetify defaults live in
|
|
192
192
|
material-web; an app keeps no defaults file and never hand-picks a colour. The
|
|
@@ -210,6 +210,12 @@ Exactly this, and nothing more:
|
|
|
210
210
|
Symbols has no brand marks (Google, GitHub, a payment network): a brand's
|
|
211
211
|
logo is the brand's own artwork, a content image or SVG used as its owner
|
|
212
212
|
publishes it, not an icon alias.
|
|
213
|
+
- **Components:** a Vuetify component chosen at run time is imported and
|
|
214
|
+
passed: `import { VDialog } from "vuetify/components"`, then `:is="VDialog"`
|
|
215
|
+
or `h(VDialog)`. A JSKIT app registers none globally, so one named as a
|
|
216
|
+
string (`resolveComponent("v-dialog")`, `is="v-dialog"`) renders nothing
|
|
217
|
+
(`ui/vuetify-by-name`). A prop a JSKIT package lists as removed (its
|
|
218
|
+
`jskit.metadata.ui.removedProps` and CHANGELOG) fails `ui/removed-prop`.
|
|
213
219
|
|
|
214
220
|
## Exact sizes
|
|
215
221
|
|
|
@@ -260,14 +266,9 @@ dialog, chip or FAB.
|
|
|
260
266
|
file, such as a CSV export), `disabled`, `loading` or `color` (`error` for a
|
|
261
267
|
destructive one); attributes such as `target` reach its button. On compact
|
|
262
268
|
widths the bar shows the single `primary` action (a form's Save) and puts
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
whole title; those that don't follow the actions into the menu, farthest
|
|
267
|
-
from the corner first, and the item nearest the corner stays unless the
|
|
268
|
-
`primary` action takes its place. An item keeps running while it is in the
|
|
269
|
-
menu, closed or open, so its live count, its panel or a floating button it
|
|
270
|
-
shows elsewhere on the page stay. Medium and wider bars show them all.
|
|
269
|
+
the other actions, and the top-right items that don't fit beside the title,
|
|
270
|
+
in its "More actions" menu, where `overflow` actions always are; medium and
|
|
271
|
+
wider bars show them all.
|
|
271
272
|
- **Window size classes:** compact under 600 (navigation bar), medium 600–839
|
|
272
273
|
(navigation rail), expanded 840–1199 and large from 1200 (drawer), read from
|
|
273
274
|
`useShellWindowSizeClass()`
|
|
@@ -280,12 +281,17 @@ dialog, chip or FAB.
|
|
|
280
281
|
starter registers it as `local.main.ui.section-tab-link-item`), and the page
|
|
281
282
|
draws one tab row, `display: flex; overflow-x: auto` over a 1dp
|
|
282
283
|
`outline-variant` bottom border. `ShellTabLinkItem` is the navigation bar's
|
|
283
|
-
destination; placed in a page it draws a section tab.
|
|
284
|
+
destination; placed in a page it draws a section tab. material.css draws an
|
|
285
|
+
app's own `v-bottom-navigation` the same way, keeping its colour props.
|
|
284
286
|
|
|
285
287
|
## Colour
|
|
286
288
|
|
|
287
289
|
- Use semantic roles through Vuetify props, classes and
|
|
288
290
|
`rgb(var(--v-theme-<role>))`; never a raw colour value in a component.
|
|
291
|
+
- Text in an `on-*` role uses its class or a `color` prop
|
|
292
|
+
(`class="text-on-surface-variant"`, `<v-icon color="on-surface-variant">`);
|
|
293
|
+
an app colour's is `class="on-partner"`, which `bg-partner` already sets.
|
|
294
|
+
Never lower text emphasis with an opacity or `text-medium-emphasis`.
|
|
289
295
|
- Status chips use `color="success"` (or `warning`, `info`, `error`) or the
|
|
290
296
|
container roles (`success-container` with `on-success-container`), and each
|
|
291
297
|
status meaning has its own hue. Never communicate status, selection, validation, or permission through
|
|
@@ -318,9 +324,11 @@ dialog, chip or FAB.
|
|
|
318
324
|
actions in the bar's overflow menu, never in the content; the record's
|
|
319
325
|
next workflow step is the one filled button, shown in context rather than a
|
|
320
326
|
row of mostly disabled actions.
|
|
321
|
-
- **Form:** the bar's close (X) cancels and Save is its filled action.
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
- **Form:** the bar's close (X) cancels and Save is its filled action. A
|
|
328
|
+
field keeps its message line (no `hide-details`, no margin under it), so an
|
|
329
|
+
error appears in place; a field outside a form (search, filter) sets
|
|
330
|
+
`hide-details`. Nothing moves under a finger: a multi-select
|
|
331
|
+
chip field keeps its input width when it loses focus. A pick-one-of-N choice is a
|
|
324
332
|
segmented button (`v-btn-toggle`, drawn by JSKIT, with 12dp segment sides as
|
|
325
333
|
in any `v-btn-group`) with `role="radiogroup"` and
|
|
326
334
|
`role="radio"`/`aria-checked` buttons.
|
|
@@ -354,11 +362,9 @@ dialog, chip or FAB.
|
|
|
354
362
|
field. A user-triggered command uses JSKIT's shared action
|
|
355
363
|
feedback/snackbar path. Never insert a transient command-error alert above
|
|
356
364
|
page content where it shifts the working layout.
|
|
357
|
-
- A snackbar never covers the FAB or the actions it reports on
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
layout room (`<v-fab app layout>`, as `ShellPrimaryAction`'s does), so the
|
|
361
|
-
snackbar shows above it.
|
|
365
|
+
- A snackbar never covers the FAB or the actions it reports on: `ShellErrorHost`
|
|
366
|
+
places it, and a FAB takes layout room (`<v-fab app layout>`, as
|
|
367
|
+
`ShellPrimaryAction`'s does), so the snackbar shows above it.
|
|
362
368
|
|
|
363
369
|
## Adaptive layout
|
|
364
370
|
|
|
@@ -374,16 +380,14 @@ Two commands read application source (no server, no browser) and print each
|
|
|
374
380
|
finding with its file, line, rule and the section of this page:
|
|
375
381
|
|
|
376
382
|
```bash
|
|
377
|
-
npx jskit check # in every app's verify: fails on any nav/*
|
|
378
|
-
npx jskit check m3 # the whole app: src/ and packages/
|
|
383
|
+
npx jskit check # in every app's verify: fails on any nav/*, m3/* or ui/* finding
|
|
384
|
+
npx jskit check m3 # these rules alone, the whole app: src/ and packages/
|
|
379
385
|
npx jskit check m3 src/pages/home/books # a section
|
|
380
386
|
npx jskit check m3 $(git diff --name-only --diff-filter=d main) # changed files
|
|
381
387
|
```
|
|
382
388
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
report until every JSKIT app has been converted to them; the JSKIT release after
|
|
386
|
-
that makes plain `jskit check`, and so every app's verify, fail on them too:
|
|
389
|
+
Both fail on the [navigation rules](#forbidden-in-app-code) and on these;
|
|
390
|
+
nothing excuses a `ui/*` finding:
|
|
387
391
|
|
|
388
392
|
| Rule | Finds |
|
|
389
393
|
|---|---|
|
|
@@ -393,12 +397,12 @@ that makes plain `jskit check`, and so every app's verify, fail on them too:
|
|
|
393
397
|
| `m3/outlined-card` | `variant="outlined"` or `border` on a card or sheet |
|
|
394
398
|
| `m3/mdi-icon` | `mdi-*` icon strings and `@mdi/js` imports |
|
|
395
399
|
| `m3/fixed-button-height` | `height`/`min-height` on buttons, in props, styles or CSS |
|
|
400
|
+
| `ui/vuetify-by-name` | a Vuetify component named as a string for Vue to resolve |
|
|
401
|
+
| `ui/removed-prop` | a prop the installed JSKIT package lists as removed from that component |
|
|
396
402
|
|
|
397
403
|
Tests, fixtures, server code and migrations are not checked. A clean check is
|
|
398
|
-
necessary, not sufficient:
|
|
399
|
-
[UI pass](./ui-pass.md)
|
|
400
|
-
affected screen at three widths in light and dark; an editor that runs one
|
|
401
|
-
automatically after each plan keeps that guard on every change.
|
|
404
|
+
necessary, not sufficient: layout, spacing and contrast need a
|
|
405
|
+
[UI pass](./ui-pass.md), every affected screen at three widths in light and dark.
|
|
402
406
|
|
|
403
407
|
### Exceptions
|
|
404
408
|
|
|
@@ -407,16 +411,13 @@ Take the approved route first; none needs an exception:
|
|
|
407
411
|
- **Brand or partner colours** are theme custom colours, used by name:
|
|
408
412
|
`colors: { partner: "#0A7D3E" }` in `src/theme/index.js`, then
|
|
409
413
|
`<v-chip color="partner">`. A hex string is harmonized toward the seed; a
|
|
410
|
-
colour that must keep its hue is `{ value: "#F5C518", blend: false }
|
|
411
|
-
|
|
414
|
+
colour that must keep its hue is `{ value: "#F5C518", blend: false }`, with
|
|
415
|
+
the same four roles.
|
|
412
416
|
- **Content colours**, colours that are content rather than UI, keep one
|
|
413
|
-
value in both schemes: an identity palette a person picks
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
`src/theme/palettes.js`, used as they are, never for UI chrome (a theme
|
|
418
|
-
colour is toned per scheme, so it can't keep an identity the same).
|
|
419
|
-
Components read them through bindings:
|
|
417
|
+
value in both schemes: an identity palette a person picks, artwork, brand
|
|
418
|
+
marks, fixed chart series, QR codes, colours in exported files. They are
|
|
419
|
+
named exports of plain hex values in `src/theme/palettes.js`, used as they
|
|
420
|
+
are, never for UI chrome. Components read them through bindings:
|
|
420
421
|
|
|
421
422
|
```vue
|
|
422
423
|
<script setup>
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
Before calling a list, a save, a test, a refactor, a screen or a deployment
|
|
6
6
|
done, measure what it costs and does, judge whether that is sane, and fix the
|
|
7
|
-
cause when it is not. This page holds JSKIT's part: the pitfalls
|
|
7
|
+
cause when it is not. This page holds JSKIT's part: the pitfalls its own
|
|
8
8
|
packages cause, then the JSKIT call, helper or setting that applies each
|
|
9
|
-
check,
|
|
9
|
+
check, and what it catches.
|
|
10
10
|
|
|
11
11
|
## JSKIT's own pitfalls
|
|
12
12
|
|
|
@@ -67,10 +67,14 @@ 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`,
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
73
73
|
([Reads](/guide/framework/data-access#reads-shaped-for-the-screen)).
|
|
74
|
+
- A grouped `aggregate()` answers every group or throws
|
|
75
|
+
`RESOURCE_GROUP_LIMIT`; past `pageLimits().request` groups, page it with
|
|
76
|
+
`page: { size, number }`
|
|
77
|
+
([The handle's calls](/guide/framework/data-access#the-handle-s-calls)).
|
|
74
78
|
- A lookup by a unique key matches at most one row: `get(id)`, or a `query()`
|
|
75
79
|
of page size 1.
|
|
76
80
|
|
|
@@ -79,12 +83,11 @@ expects the relationship id or an unselected field never gets it.
|
|
|
79
83
|
- Count a path's queries with `expectFlatQueries()` from
|
|
80
84
|
`@jskit-ai/resource-runtime/test`, or `countQueries()` from
|
|
81
85
|
`@jskit-ai/database-runtime/server/testDatabases`, at a few rows and at a
|
|
82
|
-
realistic number;
|
|
86
|
+
realistic number; it should follow tables and pages, not rows
|
|
83
87
|
([Tests](/guide/framework/data-access#tests)).
|
|
84
88
|
- A JSON:API read adds a few lookup and visibility queries per page and per
|
|
85
|
-
relationship field, and
|
|
86
|
-
|
|
87
|
-
rows, so `expectFlatQueries` passes them.
|
|
89
|
+
relationship field, and one statement per relationship for its related ids.
|
|
90
|
+
Both follow pages, not rows, so `expectFlatQueries` passes them.
|
|
88
91
|
- A handle's `get()` or `query()`, or a service `get`, inside a loop is the
|
|
89
92
|
usual per-row read; batch it with an id-list filter through `queryAll()` or
|
|
90
93
|
`include`, grouped with `Map.groupBy()`
|
|
@@ -108,8 +111,8 @@ expects the relationship id or an unselected field never gets it.
|
|
|
108
111
|
`DB_NAME`, because tests write and delete rows and would destroy the
|
|
109
112
|
developer's data. A skip for want of a test database is a failure.
|
|
110
113
|
- Test databases restore a stored snapshot checked by a digest of the
|
|
111
|
-
migration and seed sources, and the harness
|
|
112
|
-
|
|
114
|
+
migration and seed sources, and the harness times each preparation phase,
|
|
115
|
+
so slow preparation shows ([Test databases are prepared
|
|
113
116
|
once](/guide/framework/typed-features#test-databases-are-prepared-once),
|
|
114
117
|
[integration test
|
|
115
118
|
databases](/guide/app-setup/database-layer#integration-test-databases)).
|
|
@@ -135,7 +138,7 @@ Before changing a JSKIT feature, list where its behaviour lives, with the test
|
|
|
135
138
|
that covers each item, and walk the list again after: routes, actions and
|
|
136
139
|
their input schemas, row policies, CRUD fields, lookups and hooks, placements
|
|
137
140
|
and navigation meta, messages and their codes, events, jobs and migrations.
|
|
138
|
-
|
|
141
|
+
A consolidation drops behaviour there without a failing build.
|
|
139
142
|
|
|
140
143
|
### Changes others depend on
|
|
141
144
|
|
package/package.json
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@local/main": "0.1.0",
|
|
36
36
|
"@fastify/static": "^10.1.3",
|
|
37
|
-
"@jskit-ai/kernel": "0.2.
|
|
38
|
-
"@jskit-ai/material-web": "0.1.
|
|
37
|
+
"@jskit-ai/kernel": "0.2.17",
|
|
38
|
+
"@jskit-ai/material-web": "0.1.5",
|
|
39
39
|
"@tanstack/vue-query": "^5.101.0",
|
|
40
40
|
"fastify": "^5.12.7",
|
|
41
41
|
"pino": "^10.3.1",
|
|
@@ -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.17",
|
|
48
48
|
"@fastify/fast-json-stringify-compiler": "^5.1.0",
|
|
49
49
|
"@vue/devtools-api": "^8.2.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@jskit-ai/config-eslint": "0.1.248",
|
|
53
|
-
"@jskit-ai/jskit-catalog": "0.2.
|
|
53
|
+
"@jskit-ai/jskit-catalog": "0.2.43",
|
|
54
54
|
"@playwright/test": "1.61.1",
|
|
55
55
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
56
56
|
"eslint": "^10.8.0",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@local/main": "0.1.0",
|
|
36
36
|
"@fastify/static": "^10.1.3",
|
|
37
|
-
"@jskit-ai/kernel": "0.2.
|
|
38
|
-
"@jskit-ai/material-web": "0.1.
|
|
37
|
+
"@jskit-ai/kernel": "0.2.17",
|
|
38
|
+
"@jskit-ai/material-web": "0.1.5",
|
|
39
39
|
"@tanstack/vue-query": "^5.101.0",
|
|
40
40
|
"fastify": "^5.12.7",
|
|
41
41
|
"pino": "^10.3.1",
|
|
@@ -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.17",
|
|
48
|
+
"@jskit-ai/shell-web": "0.2.21",
|
|
49
49
|
"@fastify/fast-json-stringify-compiler": "^5.1.0",
|
|
50
50
|
"@vue/devtools-api": "^8.2.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@jskit-ai/config-eslint": "0.1.248",
|
|
54
|
-
"@jskit-ai/jskit-catalog": "0.2.
|
|
54
|
+
"@jskit-ai/jskit-catalog": "0.2.43",
|
|
55
55
|
"@playwright/test": "1.61.1",
|
|
56
56
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
57
57
|
"eslint": "^10.8.0",
|