@jskit-ai/agent-docs 0.1.282 → 0.1.284

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 (151) hide show
  1. package/guide/agent/app-extras/assistant.md +14 -2
  2. package/guide/agent/app-extras/realtime.md +38 -1
  3. package/guide/agent/app-setup/authentication.md +64 -14
  4. package/guide/agent/app-setup/console.md +8 -0
  5. package/guide/agent/app-setup/database-layer.md +23 -103
  6. package/guide/agent/app-setup/existing-application-migration.md +105 -72
  7. package/guide/agent/app-setup/multi-homing.md +94 -32
  8. package/guide/agent/app-setup/quickstart.md +1 -1
  9. package/guide/agent/app-setup/run-an-accounts-hub.md +1 -1
  10. package/guide/agent/app-setup/upgrading-jskit.md +8 -3
  11. package/guide/agent/app-setup/users.md +8 -5
  12. package/guide/agent/framework/application-operations.md +1 -1
  13. package/guide/agent/framework/crud-operations.md +63 -45
  14. package/guide/agent/framework/data-access.md +1031 -0
  15. package/guide/agent/framework/sanity-checks.md +26 -27
  16. package/guide/agent/framework/typed-features.md +114 -247
  17. package/guide/agent/index.md +2 -0
  18. package/package.json +6 -3
  19. package/patterns/feature-package/PATTERN.md +25 -17
  20. package/patterns/feature-package/example/booking-engine/package.json +1 -1
  21. package/patterns/feature-package/example/variations/InvoiceRollupFeature.js +6 -7
  22. package/patterns/feature-package/example/variations/invoiceResource.js +15 -0
  23. package/patterns/feature-package/example/variations/invoiceRollupActions.js +4 -4
  24. package/patterns/feature-package/example/variations/ownedTableRepository.js +27 -0
  25. package/patterns/feature-package/example/variations/types.js +1 -1
  26. package/patterns/minimal-foundation/example/AGENTS.md +4 -0
  27. package/patterns/minimal-foundation/example/package.json +4 -4
  28. package/patterns/minimal-foundation/example/server.js +4 -1
  29. package/patterns/shell-foundation/example/AGENTS.md +4 -0
  30. package/patterns/shell-foundation/example/package.json +5 -5
  31. package/patterns/shell-foundation/example/packages/main/package.json +1 -1
  32. package/patterns/shell-foundation/example/server.js +4 -1
  33. package/reference/autogen/KERNEL_MAP.md +2 -1
  34. package/reference/autogen/PATTERN_INDEX.md +70 -59
  35. package/reference/autogen/README.md +1 -1
  36. package/reference/autogen/packages/agent-docs.md +7 -3
  37. package/reference/autogen/packages/agent-runtime.md +48 -11
  38. package/reference/autogen/packages/architecture-inspector.md +6 -2
  39. package/reference/autogen/packages/assistant-core.md +4 -4
  40. package/reference/autogen/packages/assistant-runtime.md +33 -13
  41. package/reference/autogen/packages/auth-provider-local-core.md +3 -1
  42. package/reference/autogen/packages/auth-provider-local-db-core.md +65 -14
  43. package/reference/autogen/packages/auth-provider-oidc-core.md +5 -1
  44. package/reference/autogen/packages/auth-provider-supabase-core.md +1 -1
  45. package/reference/autogen/packages/connectors-core.md +22 -2
  46. package/reference/autogen/packages/console-core.md +9 -5
  47. package/reference/autogen/packages/crud-core.md +38 -173
  48. package/reference/autogen/packages/database-runtime.md +8 -32
  49. package/reference/autogen/packages/http-runtime.md +3 -3
  50. package/reference/autogen/packages/http-web.md +12 -0
  51. package/reference/autogen/packages/kernel.md +25 -2
  52. package/reference/autogen/packages/oidc-issuer-core.md +4 -0
  53. package/reference/autogen/packages/payments-core.md +29 -6
  54. package/reference/autogen/packages/realtime.md +15 -6
  55. package/reference/autogen/packages/resource-core.md +30 -5
  56. package/reference/autogen/packages/resource-crud-core.md +30 -34
  57. package/reference/autogen/packages/resource-runtime.md +198 -0
  58. package/reference/autogen/packages/rewarded-core.md +16 -10
  59. package/reference/autogen/packages/shell-web.md +1 -0
  60. package/reference/autogen/packages/users-core.md +26 -14
  61. package/reference/autogen/packages/workspaces-core.md +17 -10
  62. package/reference/autogen/tooling/config-eslint.md +69 -0
  63. package/reference/autogen/tooling/jskit-catalog.md +40 -1
  64. package/reference/autogen/tooling/jskit-release.md +3 -0
  65. package/skills/jskit/SKILL.md +4 -1
  66. package/skills/jskit/references/app-operations.md +1 -1
  67. package/skills/jskit/references/crud-operations.md +62 -44
  68. package/skills/jskit/references/data-access.md +1026 -0
  69. package/skills/jskit/references/database-layer.md +411 -0
  70. package/skills/jskit/references/existing-application-migration.md +105 -72
  71. package/skills/jskit/references/heuristics/INDEX.md +6 -4
  72. package/skills/jskit/references/heuristics/crud-authoring.md +22 -14
  73. package/skills/jskit/references/heuristics/data-access.md +19 -0
  74. package/skills/jskit/references/heuristics/filters.md +6 -6
  75. package/skills/jskit/references/heuristics/row-policies.md +40 -16
  76. package/skills/jskit/references/heuristics/server-search.md +30 -42
  77. package/skills/jskit/references/heuristics/table-resource-fields.md +78 -0
  78. package/skills/jskit/references/heuristics/typed-features.md +3 -3
  79. package/skills/jskit/references/multi-homing.md +94 -32
  80. package/skills/jskit/references/pattern-index.md +70 -59
  81. package/skills/jskit/references/patterns/app/minimal-foundation/example/AGENTS.md +4 -0
  82. package/skills/jskit/references/patterns/app/minimal-foundation/example/package.json +4 -4
  83. package/skills/jskit/references/patterns/app/minimal-foundation/example/server.js +4 -1
  84. package/skills/jskit/references/patterns/app/shell-foundation/example/AGENTS.md +4 -0
  85. package/skills/jskit/references/patterns/app/shell-foundation/example/package.json +5 -5
  86. package/skills/jskit/references/patterns/app/shell-foundation/example/packages/main/package.json +1 -1
  87. package/skills/jskit/references/patterns/app/shell-foundation/example/server.js +4 -1
  88. package/skills/jskit/references/patterns/assistant/assistant-surface/PATTERN.md +5 -2
  89. package/skills/jskit/references/patterns/auth/accounts-hub/example/package.json +3 -2
  90. package/skills/jskit/references/patterns/auth/auth-surface/PATTERN.md +15 -0
  91. package/skills/jskit/references/patterns/auth/oidc-sign-in/example/package.json +2 -2
  92. package/skills/jskit/references/patterns/auth/supabase-auth/example/package.json +1 -1
  93. package/skills/jskit/references/patterns/connectors/calendar-cli/example/package.json +4 -4
  94. package/skills/jskit/references/patterns/crud/crud-contract/PATTERN.md +133 -0
  95. package/skills/jskit/references/patterns/crud/crud-contract/example/bookResource.js +74 -0
  96. package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/PATTERN.md +69 -37
  97. package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/packages/books/package.json +5 -4
  98. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/server/BooksFeature.js +13 -0
  99. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/shared/bookResource.js +74 -0
  100. package/skills/jskit/references/patterns/crud/crud-feature-package/example/packages/books/src/shared/index.js +1 -0
  101. package/skills/jskit/references/patterns/crud/crud-screen-set/PATTERN.md +26 -8
  102. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookEditPage.vue +2 -2
  103. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookListPage.vue +2 -2
  104. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookNewPage.vue +2 -2
  105. package/skills/jskit/references/patterns/crud/crud-screen-set/example/books/BookViewPage.vue +3 -3
  106. package/skills/jskit/references/patterns/database/mysql-application/PATTERN.md +9 -4
  107. package/skills/jskit/references/patterns/database/mysql-application/example/package.json +2 -1
  108. package/skills/jskit/references/patterns/database/mysql-application/example/scripts/prepare-database.js +8 -0
  109. package/skills/jskit/references/patterns/database/postgres-application/PATTERN.md +8 -4
  110. package/skills/jskit/references/patterns/database/postgres-application/example/package.json +2 -1
  111. package/skills/jskit/references/patterns/database/postgres-application/example/scripts/prepare-database.js +8 -0
  112. package/skills/jskit/references/patterns/database/table-resource/PATTERN.md +117 -0
  113. package/skills/jskit/references/patterns/database/table-resource/example/orderResource.js +25 -0
  114. package/skills/jskit/references/patterns/realtime/realtime-application/PATTERN.md +7 -0
  115. package/skills/jskit/references/patterns/realtime/realtime-application/example/package.json +2 -2
  116. package/skills/jskit/references/patterns/rewards/google-rewarded/PATTERN.md +1 -1
  117. package/skills/jskit/references/patterns/server/feature-package/PATTERN.md +25 -17
  118. package/skills/jskit/references/patterns/server/feature-package/example/booking-engine/package.json +1 -1
  119. package/skills/jskit/references/patterns/server/feature-package/example/variations/InvoiceRollupFeature.js +6 -7
  120. package/skills/jskit/references/patterns/server/feature-package/example/variations/invoiceResource.js +15 -0
  121. package/skills/jskit/references/patterns/server/feature-package/example/variations/invoiceRollupActions.js +4 -4
  122. package/skills/jskit/references/patterns/server/feature-package/example/variations/ownedTableRepository.js +27 -0
  123. package/skills/jskit/references/patterns/server/feature-package/example/variations/types.js +1 -1
  124. package/skills/jskit/references/patterns/users/user-administration-server/PATTERN.md +59 -10
  125. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/package.json +5 -4
  126. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/server/UsersFeature.js +7 -5
  127. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/index.js +1 -1
  128. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/userCrud.js +21 -0
  129. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/package.json +11 -5
  130. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/UsersWorkspaceFeature.js +14 -5
  131. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/server/workspaceMembersOnly.js +52 -0
  132. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/index.js +1 -1
  133. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/userCrud.js +21 -0
  134. package/skills/jskit/references/patterns/workspaces/workspace-server/PATTERN.md +9 -9
  135. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/PATTERN.md +16 -0
  136. package/skills/jskit/references/patterns/workspaces/workspace-surfaces/example/src/pages/home/workspaces/index.vue +2 -0
  137. package/skills/jskit/references/sanity-checks.md +27 -28
  138. package/skills/jskit/references/typed-features.md +114 -247
  139. package/patterns/feature-package/example/variations/customKnexRepository.js +0 -40
  140. package/reference/autogen/packages/json-rest-api-core.md +0 -66
  141. package/skills/jskit/references/heuristics/crud-repository-mapping.md +0 -140
  142. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/server/BooksFeature.js +0 -13
  143. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/shared/bookResource.js +0 -83
  144. package/skills/jskit/references/patterns/crud/json-api-resource-package/example/packages/books/src/shared/index.js +0 -1
  145. package/skills/jskit/references/patterns/crud/resource-contract/PATTERN.md +0 -155
  146. package/skills/jskit/references/patterns/crud/resource-contract/example/bookResource.js +0 -87
  147. package/skills/jskit/references/patterns/server/feature-package/example/variations/customKnexRepository.js +0 -40
  148. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users/src/shared/userResource.js +0 -69
  149. package/skills/jskit/references/patterns/users/user-administration-server/example/packages/users-workspace/src/shared/userResource.js +0 -56
  150. /package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/migrations/20260815000000_books.cjs +0 -0
  151. /package/skills/jskit/references/patterns/crud/{json-api-resource-package → crud-feature-package}/example/packages/books/src/server/types.js +0 -0
