@astrale-os/sdk 0.6.0-beta.42 → 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.
package/README.md CHANGED
@@ -75,7 +75,7 @@ their verbs, a pure `output` and declared errors. An Action calls
75
75
  `query(recipe, input, { as: 'executor' | 'caller' })`; a durable Workflow reads in
76
76
  `step.query(step, recipe, input, { as: 'executor' })`. Before anything is sent, the SDK checks the
77
77
  recipe's bounds and the requirements its relations need; `checkRecipe` from
78
- `@astrale-os/sdk/testing` runs both checks in a test, on the first stage it builds for a sample
78
+ `@astrale-os/sdk/testing` runs both checks in a test, on the first step it builds for a sample
79
79
  input. A recipe reads on a Host whose Kernel serves Query v7 and the Class of each row. Query V6
80
80
  definitions keep their meaning and are deprecated. See [the Query guide](docs/queries.md) and
81
81
  [Query V6 and the move to v7](docs/legacy/query-v6.md).
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import type { ReadPlan } from '../handles/plan.js';
6
6
  import type { AnyQuery } from '../language/query.js';
7
- /** Each read of each stage, and each element of an `items.map`. */
7
+ /** Each read of each step, and each element of an `items.map`. */
8
8
  export declare const MAXIMUM_READS = 32;
9
+ /** Steps of one recipe: `steps(first, (earlier) => next, …)` takes two to this many. */
10
+ export declare const MAXIMUM_STEPS = 8;
9
11
  /** Across a recipe's nested lists. */
10
12
  export declare const MAXIMUM_ROWS = 100000;
11
13
  /** A verb's bound: its list, plus one for `all` and `first(n, { more })`, fits a Kernel page. */
@@ -1,5 +1,7 @@
1
- /** Each read of each stage, and each element of an `items.map`. */
1
+ /** Each read of each step, and each element of an `items.map`. */
2
2
  export const MAXIMUM_READS = 32;
3
+ /** Steps of one recipe: `steps(first, (earlier) => next, …)` takes two to this many. */
4
+ export const MAXIMUM_STEPS = 8;
3
5
  /** Across a recipe's nested lists. */
4
6
  export const MAXIMUM_ROWS = 100_000;
5
7
  /** A verb's bound: its list, plus one for `all` and `first(n, { more })`, fits a Kernel page. */
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * The Classes a recipe's relations cross, checked against what its Domain declares (S12, spec §
3
- * Details settled: "When S12 runs"). A relation that crosses a Class of a dependency needs `read`
4
- * and `traverse` on it in the Domain's requirements, or the read is a programming error, raised at
5
- * lowering before anything is sent, never answered as empty. It reads the document as written,
6
- * so an empty list the input gives refuses what any other list does. A requirement on a parent
7
- * covers its children, and an abstract Class with concrete children, each covered, is covered:
8
- * the check refuses only what no declaration serves.
3
+ * Details settled: "When S12 runs"). A relation that crosses a Class of a dependency other than the
4
+ * Kernel needs `read` and `traverse` on it in the Domain's requirements, or the read is a
5
+ * programming error, raised at lowering before anything is sent, never answered as empty. It reads
6
+ * the document as written, so an empty list the input gives refuses what any other list does. A
7
+ * requirement on a parent covers its children, and an abstract Class with concrete children, each
8
+ * covered, is covered: the check refuses only what no declaration serves.
9
9
  */
10
10
  import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
11
11
  /** What a relation needs on each foreign Class it crosses. */
12
12
  const NEEDED = ['read', 'traverse'];
13
+ /**
14
+ * The Kernel's origin. Its Classes need nothing, even when the Domain declares it a dependency, but
15
+ * the dependency Classes under an abstract one (`Identity`, `Node`) each do.
16
+ */
17
+ const KERNEL = 'kernel.astrale.ai';
18
+ /** The Kernel's abstract Edge Class. Every Edge Class is under it, though none names it a parent. */
19
+ const EDGE = `${KERNEL}:class.Edge`;
13
20
  /**
14
21
  * The S12 refusal: a `TypeError`, the same for every input and every attempt, so a durable Step
15
22
  * fails at once on it.
@@ -27,7 +34,7 @@ export class UndeclaredCrossing extends TypeError {
27
34
  /** The S12 check of one Domain: its dependencies, and the operations it declares on their Classes. */
