@astrale-os/sdk 0.6.0-beta.41 → 0.6.0-beta.43

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 (177) hide show
  1. package/README.md +22 -2
  2. package/dist/application/action/context.d.ts +49 -18
  3. package/dist/application/action/index.d.ts +1 -1
  4. package/dist/application/error/error.d.ts +16 -1
  5. package/dist/application/error/error.js +39 -5
  6. package/dist/application/error/index.d.ts +1 -1
  7. package/dist/application/error/index.js +1 -1
  8. package/dist/application/mutation/authoring/builder.d.ts +81 -33
  9. package/dist/application/mutation/authoring/builder.js +100 -30
  10. package/dist/application/mutation/authoring/guard.d.ts +37 -0
  11. package/dist/application/mutation/authoring/guard.js +150 -0
  12. package/dist/application/mutation/authoring/index.d.ts +5 -2
  13. package/dist/application/mutation/authoring/index.js +2 -1
  14. package/dist/application/mutation/authoring/names.d.ts +19 -0
  15. package/dist/application/mutation/authoring/names.js +83 -0
  16. package/dist/application/mutation/authoring/transition.d.ts +3 -2
  17. package/dist/application/mutation/define.d.ts +24 -10
  18. package/dist/application/mutation/define.js +49 -53
  19. package/dist/application/mutation/execution/execute.d.ts +2 -2
  20. package/dist/application/mutation/execution/execute.js +78 -7
  21. package/dist/application/mutation/execution/executor.d.ts +4 -1
  22. package/dist/application/mutation/execution/executor.js +6 -1
  23. package/dist/application/mutation/failure.d.ts +11 -1
  24. package/dist/application/mutation/failure.js +18 -3
  25. package/dist/application/mutation/index.d.ts +8 -4
  26. package/dist/application/mutation/index.js +5 -2
  27. package/dist/application/mutation/legacy/v6/builder.d.ts +84 -0
  28. package/dist/application/mutation/legacy/v6/define.d.ts +18 -0
  29. package/dist/application/mutation/legacy/v6/define.js +56 -0
  30. package/dist/application/mutation/legacy/v6/mutation.d.ts +28 -0
  31. package/dist/application/mutation/legacy/v6/mutation.js +1 -0
  32. package/dist/application/mutation/{authoring → legacy/v6}/precondition.d.ts +4 -0
  33. package/dist/application/mutation/mutation.d.ts +30 -19
  34. package/dist/application/query/answer/answer.d.ts +117 -0
  35. package/dist/application/query/answer/answer.js +1 -0
  36. package/dist/application/query/answer/conformance.d.ts +4 -0
  37. package/dist/application/query/answer/conformance.js +27 -0
  38. package/dist/application/query/answer/facts.d.ts +17 -0
  39. package/dist/application/query/answer/facts.js +57 -0
  40. package/dist/application/query/composite/builder.d.ts +1 -1
  41. package/dist/application/query/composite/builder.js +1 -1
  42. package/dist/application/query/composite/plan.d.ts +1 -1
  43. package/dist/application/query/composite/plan.js +1 -1
  44. package/dist/application/query/composite/query.d.ts +1 -2
  45. package/dist/application/query/define.d.ts +2 -33
  46. package/dist/application/query/define.js +3 -130
  47. package/dist/application/query/definition.d.ts +12 -0
  48. package/dist/application/query/definition.js +47 -0
  49. package/dist/application/query/execution/bounds.d.ts +22 -0
  50. package/dist/application/query/execution/bounds.js +88 -0
  51. package/dist/application/query/execution/crossings.d.ts +25 -0
  52. package/dist/application/query/execution/crossings.js +192 -0
  53. package/dist/application/query/execution/error.d.ts +68 -0
  54. package/dist/application/query/execution/error.js +50 -0
  55. package/dist/application/query/execution/execute.d.ts +2 -7
  56. package/dist/application/query/execution/execute.js +3 -51
  57. package/dist/application/query/execution/recipe.d.ts +85 -0
  58. package/dist/application/query/execution/recipe.js +324 -0
  59. package/dist/application/query/execution/refusal.d.ts +37 -0
  60. package/dist/application/query/execution/refusal.js +80 -0
  61. package/dist/application/query/execution/rows.d.ts +42 -0
  62. package/dist/application/query/execution/rows.js +308 -0
  63. package/dist/application/query/execution/split.d.ts +24 -0
  64. package/dist/application/query/execution/split.js +190 -0
  65. package/dist/application/query/handles/build.d.ts +10 -0
  66. package/dist/application/query/handles/build.js +53 -0
  67. package/dist/application/query/handles/conditions.d.ts +94 -0
  68. package/dist/application/query/handles/conditions.js +1 -0
  69. package/dist/application/query/handles/domain.d.ts +157 -0
  70. package/dist/application/query/handles/domain.js +1 -0
  71. package/dist/application/query/handles/handles.d.ts +268 -0
  72. package/dist/application/query/handles/handles.js +1 -0
  73. package/dist/application/query/handles/model.d.ts +96 -0
  74. package/dist/application/query/handles/model.js +246 -0
  75. package/dist/application/query/handles/plan.d.ts +99 -0
  76. package/dist/application/query/handles/plan.js +37 -0
  77. package/dist/application/query/handles/reads.d.ts +106 -0
  78. package/dist/application/query/handles/reads.js +839 -0
  79. package/dist/application/query/handles/relations.d.ts +100 -0
  80. package/dist/application/query/handles/relations.js +1 -0
  81. package/dist/application/query/handles/rows.d.ts +94 -0
  82. package/dist/application/query/handles/rows.js +4 -0
  83. package/dist/application/query/handles/terms.d.ts +103 -0
  84. package/dist/application/query/handles/terms.js +282 -0
  85. package/dist/application/query/index.d.ts +10 -7
  86. package/dist/application/query/index.js +9 -4
  87. package/dist/application/query/language/index.d.ts +2 -0
  88. package/dist/application/query/language/index.js +2 -0
  89. package/dist/application/query/language/query.d.ts +323 -0
  90. package/dist/application/query/language/query.js +2 -0
  91. package/dist/application/query/{collection → legacy/v6/collection}/identity.d.ts +2 -2
  92. package/dist/application/query/{collection → legacy/v6/collection}/metadata.d.ts +1 -1
  93. package/dist/application/query/{collection → legacy/v6/collection}/union.d.ts +3 -3
  94. package/dist/application/query/{collection → legacy/v6/collection}/union.js +1 -1
  95. package/dist/application/query/{conformance.d.ts → legacy/v6/conformance.d.ts} +3 -3
  96. package/dist/application/query/{conformance.js → legacy/v6/conformance.js} +1 -1
  97. package/dist/application/query/legacy/v6/define.d.ts +37 -0
  98. package/dist/application/query/legacy/v6/define.js +90 -0
  99. package/dist/application/query/legacy/v6/execution/execute.d.ts +16 -0
  100. package/dist/application/query/legacy/v6/execution/execute.js +50 -0
  101. package/dist/application/query/legacy/v6/index.d.ts +6 -0
  102. package/dist/application/query/legacy/v6/index.js +3 -0
  103. package/dist/application/query/legacy/v6/kernel.d.ts +6 -0
  104. package/dist/application/query/legacy/v6/kernel.js +5 -0
  105. package/dist/application/query/{single → legacy/v6/single}/pagination.js +1 -1
  106. package/dist/application/query/{single → legacy/v6/single}/query.d.ts +2 -14
  107. package/dist/application/query/legacy/v6/single/query.js +1 -0
  108. package/dist/application/query/query.d.ts +16 -1
  109. package/dist/application/query/recipe/define.d.ts +44 -0
  110. package/dist/application/query/recipe/define.js +95 -0
  111. package/dist/application/query/recipe/recipe.d.ts +134 -0
  112. package/dist/application/query/recipe/recipe.js +1 -0
  113. package/dist/application/workflow/durable.d.ts +22 -8
  114. package/dist/deployment/build/build.d.ts +8 -1
  115. package/dist/deployment/build/material.js +3 -0
  116. package/dist/execution/invocation/capabilities/bind.d.ts +6 -4
  117. package/dist/execution/invocation/capabilities/bind.js +39 -3
  118. package/dist/execution/invocation/dispatch/action.js +4 -2
  119. package/dist/execution/invocation/dispatch/context.js +1 -1
  120. package/dist/execution/invocation/dispatch/workflow.js +4 -2
  121. package/dist/execution/invocation/result/index.d.ts +1 -0
  122. package/dist/execution/invocation/result/index.js +1 -0
  123. package/dist/execution/invocation/result/recipe.d.ts +6 -0
  124. package/dist/execution/invocation/result/recipe.js +17 -0
  125. package/dist/execution/service/create.js +1 -0
  126. package/dist/execution/workflows/failure.d.ts +23 -6
  127. package/dist/execution/workflows/failure.js +78 -18
  128. package/dist/execution/workflows/receiver.d.ts +3 -0
  129. package/dist/execution/workflows/receiver.js +1 -0
  130. package/dist/execution/workflows/run.js +13 -9
  131. package/dist/execution/workflows/steps.d.ts +3 -0
  132. package/dist/execution/workflows/steps.js +2 -2
  133. package/dist/platform/schema/kernel-compiled.d.ts +2 -0
  134. package/dist/platform/schema/kernel-compiled.js +2 -0
  135. package/dist/testing/index.d.ts +2 -0
  136. package/dist/testing/index.js +1 -0
  137. package/dist/testing/recipe.d.ts +17 -0
  138. package/dist/testing/recipe.js +34 -0
  139. package/dist/tooling/cli/orchestrate.js +6 -2
  140. package/dist/tooling/linter/adapters/typescript/index.d.ts +2 -1
  141. package/dist/tooling/linter/adapters/typescript/index.js +2 -1
  142. package/dist/tooling/linter/adapters/typescript/query-observation.js +37 -12
  143. package/dist/tooling/linter/adapters/typescript/recipe-definitions.d.ts +34 -0
  144. package/dist/tooling/linter/adapters/typescript/recipe-definitions.js +88 -0
  145. package/dist/tooling/linter/adapters/typescript/type-analysis.d.ts +2 -0
  146. package/dist/tooling/linter/adapters/typescript/type-analysis.js +10 -0
  147. package/dist/tooling/linter/implementations/source/global.js +15 -9
  148. package/dist/tooling/linter/implementations/source/mutations.js +128 -19
  149. package/dist/tooling/linter/implementations/source/policy-edges.d.ts +2 -0
  150. package/dist/tooling/linter/implementations/source/policy-edges.js +113 -0
  151. package/dist/tooling/linter/implementations/source/recipes.d.ts +2 -0
  152. package/dist/tooling/linter/implementations/source/recipes.js +425 -0
  153. package/dist/tooling/linter/implementations/source/shared.d.ts +7 -1
  154. package/dist/tooling/linter/implementations/source/shared.js +31 -10
  155. package/dist/tooling/linter/implementations/source/verifiers.js +4 -0
  156. package/dist/tooling/linter/policy/generated.js +2 -2
  157. package/dist/tooling/linter/requirements/registry.js +4 -0
  158. package/docs/durable-workflows.md +48 -20
  159. package/docs/handler-dependencies.md +76 -4
  160. package/docs/legacy/query-v6.md +62 -0
  161. package/docs/queries.md +442 -0
  162. package/package.json +11 -8
  163. /package/dist/application/{query/single/query.js → mutation/legacy/v6/builder.js} +0 -0
  164. /package/dist/application/mutation/{authoring → legacy/v6}/precondition.js +0 -0
  165. /package/dist/application/query/{collection → legacy/v6/collection}/identity.js +0 -0
  166. /package/dist/application/query/{collection → legacy/v6/collection}/limits.d.ts +0 -0
  167. /package/dist/application/query/{collection → legacy/v6/collection}/limits.js +0 -0
  168. /package/dist/application/query/{collection → legacy/v6/collection}/metadata.js +0 -0
  169. /package/dist/application/query/{execution → legacy/v6/execution}/pages.d.ts +0 -0
  170. /package/dist/application/query/{execution → legacy/v6/execution}/pages.js +0 -0
  171. /package/dist/application/query/{single → legacy/v6/single}/index.d.ts +0 -0
  172. /package/dist/application/query/{single → legacy/v6/single}/index.js +0 -0
  173. /package/dist/application/query/{single → legacy/v6/single}/node.d.ts +0 -0
  174. /package/dist/application/query/{single → legacy/v6/single}/node.js +0 -0
  175. /package/dist/application/query/{single → legacy/v6/single}/pagination.d.ts +0 -0
  176. /package/dist/application/query/{single → legacy/v6/single}/selection.d.ts +0 -0
  177. /package/dist/application/query/{single → legacy/v6/single}/selection.js +0 -0
