@jskit-ai/agent-docs 0.1.279 → 0.1.281

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 (138) hide show
  1. package/guide/agent/app-extras/assistant-conversation.md +5 -1
  2. package/guide/agent/app-extras/assistant.md +123 -0
  3. package/guide/agent/app-extras/realtime.md +20 -10
  4. package/guide/agent/app-setup/authentication.md +54 -4
  5. package/guide/agent/app-setup/database-layer.md +108 -35
  6. package/guide/agent/app-setup/existing-application-migration.md +7 -1
  7. package/guide/agent/app-setup/material-3-migration.md +38 -9
  8. package/guide/agent/app-setup/multi-homing.md +14 -4
  9. package/guide/agent/app-setup/upgrading-jskit.md +105 -0
  10. package/guide/agent/app-setup/users.md +9 -1
  11. package/guide/agent/framework/client-features.md +35 -1
  12. package/guide/agent/framework/material-3.md +111 -71
  13. package/guide/agent/framework/sanity-checks.md +181 -0
  14. package/guide/agent/framework/translation.md +4 -0
  15. package/guide/agent/framework/typed-features.md +222 -120
  16. package/guide/agent/framework/ui-operations.md +3 -1
  17. package/guide/agent/index.md +3 -0
  18. package/package.json +2 -2
  19. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  20. package/patterns/minimal-foundation/PATTERN.md +6 -2
  21. package/patterns/minimal-foundation/example/AGENTS.md +22 -5
  22. package/patterns/minimal-foundation/example/package.json +6 -6
  23. package/patterns/minimal-foundation/example/playwright.config.mjs +3 -30
  24. package/patterns/minimal-foundation/example/server.js +7 -1
  25. package/patterns/minimal-foundation/example/src/main.js +3 -2
  26. package/patterns/minimal-foundation/example/tests/e2e/startup.spec.ts +13 -0
  27. package/patterns/minimal-foundation/example/vite.config.mjs +3 -1
  28. package/patterns/shell-foundation/PATTERN.md +4 -1
  29. package/patterns/shell-foundation/example/AGENTS.md +22 -5
  30. package/patterns/shell-foundation/example/package.json +7 -7
  31. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  32. package/patterns/shell-foundation/example/packages/main/src/client/providers/MainClientProvider.js +3 -0
  33. package/patterns/shell-foundation/example/playwright.config.mjs +3 -30
  34. package/patterns/shell-foundation/example/server.js +7 -1
  35. package/patterns/shell-foundation/example/src/components/menus/SectionTabLinkItem.vue +42 -0
  36. package/patterns/shell-foundation/example/src/main.js +3 -2
  37. package/patterns/shell-foundation/example/src/pages/home.vue +1 -1
  38. package/patterns/shell-foundation/example/src/views/NotFound.vue +1 -1
  39. package/patterns/shell-foundation/example/tests/e2e/startup.spec.ts +13 -0
  40. package/patterns/shell-foundation/example/vite.config.mjs +3 -1
  41. package/reference/autogen/PATTERN_INDEX.md +37 -37
  42. package/reference/autogen/packages/agent-docs.md +4 -0
  43. package/reference/autogen/packages/architecture-inspector.md +1 -0
  44. package/reference/autogen/packages/assistant-runtime.md +28 -3
  45. package/reference/autogen/packages/auth-provider-local-core.md +2 -1
  46. package/reference/autogen/packages/auth-web.md +10 -0
  47. package/reference/autogen/packages/connectors-catalog.md +8 -0
  48. package/reference/autogen/packages/crud-core.md +3 -0
  49. package/reference/autogen/packages/database-runtime.md +33 -0
  50. package/reference/autogen/packages/http-web.md +0 -4
  51. package/reference/autogen/packages/json-rest-api-core.md +17 -4
  52. package/reference/autogen/packages/kernel.md +37 -2
  53. package/reference/autogen/packages/material-web.md +2 -1
  54. package/reference/autogen/packages/oidc-issuer-core.md +0 -1
  55. package/reference/autogen/packages/realtime.md +8 -5
  56. package/reference/autogen/packages/shell-web.md +88 -3
  57. package/reference/autogen/packages/users-web.md +5 -2
  58. package/reference/autogen/packages/workspaces-core.md +3 -1
  59. package/reference/autogen/tooling/jskit-catalog.md +122 -5
  60. package/reference/autogen/tooling/testUtils.md +15 -1
  61. package/skills/jskit/SKILL.md +17 -5
  62. package/skills/jskit/references/client-features.md +35 -1
  63. package/skills/jskit/references/existing-application-migration.md +7 -1
  64. package/skills/jskit/references/heuristics/INDEX.md +3 -1
  65. package/skills/jskit/references/heuristics/row-policies.md +3 -3
  66. package/skills/jskit/references/heuristics/typed-features.md +7 -7
  67. package/skills/jskit/references/heuristics/ui-testing.md +27 -21
  68. package/skills/jskit/references/material-3.md +111 -71
  69. package/skills/jskit/references/multi-homing.md +14 -4
  70. package/skills/jskit/references/pattern-index.md +37 -37
  71. package/skills/jskit/references/patterns/app/minimal-foundation/PATTERN.md +6 -2
  72. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +22 -5
  73. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +6 -6
  74. package/skills/jskit/references/patterns/app/minimal-foundation/example/playwright.config.mjs +3 -30
  75. package/skills/jskit/references/patterns/app/minimal-foundation/example/server.js +7 -1
  76. package/skills/jskit/references/patterns/app/minimal-foundation/example/src/main.js +3 -2
  77. package/skills/jskit/references/patterns/app/minimal-foundation/example/tests/e2e/startup.spec.ts +13 -0
  78. package/skills/jskit/references/patterns/app/minimal-foundation/example/vite.config.mjs +3 -1
  79. package/skills/jskit/references/patterns/app/shell-foundation/PATTERN.md +4 -1
  80. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +22 -5
  81. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +7 -7
  82. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  83. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/src/client/providers/MainClientProvider.js +3 -0
  84. package/skills/jskit/references/patterns/app/shell-foundation/example/playwright.config.mjs +3 -30
  85. package/skills/jskit/references/patterns/app/shell-foundation/example/server.js +7 -1
  86. package/skills/jskit/references/patterns/app/shell-foundation/example/src/components/menus/SectionTabLinkItem.vue +42 -0
  87. package/skills/jskit/references/patterns/app/shell-foundation/example/src/main.js +3 -2
  88. package/skills/jskit/references/patterns/app/shell-foundation/example/src/pages/home.vue +1 -1
  89. package/skills/jskit/references/patterns/app/shell-foundation/example/src/views/NotFound.vue +1 -1
  90. package/skills/jskit/references/patterns/app/shell-foundation/example/tests/e2e/startup.spec.ts +13 -0
  91. package/skills/jskit/references/patterns/app/shell-foundation/example/vite.config.mjs +3 -1
  92. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +2 -2
  93. package/skills/jskit/references/patterns/auth/auth-surface/PATTERN.md +18 -5
  94. package/skills/jskit/references/patterns/auth/auth-surface/example/src/components/AppBrand.vue +17 -0
  95. package/skills/jskit/references/patterns/auth/auth-surface/example/src/pages/auth/login.vue +2 -2
  96. package/skills/jskit/references/patterns/auth/auth-surface/example/src/pages/auth/reset-password.vue +2 -2
  97. package/skills/jskit/references/patterns/auth/auth-surface/example/src/pages/auth/signout.vue +2 -2
  98. package/skills/jskit/references/patterns/auth/auth-surface/example/src/views/auth/LoginView.vue +6 -1
  99. package/skills/jskit/references/patterns/auth/auth-surface/example/src/views/auth/ResetPasswordView.vue +6 -1
  100. package/skills/jskit/references/patterns/auth/auth-surface/example/src/views/auth/SignOutView.vue +6 -1
  101. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  102. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  103. package/skills/jskit/references/patterns/connectors/aws-storage-queries/example/formats/package.json +1 -1
  104. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  105. package/skills/jskit/references/patterns/console/console-surface/example/src/pages/console.vue +1 -1
  106. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/package.json +2 -2
  107. package/skills/jskit/references/patterns/database/mysql-application/PATTERN.md +34 -24
  108. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +3 -2
  109. package/skills/jskit/references/patterns/database/mysql-application/example/tests/e2e/databases.js +25 -0
  110. package/skills/jskit/references/patterns/database/postgres-application/PATTERN.md +12 -0
  111. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +3 -2
  112. package/skills/jskit/references/patterns/database/postgres-application/example/tests/e2e/databases.js +25 -0
  113. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  114. package/skills/jskit/references/patterns/rewards/google-rewarded/example/googlePublisherTag.js +5 -2
  115. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  116. package/skills/jskit/references/patterns/shell/application-shell/PATTERN.md +11 -6
  117. package/skills/jskit/references/patterns/shell/application-shell/example/src/components/menus/SectionTabLinkItem.vue +42 -0
  118. package/skills/jskit/references/patterns/shell/application-shell/example/src/pages/home.vue +1 -1
  119. package/skills/jskit/references/patterns/shell/application-shell/example/src/views/NotFound.vue +1 -1
  120. package/skills/jskit/references/patterns/ui/page-and-placement/PATTERN.md +6 -0
  121. package/skills/jskit/references/patterns/ui/page-and-placement/example/src/pages/home/reports.vue +9 -4
  122. package/skills/jskit/references/patterns/ui/page-and-placement/example/src/placementTopology.js +5 -3
  123. package/skills/jskit/references/patterns/users/account-settings/PATTERN.md +24 -3
  124. package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsNotificationsSection.vue +1 -1
  125. package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsPreferencesSection.vue +2 -1
  126. package/skills/jskit/references/patterns/users/account-settings/example/src/components/account/settings/AccountSettingsProfileSection.vue +3 -2
  127. package/skills/jskit/references/patterns/users/account-settings/example/src/pages/account/[section].vue +23 -0
  128. package/skills/jskit/references/patterns/users/account-settings/example/src/pages/account.vue +4 -3
  129. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +2 -2
  130. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +3 -3
  131. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/example/packages/main/src/client/components/AccountPendingInvitesCue.vue +3 -3
  132. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/example/src/surfaces/admin/root.vue +1 -1
  133. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/example/src/surfaces/app/root.vue +1 -1
  134. package/skills/jskit/references/sanity-checks.md +181 -0
  135. package/skills/jskit/references/translation.md +4 -0
  136. package/skills/jskit/references/typed-features.md +222 -120
  137. package/skills/jskit/references/ui-operations.md +3 -1
  138. package/skills/jskit/references/patterns/database/mysql-application/example/tests/browser/database.js +0 -68