@@ -8,6 +8,9 @@ what to do, why in one line, and shows good and bad code. `npm run lint`
8
8
  reports the rules a machine can see ([checks](#checks)); the others are
9
9
  checked by reading. The `server/feature-package` pattern in the
10
10
  [bundled source pattern index](pattern-index.md) is the smallest complete feature to copy.
11
+ Code that reads or writes a table starts from [data access](data-access.md):
12
+ the table resource and its owner feature come first, and this page shapes the
13
+ code around them.
11
14
 
12
15
  The [typed features heuristic](heuristics/typed-features.md)
13
16
  maps keywords and lint findings to the section that fixes them. Client code
@@ -39,11 +42,13 @@ it reports the call as exact instead of guessing from the method name.
39
42
  ### Feature files
40
43
 
41
44
  **A `defineFeature` lives in `<Name>Feature.js`, its keys in one order, one
42
- per line: `id, domain, requires, optional, provides, setup, actions, boot,
43
- shutdown`.** A `defineProvider` lives in `<Name>Provider.js`.
45
+ per line: `id, domain, owns, systemScope, requires, optional, provides,
46
+ setup, actions, boot, shutdown`.** A `defineProvider` lives in `<Name>Provider.js`.
44
47
 
45
48
  - `setup` builds each provided value with a named `create<Name>` factory from
46
- a module of its own, never inline.
49
+ a module of its own, never inline. It returns exactly the keys `provides`
50
+ names, or boot refuses it; a value only `actions` needs is built in
51
+ `actions`, which receives the requires.
47
52
  - `actions` is a method that returns `create<Name>Actions({ … })`.
48
53
  - The hooks take `capabilities`, destructured and typed.
49
54
 
@@ -53,15 +58,13 @@ Why: every feature reads the same way, top to bottom.
53
58
  // good
54
59
  const InvoicesFeature = defineFeature({
55
60
  id: "feature.invoices",
56
- requires: {
57
- database: "runtime.database"
58
- },
61
+ owns: [invoiceResource],
59
62
  provides: {
60
63
  invoices: "feature.invoices"
61
64
  },
62
- /** @param {{ database: import("@jskit-ai/database-runtime/server/types").RuntimeDatabase }} capabilities */
63
- setup({ database }) {
64
- return { invoices: createInvoices({ knex: database.knex }) };
65
+ /** @param {{ resources: { invoices: import("@jskit-ai/resource-runtime/server/types").ResourceHandle } }} capabilities */
66
+ setup({ resources }) {
67
+ return { invoices: createInvoices({ invoices: resources.invoices }) };
65
68
  },
66
69
  actions({ invoices }) {
67
70
  return createInvoiceActions({ invoices });
@@ -69,7 +72,7 @@ const InvoicesFeature = defineFeature({
69
72
  });
70
73
 
71
74
  // bad: the value built inline, the actions an inline array
72
- setup: ({ database }) => ({ invoices: { list: () => database.knex("invoices") } }),
75
+ setup: ({ resources }) => ({ invoices: { list: (options) => resources.invoices.queryAll({}, options) } }),
73
76
  actions: ({ invoices }) => [{ id: "invoices.list", execute: () => invoices.list() }]
74
77
  ```
75
78
 
@@ -88,7 +91,8 @@ there.**
88
91
  - Hand-write a contract only for a token with several implementations (such as
89
92
  `auth.service`), in its owning package, with optional operations marked
90
93
  optional.
91
- - JSKIT's names: `RuntimeKnex`, `RuntimeKnexTransaction`,
94
+ - JSKIT's names: `ResourceHandle`, `ResourceCallOptions`, `SystemContext`
95
+ (`@jskit-ai/resource-runtime/server/types`), `RuntimeKnex`,
92
96
  `RuntimeKnexQueryBuilder`, `RuntimeRouter`, `RuntimeLogger`, `RuntimeEnv`,
93
97
  `CrudService`, `CrudRepository`.
94
98
 
@@ -124,13 +128,13 @@ Why: the type is what turns a call into one step.
124
128
  // good
125
129
  /**
126
130
  * The invoices the feature provides.
127
- * @param {{ knex: import("@jskit-ai/database-runtime/server/types").RuntimeKnex }} dependencies
131
+ * @param {{ invoices: import("@jskit-ai/resource-runtime/server/types").ResourceHandle }} dependencies
128
132
  */
129
- function createInvoices({ knex }) {
133
+ function createInvoices({ invoices }) {
130
134
 
131
135
  // bad: untyped, read from a bag in the body
132
136
  function createInvoices(dependencies) {
133
- const knex = dependencies.knex;
137
+ const invoices = dependencies.invoices;
134
138
  ```
135
139
 
136
140
  ### Required collaborators
@@ -268,6 +272,14 @@ filled after construction.**
268
272
  saying so.
269
273
  - No late-bound closures between two tokens of one feature: the consumer
270
274
  requires the other token.
275
+ - `boot()` and `shutdown()` receive what the feature requires, as `setup()`
276
+ does, and its own setup's values second, as `outputs`:
277
+ `boot(capabilities, { outputs })`.
278
+ - A periodic job is such a capability; JSKIT ships no scheduler. `setup()`
279
+ builds the job, `boot()` starts its timer (`setInterval(runOnce,
280
+ everyMs).unref()`), `shutdown()` clears it and awaits a run in progress, and
281
+ tests call `runOnce()` itself. Every server runs the timer, so a run takes
282
+ its rows with `claim()` ([data access, Layer 4](data-access.md#layer-4-locks)).
271
283
 
272
284
  Why: module state is shared by every runtime in the process and outlives
273
285
  `shutdown()`.
@@ -277,8 +289,8 @@ Why: module state is shared by every runtime in the process and outlives
277
289
  setup() {
278
290
  return { jobs: createJobQueue() };
279
291
  },
280
- boot({ jobs }) {
281
- jobs.start();
292
+ boot(_capabilities, { outputs }) {
293
+ outputs.jobs.start();
282
294
  },
283
295
 
284
296
  // bad
@@ -303,14 +315,14 @@ setup({ events }) {
303
315
  if (!config.remindersEnabled) return {};
304
316
  return { reminders: createReminders({ events }) };
305
317
  },
306
- shutdown({ reminders }) {
318
+ shutdown(_capabilities, { outputs }) {
307
319
  if (!config.remindersEnabled) return;
308
- return reminders.stop();
320
+ return outputs.reminders.stop();
309
321
  }
310
322
 
311
323
  // bad
312
- shutdown({ reminders }) {
313
- return reminders?.stop?.();
324
+ shutdown(_capabilities, { outputs }) {
325
+ return outputs.reminders?.stop?.();
314
326
  }
315
327
  ```
316
328
 
@@ -532,6 +544,44 @@ const actorId = context?.actor?.id || options.context?.user?.id || null;
532
544
 
533
545
  ## Services and repositories
534
546
 
547
+ ### Resource first
548
+
549
+ **Every table has one table resource, and only its owner feature reads or
550
+ writes it, through the handle it lists in `owns`. A repository holds the
551
+ owner's queries on its handles (a plain CRUD feature's repository is the
552
+ handle itself); a service calls repositories and other features' services or
553
+ capabilities, and never builds a query or reaches another feature's table.** [Data access](data-access.md)
554
+ gives the five layers, from one record to reports.
555
+
556
+ Why: ownership, the row policy, validation and audits run only on the
557
+ resource's path; Knex in a repository skips all of them.
558
+
559
+ ```js
560
+ // good
561
+
562
+ /**
563
+ * The owner's repository, on its handle.
564
+ * @param {{ receivals: import("@jskit-ai/resource-runtime/server/types").ResourceHandle }} handles
565
+ */
566
+ function createReceivalsRepository({ receivals }) {
567
+ /**
568
+ * @param {string} id
569
+ * @param {string} arrivedAt
570
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceCallOptions} options
571
+ */
572
+ function markArrived(id, arrivedAt, options) {
573
+ return receivals.patch(id, { status: "arrived", arrivedAt }, options);
574
+ }
575
+
576
+ return Object.freeze({ markArrived });
577
+ }
578
+
579
+ // bad
580
+ function markArrived(id, arrivedAt) {
581
+ return knex("receivals").where({ id }).update({ status: "arrived", arrived_at: arrivedAt });
582
+ }
583
+ ```
584
+
535
585
  ### Validated input as is
536
586
 
537
587
  **A method that receives an action's validated input takes it as it is: no
@@ -559,8 +609,8 @@ async function rename(input = {}, options = {}) {
559
609
  it, destructure and use.**
560
610
 
561
611
  - An options bag whose fields are optional by contract keeps `options = {}`,
562
- with no `?.` or second `|| {}` on it. Pass it on as `options`, or
563
- `{ ...options, trx }` inside a transaction.
612
+ with no `?.` or second `|| {}` on it. Pass it on as `options`, or as the
613
+ `txOptions` that `withTransaction` hands its work inside a transaction.
564
614
  - A parameter or bag no caller passes is dropped and its values written in
565
615
  place. A helper's argument takes no default unless a real caller omits it,
566
616
  and a default never comes before a required parameter.
@@ -573,13 +623,13 @@ second method inside the first.
573
623
 
574
624
  ```js
575
625
  // good
576
- async function list(query, options = {}) {
577
- return resolveRepoClient(knex, options)("invoices").where(query);
626
+ async function list(filters, options = {}) {
627
+ return invoices.queryAll({ filters }, options);
578
628
  }
579
629
 
580
630
  // bad
581
- async function list(query = {}, options = {}) {
582
- const client = options?.trx || knex;
631
+ async function list(filters = {}, options = {}) {
632
+ const context = options?.context || null;
583
633
  }
584
634
  async function save(invoice, skipAudit = false) {
585
635
  ```
@@ -594,8 +644,8 @@ map shows.
594
644
 
595
645
  ```js
596
646
  // good
597
- function createInvoices({ knex }) {
598
- async function list(query) { … }
647
+ function createInvoices({ invoices }) {
648
+ async function list(filters, options) { … }
599
649
  async function send(input, options) { … }
600
650
  return Object.freeze({
601
651
  list,
@@ -661,180 +711,51 @@ helper, never by hand.**
661
711
 
662
712
  | Fact | Reader |
663
713
  | --- | --- |
664
- | the call's transaction, or the client to query through | `resolveTransaction(options)`, `resolveRepoClient(knex, options)` (`@jskit-ai/database-runtime/shared`) |
665
- | what a service hands its repository | `repositoryCallOptions(options)` (`@jskit-ai/crud-core/server/jsonApiService`), only at the repository call |
666
714
  | the signed-in user | `resolveActor(context)`, `resolveActorId(context)` |
667
715
  | a tagged result's records, or its document | `simplifyJsonApiResult(result)`, `jsonApiResultValue(result)` (`@jskit-ai/http-runtime/shared`) |
668
716
  | a collection's next-page cursor | `jsonApiNextCursor(document)` (`@jskit-ai/http-runtime/shared`) |
669
717
  | a created record's id | `resolveCrudJsonApiEntityIdFromResult(result)` (`@jskit-ai/crud-core/server/serviceEvents`) |
670
718
  | a CRUD record change's event | `buildCrudRecordChangedEvent({ namespace, operation, entityId, audience })` (`@jskit-ai/crud-core/server/serviceEvents`) |
671
719
  | the workspace a request asks for, before any action context | `requestedWorkspaceScope(request)` (`@jskit-ai/workspaces-core/server/support/workspaceRouteInput`) |
672
- | a context's workspace id | `resolveWorkspaceScopeValue(context)` (`@jskit-ai/json-rest-api-core/server/jsonRestApiHost`) |
720
+ | a context's workspace id | `resolveWorkspaceScopeValue(context)` (`@jskit-ai/resource-runtime/server`) |
673
721
 
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.
722
+ A call's transaction is not a fact to read: pass `options` on, and inside a
723
+ transaction the `txOptions` that `withTransaction` hands its work
724
+ ([Transactions](data-access.md#transactions)).
725
+
726
+ Why: each reader knows every place JSKIT keeps its fact (a tagged result's
727
+ wrapper, the actor in a context), and a hand read misses one.
676
728
 
677
729
  ```js
678
730
  // good
679
- const trx = resolveTransaction(options);
731
+ const actorId = resolveActorId(context);
680
732
 
681
733
  // bad
682
- const trx = options?.trx || options?.transaction || null;
734
+ const actorId = context?.actor?.id || context?.auth?.userId || null;
683
735
  ```
684
736
 
685
737
  ### Every row
686
738
 
687
- **A server-side resource query that needs every row it matches reads them
688
- with `queryAllJsonRestCollectionRows(resource, params, context)`
689
- (`@jskit-ai/json-rest-api-core/server/jsonRestApiHost`). A query without a
690
- page means "the first 20 rows".**
691
-
692
- - json-rest-api answers a query that names no page with the resource's
693
- default page, 20 rows, and says nothing about the rest. The CRUD
694
- repository's `queryDocuments(query)` without a `limit` is the same first
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.
706
- - A lookup by a unique key (an id, a slug, a token hash, a unique pair such
707
- as workspace and user) matches at most one row and may read `rows[0]` of a
708
- plain query. A read of the newest or first few rows names its own `sort`
709
- and `page`.
710
- - HTTP list routes keep the caller's page: their default and maximum page
711
- sizes are public behaviour, not a server-side limit to raise.
712
-
713
- Why: a list read without a page looks complete until the 21st row, then
714
- silently drops the rest from every person picker, scope and delivery built on it.
715
-
716
- ```js
717
- // good
718
- const rows = await queryAllJsonRestCollectionRows(api.resources.workspaceMemberships, {
719
- queryParams: { filters: { workspace: workspaceId, status: "active" }, include: ["user"] },
720
- transaction: resolveTransaction(options),
721
- format: "plain"
722
- }, options.context);
723
-
724
- // bad: the first 20 members only
725
- const rows = extractJsonRestCollectionRows(await api.resources.workspaceMemberships.query({
726
- queryParams: { filters: { workspace: workspaceId, status: "active" }, include: ["user"] },
727
- transaction: resolveTransaction(options),
728
- format: "plain"
729
- }, createJsonRestContext(options.context)));
730
- ```
739
+ **A server-side read that needs every row it matches reads them with the
740
+ handle's `queryAll(params, options)`. A `query()` names its page size, and
741
+ answers one page.** [Reads shaped for the screen](data-access.md#reads-shaped-for-the-screen)
742
+ gives the rules and examples.
731
743
 
732
744
  ### Lists read related rows in batches
733
745
 
734
746
  **A read that builds a list fetches its related rows in batches, an id-list
735
747
  query per related table, and groups them in memory, so its query count follows
736
- the tables and pages it reads, not the rows.**
737
-
738
- - Collect the ids from the first read, then read each related table with an
739
- id-list filter: a `searchSchema` entry
740
- `{ type: "array", actualField: "<column>", filterOperator: "in" }`, read
741
- through `queryAllJsonRestCollectionRows()`, or the resource's `include`.
742
- Group the rows by id with `Map.groupBy()`.
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.
748
- - Work derived from a row (hashing, preparing a form, assembling a snapshot)
749
- runs once per distinct input in the call, not once per row it appears in.
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)).
755
-
756
- Why: each JSKIT single-record read runs its lookups and row policy too, so a
757
- read per row repeats all of them.
758
-
759
- ```js
760
- // good: a read per related table, however many invoices there are
761
- const invoiceRows = await queryAllJsonRestCollectionRows(api.resources.invoices, {
762
- queryParams: { filters: { workspace: workspaceId } },
763
- transaction: resolveTransaction(options),
764
- format: "plain"
765
- }, options.context);
766
- const lineRows = await queryAllJsonRestCollectionRows(api.resources.invoiceLines, {
767
- queryParams: { filters: { invoiceIds: invoiceRows.map((invoice) => invoice.id) } },
768
- transaction: resolveTransaction(options),
769
- format: "plain"
770
- }, options.context);
771
- const linesByInvoice = Map.groupBy(lineRows, (line) => line.invoiceId);
772
- return invoiceRows.map((invoice) => ({ ...invoice, lines: linesByInvoice.get(invoice.id) || [] }));
773
-
774
- // bad: one more query, and its lookups and row policy, for every invoice
775
- for (const invoice of invoiceRows) {
776
- invoice.lines = await invoiceLines.queryDocuments({ invoiceId: invoice.id }, options);
777
- }
778
- ```
748
+ the tables and pages it reads, not the rows.** [Reads shaped for the
749
+ screen](data-access.md#reads-shaped-for-the-screen) shows how.
779
750
 
780
751
  ### Bounded access checks
781
752
 
782
753
  **A row policy costs what the rows its query reads cost, never what the
783
754
  subject's stored history costs, and a write decides access once instead of
784
- paying a read policy for every row it writes.**
785
-
786
- - A `rowPolicy` adds one predicate to the query it is given: a join, `EXISTS`
787
- or subquery correlated with the row being read, on indexed columns. It never
788
- loads everything the subject was ever linked to (every source, notice or
789
- grant in their history) to build an `IN` list, and never calls another
790
- owner's access check once per item of that history. The
791
- [row policies heuristic](heuristics/row-policies.md)
792
- has the policy contract and its package composition.
793
- - The policy runs on every single-record read, and a CRUD write includes one:
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.
813
-
814
- ```js
815
- // good: one predicate on the row being read
816
- function noticeVisibilityPolicy({ query, context, column, value, db }) {
817
- const userId = resolveActorId(context);
818
- if (!userId) return false;
819
- query.where(column("recipientId"), value("recipientId", userId)).whereExists(
820
- db("sources").select(1)
821
- .whereColumn("sources.id", column("sourceId"))
822
- .where("sources.status", "published")
823
- );
824
- return true;
825
- }
826
-
827
- // bad: every source the recipient was ever notified about, checked one by one
828
- async function noticeVisibilityPolicy({ query, context, column, value }) {
829
- const sourceIds = await noticeHistory.listSourceIds(resolveActorId(context));
830
- const readable = [];
831
- for (const sourceId of sourceIds) {
832
- if (await sources.canRead(sourceId, context)) readable.push(sourceId);
833
- }
834
- query.whereIn(column("sourceId"), readable.map((id) => value("sourceId", id)));
835
- return true;
836
- }
837
- ```
755
+ paying a read policy for every row it writes.** [Layer
756
+ 1](data-access.md#layer-1-the-table-resource) has the policy's
757
+ shape and [Layer 3](data-access.md#layer-3-many-rows-at-once)
758
+ the batch writes.
838
759
 
839
760
  ### Routes
840
761
 
@@ -864,7 +785,7 @@ router.register("POST", "/api/invoices/:invoiceId/send", { auth: "required", met
864
785
  });
865
786
 
866
787
  // bad: the route reads storage itself
867
- router.register("GET", "/api/invoices", {}, async (request, reply) => reply.send(await knex("invoices")));
788
+ router.register("GET", "/api/invoices", {}, async (request, reply) => reply.send(await invoices.queryAll({}, { context: request })));
868
789
  ```
869
790
 
870
791
  ## Errors
@@ -974,7 +895,7 @@ change with its package.
974
895
 
975
896
  ```js
976
897
  // good
977
- const invoices = createInvoices({ knex, mailer: { send: async () => ({ accepted: true }) } });
898
+ const invoices = createInvoices({ invoices: resources.invoices, mailer: { send: async () => ({ accepted: true }) } });
978
899
 
979
900
  // bad: production code that exists for the double
980
901
  if (typeof mailer.send !== "function") return;
@@ -1025,72 +946,10 @@ export function resetInvoiceCacheForTests() {
1025
946
 
1026
947
  ### Query counts at realistic sizes
1027
948
 
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.
1056
-
1057
- ```js
1058
- // good
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")}`);
1086
- });
1087
-
1088
- // bad: three invoices, nothing counted, so a read per invoice passes
1089
- test("the invoice list has its lines", async () => {
1090
- await seedInvoices(knex, { workspace, count: 3, linesEach: 1 });
1091
- assert.equal((await invoices.listWithLines(workspace, { context })).length, 3);
1092
- });
1093
- ```
949
+ **A list, batch or write path's database test proves with
950
+ `expectFlatQueries()` that its statements follow the tables and pages it
951
+ reads, not the rows.** [Tests](data-access.md#tests) gives the
952
+ one-line test.
1094
953
 
1095
954
  ### Test files run in parallel
1096
955
 
@@ -1127,7 +986,11 @@ and Vitest's `maxWorkers` come from `testConcurrency()`.**
1127
986
  spec file's tests run in order in one worker. With `PLAYWRIGHT_BASE_URL`, the
1128
987
  tests use that managed target as its host serves it. Every run is bounded by
1129
988
  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
989
+ with its 10 slowest tests. Each action and navigation is bounded too
990
+ (`actionTimeout` 15 s, `navigationTimeout` 30 s): a stale locator should
991
+ fail in seconds, not minutes, so a test waiting on something that never
992
+ comes fails with that action's own error. An app sets a longer bound only
993
+ for a measured need, never 0. With a `signIn` module, each worker's server is
1131
994
  signed in once and every test of that worker starts signed in, instead of
1132
995
  each test signing in. `jskit check` fails a webServer that runs the development server
1133
996
  (`npm run develop`, `vite`), one webServer shared by every worker, and a
@@ -1304,7 +1167,8 @@ Why: a comment that narrates history is wrong the day after it is written.
1304
1167
  ### Running the checks
1305
1168
 
1306
1169
  `npm run lint` runs ESLint with `@jskit-ai/config-eslint`, and `npm run
1307
- verify` runs `jskit check` (the package graph, navigation and [test
1170
+ verify` runs `jskit check` (the package graph, navigation, [data
1171
+ access](data-access.md#exceptions) and [test
1308
1172
  runs](#test-run-rules)), lint, the tests and the build. The foundations'
1309
1173
  `eslint.config.mjs` already holds a new
1310
1174
  application's code to every check:
@@ -1345,7 +1209,9 @@ export default [
1345
1209
  - `formatterConfig(globs)` is the formatter.
1346
1210
  - `baseConfig` reports the `jskit/*` rules as warnings on any
1347
1211
  `**/src/server/**`, so an existing application sees them before it lists a
1348
- package; it lists each package as it converts it.
1212
+ package; it lists each package as it converts it. It makes the
1213
+ [data-access rules](data-access.md#hard-rules) errors in all
1214
+ code but the client's, tests, migrations and seeds.
1349
1215
  - `clientFeaturesConfig(globs)` holds client code to the same rules; see
1350
1216
  [client features](client-features.md#running-the-checks).
1351
1217
 
@@ -1424,6 +1290,7 @@ if (typeof buildSerializer !== "function") {
1424
1290
  | `filled by <repository>; convert with its supplier` | keeps a setter that another repository's code calls |
1425
1291
  | `cross-repository slot <token>` | keeps a probe for a token another repository supplies |
1426
1292
  | `shared resource:` | keeps client module state that every instance on the page shares on purpose ([client features](client-features.md#module-state)) |
1293
+ | `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)) |
1427
1294
 
1428
1295
  One kind of probe gets one prefix and one wording everywhere, and a bare
1429
1296
  `typeof x` guard of that kind carries the same comment even where no rule
@@ -1,40 +0,0 @@
1
- import { createWithTransaction } from "@jskit-ai/database-runtime/shared";
2
-
3
- /**
4
- * The invoice rollup's persistence: a reviewed custom Knex repository.
5
- * @param {{ knex: import("@jskit-ai/database-runtime/server/types").RuntimeKnex }} dependencies
6
- */
7
- function createInvoiceRollup({ knex }) {
8
- const withTransaction = createWithTransaction(knex);
9
-
10
- async function getStatus(input, options = {}) {
11
- return {
12
- ok: true,
13
- feature: "invoice-rollup",
14
- persistence: "custom-knex",
15
- tableName: "invoice_rollups",
16
- hasTransaction: Boolean(options.trx),
17
- input
18
- };
19
- }
20
-
21
- async function execute(input, options = {}) {
22
- return {
23
- accepted: false,
24
- feature: "invoice-rollup",
25
- persistence: "custom-knex",
26
- tableName: "invoice_rollups",
27
- hasTransaction: Boolean(options.trx),
28
- input,
29
- message: "Replace this example with the reviewed domain-specific query."
30
- };
31
- }
32
-
33
- return Object.freeze({
34
- withTransaction,
35
- getStatus,
36
- execute
37
- });
38
- }
39
-
40
- export { createInvoiceRollup };
@@ -1,66 +0,0 @@
1
- # packages/json-rest-api-core
2
-
3
- Generated by `npm run agent-docs:build`.
4
- Do not edit manually.
5
-
6
- Generated inventory for `packages/json-rest-api-core`.
7
- Use this on demand; do not load the full index at startup.
8
-
9
- ## Scope
10
- - Source: `packages/json-rest-api-core/**/*{.js,.mjs,.cjs,.vue}`
11
- - Excludes: `.build/`, `.git/`, `.jscpd/`, `.jskit/`, `coverage/`, `dist/`, `docs/`, `node_modules/`, `test/`, `tests/`, `__tests__/`, `.vitepress/cache/`, `.vitepress/dist/`, `skills/jskit/references/patterns/`, `*.test.*`, `*.spec.*`, `*.vitest.*`
12
-
13
- ## Sections
14
-
15
- ### src
16
-
17
- ### `src/server/jsonRestApiHost.js`
18
- Exports
19
- - `addResourceIfMissing(api, scopeName, resourceConfig)`
20
- - `buildJsonRestQueryParams(resourceType = "", query = {}, { include = undefined } = {})`
21
- - `createJsonRestResourceScopeOptions(resource = {}, { writeSerializers = {}, normalizeId, rowPolicy, searchSchema, queryFields } = {})`
22
- - `createJsonRestContext`
23
- - `extractJsonRestCollectionRows`
24
- - `queryAllJsonRestCollectionDocument`
25
- - `queryAllJsonRestCollectionRows`
26
- - `returnNullWhenJsonRestResourceMissing(run)`
27
- - `returnBadRequestWhenJsonRestFieldsetInvalid(run)`
28
- - `resolveWorkspaceScopeValue(context = null)`
29
- - `resolveUserScopeValue(context = null)`
30
- - `createJsonRestApiHost({ knex, logger })`
31
- Local functions
32
- - `cloneJsonRestResourceValue(value, { writeSerializers = {} } = {})`
33
- - `normalizeScopeValue(value)`
34
- - `normalizeJsonRestFilterValue(value)`
35
- - `normalizeJsonRestList(value)`
36
- - `resolveJsonRestCollectionRelationships(resource)`
37
- - `normalizeJsonRestQueryField(fieldName = "", fieldDefinition = {}, projectionDefinition = null)`
38
- - `isJsonRestVirtualField(fieldDefinition = null)`
39
- - `applyJsonRestQueryFields(scopeOptions = {}, extraQueryFields = {})`
40
- - `resolveJsonRestDefaultExcludedFields(resource)`
41
- - `applyJsonRestDefaultExclusions(scopeOptions = {}, resource = {})`
42
- - `workspaceScopeOwnerId(visibilityContext)`
43
- - `isJsonRestResourceMissingError(error = null)`
44
- - `isJsonRestSparseFieldError(error = null)`
45
-
46
- ### `src/server/JsonRestApiProvider.js`
47
- Exports
48
- - `JsonRestApiProvider`
49
-
50
- ### `src/server/jsonRestCollectionReads.js`
51
- Exports
52
- - `createJsonRestContext(context = null)`
53
- - `extractJsonRestCollectionRows(payload)`
54
- - `queryAllJsonRestCollectionDocument(resource, params, context)`
55
- - `queryAllJsonRestCollectionRows(resource, params, context)`
56
- Local functions
57
- - `readEveryJsonRestCollectionPage(resource, { queryParams, ...params }, context, { caller, onPage })`
58
-
59
- ### `src/server/jsonRestValues.js`
60
- Exports
61
- - `isPlainJsonRestObject(value)`
62
-
63
- ### `src/server/types.js`
64
- Exports
65
- - `type RuntimeJsonRestApi`
66
- - `type JsonRestResource`