@@ -0,0 +1,442 @@
1
+ # Queries
2
+
3
+ A Query recipe is a typed business observation over the graph. Its guarantees:
4
+
5
+ - **Typed by the Domain.** Handles, conditions, rows and answers are typed from the Domain's
6
+ Schema. A Class, Property or relation the Domain cannot name does not compile.
7
+ - **Bounded before it reads.** Every verb carries its bound. Before anything is sent, the SDK
8
+ refuses a recipe past its bounds, and a relation that crosses a dependency Class the Domain does
9
+ not declare.
10
+ - **One snapshot per read.** Each read is one Kernel invocation at its own snapshot. Facets,
11
+ steps and pages of one recipe share none. A read whose root lists more than 32,768 ids in
12
+ `id.in` is the exception: the SDK sends it in consecutive parts, each at its own snapshot, and
13
+ merges their rows in the Kernel's order.
14
+ - **Authority-neutral.** A recipe names no authority; each call names the authority it reads
15
+ under.
16
+ - **One error per refusal.** A read the SDK or the Kernel refuses raises one `QueryError` by code.
17
+ A network failure, and a refusal that says when to retry, pass as they came. Business refusals
18
+ are the recipe's declared errors.
19
+
20
+ A recipe reads on a Host whose Kernel serves Query v7 and the Class of each row. A Kernel that
21
+ refuses Query v7 by name raises `QueryError` `UNSUPPORTED` with `feature: 'v7'`; a Runtime that
22
+ does not name that refusal answers `REFUSED`, with `kernelCode: 'INTERNAL_ERROR'`. An answer that
23
+ leaves out a row's Class raises `UNSUPPORTED` with `feature: 'classes'`. Query v7 is not served
24
+ in maintenance or repair sessions: the Kernel refuses it with `INSTANCE_MAINTENANCE`, which passes
25
+ as it came, and a migration that runs there reads with [Query V6](legacy/query-v6.md).
26
+
27
+ ```ts
28
+ import type { NodeId } from '@astrale-os/sdk/graph/node'
29
+ import { defineQuery } from '@astrale-os/sdk/query'
30
+ import { z } from 'zod'
31
+
32
+ import type { schema } from '#schema'
33
+
34
+ export const articleWithComments = defineQuery<typeof schema>()('publishing.article.with-comments', {
35
+ build: (article: NodeId, d) =>
36
+ d.Article.with((a) => ({
37
+ author: a.author.find(),
38
+ pinned: a
39
+ .via(d.comment_on, { direction: 'incoming' })
40
+ .where((_comment, edge) => edge.pinned.eq(true))
41
+ .orderBy((comment) => comment.postedAt.desc())
42
+ .first(3),
43
+ comments: a.via(d.comment_on, { direction: 'incoming' }).count({ upTo: 1_000 }),
44
+ })).find(article),
45
+ output: (row, article, { error }) => {
46
+ if (row === undefined) throw error('ARTICLE_NOT_FOUND', { article })
47
+ return row
48
+ },
49
+ errors: {
50
+ ARTICLE_NOT_FOUND: {
51
+ family: 'NOT_FOUND',
52
+ message: 'The article does not exist.',
53
+ details: z.strictObject({ article: z.string() }),
54
+ },
55
+ },
56
+ })
57
+
58
+ // In an Action that declares `errors: { ...articleWithComments.errors }`
59
+ const article = await query(articleWithComments, input.article, { as: 'caller' })
60
+ ```
61
+
62
+ ## Handles
63
+
64
+ `build(input, d)` receives the handles of the Domain:
65
+
66
+ | Handle | What it reads |
67
+ | --- | --- |
68
+ | `d.Article` | A Node Class of the Domain. An Edge Class handle (`d.comment_on`) reads Edges at the root. |
69
+ | `d.directory.Person` | A Class of the dependency the Schema aliases `directory`. |
70
+ | `d.kernel.<Class>` | A Kernel Class. |
71
+ | `d.core.<node>` | A Core node of the Domain, as a target: `get(d.core.settings)`, `rel.is(d.core.settings)`. |
72
+ | `d.origin` | The Domain's origin. |
73
+ | `d.document(query)` | One canonical v7 document; see [Canonical documents](#canonical-documents). |
74
+
75
+ A Class or alias named `kernel`, `core`, `origin` or `document` has no handle.
76
+
77
+ ## Reads
78
+
79
+ A read starts from a Class handle, is narrowed with `where`, ordered with `orderBy`, carries
80
+ relations with `with`, and ends with the verb that says how much it reads:
81
+
82
+ | Verb | Answers | Refusals |
83
+ | --- | --- | --- |
84
+ | `get(id?)` | Exactly one row | None: `NOT_FOUND`. A second: `INTEGRITY`. One the reader may not read: `NOT_READABLE`. |
85
+ | `find(id?)` | One row, or `undefined` | A second: `INTEGRITY`. One the reader may not read: `NOT_READABLE`. |
86
+ | `first()` | The first row, or `undefined` | |
87
+ | `first(n)` | Up to `n` rows | |
88
+ | `first(n, { more: true })` | `{ rows, more }`, reading `n + 1` rows | |
89
+ | `page({ max })` | `{ rows, next? }`; only as a recipe's single read | |
90
+ | `all({ max })` | Every row | Past `max`: `TOO_MANY`. |
91
+ | `exists(id?)` | `boolean` | |
92
+ | `count({ upTo })` | `{ count, more }`; `more` says the count reached `upTo` | |
93
+
94
+ `get` asserts that the row exists, so it reads the ids the Domain itself holds; for an id a caller
95
+ sends, use `find` and raise a declared error from `output` (see [Errors](#errors)).
96
+
97
+ `id` is a `NodeId`, a `NodePath` or a Core node. `.pick()` renders rows as references,
98
+ `{ id, $class }`. `.includeWithheld()` keeps the rows the reader may not read in a list, as
99
+ `{ id, $class, $withheld }` naming the values withheld. `get` and `find` raise `NOT_READABLE` on
100
+ such a row, since it exists. `d.A.union(d.B)` reads the Nodes of both Classes.
101
+
102
+ ### Conditions
103
+
104
+ `where` takes one condition or a list, an implicit `and`. A falsy entry of the list is skipped, so
105
+ an optional filter is written in place:
106
+
107
+ ```ts
108
+ d.Article.where((a) => [
109
+ a.state.eq('published'),
110
+ filter.author && a.author.is(filter.author),
111
+ filter.since && a.publishedAt.gte(filter.since),
112
+ filter.featured !== undefined && a.featured.eq(filter.featured),
113
+ ])
114
+ ```
115
+
116
+ A filter whose value may be `false` or `0` is written `filter.x !== undefined && …` when it
117
+ applies whenever the caller sends it: with `filter.x && …`, those values would skip the entry. When
118
+ `false` means no filter, say so: `filter.x ? a.x.eq(true) : undefined`.
119
+
120
+ | Term | Meaning |
121
+ | --- | --- |
122
+ | `p.eq(v)` | Equal. `eq(null)` matches a stored null. |
123
+ | `p.in(values)` | One of at most 16 values. |
124
+ | `node.id.eq(id)`, `node.id.in(ids)` | The Node is this one, or one of these. `id.in` takes any length at a read's root; elsewhere a read carries at most 32,768 ids and values. |
125
+ | `node.path.eq(path)`, `node.path.in(paths)` | The Node is at this path, or at one of these. |
126
+ | `p.isSet()`, `p.isMissing()` | The value is present, or absent. |
127
+ | `p.gt`, `p.gte`, `p.lt`, `p.lte` | Ranges, on text, numbers and open values. |
128
+ | `p.between(lower, upper)` | `lower <= value < upper`. |
129
+ | `p.startsWith(prefix)` | A text prefix. |
130
+ | `node.is(Class)`, `node.isExactly(Class)` | The Node is of the Class, or a subclass for `is`. It narrows the handle and the rows. |
131
+ | `node.is(Class, (n) => conditions)` | The Node is of the Class and meets conditions written on the narrowed handle. |
132
+ | `c.and(…)`, `c.or(…)`, `c.not()` | Combinators on any condition. |
133
+
134
+ A relation is a condition too:
135
+
136
+ | Term | Meaning |
137
+ | --- | --- |
138
+ | `rel.is(target)` | The relation reaches this Node. |
139
+ | `rel.exists()` | It reaches any Node. |
140
+ | `rel.some(cond?)`, `rel.none(cond?)`, `rel.every(cond)` | Over the Nodes it reaches. |
141
+ | `rel.atLeast(n, cond?)`, `rel.atMost(n, cond?)`, `rel.exactly(n, cond?)` | How many it reaches. |
142
+
143
+ The callback of a relation condition receives `(node, edge)`: conditions on the far Node and on
144
+ the Edge's Properties.
145
+
146
+ ### Order
147
+
148
+ `orderBy` takes one key or a list: `orderBy((a) => [a.publishedAt.desc(), a.title.asc()])`. Rows
149
+ without the value sort last; `asc({ missing: 'first' })` sorts them first. On a union whose
150
+ Classes map one name to different Properties, the name filters but does not order.
151
+
152
+ ### Relations
153
+
154
+ A to-one end is named by its far role: `a.author` reads the author of an Article. Any Edge is read
155
+ with `.via(edge, { direction })`, where `direction` is `outgoing`, `incoming` or `incident`.
156
+ `.via([e1, e2])` reads several Edge Classes. `direction` is required when both ends accept the
157
+ Class, or when the Edges listed leave it in different directions.
158
+
159
+ A relation reads with the root verbs, per parent row: `get`, `find`, `first`, `all`, `count` and
160
+ `exists`. A relation's `get` that finds none, or a second row, raises `INTEGRITY` naming the
161
+ relation. A relation also takes `where`, `orderBy`, `with`, `pick`, `includeWithheld`, `distinct`
162
+ and `withEdge()`, which adds the Edge's Class and Properties to each row under `$edge`.
163
+
164
+ `.with((a) => ({ name: read }))` adds each relation's answer to the row under the name given. A
165
+ name a row already carries does not compile, and neither does a `.with` that reads another root:
166
+ read it as a facet of the recipe.
167
+
168
+ ### Rows
169
+
170
+ A Node row is portable JSON: `id`, `$class`, and every Property by name. An optional Property may
171
+ be absent; `null` is a stored null. `$class` is typed as the Class or any subclass the Domain can
172
+ name. A Property whose name the SDK takes goes under `$prop` by its name: `id` on rows, a handle
173
+ member (`id`, `path`, `is`, `isExactly`, `via`) on handles, and an Edge's roles on an Edge read at
174
+ the root. A Property named `path` is `row.path` on a row and `a.$prop.path` on a handle. These
175
+ names are frozen, and no Property name starts with `$`, so a Property never moves. Two Properties of one name that two parent Classes declare, when the Class
176
+ declares none of its own, each go under `$` and their owner's name in lower camel case:
177
+ `row.$record.title`. The SDK's row keys (`$class`, `$edge`, `$source`, `$target`, `$withheld` and
178
+ `$prop`) are frozen too, and an owner group that would take one gets one more `$`: an owner named
179
+ `Class` groups under `$$class`.
180
+
181
+ An Edge read at the root answers rows of `{ $class, $source, $target }`, the Edge's Properties,
182
+ and both ends' rows under their roles. `edgeKey(row)` gives the `{ source, target, class }` a
183
+ Mutation or a guard names an Edge by. An Edge root's `get()`, `find()` and `exists()` take no id,
184
+ and its `first(n)` takes no `{ more }`.
185
+
186
+ ## Recipes
187
+
188
+ `defineQuery<Schema>()(id, { build, output?, errors? })` defines a recipe. Its id is unique in the
189
+ Domain: dot-separated segments of lowercase letters, digits and `-`.
190
+
191
+ `build` returns one read, or a record of facets read together:
192
+
193
+ ```ts
194
+ build: (account: NodeId, d) => ({
195
+ account: d.Account.get(account),
196
+ invoices: d.Invoice.where((i) => i.account.is(account)).all({ max: 500 }),
197
+ })
198
+ ```
199
+
200
+ A facet may be a read, a list of reads (`items.map((item) => d.Item.get(item))`), or `undefined`
201
+ for a read the input makes optional.
202
+
203
+ Reads that wait for others are chained with `steps`, `build`'s third parameter.
204
+ `steps(first, (earlier) => next, …)` takes two to eight steps and reads them in order. The first
205
+ is a record of facets; each next one is a function of the merged results of the steps before it,
206
+ and returns a record of facets none of them read. A facet name read again does not compile, and
207
+ throws a `TypeError` before the step reads. A recipe whose reads wait for none returns them
208
+ without `steps`. These steps order the reads of one call: they are not a Workflow's durable
209
+ Steps, and nothing of them is journaled or replayed.
210
+
211
+ ```ts
212
+ build: (id: NodeId, { Article, Comment }, steps) =>
213
+ steps({ article: Article.find(id) }, ({ article }) => ({
214
+ comments:
215
+ article &&
216
+ Comment.where((c) => [
217
+ c.article.is(id),
218
+ article.publishedAt && c.postedAt.gte(article.publishedAt),
219
+ ]).first(20),
220
+ })),
221
+ ```
222
+
223
+ `output(results, input, { error, is })` maps the results to the recipe's answer. It is
224
+ synchronous and pure, and returns portable JSON. Without it, the recipe answers what `build` read.
225
+ `is(row, classKey)` narrows a row to a Class or its subclasses. An expected case is a union in the
226
+ output, not an error.
227
+
228
+ ## Calling a recipe
229
+
230
+ An Action calls `query(recipe, input, { as })`, where `as` is `executor` (the authority the handler
231
+ executes with) or `caller` (the incoming caller). An `auth: 'anonymous'` callable reads as
232
+ `executor` only. A recipe with no input is called with `undefined`.
233
+
234
+ A page recipe, whose single read is `.page({ max })`, takes the page size and the cursor at the
235
+ call and answers `{ rows, next? }`; its `output` keeps `rows` and `next`. `size` is a whole number
236
+ from 1 to `max`, `max` when omitted; any other value throws a `TypeError`:
237
+
238
+ ```ts
239
+ const first = await query(articleTitles, undefined, { as: 'caller', size: 50 })
240
+ const second = first.next
241
+ ? await query(articleTitles, undefined, { as: 'caller', size: 50, after: first.next })
242
+ : undefined
243
+ ```
244
+
245
+ A durable Workflow reads in a Step, as its executor:
246
+ `step.query('load-article', articleById, input.article, { as: 'executor' })`. The output is
247
+ journaled, and a replay returns it. A `QueryError` other than `REFUSED`, or the `TypeError` of an
248
+ undeclared crossing (S12), fails the Step at once, and a replay throws it again. A `REFUSED` read
249
+ is a failed attempt like any Kernel refusal: a permanent one exhausts the Step at once, and a
250
+ stale snapshot spends no attempt. An inline Workflow calls `query(recipe, input, { as })` from its
251
+ context, as an Action does, inside a named `step.run`.
252
+
253
+ ## Errors
254
+
255
+ A read that cannot be answered raises `QueryError`, with the recipe's id, a code and the details
256
+ of that code:
257
+
258
+ | Code | When | Details |
259
+ | --- | --- | --- |
260
+ | `NOT_FOUND` | A root `get` found nothing. | `class`; `target` when the read named one |
261
+ | `NOT_READABLE` | `get` or `find` met a fact the reader may not read. | `class`; `id`, a Node's id or an Edge's key |
262
+ | `INTEGRITY` | A relation's `get` found none, or a second row came where one is allowed. | `relation`; `parent` for a relation; `found: 0 \| 2` |
263
+ | `TOO_MANY` | Past a verb's `max`, a verb bound of 83,332, the recipe's 100,000 rows or its 32 reads. | `limit: 'max' \| 'rows' \| 'reads'`; `bound` |
264
+ | `REFUSED` | The Kernel refused the read. | `kernelCode`; `retryable`, whether a retry may succeed |
265
+ | `INPUT_INVALID` | The SDK or the Kernel refused the query the recipe built, before reading anything. | `kernelCode`, `phase` and `path` when the Kernel names them; `limit: 'in'` for an `in` past 16 values |
266
+ | `OUTDATED` | The Host's Registry does not match the Domain's build. | `kernelCode: 'QUERY_EXPECTATION_MISMATCH'` |
267
+ | `UNSUPPORTED` | The Kernel does not serve Query v7, or its answer leaves out a row's Class. | `feature: 'v7' \| 'classes'` |
268
+
269
+ `isQueryError(error, code?)` recognizes one.
270
+
271
+ A refusal the caller handles by code is declared in `errors`, as on Actions, and raised by
272
+ `output`:
273
+
274
+ ```ts
275
+ const articleNotFound = {
276
+ family: 'NOT_FOUND',
277
+ message: 'The article does not exist.',
278
+ details: z.strictObject({ article: z.string() }),
279
+ } as const
280
+
281
+ export const articleAuthor = defineQuery<typeof schema>()('publishing.article.author', {
282
+ build: (article: NodeId, d) => d.Article.with((a) => ({ author: a.author.find() })).find(article),
283
+ output: (row, article, { error }) => {
284
+ if (row === undefined) throw error('ARTICLE_NOT_FOUND', { article })
285
+ return row.author === undefined
286
+ ? { kind: 'unassigned' as const }
287
+ : { kind: 'assigned' as const, author: row.author.id }
288
+ },
289
+ errors: { ARTICLE_NOT_FOUND: articleNotFound },
290
+ })
291
+ ```
292
+
293
+ A Function lets a recipe's error reach its own caller by declaring it again:
294
+ `{ errors: { ...articleAuthor.errors } }`.
295
+
296
+ ## Bounds and checks
297
+
298
+ Before a step sends anything, the SDK checks:
299
+
300
+ - **Bounds (S11).** A recipe makes at most 32 reads and reads at most 100,000 rows, counted from
301
+ its verbs' bounds and its relations per parent row. A verb's bound is at most 83,332. Past one,
302
+ the read raises `TOO_MANY`. An `in` takes at most 16 values; past them, it raises
303
+ `INPUT_INVALID`. Fewer than 2 or more than 8 steps do not compile, and `steps` throws a
304
+ `TypeError` when `build` calls it.
305
+ - **Requirements (S12).** A relation that crosses a Class of a direct dependency needs `read` and
306
+ `traverse` on that Class in the Domain's `requirements({ classes })`. A requirement on a parent
307
+ covers its children. Otherwise the call throws a `TypeError` naming the recipe, the relation and
308
+ the requirement to add. Roots, Kernel Classes and the Domain's own Classes need nothing. A
309
+ relation an empty list folds away is checked as if the list were not empty. A condition the
310
+ input leaves unbuilt, such as a falsy `where` entry or an `undefined` facet, is not checked.
311
+
312
+ `checkRecipe(recipe, sampleInput, { domain })` from `@astrale-os/sdk/testing` runs both checks in
313
+ a test, without a Kernel, on the first step it builds for its sample input. Give it an input that
314
+ builds every optional condition, and test later steps through a call. Given the Domain's
315
+ definition it checks S12; given the Schema alone it skips S12.
316
+
317
+ The Domain linter also reads recipes:
318
+
319
+ - `QRY-RECIPE-CLOCK` refuses a clock read in `build` or `output`. Take now in the input.
320
+ - `QRY-RECIPE-ROWS` warns when a recipe's constant bounds multiply past 10,000 rows.
321
+ - `QRY-RECIPE-FALSY` warns on a condition entry `x && p.eq(x)` or `x ? p.eq(x) : undefined`
322
+ whose `x` may be `false` or `0`, wherever falsy entries are skipped. Write
323
+ `x !== undefined && p.eq(x)` to compare whenever `x` is set, or say that `false` or `0` means no
324
+ filter: `x ? p.eq(true) : undefined` for a flag, `x !== undefined && x !== 0 && p.gte(x)` for a
325
+ number.
326
+
327
+ ## Beyond v7
328
+
329
+ What v7 cannot express is bridged inside the recipe, with a step or in `output`. A bridge's bound
330
+ and steps count toward the recipe's bounds, and its callers never see it. When the Kernel serves
331
+ the construct, the bridge moves into the read and the output type does not change. Beyond a long
332
+ root `id.in`, the SDK never splits a read behind its author.
333
+
334
+ | The business asks | Bridge inside the recipe |
335
+ | --- | --- |
336
+ | A value compared with a parent's | A first step reads the parent and the next compares with its value; or read both and filter in `output`. |
337
+ | Two parts of a pattern meeting at one Node | Read both with `.with`, compare in `output`. |
338
+ | Ancestors, a sub-tree at any depth | One step per level reads the next with `id.in`, up to 8 steps. |
339
+ | Sums, averages, groups | Read the values with `all({ max })`, compute in `output`. |
340
+ | Case-insensitive search, locale sorting | A normalized Property the Domain writes, or filter and sort in `output`. |
341
+ | Several unrelated lists | Facets of one recipe, each read at its own snapshot. |
342
+
343
+ A filter in `output` runs after the Kernel applied the verb's bound.
344
+
345
+ ## Canonical documents
346
+
347
+ `d.document(query)` reads one canonical v7 document whose lists carry a `limit`. Its rows are typed
348
+ `unknown`, so `output` validates them:
349
+
350
+ ```ts
351
+ import { z } from 'zod'
352
+
353
+ export const recentArticles = defineQuery<typeof schema>()('publishing.article.recent', {
354
+ build: (_: undefined, d) =>
355
+ d.document({
356
+ query: 'astrale.query/7',
357
+ list: { nodes: ['publishing.example:class.Article'], select: 'value', limit: 20 },
358
+ }),
359
+ output: (rows) => z.array(z.looseObject({ id: z.string() })).parse(rows),
360
+ })
361
+ ```
362
+
363
+ ## Mutation recipes
364
+
365
+ A Mutation takes the same form, over the same handles:
366
+ `defineMutation<Schema>()(id, { build, output?, reject?, errors? })`. `build(input, d, mutation)`
367
+ is synchronous; each write names its Class by its handle, and a write names one Class:
368
+
369
+ ```ts
370
+ // The Schema declares the key: no two Workspaces share a slug.
371
+ const Workspace = nodeClass({ properties: { slug: property(z.string()) }, unique: [['slug']], … })
372
+
373
+ export const createWorkspace = defineMutation<typeof schema>()('workspace.create', {
374
+ build: (input: { readonly slug: string }, { Workspace }, mutation) => {
375
+ mutation.createNode({ as: 'workspace', class: Workspace, props: { slug: input.slug } })
376
+ },
377
+ reject: (failure, input, { error }) => {
378
+ if (failure.code !== 'MUTATION_POST_STATE_INVALID' || failure.rule !== 'duplicate-key') {
379
+ throw failure
380
+ }
381
+ throw error('SLUG_TAKEN', { slug: input.slug })
382
+ },
383
+ errors: { SLUG_TAKEN: slugTaken },
384
+ })
385
+
386
+ await mutate(createWorkspace, { slug }, { as: 'caller' })
387
+ ```
388
+
389
+ `output(result, input)` maps the committed `MutationResult`, which the recipe answers without it.
390
+ `reject(failure, input, { error, is })` maps an expected refusal to the same output, or raises a
391
+ declared error: here the Kernel refuses a second Workspace with the slug, `duplicate-key`, on every
392
+ write that could make one, and the recipe names that refusal. A Mutation recipe shares the Query
393
+ recipes' ids, and every call names its authority: `mutate(recipe, input, { as })`,
394
+ `step.mutate(id, recipe, input, { as: 'executor' })`. The closure form,
395
+ `defineMutation<Schema>()((domain) => ({ id, build, project? }))`, is deprecated.
396
+
397
+ ## Guards
398
+
399
+ A Mutation guards its write on a range: a Class handle after `where`, `union` and `orderBy`, or a
400
+ canonical v7 Query. `mutation.expect.none(range)`, `expect.exists(range)`,
401
+ `expect.count(range, n | { atLeast?, atMost? })` or `expect.unchanged(range, ids)`. The Kernel
402
+ decides the guard with the Mutation, on the range's Classes and `where`, and for `unchanged` its
403
+ `orderBy`. A read (`first`, `exists`, `count`…) and a rendered set (`with`, `pick`) are refused:
404
+ their verb, size or rendering would not reach the guard. Each verb takes `{ name }` last, and a
405
+ refusal carries the failed precondition's name as `failure.precondition`.
406
+
407
+ A guard states what the Schema cannot: a condition across Nodes at commit, a count that no
408
+ cardinality states (at most 100 Tasks in a Project), an unchanged list. What the Schema declares is
409
+ never a guard: a key (`unique`), a cardinality, the endpoints an Edge accepts, a state machine's
410
+ transitions. The Kernel checks those on every write, where a guard costs a read and holds only for
411
+ the Mutations that carry it. Closing a Project is its state machine's transition; that none of its
412
+ Tasks is open is a guard:
413
+
414
+ ```ts
415
+ export const closeProject = defineMutation<typeof schema>()('project.close', {
416
+ build: (input: { readonly project: NodeId }, { Project, Task }, mutation) => {
417
+ mutation.expect.none(
418
+ Task.where((t) => [t.project.is(input.project), t.status.eq('open')]),
419
+ { name: 'open-tasks' },
420
+ )
421
+ mutation.transition({
422
+ node: input.project,
423
+ class: Project,
424
+ property: 'status',
425
+ from: 'open',
426
+ event: 'close',
427
+ })
428
+ },
429
+ reject: (failure, input, { error }) => {
430
+ if (failure.code !== 'MUTATION_PRECONDITION_FAILED' || failure.precondition !== 'open-tasks') {
431
+ throw failure
432
+ }
433
+ throw error('OPEN_TASKS', { project: input.project })
434
+ },
435
+ errors: { OPEN_TASKS: openTasks },
436
+ })
437
+ ```
438
+
439
+ ## Query V6
440
+
441
+ Query V6 definitions keep their meaning and are deprecated. See
442
+ [Query V6 and the move to v7](legacy/query-v6.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/sdk",
3
- "version": "0.6.0-beta.41",
3
+ "version": "0.6.0-beta.43",
4
4
  "description": "Schema-first SDK for defining, composing, and deploying Astrale domains",
5
5
  "keywords": [
6
6
  "astrale",
@@ -258,11 +258,11 @@
258
258
  "registry": "https://registry.npmjs.org/"
259
259
  },
260
260
  "dependencies": {
261
- "@astrale-os/kernel-client": "1.0.0-beta.19",
262
- "@astrale-os/kernel-core": "1.0.0-beta.16",
263
- "@astrale-os/kernel-dsl": "1.0.0-beta.9",
264
- "@astrale-os/kernel-protocol": "1.0.0-beta.16",
265
- "@astrale-os/kernel-server": "1.0.0-beta.16",
261
+ "@astrale-os/kernel-client": "1.0.0-beta.27",
262
+ "@astrale-os/kernel-core": "1.0.0-beta.24",
263
+ "@astrale-os/kernel-dsl": "1.0.0-beta.17",
264
+ "@astrale-os/kernel-protocol": "1.0.0-beta.24",
265
+ "@astrale-os/kernel-server": "1.0.0-beta.24",
266
266
  "@lucide/icons": "1.34.0",
267
267
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
268
268
  "cross-spawn": "7.0.6",
@@ -339,13 +339,14 @@
339
339
  "e2e": "node __e2e__/flagship/runner.mjs",
340
340
  "typecheck": "tsgo --noEmit && pnpm run typecheck:workers && pnpm run typecheck:specs && pnpm run typecheck:types && pnpm run typecheck:tests && pnpm run typecheck:e2e && pnpm run typecheck:knowledge",
341
341
  "typecheck:workers": "tsgo --noEmit -p tsconfig.workers.json",
342
- "typecheck:specs": "tsgo --noEmit -p src/application/query/.spec/tsconfig.json && tsgo --noEmit -p src/platform/schema/.spec/tsconfig.json && tsgo --noEmit -p src/application/mutation/.spec/tsconfig.json && tsgo --noEmit -p src/tooling/cli/.spec/tsconfig.json",
342
+ "typecheck:specs": "tsgo --noEmit -p src/application/query/.spec/tsconfig.json && tsgo --noEmit -p src/application/query/legacy/v6/.spec/tsconfig.json && tsgo --noEmit -p src/platform/schema/.spec/tsconfig.json && tsgo --noEmit -p src/application/mutation/.spec/tsconfig.json && tsgo --noEmit -p src/tooling/cli/.spec/tsconfig.json",
343
343
  "typecheck:types": "tsgo --noEmit -p __tests__/tsconfig.types.json",
344
344
  "typecheck:tests": "tsgo --noEmit -p tsconfig.test.json",
345
345
  "typecheck:e2e": "tsgo --noEmit -p __e2e__/flagship/fixtures/tsconfig.v1.json && tsgo --noEmit -p __e2e__/flagship/fixtures/tsconfig.json",
346
346
  "typecheck:knowledge": "tsgo --noEmit -p knowledge/cli/tsconfig.json",
347
+ "typecheck:budgets": "node scripts/type-budget/run.mjs",
347
348
  "test": "pnpm run test:checks && pnpm run test:file && pnpm run test:scripts",
348
- "test:checks": "pnpm run check:knowledge && pnpm run check:linter-policy && pnpm run check:issues && pnpm run test:node-server-v1",
349
+ "test:checks": "pnpm run check:knowledge && pnpm run check:linter-policy && pnpm run check:kernel-schema && pnpm run check:issues && pnpm run test:node-server-v1",
349
350
  "test:file": "vitest run --config __tests__/vitest.config.ts",
350
351
  "test:scripts": "node --test scripts/*.test.mjs actions/domain/*.test.mjs",
351
352
  "test:node-server-v1": "vitest run --config __tests__/vitest.node-server-v1.config.ts",
@@ -358,10 +359,12 @@
358
359
  "format:check": "pnpm exec oxfmt --check .",
359
360
  "check:knowledge": "node knowledge/cli/main.mjs check knowledge && bash knowledge/cli/test.sh",
360
361
  "check:linter-policy": "node scripts/sync-domain-policy.mjs --check",
362
+ "check:kernel-schema": "node scripts/sync-kernel-schema.mjs --check",
361
363
  "check:issues": "issues/.cli/issues check && node --test issues/.cli/history.test.mjs && node --test issues/.cli/view-server.test.mjs && node --test issues/.view/contract.test.mjs",
362
364
  "qualify:publications": "node scripts/qualify-publications.mjs",
363
365
  "qualify": "node scripts/qualify.mjs",
364
366
  "sync:linter-policy": "node scripts/sync-domain-policy.mjs",
367
+ "sync:kernel-schema": "node scripts/sync-kernel-schema.mjs",
365
368
  "setup": "bash scripts/setup/setup.sh",
366
369
  "setup:codex": "AGENT_HARNESSES=codex bash scripts/setup/setup.sh",
367
370
  "setup:claude": "AGENT_HARNESSES=claude bash scripts/setup/setup.sh",