@@ -438,6 +438,9 @@ record declares `mode: "patch"`; a command never does.**
438
438
 
439
439
  - Patch mode skips `required`, so a service keeps its guard for a field a
440
440
  patch input lists as required.
441
+ - An input field lists its allowed values with `enum`; the kernel refuses
442
+ `oneOf` on an input field at startup, because json-rest-schema checks no
443
+ `oneOf` and the field would accept any value.
441
444
 
442
445
  Why: a command whose required field is never enforced reaches the service
443
446
  without it.
@@ -639,7 +642,7 @@ way (is this user an operator) is a member of its owner's capability.**
639
642
  - A helper two packages of an application use lives in its shared package,
640
643
  never inside one of them.
641
644
 
642
- Why: copies drift, and the next reader cannot tell which one is right.
645
+ Why: the kernel's helpers fix one meaning of empty, and a copy brings its own.
643
646
 
644
647
  ```js
645
648
  // good
@@ -668,7 +671,8 @@ helper, never by hand.**
668
671
  | the workspace a request asks for, before any action context | `requestedWorkspaceScope(request)` (`@jskit-ai/workspaces-core/server/support/workspaceRouteInput`) |
669
672
  | a context's workspace id | `resolveWorkspaceScopeValue(context)` (`@jskit-ai/json-rest-api-core/server/jsonRestApiHost`) |
670
673
 
671
- Why: each hand-written copy treats a missing value its own way.
674
+ Why: each reader knows every place JSKIT keeps its fact (`trx` or
675
+ `transaction`, a tagged result's wrapper), and a hand read misses one.
672
676
 
673
677
  ```js