28
35
  export class Crossings {
29
36
  model;
30
- /** Each direct dependency's origin, by the alias the Domain gives it. */
37
+ /** Each direct dependency's origin but the Kernel's, by the alias the Domain gives it. */
31
38
  #dependencies = new Map();
32
39
  /** The operations declared on each Class, by key. */
33
40
  #declared = new Map();
@@ -36,7 +43,8 @@ export class Crossings {
36
43
  constructor(model, domain, declared) {
37
44
  this.model = model;
38
45
  for (const [alias, dependency] of Object.entries(domain.dependencies))
39
- this.#dependencies.set(dependency.origin, alias);
46
+ if (dependency.origin !== KERNEL)
47
+ this.#dependencies.set(dependency.origin, alias);
40
48
  for (const requirement of declared) {
41
49
  const key = Key.of(requirement.class);
42
50
  const operations = this.#declared.get(key) ?? new Set();
@@ -115,6 +123,12 @@ export class Crossings {
115
123
  return [...new Set(classes)];
116
124
  }
117
125
  #require(recipe, relation, c) {
126
+ if (c.ref.origin === KERNEL) {
127
+ if (c.abstract)
128
+ for (const child of this.#under(c))
129
+ this.#require(recipe, relation, child);
130
+ return;
131
+ }
118
132
  const alias = this.#dependencies.get(c.ref.origin);
119
133
  if (alias === undefined)
120
134
  return;
@@ -127,14 +141,23 @@ export class Crossings {
127
141
  }
128
142
  /** What a Class lacks of `read` and `traverse`, declared on it or on a parent. */
129
143
  #missing(c) {
130
- const declared = new Set([...this.model.satisfies(c)].flatMap((key) => [...(this.#declared.get(key) ?? [])]));
144
+ const declared = new Set([...this.#satisfies(c)].flatMap((key) => [...(this.#declared.get(key) ?? [])]));
131
145
  return NEEDED.filter((operation) => !declared.has(operation));
132
146
  }
133
147
  /** An abstract Class has concrete Classes under it, and the Domain may cross each. */
134
148
  #covered(c) {
135
- const children = this.#concrete.filter((k) => this.model.satisfies(k).has(c.key));
149
+ const children = this.#under(c);
136
150
  return children.length > 0 && children.every((k) => this.#served(k));
137
151
  }
152
+ /** The concrete Classes under a Class, of the Domain and its dependencies. */
153
+ #under(c) {
154
+ return this.#concrete.filter((k) => this.#satisfies(k).has(c.key));
155
+ }
156
+ /** The keys a Class satisfies, the Kernel's `Edge` included for an Edge Class. */
157
+ #satisfies(c) {
158
+ const keys = this.model.satisfies(c);
159
+ return c.kind === 'edge' ? new Set([...keys, EDGE]) : keys;
160
+ }
138
161
  /** Whether a relation may cross a Class: the Domain's own, or a dependency's it declares. */
139
162
  #served(c) {
140
163
  return !this.#dependencies.has(c.ref.origin) || this.#missing(c).length === 0;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Run a v7 recipe (WP-S3): its stages in order, each one's reads together, at most 8 in flight,
3
- * each read at its own snapshot (the stand-in); then `output`. A stage's bounds (S11) are checked
2
+ * Run a v7 recipe (WP-S3): its steps in order, each one's reads together, at most 8 in flight,
3
+ * each read at its own snapshot (the stand-in); then `output`. A step's bounds (S11) are checked
4
4
  * before it sends anything, and every Kernel refusal of a read is a `QueryError`.
5
5
  */
6
6
  import type { Domain } from '../../../platform/schema/index.js';
@@ -28,6 +28,7 @@ export interface RecipeCallOptions {
28
28
  export type RecipeOptions<R, As extends string> = {
29
29
  readonly as: As;
30
30
  } & ('~page' extends keyof R ? RecipeCallOptions : {});
31
+ type Step = (...parameters: readonly unknown[]) => unknown;
31
32
  /** One recipe call: its reads so far, against S11, and the Classes they cross, against S12. */
32
33
  export declare class Run {
33
34
  #private;
@@ -37,29 +38,43 @@ export declare class Run {
37
38
  readonly model: Model;
38
39
  constructor(client: RecipeClient, domain: Domain, recipe: string, declared?: readonly DeclaredClass[]);
39
40
  /**
40
- * S11, before a stage sends: its reads, its verbs' bounds, its rows and each property `in` its
41
+ * S11, before a step sends: its reads, its verbs' bounds, its rows and each property `in` its
41
42
  * documents write. Then S12: each foreign Class its relations cross.
42
43
  */
43
44
  admit(plans: readonly ReadPlan[]): void;
44
45
  /** One read's result: its verb's empty answer, or its documents' answers merged and rendered. */
45
46
  read(plan: ReadPlan, paging?: RecipeCallOptions): Promise<unknown>;
46
47
  }
48
+ /** What `steps(…)` returned: its first record of facets, and the functions of the next ones. */
49
+ interface StepsOf {
50
+ readonly first: unknown;
51
+ readonly next: readonly Step[];
52
+ }
53
+ /**
54
+ * The `steps` a recipe's `build` receives: two to eight steps, the first a record of facets and
55
+ * each next one a function of the merged results before it. It reads nothing: what it returns is
56
+ * read when `build` returns it.
57
+ */
58
+ export declare function stepsFor(recipe: string): (...steps: readonly unknown[]) => object;
59
+ /** The steps `build` returned, or `undefined` for one read or one record of facets. */
60
+ export declare function stepsOf(built: unknown): StepsOf | undefined;
47
61
  /** Where each facet's results go: one read's index, a list of them, or nothing. */
48
62
  type Shape = readonly (readonly [string, number | readonly number[] | undefined])[];
49
63
  /**
50
- * The reads of one stage, admitted (S11, S12): a single read, or each facet's in order and the
51
- * shape that puts their results back. A facet takes a name no `earlier` stage read: the merged
64
+ * The reads of one step, admitted (S11, S12): a single read, or each facet's in order and the
65
+ * shape that puts their results back. A facet takes a name no `earlier` step read: the merged
52
66
  * results keep one value per name.
53
67
  */
54
- export declare function planStage(run: Run, built: unknown, single: boolean, earlier?: Readonly<Record<string, unknown>>): {
68
+ export declare function planStep(run: Run, built: unknown, single: boolean, earlier?: Readonly<Record<string, unknown>>): {
55
69
  readonly plans: readonly ReadPlan[];
56
70
  readonly shape?: Shape;
57
71
  };
58
72
  /**
59
- * Run a recipe for `input` against `domain`, the resolved Domain it was written for: its stages,
60
- * then its `output`. A page recipe takes its call's `size` and `after`. A read the Kernel refuses
61
- * raises a `QueryError`; `output` raises the recipe's declared errors. With the Class requirements
62
- * the Domain's build `declared`, each stage's relations are checked against them first (S12).
73
+ * Run a recipe for `input` against `domain`, the resolved Domain it was written for: what `build`
74
+ * returns, each of its steps in order, then its `output`. A page recipe takes its call's `size`
75
+ * and `after`. A read the Kernel refuses raises a `QueryError`; `output` raises the recipe's
76
+ * declared errors. With the Class requirements
77
+ * the Domain's build `declared`, each step's relations are checked against them first (S12).
63
78
  */
64
79
  export declare function runQueryRecipe<R extends Recipe>(client: RecipeClient, domain: Domain, recipe: R, input: InputOf<R>, call?: RecipeCallOptions, declared?: readonly DeclaredClass[]): Promise<OutputOf<R>>;
65
80
  /**
@@ -7,13 +7,13 @@ import { modelOf } from '../handles/model.js';
7
7
  import { isRead, planOf } from '../handles/plan.js';
8
8
  import { describe } from '../handles/terms.js';
9
9
  import { recipeSpecOf } from '../recipe/define.js';
10
- import { MAXIMUM_BOUND, MAXIMUM_READS, MAXIMUM_ROWS, pastPage, rowsOf, wideIn } from './bounds.js';
10
+ import { MAXIMUM_BOUND, MAXIMUM_READS, MAXIMUM_ROWS, MAXIMUM_STEPS, pastPage, rowsOf, wideIn, } from './bounds.js';
11
11
  import { Crossings } from './crossings.js';
12
12
  import { QueryError } from './error.js';
13
13
  import { describeRefusals, isUnserved, refusalsOf } from './refusal.js';
14
14
  import { emptyResult, rootResult } from './rows.js';
15
15
  import { merge, split } from './split.js';
16
- /** Reads of one stage in flight at once. */
16
+ /** Reads of one step in flight at once. */
17
17
  const IN_FLIGHT = 8;
18
18
  /** `QUERY_SNAPSHOT_STALE`: nothing was read, so the read is sent again at once, this many times. */
19
19
  const STALE_RESENDS = 3;
@@ -37,7 +37,7 @@ export class Run {
37
37
  declared === undefined ? undefined : new Crossings(this.model, domain, declared);
38
38
  }
39
39
  /**
40
- * S11, before a stage sends: its reads, its verbs' bounds, its rows and each property `in` its
40
+ * S11, before a step sends: its reads, its verbs' bounds, its rows and each property `in` its
41
41
  * documents write. Then S12: each foreign Class its relations cross.
42
42
  */
43
43
  admit(plans) {
@@ -151,28 +151,72 @@ async function pooled(tasks, limit) {
151
151
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
152
152
  return results;
153
153
  }
154
+ const stepped = new WeakMap();
154
155
  /**
155
- * The facets a stage returned, each a read, a list of reads (`items.map`) or nothing. One read is
156
- * a single stage's alone: each of several returns a record of facets.
156
+ * The `steps` a recipe's `build` receives: two to eight steps, the first a record of facets and
157
+ * each next one a function of the merged results before it. It reads nothing: what it returns is
158
+ * read when `build` returns it.
159
+ */
160
+ export function stepsFor(recipe) {
161
+ return (...steps) => {
162
+ if (steps.length < 2 || steps.length > MAXIMUM_STEPS)
163
+ throw new TypeError(`Recipe ${recipe}: steps takes 2 to ${MAXIMUM_STEPS} steps; got ${steps.length}. ${steps.length < 2
164
+ ? 'Reads that wait for none are what build returns: one read, or a record of facets.'
165
+ : 'Read the facets that wait for the same results in one step.'}`);
166
+ const [first, ...next] = steps;
167
+ if (!isFacets(first))
168
+ throw new TypeError(`Recipe ${recipe}: the first step is a record of facets; got ${described(first)}.`);
169
+ const position = next.findIndex((step) => typeof step !== 'function');
170
+ if (position !== -1)
171
+ throw new TypeError(`Recipe ${recipe}: each step after the first is a function of the results before it; step ${position + 2} is ${describe(next[position])}.`);
172
+ const value = Object.freeze({});
173
+ stepped.set(value, Object.freeze({ first, next: Object.freeze(next) }));
174
+ return value;
175
+ };
176
+ }
177
+ /** A record of facets: a plain object, neither a read nor what `steps` returned. */
178
+ function isFacets(value) {
179
+ if (typeof value !== 'object' || value === null || isRead(value) || stepped.has(value))
180
+ return false;
181
+ const prototype = Object.getPrototypeOf(value);
182
+ return prototype === Object.prototype || prototype === null;
183
+ }
184
+ /** What a step was, for its refusal: `steps(…)` and a Promise are named with their fix. */
185
+ function described(value) {
186
+ if (typeof value === 'object' && value !== null && stepped.has(value))
187
+ return 'steps(…), which only build returns';
188
+ if (value instanceof Promise)
189
+ return 'a Promise: build and its steps are not async';
190
+ return describe(value);
191
+ }
192
+ /** The steps `build` returned, or `undefined` for one read or one record of facets. */
193
+ export function stepsOf(built) {
194
+ return typeof built === 'object' && built !== null ? stepped.get(built) : undefined;
195
+ }
196
+ /**
197
+ * The facets a step returned, each a read, a list of reads (`items.map`) or nothing. One read is
198
+ * `build`'s alone, without steps: each step returns a record of facets.
157
199
  */
158
200
  function facetsOf(recipe, built, single) {
159
- if (typeof built !== 'object' || built === null || Array.isArray(built) || isRead(built)) {
160
- const expected = single ? 'a stage returns one read or' : 'each of several stages returns';
161
- throw new TypeError(`Recipe ${recipe}: ${expected} a record of facets; got ${describe(built)}.`);
201
+ if (!isFacets(built)) {
202
+ const expected = single
203
+ ? 'build returns one read, a record of facets, or steps(…)'
204
+ : 'each step returns a record of facets';
205
+ throw new TypeError(`Recipe ${recipe}: ${expected}; got ${described(built)}.`);
162
206
  }
163
207
  return Object.entries(built);
164
208
  }
165
- /** A page is a recipe's single read: one stage that returns it. */
209
+ /** A page is a recipe's single read: one step that returns it. */
166
210
  function refusePage(recipe, plan) {
167
211
  if (plan.verb === 'page')
168
212
  throw new TypeError(`Recipe ${recipe}: a page is a recipe's single read (D22); read the others with first or all.`);
169
213
  }
170
214
  /**
171
- * The reads of one stage, admitted (S11, S12): a single read, or each facet's in order and the
172
- * shape that puts their results back. A facet takes a name no `earlier` stage read: the merged
215
+ * The reads of one step, admitted (S11, S12): a single read, or each facet's in order and the
216
+ * shape that puts their results back. A facet takes a name no `earlier` step read: the merged
173
217
  * results keep one value per name.
174
218
  */
175
- export function planStage(run, built, single, earlier = {}) {
219
+ export function planStep(run, built, single, earlier = {}) {
176
220
  if (single && isRead(built)) {
177
221
  const plans = [planOf(built)];
178
222
  run.admit(plans);
@@ -181,7 +225,7 @@ export function planStage(run, built, single, earlier = {}) {
181
225
  const facets = facetsOf(run.recipe, built, single);
182
226
  for (const [name] of facets)
183
227
  if (Object.hasOwn(earlier, name))
184
- throw new TypeError(`Recipe ${run.recipe}: an earlier stage read the facet ${name}; name this stage's facet otherwise.`);
228
+ throw new TypeError(`Recipe ${run.recipe}: an earlier step read the facet ${name}; name this step's facet otherwise.`);
185
229
  const plans = [];
186
230
  const shape = [];
187
231
  for (const [name, facet] of facets) {
@@ -196,9 +240,9 @@ export function planStage(run, built, single, earlier = {}) {
196
240
  run.admit(plans);
197
241
  return { plans, shape };
198
242
  }
199
- /** The results of one stage: a read's result, or a record of each facet's. */
200
- async function runStage(run, built, single, call, earlier) {
201
- const { plans, shape } = planStage(run, built, single, earlier);
243
+ /** The results of one step: a read's result, or a record of each facet's. */
244
+ async function runStep(run, built, single, call, earlier) {
245
+ const { plans, shape } = planStep(run, built, single, earlier);
202
246
  if (shape === undefined) {
203
247
  const plan = plans[0];
204
248
  return run.read(plan, plan.verb === 'page' ? call : undefined);
@@ -229,30 +273,34 @@ function pageCall(recipe, call, max) {
229
273
  return { size: size ?? max, ...(after === undefined ? {} : { after }) };
230
274
  }
231
275
  /**
232
- * Run a recipe for `input` against `domain`, the resolved Domain it was written for: its stages,
233
- * then its `output`. A page recipe takes its call's `size` and `after`. A read the Kernel refuses
234
- * raises a `QueryError`; `output` raises the recipe's declared errors. With the Class requirements
235
- * the Domain's build `declared`, each stage's relations are checked against them first (S12).
276
+ * Run a recipe for `input` against `domain`, the resolved Domain it was written for: what `build`
277
+ * returns, each of its steps in order, then its `output`. A page recipe takes its call's `size`
278
+ * and `after`. A read the Kernel refuses raises a `QueryError`; `output` raises the recipe's
279
+ * declared errors. With the Class requirements
280
+ * the Domain's build `declared`, each step's relations are checked against them first (S12).
236
281
  */
237
282
  export async function runQueryRecipe(client, domain, recipe, input, call = {}, declared) {
238
283
  const spec = recipeSpecOf(recipe);
239
284
  if (spec === undefined)
240
285
  throw new TypeError(`Not a v7 recipe: define one with defineQuery<Schema>()(id, { build }); got ${describe(recipe)}.`);
241
286
  const run = new Run(client, domain, recipe.id, declared);
242
- const d = handlesOf(domain);
243
- const single = spec.stages.length === 1;
244
- let results;
245
- let merged = {};
246
- for (const [index, stage] of spec.stages.entries()) {
247
- const built = index === 0
248
- ? stage(input, d)
249
- : stage(Object.freeze({ ...merged }), input, d);
250
- const page = single && isRead(built) && planOf(built).verb === 'page';
287
+ const built = spec.build(input, handlesOf(domain), stepsFor(recipe.id));
288
+ const steps = stepsOf(built);
289
+ let answered;
290
+ if (steps === undefined) {
291
+ const page = isRead(built) && planOf(built).verb === 'page';
251
292
  const options = pageCall(recipe.id, call, page ? planOf(built).bound : undefined);
252
- results = await runStage(run, built, single, options, merged);
253
- merged = { ...merged, ...results };
293
+ answered = await runStep(run, built, true, options, {});
294
+ }
295
+ else {
296
+ pageCall(recipe.id, call, undefined);
297
+ let merged = {};
298
+ for (const step of [() => steps.first, ...steps.next]) {
299
+ const results = await runStep(run, step(Object.freeze({ ...merged })), false, {}, merged);
300
+ merged = { ...merged, ...results };
301
+ }
302
+ answered = Object.freeze(merged);
254
303
  }
255
- const answered = single ? results : Object.freeze(merged);
256
304
  if (spec.output === undefined)
257
305
  return answered;
258
306
  return spec.output(answered, input, outputContext(recipe, run.model));
@@ -2,7 +2,7 @@ var _a, _b, _c;
2
2
  import { QUERY } from '../language/query.js';
3
3
  import { HANDLE_MEMBERS, ROW_KEYS } from './model.js';
4
4
  import { RelationRead, RootRead } from './plan.js';
5
- import { and, atom, Condition, describe, foldedTo, folding, kept, orderKeys, propertyHandle, scope, targetCondition, targetLabel, whereOf, within, } from './terms.js';
5
+ import { and, atom, Condition, describe, foldedTo, folding, isPathText, kept, orderKeys, propertyHandle, scope, targetCondition, targetLabel, whereOf, within, } from './terms.js';
6
6
  const defined = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
7
7
  const nonEmpty = (items) => items.length > 0 ? items : undefined;
8
8
  const keysOf = (classes) => classes.map((c) => c.key);
@@ -221,8 +221,8 @@ function pathCondition(context, paths) {
221
221
  if (paths.length === 0)
222
222
  return foldedTo(Condition.NEVER, atom(context, { op: 'path', paths: [] }));
223
223
  for (const path of paths)
224
- if (typeof path !== 'string' || !path.startsWith('/'))
225
- throw new TypeError(`A path starts with "/"; got ${describe(path)}.`);
224
+ if (!isPathText(path))
225
+ throw new TypeError(`A path starts with "/" or "@"; got ${describe(path)}.`);
226
226
  return atom(context, { op: 'path', paths });
227
227
  }
228
228
  /** The named to-one relation of a handle's Classes: on a union, one every Class names alike. */
@@ -89,7 +89,15 @@ export declare class CoreNode {
89
89
  readonly '~core': string;
90
90
  constructor(key: string, path: string);
91
91
  }
92
- /** The condition a list of targets gives: ids by `id`, paths and Core nodes by `path`. */
92
+ /**
93
+ * Whether text is written as a NodePath: it starts with `/` (a Domain anchor) or `@` (a Node's id
94
+ * as its anchor). A NodeId holds neither, so the two never meet.
95
+ */
96
+ export declare function isPathText(value: unknown): value is string;
97
+ /**
98
+ * The condition a list of targets gives: ids by `id`, an `@<id>` path by its id, so a long list
99
+ * splits as ids do; any other path, and a Core node, by `path`.
100
+ */
93
101
  export declare function targetCondition(context: Context, targets: readonly unknown[]): Condition;
94
102
  /** A target as an error names it: its id or its path. */
95
103
  export declare function targetLabel(target: unknown): string;
@@ -239,7 +239,19 @@ export class CoreNode {
239
239
  Object.freeze(this);
240
240
  }
241
241
  }
242
- /** The condition a list of targets gives: ids by `id`, paths and Core nodes by `path`. */
242
+ /**
243
+ * Whether text is written as a NodePath: it starts with `/` (a Domain anchor) or `@` (a Node's id
244
+ * as its anchor). A NodeId holds neither, so the two never meet.
245
+ */
246
+ export function isPathText(value) {
247
+ return typeof value === 'string' && (value.startsWith('/') || value.startsWith('@'));
248
+ }
249
+ /** A NodePath that is a Node's id and no step: `@<id>`, a NodeId being 1 to 255 of `A-Za-z0-9._-`. */
250
+ const ID_PATH = /^@([A-Za-z0-9._-]{1,255})$/;
251
+ /**
252
+ * The condition a list of targets gives: ids by `id`, an `@<id>` path by its id, so a long list
253
+ * splits as ids do; any other path, and a Core node, by `path`.
254
+ */
243
255
  export function targetCondition(context, targets) {
244
256
  if (targets.length === 0)
245
257
  return foldedTo(Condition.NEVER, atom(context, { op: 'id', ids: [] }));
@@ -248,8 +260,13 @@ export function targetCondition(context, targets) {
248
260
  for (const target of targets) {
249
261
  if (target instanceof CoreNode)
250
262
  paths.push(target.path);
251
- else if (typeof target === 'string' && target.startsWith('/'))
252
- paths.push(target);
263
+ else if (isPathText(target)) {
264
+ const id = ID_PATH.exec(target)?.[1];
265
+ if (id === undefined)
266
+ paths.push(target);
267
+ else
268
+ ids.push(id);
269
+ }
253
270
  else if (typeof target === 'string' && target.length > 0)
254
271
  ids.push(target);
255
272
  else
@@ -9,14 +9,13 @@ import type { Recipe, RecipeDefinitions } from './recipe.js';
9
9
  /** A recipe id: lowercase segments of letters, digits and hyphens, joined by dots. */
10
10
  export declare const RECIPE_ID: RegExp;
11
11
  export declare const MAXIMUM_RECIPE_ID = 128;
12
- type Stage = (...parameters: never[]) => unknown;
13
- /** What a recipe reads and answers: its stages, in order, and its `output`. */
12
+ type Callback = (...parameters: never[]) => unknown;
13
+ /** What a recipe reads and answers: its `build`, of `(input, d, steps)`, and its `output`. */
14
14
  export interface RecipeSpec {
15
- /** The first stage receives `(input, d)`; each next one `(earlier, input, d)`. */
16
- readonly stages: readonly Stage[];
17
- readonly output?: Stage;
15
+ readonly build: Callback;
16
+ readonly output?: Callback;
18
17
  }
19
- /** The stages and the output of a v7 recipe; `undefined` for anything else. */
18
+ /** The build and the output of a v7 recipe; `undefined` for anything else. */
20
19
  export declare function recipeSpecOf(recipe: unknown): RecipeSpec | undefined;
21
20
  /** A recipe of the Domain's namespace, a Query's or a Mutation's: its id, and its errors. */
22
21
  export type NamedRecipe = Pick<Recipe, 'id' | 'errors'>;
@@ -4,12 +4,11 @@ import { defineQuery as defineV6Query } from '../legacy/v6/define.js';
4
4
  /** A recipe id: lowercase segments of letters, digits and hyphens, joined by dots. */
5
5
  export const RECIPE_ID = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/u;
6
6
  export const MAXIMUM_RECIPE_ID = 128;
7
- const MAXIMUM_STAGES = 8;
8
7
  const FIELDS = new Set(['build', 'output', 'errors']);
9
8
  const specs = new WeakMap();
10
9
  /** The collections open while a build loads a Domain; no recipe is kept outside one. */
11
10
  const collections = new Set();
12
- /** The stages and the output of a v7 recipe; `undefined` for anything else. */
11
+ /** The build and the output of a v7 recipe; `undefined` for anything else. */
13
12
  export function recipeSpecOf(recipe) {
14
13
  return typeof recipe === 'object' && recipe !== null ? specs.get(recipe) : undefined;
15
14
  }
@@ -22,8 +21,9 @@ function defineRecipe(id, spec) {
22
21
  const unknown = Reflect.ownKeys(fields).filter((key) => typeof key !== 'string' || !FIELDS.has(key));
23
22
  if (unknown.length > 0)
24
23
  throw new TypeError(`Recipe ${id} takes build, output and errors only; got ${unknown.map(String).join(', ')}: do that work in build or output.`);
25
- const stages = stagesOf(id, fields.build);
26
- const { output } = fields;
24
+ const { build, output } = fields;
25
+ if (typeof build !== 'function')
26
+ throw new TypeError(`Recipe ${id}'s build is a function of (input, d, steps) that returns a read, a record of facets, or steps(first, (earlier) => next, …); got ${describe(build)}.`);
27
27
  if (output !== undefined && typeof output !== 'function')
28
28
  throw new TypeError(`Recipe ${id}'s output is a function of (results, input, context); got ${describe(output)}.`);
29
29
  const errors = fields.errors ?? {};
@@ -31,19 +31,12 @@ function defineRecipe(id, spec) {
31
31
  throw new TypeError(`Recipe ${id}'s errors is a record of declared errors by UPPER_SNAKE code; got ${describe(errors)}.`);
32
32
  const recipe = Object.freeze({ id, errors: Object.freeze({ ...errors }) });
33
33
  collectRecipe(recipe);
34
- specs.set(recipe, Object.freeze({ stages, ...(output !== undefined ? { output: output } : {}) }));
34
+ specs.set(recipe, Object.freeze({
35
+ build: build,
36
+ ...(output !== undefined ? { output: output } : {}),
37
+ }));
35
38
  return recipe;
36
39
  }
37
- function stagesOf(id, build) {
38
- if (typeof build === 'function')
39
- return Object.freeze([build]);
40
- if (!Array.isArray(build) ||
41
- build.length < 2 ||
42
- build.length > MAXIMUM_STAGES ||
43
- build.some((stage) => typeof stage !== 'function'))
44
- throw new TypeError(`Recipe ${id}'s build is a function, or a list of 2 to ${MAXIMUM_STAGES} stage functions; got ${describe(build)}.`);
45
- return Object.freeze([...build]);
46
- }
47
40
  /**
48
41
  * Keep the errors a recipe declares, a Query's or a Mutation's, and collect it into the
49
42
  * collections open: a Mutation recipe shares its Domain's recipe ids and error definitions.