674
678
  // good
@@ -688,11 +692,17 @@ page means "the first 20 rows".**
688
692
  - json-rest-api answers a query that names no page with the resource's
689
693
  default page, 20 rows, and says nothing about the rest. The CRUD
690
694
  repository's `queryDocuments(query)` without a `limit` is the same first
691
- page.
692
- - The helper reads cursor pages of the resource's maximum size
693
- (`queryMaxLimit`, 100 unless the resource sets it) in id order until none
694
- follows, keeping the call's transaction and context, so no row is skipped
695
- or repeated. It sets the page and sort itself; sort the rows it returns.
695
+ page, and one with a `limit` above its `queryMaxLimit()` (100) throws,
696
+ because json-rest-api would answer only 100. A CRUD read of every record
697
+ uses the repository's `queryEveryDocument(query, options)`, which answers
698
+ one JSON:API document `{ data, included }` with no `limit`, `cursor` or
699
+ `sort`.
700
+ - The helper reads cursor pages of 1,000 rows (json-rest-api's server-only
701
+ `pageSizeLimit`; a resource whose `queryMaxLimit` is larger uses that) in id
702
+ order until none follows, keeping the call's transaction and context, so no
703
+ row is skipped or repeated. Each page pays the row policy and linkage
704
+ queries once, so 480 rows cost 3 statements, not 15. It sets the page and
705
+ sort itself; sort the rows it returns.
696
706
  - A lookup by a unique key (an id, a slug, a token hash, a unique pair such
697
707
  as workspace and user) matches at most one row and may read `rows[0]` of a
698
708
  plain query. A read of the newest or first few rows names its own `sort`
@@ -719,33 +729,35 @@ const rows = extractJsonRestCollectionRows(await api.resources.workspaceMembersh
719
729
  }, createJsonRestContext(options.context)));
720
730
  ```
721
731
 
722
- ### One read per table
732
+ ### Lists read related rows in batches
723
733
 
724
- **A read that builds a list reads each table once for the whole list: one
725
- query per table with an id-list filter, the rows grouped in memory. It never
726
- reads one row, or runs one lookup, per item of the list.**
734
+ **A read that builds a list fetches its related rows in batches, an id-list
735
+ query per related table, and groups them in memory, so its query count follows
736
+ the tables and pages it reads, not the rows.**
727
737
 
728
- - Collect the ids from the first read, then read each related table once with
729
- an id-list filter: a `searchSchema` entry
738
+ - Collect the ids from the first read, then read each related table with an
739
+ id-list filter: a `searchSchema` entry
730
740
  `{ type: "array", actualField: "<column>", filterOperator: "in" }`, read
731
741
  through `queryAllJsonRestCollectionRows()`, or the resource's `include`.
732
742
  Group the rows by id with `Map.groupBy()`.
733
- - `getDocumentById()`, `queryDocuments()`, a service `get` or another owner's
734
- single-record read inside a loop over the list is a read per row, however
735
- the loop is spelled (`for`, `map` with `Promise.all`, a helper called per
736
- item). So is a single-record helper reused for a list.
743
+ - A count that grows with the rows means something runs per row:
744
+ `getDocumentById()`, `queryDocuments()`, a service `get` or another owner's
745
+ single-record read inside a loop over the list, however the loop is spelled
746
+ (`for`, `map` with `Promise.all`, a helper called per item), or a
747
+ single-record helper reused for a list.
737
748
  - Work derived from a row (hashing, preparing a form, assembling a snapshot)
738
749
  runs once per distinct input in the call, not once per row it appears in.
739
- - The list's query count follows its tables and pages (100 rows a page unless
740
- the resource sets `queryMaxLimit`), never its rows: three hundred invoices
741
- and their lines take a handful of queries, not three hundred more (see
742
- [Budgets at realistic counts](#budgets-at-realistic-counts)).
750
+ - A JSON:API read also costs a few lookup and visibility queries per page and
751
+ relationship field, and pages hold the resource's `queryMaxLimit` (100 rows
752
+ unless it sets one). That cost follows pages, so three hundred invoices take
753
+ a few more queries than ten, not three hundred more ([Query counts at
754
+ realistic sizes](#query-counts-at-realistic-sizes)).
743
755
 
744
- Why: a read per row costs nothing with three test rows and seconds with a few
745
- hundred real ones, and every lookup and visibility check inside it repeats too.
756
+ Why: each JSKIT single-record read runs its lookups and row policy too, so a
757
+ read per row repeats all of them.
746
758
 
747
759
  ```js
748
- // good: one read per table, however many invoices there are
760
+ // good: a read per related table, however many invoices there are
749
761
  const invoiceRows = await queryAllJsonRestCollectionRows(api.resources.invoices, {
750
762
  queryParams: { filters: { workspace: workspaceId } },
751
763
  transaction: resolveTransaction(options),
@@ -779,21 +791,25 @@ paying a read policy for every row it writes.**
779
791
  [row policies heuristic](heuristics/row-policies.md)
780
792
  has the policy contract and its package composition.
781
793
  - The policy runs on every single-record read, and a CRUD write includes one:
782
- `createDocument()` and `patchDocumentById()` write with `returning: "full"`
783
- and read the record back through the policy, and `patch` and `delete` first
784
- look the record up through it. A write therefore pays the read policy for
785
- each row it writes.
786
- - A write path decides access once, inside its transaction. A batch write (a
787
- delivery, an import, a fan-out) whose per-row read-back would pay an
788
- expensive policy writes through a repository method added with
789
- `decorateRepository`, on `resolveRepoClient(database.knex, options)`,
790
- setting the same columns the resource would, its ownership columns
791
- included, and reading back only what it returns.
792
- - Its test runs one read and one write for a subject with a short history and
793
- with a realistic long one, and asserts the same query count.
794
-
795
- Why: a check sized by history grows with every record the subject receives,
796
- so the same write that takes milliseconds in a test takes seconds a year later.
794
+ `createDocument()` and `patchDocumentById()` default to `returning: "full"`,
795
+ which reads the record back through the policy, and `patch` and `delete`
796
+ first look the record up through it.
797
+ - A write path decides access once, inside its transaction. A write that does
798
+ not use its result passes `returning: "minimal"` or `"none"`, and a batch of
799
+ creates (a delivery, an import, a fan-out) goes through
800
+ `createDocuments(payloads, { trx, context })`, which reads nothing back
801
+ unless asked
802
+ ([writes that return less](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#writes-that-return-less)).
803
+ Any other batch whose per-row lookup would pay an expensive policy writes
804
+ through a repository method added with `decorateRepository`, on
805
+ `resolveRepoClient(database.knex, options)`, setting the same columns the
806
+ resource would, its ownership columns included.
807
+ - Its test counts the queries of one read and one write for a subject with a
808
+ short history and for one with a long one ([Query counts at realistic
809
+ sizes](#query-counts-at-realistic-sizes)).
810
+
811
+ Why: the row policy runs on every read, write read-back and lookup, so every
812
+ request pays what it costs.
797
813
 
798
814
  ```js
799
815
  // good: one predicate on the row being read
@@ -930,7 +946,8 @@ carries on.**
930
946
  (`response.body.cancel()`), for queue tails whose error reaches the queued
931
947
  caller, and for client best effort, each with a short comment saying so.
932
948
 
933
- Why: `() => {}` hides a wiring `TypeError` along with the failure.
949
+ Why: a collaborator call's failure can be a wiring `TypeError`, and
950
+ `() => {}` hides it with the rest.
934
951
 
935
952
  ```js
936
953
  // good
@@ -952,8 +969,8 @@ frame.**
952
969
  - Complete a double with the method the code calls, returning its neutral
953
970
  value; never shape production code around a double.
954
971
 
955
- Why: a double that mirrors internals pins them, so the test breaks on a
956
- refactor and passes on a real bug.
972
+ Why: an owner's seams are its contract; its id formats and stream frames
973
+ change with its package.
957
974
 
958
975
  ```js
959
976
  // good
@@ -972,8 +989,8 @@ double.**
972
989
  - One named clock per factory (`now = Date.now`) is a platform default, not a
973
990
  test seam.
974
991
 
975
- Why: a seam kept for tests is a shape no real caller uses, and a reader
976
- cannot tell it apart from one that matters.
992
+ Why: a factory's parameters are its contract with the features that call it,
993
+ and a test is not one of them.
977
994
 
978
995
  ```js
979
996
  // good
@@ -992,8 +1009,8 @@ through the feature, and pure helpers through `__testables`.**
992
1009
  - `__testables` holds pure helpers only, never one that touches a
993
1010
  collaborator or reads request facts.
994
1011
 
995
- Why: a test-only export widens the module's API and invites production
996
- callers.
1012
+ Why: `__testables` is the one way a test reaches inside a module, so every
1013
+ other export is production API.
997
1014
 
998
1015
  ```js
999
1016
  // good
@@ -1006,107 +1023,171 @@ export function resetInvoiceCacheForTests() {
1006
1023
  }
1007
1024
  ```
1008
1025
 
1009
- ### Budgets at realistic counts
1010
-
1011
- **A list, batch or write path has a database test at the row counts and
1012
- history a real workspace holds, and that test fails above a fixed query
1013
- budget, one that a read per row or a history-sized check cannot meet.**
1014
-
1015
- - Seed hundreds of records and long per-person histories, not one to three
1016
- rows: a read per row and a history-sized policy cost nothing at three.
1017
- - Count the queries with the test Knex instance's `query` event and assert a
1018
- ceiling: the count the correct implementation needs at that size, which a
1019
- read per row overshoots by hundreds. A path that pages nothing (one read,
1020
- one write) asserts the same count at a short and a long history. A query
1021
- count is stable in CI; also report the time the call took.
1022
- - Meet the budget by fixing the reads ([One read per
1023
- table](#one-read-per-table), [Bounded access checks](#bounded-access-checks)),
1024
- never by shrinking the data or raising the budget.
1025
-
1026
- Why: without a budget at real counts, a path that does one read per row ships,
1027
- and its first sign is a request that takes seconds in production.
1026
+ ### Query counts at realistic sizes
1027
+
1028
+ **A list, batch or write path's database test counts its queries with
1029
+ `countQueries()` at a few rows and at a realistic number, and fails when the
1030
+ count grows with the rows instead of the pages.**
1031
+
1032
+ - `countQueries(knex, work)`, from
1033
+ `@jskit-ai/database-runtime/server/testDatabases`, runs `work` and returns
1034
+ its `result`, the `count` of statements the Knex instance or transaction
1035
+ sent meanwhile (a transaction's `BEGIN` and `COMMIT` included) and their
1036
+ SQL in `queries`. Pass the Knex the path runs on: the one the test built its
1037
+ repository or service with.
1038
+ - A route is counted through the server the test boots in its own process
1039
+ (`createServer()`, then `app.inject()`): `countRuntimeQueries(work)`, from
1040
+ the same module, records what the app's own `runtime.database` sends while
1041
+ `work` runs, and leaves out the test's own Knex. A server in another process
1042
+ is out of its reach, so the route's guard boots it in the test.
1043
+ - Pages add a few queries; rows should add none. A path that pages nothing
1044
+ (one read, one write) compares a short and a long history.
1045
+ - When the counts differ more than the pages explain, read `queries`: the
1046
+ statement that repeats once per row is the one to batch. Judge the
1047
+ comparison by what the path has to read; never tune a fixed ceiling until
1048
+ it passes.
1049
+ - When it fails, fix the reads ([Lists read related rows in
1050
+ batches](#lists-read-related-rows-in-batches), [Bounded access
1051
+ checks](#bounded-access-checks)).
1052
+
1053
+ Why: JSON:API reads add queries per page and relationship field, so comparing
1054
+ two sizes separates that cost from a read per row, where one fixed number
1055
+ cannot.
1028
1056
 
1029
1057
  ```js
1030
1058
  // good
1031
- test("the invoice list reads each table once", async () => {
1032
- await seedInvoices(knex, { workspace, count: 300, linesEach: 5 });
1033
- let queries = 0;
1034
- const count = () => { queries += 1; };
1035
- knex.on("query", count);
1036
- try {
1037
- await invoices.listWithLines(workspace, { context });
1038
- } finally {
1039
- knex.off("query", count);
1040
- }
1041
- // A page of each table at a time; one read per invoice would add 300.
1042
- assert.ok(queries <= 40, `${queries} queries`);
1059
+ import { countQueries, countRuntimeQueries } from "@jskit-ai/database-runtime/server/testDatabases";
1060
+
1061
+ test("the invoice list's queries follow its pages, not its invoices", async () => {
1062
+ const listAt = async (count) => {
1063
+ const workspace = await createWorkspace(knex);
1064
+ await seedInvoices(knex, { workspace, count, linesEach: 5 });
1065
+ return countQueries(knex, () => invoices.listWithLines(workspace, { context }));
1066
+ };
1067
+ const few = await listAt(10);
1068
+ const many = await listAt(300);
1069
+ // Thirty times the invoices may add pages; a read per invoice would add about 290 queries.
1070
+ assert.ok(many.count <= few.count * 2,
1071
+ `${few.count} queries for 10 invoices, ${many.count} for 300:\n${many.queries.join("\n")}`);
1072
+ });
1073
+
1074
+ // good: the same comparison for the route, through the server this test booted
1075
+ test("GET /api/invoices reads per page, not per invoice", async () => {
1076
+ const app = await createServer({ runtimeEnv });
1077
+ const listAt = async (count) => {
1078
+ const workspace = await createWorkspace(knex);
1079
+ await seedInvoices(knex, { workspace, count, linesEach: 5 });
1080
+ return countRuntimeQueries(() => app.inject({ url: invoicesUrl(workspace), headers: signedIn }));
1081
+ };
1082
+ const few = await listAt(10);
1083
+ const many = await listAt(300);
1084
+ assert.ok(many.count <= few.count * 2,
1085
+ `${few.count} queries for 10 invoices, ${many.count} for 300:\n${many.queries.join("\n")}`);
1043
1086
  });
1044
1087
 
1045
- // bad: three invoices and no budget, so a read per invoice passes
1088
+ // bad: three invoices, nothing counted, so a read per invoice passes
1046
1089
  test("the invoice list has its lines", async () => {
1047
1090
  await seedInvoices(knex, { workspace, count: 3, linesEach: 1 });
1048
1091
  assert.equal((await invoices.listWithLines(workspace, { context })).length, 3);
1049
1092
  });
1050
1093
  ```
1051
1094
 
1052
- ### Time limits are not fixes
1053
-
1054
- **A slow test, suite, preparation or request is a defect to find, never a time
1055
- limit to raise. Measure where the time goes, fix that cause, and keep the
1056
- default limits.**
1057
-
1058
- - Raising `--test-timeout`, a test's `{ timeout }`, Playwright's `timeout`,
1059
- `test.setTimeout()` or an `expect` timeout, adding retries, or calling a
1060
- request in a loop until it answers hides the cause and lets it grow.
1061
- - Read the preparation phases' durations and the query counts first; the
1062
- cause is usually a read per row, a history-sized policy, or a database
1063
- prepared by replaying migrations ([Test databases are prepared
1064
- once](#test-databases-are-prepared-once)).
1065
- - A longer limit is right only for work that is long by nature, such as a
1066
- fixture of deliberate size, and the test names that work.
1067
- - A time limit still bounds every run, so a hang ends; it is not a speed
1068
- setting.
1069
-
1070
- Why: a raised limit absorbs today's slowness and every later one, until a run
1071
- takes hours and nobody knows which change made it slow.
1095
+ ### Test files run in parallel
1096
+
1097
+ **A test suite runs its files in parallel, as many at a time as the machine's
1098
+ test budget allows: never one file at a time, and never more than the machine
1099
+ can take. Node's test runner runs through `jskit test`; Playwright's `workers`
1100
+ and Vitest's `maxWorkers` come from `testConcurrency()`.**
1101
+
1102
+ - The budget is `V64_TEST_CONCURRENCY` when it is a positive whole number (a
1103
+ test runner that hands out slots exports it for each run), otherwise half of
1104
+ the machine's CPUs, rounded down and at least 1: a 2-vCPU VPS runs one file
1105
+ at a time, an 8-thread machine four.
1106
+ - `jskit test` (from `@jskit-ai/jskit-catalog`) passes its arguments to
1107
+ `node --test` and sets `--test-concurrency` to the budget.
1108
+ `testConcurrency()` from `@jskit-ai/jskit-catalog/test-budget` returns the
1109
+ same number for a config.
1110
+ - No script passes a fixed concurrency or worker count. `jskit check` fails a
1111
+ package script that runs `node --test` directly or passes one, and a
1112
+ Playwright or Vitest config those scripts use whose workers do not come from
1113
+ `testConcurrency()` ([test-run rules](#test-run-rules)).
1114
+ - Each file isolates what it shares: a port the system picks (`listen(0)`), a
1115
+ directory from `mkdtemp`, its own database or schema with a name no other
1116
+ file uses, its own Vite `cacheDir` for each dev server it starts (Vite
1117
+ replaces a shared dependency cache under servers still reading it), and no
1118
+ writes into the source tree. Files that collide are isolated, never
1119
+ serialised. A database test file gets its own copy of `TEST_DB_NAME` from
1120
+ `jskit test --databases`
1121
+ ([integration test databases](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#integration-test-databases)).
1122
+ - An application's `playwright.config.mjs` is
1123
+ `defineConfig(browserTestConfig({ testDir: "./tests/e2e" }))` from
1124
+ `@jskit-ai/jskit-catalog/browser-tests`. It builds the production frontend
1125
+ once (reused while its sources are unchanged) and gives each worker its own
1126
+ server, port and, through its `prepare` module, its own seeded database; a
1127
+ spec file's tests run in order in one worker. With `PLAYWRIGHT_BASE_URL`, the
1128
+ tests use that managed target as its host serves it. Every run is bounded by
1129
+ a `globalTimeout` (20 minutes, or the suite's own measured bound) and ends
1130
+ with its 10 slowest tests. With a `signIn` module, each worker's server is
1131
+ signed in once and every test of that worker starts signed in, instead of
1132
+ each test signing in. `jskit check` fails a webServer that runs the development server
1133
+ (`npm run develop`, `vite`), one webServer shared by every worker, and a
1134
+ config without a `globalTimeout`.
1135
+ - A file that truly cannot run beside others, because it holds a resource of
1136
+ the whole machine, says why on its first line,
1137
+ `// jskit-test: alone - <reason>`. `jskit test` runs it after the others,
1138
+ one such file at a time, and fails on a marker without a reason or below the
1139
+ first line.
1140
+ - `jskit test` ends with its 10 slowest test files and tests, records each
1141
+ file's time in `node_modules/.cache/jskit-test/file-times.json`, and fails
1142
+ when a file takes more than 10 minutes or the whole command more than 45:
1143
+ bounds only a clearly broken suite reaches, such as a file waiting out its
1144
+ timeouts. A suite whose measured work needs more sets `runMs`, `fileMs` or a
1145
+ file's own bound in `test-time-bounds.json`, with the measurement in the
1146
+ commit that raises it; a file that got slow is fixed instead. A file still
1147
+ running at twice its bound, or sooner at ten times the time it last finished
1148
+ in (at least two minutes), is stopped and fails the run by name, so the other
1149
+ files still finish and report: look for what it leaves open (a server, child
1150
+ process, timer or socket) or a test without a timeout. A file with its own
1151
+ bound is stopped at twice that bound.
1152
+
1153
+ Why: `jskit test` and `testConcurrency()` read one budget, so every runner
1154
+ uses the machine without starving the other runs on it.
1072
1155
 
1073
1156
  ```js
1074
- // good: the slow step was found and fixed, so the default limit holds
1075
- test("delivers a batch of notices", async () => {
1076
- await deliveries.processBatch(workspace, { context });
1077
- });
1157
+ // good: package.json "test": "jskit test", and playwright.config.mjs
1158
+ import { browserTestConfig } from "@jskit-ai/jskit-catalog/browser-tests";
1078
1159
 
1079
- // bad
1080
- test("delivers a batch of notices", { timeout: 600_000 }, async () => {
1081
- await deliveries.processBatch(workspace, { context });
1082
- });
1160
+ export default defineConfig(browserTestConfig({ testDir: "./tests/e2e" }));
1161
+
1162
+ // bad: "test": "node --test --test-concurrency=1", and
1163
+ export default defineConfig({ workers: 1, webServer: { command: "npm run develop" } });
1083
1164
  ```
1084
1165
 
1085
1166
  ### Test databases are prepared once
1086
1167
 
1087
- **A test run prepares each database once, from a stored snapshot of its
1088
- schema, migration ledger and seed rows checked by a digest of their sources,
1089
- and never replays the whole migration history inside the run.**
1168
+ **A test run prepares each database once and restores it from a stored
1169
+ snapshot of the schema and the rows the migrations write, migration ledger
1170
+ included, checked by a digest of the migrations.**
1090
1171
 
1091
- - The snapshot is rebuilt only when the digest of the migration and seed
1092
- sources changes, once and outside the timed tests; every disposable
1172
+ - The snapshot is rebuilt only when the digest of the migrations' names and
1173
+ contents changes, once and outside the timed tests; every disposable
1093
1174
  database, and every database a test migrated up or down, is restored from
1094
- it.
1175
+ it. A browser run's fixture seed runs after each restore.
1095
1176
  - The full migration history runs once, in its own fresh-schema check
1096
1177
  ([database verification](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#verification)).
1097
- - The harness reports each preparation phase and its duration, and the suite
1098
- has a time budget, so slow preparation shows instead of hiding inside the
1178
+ - The harness reports each preparation phase and its duration, and those
1179
+ durations are read, so slow preparation shows instead of hiding inside the
1099
1180
  next test that waits for it.
1100
1181
  - The database layer guide's [integration test
1101
1182
  databases](https://mobily-enterprises.github.io/jskit-ai/guide/app-setup/database-layer#integration-test-databases)
1102
1183
  gives the snapshot's contents per engine.
1103
1184
 
1104
- Why: the migration history only grows, so a run that replays it whenever a
1105
- schema changed gets slower with every migration the application adds.
1185
+ Why: an application's migrations include those of every installed package, so
1186
+ replaying them costs more with each package as well as each migration.
1106
1187
 
1107
1188
  ```js
1108
- // good: the application's test support restores the snapshot its digest names
1109
- before(async () => restoreTestDatabase(knex, await testDatabaseSnapshot()));
1189
+ // good: package.json "test": "jskit test --databases"; each file gets a restored copy of TEST_DB_NAME
1190
+ const { databases } = await prepareTestDatabases({ knexConfig, environment: process.env, count: workers, seed });
1110
1191
 
1111
1192
  // bad: every file replays every migration
1112
1193
  before(() => knex.migrate.latest());
@@ -1120,6 +1201,10 @@ before(() => knex.migrate.latest());
1120
1201
  code: server, shared, client (`.vue` and `.js`) and scripts. One concern per
1121
1202
  factory.**
1122
1203
 
1204
+ - The limit is an outer bound, not a size to aim for: a file near it usually
1205
+ holds more than one concern. Split it by concern; never trim lines or join
1206
+ statements to fit.
1207
+
1123
1208
  Why: a reader holds one concern at a time; a 2,000-line closure holds ten.
1124
1209
 
1125
1210
  ```text
@@ -1219,8 +1304,9 @@ Why: a comment that narrates history is wrong the day after it is written.
1219
1304
  ### Running the checks
1220
1305
 
1221
1306
  `npm run lint` runs ESLint with `@jskit-ai/config-eslint`, and `npm run
1222
- verify` runs `jskit check` (the package graph), lint, the tests and the
1223
- build. The foundations' `eslint.config.mjs` already holds a new
1307
+ verify` runs `jskit check` (the package graph, navigation and [test
1308
+ runs](#test-run-rules)), lint, the tests and the build. The foundations'
1309
+ `eslint.config.mjs` already holds a new
1224
1310
  application's code to every check:
1225
1311
 
1226
1312
  ```js
@@ -1292,6 +1378,22 @@ Every finding ends with a link to the section of this page that explains it.
1292
1378
  | `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) |
1293
1379
  | `max-lines`, `max-lines-per-function` | files over 750 lines, functions over 600 | [Size limits](#size-limits) |
1294
1380
 
1381
+ ### Test-run rules
1382
+
1383
+ `jskit check` reports these in every package script, and in the Playwright
1384
+ and Vitest configs those scripts use. No comment excuses them; a test file
1385
+ that must run alone says so on its own first line instead.
1386
+
1387
+ | Rule | Reports | Section |
1388
+ | --- | --- | --- |
1389
+ | `test/node-test` | a script that runs `node --test` directly instead of `jskit test` | [Test files run in parallel](#test-files-run-in-parallel) |
1390
+ | `test/fixed-concurrency` | `--test-concurrency` in a script, or a script that pins `V64_TEST_CONCURRENCY` or `PLAYWRIGHT_WORKERS` | [Test files run in parallel](#test-files-run-in-parallel) |
1391
+ | `test/playwright-workers` | `--workers` or `-j` passed to `playwright test`, its config without `workers: testConcurrency()` or `browserTestConfig()`, or with `workers` set over `browserTestConfig()`'s | [Test files run in parallel](#test-files-run-in-parallel) |
1392
+ | `test/playwright-dev-server` | a Playwright webServer command that runs the development server (`npm run develop`, `npm run dev`, `bin/develop.js`, `vite`, `vite dev`) | [Test files run in parallel](#test-files-run-in-parallel) |
1393
+ | `test/playwright-isolation` | a Playwright webServer every worker shares, without `browserTestConfig()` | [Test files run in parallel](#test-files-run-in-parallel) |
1394
+ | `test/playwright-global-timeout` | a Playwright config without a `globalTimeout` (or `browserTestConfig()`), or with `globalTimeout: 0` | [Test files run in parallel](#test-files-run-in-parallel) |
1395
+ | `test/vitest-workers` | `--maxWorkers`, `--minWorkers` or `--no-file-parallelism` passed to Vitest, or its config without `maxWorkers: testConcurrency()` or with `fileParallelism: false` | [Test files run in parallel](#test-files-run-in-parallel) |
1396
+
1295
1397
  The rules see shapes, not intent. Feature files, owned state beyond
1296
1398
  registries, shutdown, loggers, shared action fields, strict inputs, the action
1297
1399
  policy, request context, validated input, one reader per fact, routes,
@@ -31,7 +31,9 @@ Cancel, Close, and a wizard's Previous step; keep existing unsaved-change
31
31
  guards.
32
32
 
33
33
  `createShellRouter()` owns scroll position (`shell/application-shell`
34
- pattern); never reset scroll in page components.
34
+ pattern); never reset scroll in page components. An app's own scroll behaviour
35
+ is its `scrollBehavior` option, never a `createRouter` wrapper's (that throws);
36
+ a hash target opens below its `scroll-margin-top`.
35
37
 
36
38
  ## Application-owned files
37
39
 
@@ -1,68 +0,0 @@
1
- import knex from "knex";
2
-
3
- // Application-owned test setup. Schema migration tests use a separate fresh database.
4
- async function prepareBrowserTestDatabase({ knexConfig, environment = process.env, seed }) {
5
- const started = performance.now();
6
- if (environment.NODE_ENV === "production") throw new Error("Browser tests cannot prepare a production runtime.");
7
- if (typeof seed !== "function") throw new TypeError("An explicit browser fixture seed is required.");
8
- if (knexConfig?.client !== "mysql2" || !knexConfig.connection || typeof knexConfig.connection !== "object") {
9
- throw new Error("Browser preparation requires a resolved MySQL Knex configuration.");
10
- }
11
- const database = String(environment.BROWSER_TEST_DB_NAME || "").trim();
12
- if (!/^[a-zA-Z0-9_]{1,64}$/.test(database)) throw new Error("BROWSER_TEST_DB_NAME must name the exact browser database.");
13
- const protectedNames = [knexConfig.connection.database, environment.DB_NAME, environment.TEST_DB_NAME];
14
- if (environment.DATABASE_URL) {
15
- try { protectedNames.push(decodeURIComponent(new URL(environment.DATABASE_URL).pathname.slice(1))); }
16
- catch { throw new Error("DATABASE_URL must be a valid database URL."); }
17
- }
18
- if (!knexConfig.connection.database || protectedNames.some(name => String(name || "").trim().toLowerCase() === database.toLowerCase())) {
19
- throw new Error("BROWSER_TEST_DB_NAME must differ from the application and disposable TEST_DB_NAME databases.");
20
- }
21
- const adminConnection = { ...knexConfig.connection };
22
- delete adminConnection.database;
23
- const admin = knex({ ...knexConfig, connection: adminConnection, pool: { min: 0, max: 1 } });
24
- let schemaExisted;
25
- try {
26
- const [[row]] = await admin.raw("SELECT COUNT(*) AS count FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?", [database]);
27
- schemaExisted = Number(row.count) === 1;
28
- await admin.raw("CREATE DATABASE IF NOT EXISTS ??", [database]);
29
- }
30
- finally { await admin.destroy(); }
31
- console.info(`Browser database ${database}: ${schemaExisted ? "existing schema" : "new schema"}.`);
32
- const db = knex({ ...knexConfig, connection: { ...knexConfig.connection, database } });
33
- try {
34
- const migrationStarted = performance.now();
35
- const [, migrations] = await db.migrate.latest();
36
- const migrationMs = Math.round(performance.now() - migrationStarted);
37
- console.info(`Browser migrations: ${migrations.length} applied in ${migrationMs}ms.`);
38
- const resetStarted = performance.now();
39
- let resetTables = 0;
40
- const connection = await db.client.acquireConnection();
41
- try {
42
- const [[actual]] = await db.raw("SELECT DATABASE() AS databaseName").connection(connection);
43
- if (actual.databaseName !== database) throw new Error("Browser reset requires the exact BROWSER_TEST_DB_NAME connection.");
44
- const [tables] = await db.raw(
45
- "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'", [database]
46
- ).connection(connection);
47
- const ledger = knexConfig.migrations?.tableName || "knex_migrations";
48
- await db.raw("SET FOREIGN_KEY_CHECKS = 0").connection(connection);
49
- try {
50
- for (const { name } of tables) {
51
- if (name === ledger || name === `${ledger}_lock`) continue;
52
- await db.raw("TRUNCATE TABLE ??", [name]).connection(connection);
53
- resetTables += 1;
54
- }
55
- } finally { await db.raw("SET FOREIGN_KEY_CHECKS = 1").connection(connection); }
56
- } finally { await db.client.releaseConnection(connection); }
57
- const resetMs = Math.round(performance.now() - resetStarted);
58
- console.info(`Browser fixture reset: ${resetTables} tables in ${resetMs}ms.`);
59
- const seedStarted = performance.now();
60
- await db.transaction(seed);
61
- const seedMs = Math.round(performance.now() - seedStarted);
62
- const totalMs = Math.round(performance.now() - started);
63
- console.info(`Browser fixture seed: ${seedMs}ms; database preparation total: ${totalMs}ms.`);
64
- return { database, migrations, schemaExisted, timings: { migrationMs, resetMs, seedMs, totalMs } };
65
- } finally { await db.destroy(); }
66
- }
67
-
68
- export { prepareBrowserTestDatabase };