@astrale-os/sdk 0.6.0-beta.39 → 0.6.0-beta.40
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/dist/application/workflow/authoring.d.ts +1 -1
- package/dist/application/workflow/durable.d.ts +24 -0
- package/dist/application/workflow/index.d.ts +1 -1
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.js +1 -1
- package/dist/execution/workflows/engine.d.ts +9 -0
- package/dist/execution/workflows/index.d.ts +2 -2
- package/dist/execution/workflows/index.js +1 -1
- package/dist/execution/workflows/options.d.ts +6 -1
- package/dist/execution/workflows/options.js +21 -3
- package/dist/execution/workflows/run.js +95 -9
- package/dist/testing/durable/engine.js +23 -1
- package/dist/tooling/linter/implementations/source/shared.d.ts +5 -0
- package/dist/tooling/linter/implementations/source/shared.js +15 -7
- package/dist/tooling/linter/implementations/source/workflows.js +77 -7
- package/dist/tooling/linter/policy/generated.js +2 -2
- package/dist/tooling/linter/requirements/registry.js +1 -0
- package/docs/admission-age.md +88 -0
- package/docs/auth-api-migration.md +55 -0
- package/docs/business-errors.md +42 -0
- package/docs/domain-definition-migration.md +44 -0
- package/docs/domain-schema-migration.md +36 -0
- package/docs/durable-workflows.md +418 -0
- package/docs/handler-dependencies.md +213 -0
- package/docs/release.md +155 -0
- package/docs/schema-cost.md +124 -0
- package/package.json +3 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { defineWorkflow } from './define.js';
|
|
2
2
|
export type { InlineWorkflowContext, WorkflowContext } from './context.js';
|
|
3
|
-
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
3
|
+
export type { DurableCallable, DurableExecution, DurableParallelOutputs, DurableParallelStep, DurableParallelSteps, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
4
4
|
export type { Workflow, WorkflowAddress, WorkflowDefinition, WorkflowOptions } from './workflow.js';
|
|
5
5
|
export type { DeclaredErrorDefinition, DeclaredErrorFactory, DeclaredErrorFamily, DeclaredErrorOptions, DeclaredErrors, } from '../error/index.js';
|
|
@@ -110,7 +110,31 @@ export interface DurableStep<DomainValue extends Domain = Domain, Definitions ex
|
|
|
110
110
|
* from the Kernel's receipt instead of writing again.
|
|
111
111
|
*/
|
|
112
112
|
mutate<Input, Output>(step: DurableStepId, mutation: Mutation<DomainValue, Input, Output>, input: Input): Promise<Output>;
|
|
113
|
+
/**
|
|
114
|
+
* Run the Steps `declare` declares together, in one round trip to the engine, the one way to await
|
|
115
|
+
* several Steps at once. Each keeps its id, retry budget, timeout and keys. Once every Step has
|
|
116
|
+
* settled, resolves with their outputs in declaration order, or throws the failure of the first
|
|
117
|
+
* failed Step in that order, as that Step alone would throw it; the other Steps' effects stand.
|
|
118
|
+
* An empty list resolves with `[]`.
|
|
119
|
+
*/
|
|
120
|
+
parallel<const Steps extends readonly [] | readonly DurableParallelStep<unknown>[]>(declare: (steps: DurableParallelSteps<DomainValue, Definitions>) => Steps): Promise<DurableParallelOutputs<Steps>>;
|
|
121
|
+
}
|
|
122
|
+
declare const PARALLEL_STEP: unique symbol;
|
|
123
|
+
/** A Step `step.parallel` starts with its siblings. It runs nothing on its own and is never awaited. */
|
|
124
|
+
export interface DurableParallelStep<out Output> {
|
|
125
|
+
readonly [PARALLEL_STEP]: Output;
|
|
113
126
|
}
|
|
127
|
+
/** The Steps one `step.parallel` declares, with the arguments of `step.run`, `query` and `mutate`. */
|
|
128
|
+
export interface DurableParallelSteps<DomainValue extends Domain = Domain, Definitions extends Integrations = Readonly<Record<never, never>>> {
|
|
129
|
+
run<Output>(step: DurableStepId, execute: (authority: DurableStepAuthority<DomainValue, Definitions>) => Output | Promise<Output>): DurableParallelStep<Output>;
|
|
130
|
+
query<Output>(step: DurableStepId, query: QueryDefinition<DomainValue, void, Output>): DurableParallelStep<Output>;
|
|
131
|
+
query<Input, Output>(step: DurableStepId, query: QueryDefinition<DomainValue, Input, Output>, input: Input): DurableParallelStep<Output>;
|
|
132
|
+
mutate<Input, Output>(step: DurableStepId, mutation: Mutation<DomainValue, Input, Output>, input: Input): DurableParallelStep<Output>;
|
|
133
|
+
}
|
|
134
|
+
/** The outputs of parallel Steps, in their declaration order. */
|
|
135
|
+
export type DurableParallelOutputs<Steps extends readonly DurableParallelStep<unknown>[]> = {
|
|
136
|
+
-readonly [Index in keyof Steps]: Steps[Index] extends DurableParallelStep<infer Output> ? Output : never;
|
|
137
|
+
};
|
|
114
138
|
type DurableSelfOf<Callable> = Callable extends ResolvedMethod<unknown, unknown, false> ? {
|
|
115
139
|
readonly self: NodeId;
|
|
116
140
|
} : {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { defineWorkflow, isWorkflow } from './define.js';
|
|
2
2
|
export type { InlineWorkflowContext, WorkflowContext } from './context.js';
|
|
3
|
-
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
3
|
+
export type { DurableCallable, DurableExecution, DurableParallelOutputs, DurableParallelStep, DurableParallelSteps, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
4
4
|
export type { CallableAtWorkflowAddress, Workflow, WorkflowAddress, WorkflowCallable, WorkflowDefinition, WorkflowOptions, } from './workflow.js';
|
|
5
5
|
export * as step from './step/index.js';
|
|
6
6
|
export * as runner from './runner/index.js';
|
|
@@ -3,5 +3,5 @@ export type { ExecutionContext, ServedDomain, ServeInput } from './serve.js';
|
|
|
3
3
|
export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, type DeploymentCache, type MemoryCacheOptions, } from './cache/index.js';
|
|
4
4
|
export { createIdentity, maximumExchangeTtl, type ExchangeOptions, type Identity, type IdentityInput, } from './identity/index.js';
|
|
5
5
|
export type { DurableEngine, DurableServing, DurableServingProvider } from './workflows/index.js';
|
|
6
|
-
export { DURABLE_ENGINE_EXPORTS } from './workflows/options.js';
|
|
6
|
+
export { DURABLE_ENGINE_EXPORTS, isDurableEngine } from './workflows/options.js';
|
|
7
7
|
export { isCanonicalDurableRequest } from './workflows/route.js';
|
package/dist/execution/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { serve } from './serve.js';
|
|
2
2
|
export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, } from './cache/index.js';
|
|
3
3
|
export { createIdentity, maximumExchangeTtl, } from './identity/index.js';
|
|
4
|
-
export { DURABLE_ENGINE_EXPORTS } from './workflows/options.js';
|
|
4
|
+
export { DURABLE_ENGINE_EXPORTS, isDurableEngine } from './workflows/options.js';
|
|
5
5
|
export { isCanonicalDurableRequest } from './workflows/route.js';
|
|
@@ -22,6 +22,15 @@ export interface DurableEngine {
|
|
|
22
22
|
}) => Error;
|
|
23
23
|
/** The rejection of `orTimeout` once its duration elapses first. */
|
|
24
24
|
readonly TimeoutError: abstract new (...input: never[]) => Error;
|
|
25
|
+
/**
|
|
26
|
+
* The engine's own combinators, the one way to await several journaled operations together: the
|
|
27
|
+
* engine then follows them as one operation, and a replay settles them as the first run did.
|
|
28
|
+
*/
|
|
29
|
+
readonly RestatePromise: EngineCombinators;
|
|
30
|
+
}
|
|
31
|
+
export interface EngineCombinators {
|
|
32
|
+
/** Settle once every one of `values`, promises of this engine's `run`, has settled. */
|
|
33
|
+
allSettled(values: readonly PromiseLike<unknown>[]): PromiseLike<readonly PromiseSettledResult<unknown>[]>;
|
|
25
34
|
}
|
|
26
35
|
export interface EngineWorkflowDefinition {
|
|
27
36
|
readonly name: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { DurableEngine, EnginePromise, EngineRetryPolicy, EngineRunOptions, EngineSharedContext, EngineWorkflowContext, EngineWorkflowDefinition, } from './engine.js';
|
|
2
|
-
export { DURABLE_ENGINE_EXPORTS, durableServing, type AdmittedDurableServing, type DurableServing, type DurableServingProvider, } from './options.js';
|
|
1
|
+
export type { DurableEngine, EnginePromise, EngineRetryPolicy, EngineCombinators, EngineRunOptions, EngineSharedContext, EngineWorkflowContext, EngineWorkflowDefinition, } from './engine.js';
|
|
2
|
+
export { DURABLE_ENGINE_EXPORTS, durableServing, isDurableEngine, type AdmittedDurableServing, type DurableServing, type DurableServingProvider, } from './options.js';
|
|
3
3
|
export { createDurableReceiver, type DurableReceiver } from './receiver.js';
|
|
4
4
|
export { isDurableRequest, isCanonicalDurableRequest } from './route.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { DURABLE_ENGINE_EXPORTS, durableServing, } from './options.js';
|
|
1
|
+
export { DURABLE_ENGINE_EXPORTS, durableServing, isDurableEngine, } from './options.js';
|
|
2
2
|
export { createDurableReceiver } from './receiver.js';
|
|
3
3
|
export { isDurableRequest, isCanonicalDurableRequest } from './route.js';
|
|
@@ -42,7 +42,12 @@ export interface StepServing {
|
|
|
42
42
|
* The members of the Restate SDK Fetch entrypoint the durable receiver uses, which a platform's
|
|
43
43
|
* engine module must export.
|
|
44
44
|
*/
|
|
45
|
-
export declare const DURABLE_ENGINE_EXPORTS: readonly ["createEndpointHandler", "workflow", "TerminalError", "CancelledError", "RetryableError", "TimeoutError"];
|
|
45
|
+
export declare const DURABLE_ENGINE_EXPORTS: readonly ["createEndpointHandler", "workflow", "TerminalError", "CancelledError", "RetryableError", "TimeoutError", "RestatePromise"];
|
|
46
|
+
/**
|
|
47
|
+
* Whether `module` exports the members of the Restate SDK Fetch entrypoint the durable receiver
|
|
48
|
+
* uses: each a function, but `RestatePromise`, the namespace of its combinators.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isDurableEngine(module: unknown): boolean;
|
|
46
51
|
/**
|
|
47
52
|
* How long the engine lets an attempt run without progress before it aborts it, declared on every
|
|
48
53
|
* Workflow service. Restate's own default is ten minutes, which a ten-minute Step would race: the
|
|
@@ -9,7 +9,24 @@ export const DURABLE_ENGINE_EXPORTS = Object.freeze([
|
|
|
9
9
|
'CancelledError',
|
|
10
10
|
'RetryableError',
|
|
11
11
|
'TimeoutError',
|
|
12
|
+
'RestatePromise',
|
|
12
13
|
]);
|
|
14
|
+
/**
|
|
15
|
+
* Whether `module` exports the members of the Restate SDK Fetch entrypoint the durable receiver
|
|
16
|
+
* uses: each a function, but `RestatePromise`, the namespace of its combinators.
|
|
17
|
+
*/
|
|
18
|
+
export function isDurableEngine(module) {
|
|
19
|
+
if (module === null || typeof module !== 'object')
|
|
20
|
+
return false;
|
|
21
|
+
return DURABLE_ENGINE_EXPORTS.every((member) => {
|
|
22
|
+
const value = Reflect.get(module, member);
|
|
23
|
+
if (member !== 'RestatePromise')
|
|
24
|
+
return typeof value === 'function';
|
|
25
|
+
return (typeof value === 'object' &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
typeof Reflect.get(value, 'allSettled') === 'function');
|
|
28
|
+
});
|
|
29
|
+
}
|
|
13
30
|
const HOUR_MS = 60 * 60_000;
|
|
14
31
|
const DAY_MS = 24 * HOUR_MS;
|
|
15
32
|
const DEFAULT_ATTEMPTS = 20;
|
|
@@ -40,9 +57,7 @@ export function durableServing(input) {
|
|
|
40
57
|
invalid('Durable serving options are invalid.');
|
|
41
58
|
}
|
|
42
59
|
const engine = input.engine;
|
|
43
|
-
if (engine
|
|
44
|
-
typeof engine !== 'object' ||
|
|
45
|
-
DURABLE_ENGINE_EXPORTS.some((member) => typeof engine[member] !== 'function')) {
|
|
60
|
+
if (!isDurableEngine(engine)) {
|
|
46
61
|
invalid('Durable serving requires the Restate SDK Fetch entrypoint of this platform.');
|
|
47
62
|
}
|
|
48
63
|
const fetch = input.fetch ?? globalThis.fetch.bind(globalThis);
|
|
@@ -57,6 +72,9 @@ export function durableServing(input) {
|
|
|
57
72
|
CancelledError: engine.CancelledError,
|
|
58
73
|
RetryableError: engine.RetryableError,
|
|
59
74
|
TimeoutError: engine.TimeoutError,
|
|
75
|
+
RestatePromise: Object.freeze({
|
|
76
|
+
allSettled: engine.RestatePromise.allSettled.bind(engine.RestatePromise),
|
|
77
|
+
}),
|
|
60
78
|
}),
|
|
61
79
|
fetch,
|
|
62
80
|
retentionMs: bounded(input.retentionMs, 7 * DAY_MS, HOUR_MS, 365 * DAY_MS, 'retentionMs'),
|
|
@@ -72,14 +72,40 @@ function durableContext(input, context, envelope) {
|
|
|
72
72
|
const execution = envelope.execution;
|
|
73
73
|
const signal = context.request().attemptCompletedSignal;
|
|
74
74
|
const steps = new Set();
|
|
75
|
+
let running;
|
|
76
|
+
/**
|
|
77
|
+
* Starts one durable operation, a Step or a wait, once the previous one has settled. The engine
|
|
78
|
+
* follows one awaited operation at a time: operations awaited together through `Promise` combinators
|
|
79
|
+
* make it lose track of the run, which then pauses.
|
|
80
|
+
*/
|
|
81
|
+
const sequenced = (operation, start) => {
|
|
82
|
+
if (running !== undefined) {
|
|
83
|
+
return Promise.reject(new TypeError(`Workflow ${operation} started while ${running} runs: durable Steps and waits run one after another.`));
|
|
84
|
+
}
|
|
85
|
+
running = operation;
|
|
86
|
+
let started;
|
|
87
|
+
try {
|
|
88
|
+
started = start();
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
running = undefined;
|
|
92
|
+
return Promise.reject(cause);
|
|
93
|
+
}
|
|
94
|
+
return started.finally(() => {
|
|
95
|
+
running = undefined;
|
|
96
|
+
});
|
|
97
|
+
};
|
|
75
98
|
/**
|
|
76
99
|
* One journaled unit of work holding the capabilities of an Action as the Domain alone. Its
|
|
77
100
|
* Mutations, durable calls and Integration calls share one key sequence derived from its name.
|
|
78
101
|
* With a `retry` budget, the engine retries a failed attempt within it, after its backoff or the
|
|
79
102
|
* delay the failure prescribes, and the Step throws `StepExhausted` once it is spent; without
|
|
80
103
|
* one, a failed attempt fails the run's attempt, which the engine retries until it pauses.
|
|
104
|
+
*
|
|
105
|
+
* The engine promise is returned unawaited: awaiting it is what tells the engine the run waits on
|
|
106
|
+
* it, so parallel Steps hand theirs to the engine's own combinator instead.
|
|
81
107
|
*/
|
|
82
|
-
const
|
|
108
|
+
const journaled = (id, name, timeoutMs, retry, execute) => context.run(name, async () => {
|
|
83
109
|
const expiry = new AbortController();
|
|
84
110
|
const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
|
|
85
111
|
let step;
|
|
@@ -110,7 +136,9 @@ function durableContext(input, context, envelope) {
|
|
|
110
136
|
clearTimeout(timer);
|
|
111
137
|
step?.close();
|
|
112
138
|
}
|
|
113
|
-
}, retry)
|
|
139
|
+
}, retry);
|
|
140
|
+
/** One Step, awaited alone; its failure reaches the Workflow as `replayedFailure` reads it. */
|
|
141
|
+
const journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(journaled(id, name, timeoutMs, retry, execute)).catch((cause) => {
|
|
114
142
|
throw replayedFailure(engine, cause, definition, id) ?? cause;
|
|
115
143
|
});
|
|
116
144
|
const step = (declared, execute) => {
|
|
@@ -125,7 +153,63 @@ function durableContext(input, context, envelope) {
|
|
|
125
153
|
}
|
|
126
154
|
steps.add(accepted.id);
|
|
127
155
|
const { held } = accepted;
|
|
128
|
-
return journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute);
|
|
156
|
+
return sequenced(`Step ${accepted.id}`, () => journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute));
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* The Steps `declare` declares, started together and awaited through the engine's combinator as
|
|
160
|
+
* one operation. Once all have settled, their outputs in declaration order, or the failure of the
|
|
161
|
+
* first failed one in that order, as that Step alone fails.
|
|
162
|
+
*/
|
|
163
|
+
const parallel = (declare) => {
|
|
164
|
+
const declared = new Map();
|
|
165
|
+
const declaration = (id, execute) => {
|
|
166
|
+
const accepted = stepDeclaration(id, serving.step);
|
|
167
|
+
const token = Object.freeze({});
|
|
168
|
+
declared.set(token, { ...accepted, execute });
|
|
169
|
+
return token;
|
|
170
|
+
};
|
|
171
|
+
let group;
|
|
172
|
+
try {
|
|
173
|
+
if (typeof declare !== 'function') {
|
|
174
|
+
throw new TypeError('step.parallel takes a function declaring its Steps.');
|
|
175
|
+
}
|
|
176
|
+
const returned = declare(Object.freeze({
|
|
177
|
+
run: (id, execute) => declaration(id, execute),
|
|
178
|
+
query: (id, query, value) => declaration(id, (authority) => authority.query(query, value)),
|
|
179
|
+
mutate: (id, mutation, value) => declaration(id, (authority) => authority.mutate(mutation, value)),
|
|
180
|
+
}));
|
|
181
|
+
if (!Array.isArray(returned) ||
|
|
182
|
+
returned.length !== declared.size ||
|
|
183
|
+
new Set(returned).size !== returned.length ||
|
|
184
|
+
returned.some((entry) => !declared.has(entry))) {
|
|
185
|
+
throw new TypeError('step.parallel takes exactly the Steps its function declares, each once, as an array.');
|
|
186
|
+
}
|
|
187
|
+
group = returned.map((entry) => declared.get(entry));
|
|
188
|
+
const ids = new Set();
|
|
189
|
+
for (const { id } of group) {
|
|
190
|
+
if (steps.has(id) || ids.has(id)) {
|
|
191
|
+
throw new TypeError(`Workflow Step ${id} occurs more than once.`);
|
|
192
|
+
}
|
|
193
|
+
ids.add(id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (cause) {
|
|
197
|
+
return Promise.reject(cause);
|
|
198
|
+
}
|
|
199
|
+
if (group.length === 0)
|
|
200
|
+
return Promise.resolve([]);
|
|
201
|
+
for (const { id } of group)
|
|
202
|
+
steps.add(id);
|
|
203
|
+
return sequenced(`parallel Steps ${group.map(({ id }) => id).join(', ')}`, async () => {
|
|
204
|
+
const started = group.map(({ id, held, execute }) => journaled(id, `step:${id}`, held.timeoutMs, held.retry, execute));
|
|
205
|
+
const settled = await engine.RestatePromise.allSettled(started);
|
|
206
|
+
const failed = settled.findIndex(({ status }) => status === 'rejected');
|
|
207
|
+
if (failed === -1) {
|
|
208
|
+
return settled.map((result) => result.value);
|
|
209
|
+
}
|
|
210
|
+
const cause = settled[failed].reason;
|
|
211
|
+
throw replayedFailure(engine, cause, definition, group[failed].id) ?? cause;
|
|
212
|
+
});
|
|
129
213
|
};
|
|
130
214
|
return Object.freeze({
|
|
131
215
|
domain,
|
|
@@ -136,26 +220,28 @@ function durableContext(input, context, envelope) {
|
|
|
136
220
|
run: step,
|
|
137
221
|
query: (id, query, value) => step(id, (authority) => authority.query(query, value)),
|
|
138
222
|
mutate: (id, mutation, value) => step(id, (authority) => authority.mutate(mutation, value)),
|
|
223
|
+
parallel,
|
|
139
224
|
}),
|
|
140
225
|
async sleep(durationMs) {
|
|
141
226
|
if (!Number.isSafeInteger(durationMs) || durationMs < 0 || durationMs > MAXIMUM_SLEEP_MS) {
|
|
142
227
|
throw new TypeError(`Durable sleep must be an integer between 0 and ${MAXIMUM_SLEEP_MS} ms.`);
|
|
143
228
|
}
|
|
144
|
-
await context.sleep(durationMs);
|
|
229
|
+
await sequenced('sleep', () => Promise.resolve(context.sleep(durationMs)));
|
|
145
230
|
},
|
|
146
231
|
async awaitSignal(name, options) {
|
|
147
232
|
if (typeof name !== 'string' || name.length === 0 || name.length > MAXIMUM_SIGNAL_NAME) {
|
|
148
233
|
throw new TypeError('Durable signal name must have 1 to 128 characters.');
|
|
149
234
|
}
|
|
150
|
-
if (options === undefined)
|
|
151
|
-
return (await context.promise(name).get());
|
|
235
|
+
if (options === undefined) {
|
|
236
|
+
return (await sequenced(`wait for signal ${name}`, () => Promise.resolve(context.promise(name).get())));
|
|
237
|
+
}
|
|
152
238
|
const timeoutMs = options?.timeoutMs;
|
|
153
239
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAXIMUM_SLEEP_MS) {
|
|
154
240
|
throw new TypeError(`Durable signal timeout must be an integer between 0 and ${MAXIMUM_SLEEP_MS} ms.`);
|
|
155
241
|
}
|
|
156
242
|
// The engine journals the race between the signal and the timer: a replay settles it alike.
|
|
157
243
|
try {
|
|
158
|
-
const value = await context.promise(name).get().orTimeout(timeoutMs);
|
|
244
|
+
const value = await sequenced(`wait for signal ${name}`, () => Promise.resolve(context.promise(name).get().orTimeout(timeoutMs)));
|
|
159
245
|
return Object.freeze({ kind: 'signal', value });
|
|
160
246
|
}
|
|
161
247
|
catch (cause) {
|
|
@@ -167,7 +253,7 @@ function durableContext(input, context, envelope) {
|
|
|
167
253
|
cancelled: (error) => error instanceof engine.CancelledError,
|
|
168
254
|
exhausted: (error) => error instanceof StepExhausted,
|
|
169
255
|
retryAfter: (delayMs, cause) => new StepRetryAfter(delayMs, cause),
|
|
170
|
-
|
|
256
|
+
awaitExecution: (target) => sequenced(`wait for Execution ${target.id}`, async () => {
|
|
171
257
|
for (let poll = 1;; poll++) {
|
|
172
258
|
// A poll is no Step of the author's: it has no budget, so an outage pauses the run instead.
|
|
173
259
|
const name = `await:${target.id}:${poll}`;
|
|
@@ -189,7 +275,7 @@ function durableContext(input, context, envelope) {
|
|
|
189
275
|
return polled.outcome;
|
|
190
276
|
await context.sleep(Math.min(AWAIT_MAXIMUM_PAUSE_MS, 1_000 * 2 ** Math.min(poll, 16)));
|
|
191
277
|
}
|
|
192
|
-
},
|
|
278
|
+
}),
|
|
193
279
|
...(execution.receiver === undefined ? {} : { self: execution.receiver }),
|
|
194
280
|
});
|
|
195
281
|
}
|
|
@@ -59,6 +59,8 @@ class JournalMismatch extends Error {
|
|
|
59
59
|
this.name = 'JournalMismatch';
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** The promises the test engine's `run` returned: the only ones its combinator accepts. */
|
|
63
|
+
const RUNS = new WeakSet();
|
|
62
64
|
const ENGINE = Object.freeze({
|
|
63
65
|
createEndpointHandler: () => () => Promise.reject(new Error('A test engine serves no HTTP.')),
|
|
64
66
|
workflow: (definition) => definition,
|
|
@@ -66,6 +68,17 @@ const ENGINE = Object.freeze({
|
|
|
66
68
|
CancelledError,
|
|
67
69
|
RetryableError,
|
|
68
70
|
TimeoutError,
|
|
71
|
+
RestatePromise: Object.freeze({
|
|
72
|
+
allSettled(values) {
|
|
73
|
+
// As the engine refuses a promise it did not create: only its own runs combine.
|
|
74
|
+
values.forEach((value, index) => {
|
|
75
|
+
if (!RUNS.has(value)) {
|
|
76
|
+
throw new TypeError(`Promise index ${index} used inside the combinator is not a run.`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return Promise.allSettled(values);
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
69
82
|
});
|
|
70
83
|
/** Create an in-memory workflow engine for tests. */
|
|
71
84
|
export function createTestEngine(options = {}) {
|
|
@@ -299,7 +312,11 @@ class Attempt {
|
|
|
299
312
|
this.run = run;
|
|
300
313
|
this.mode = mode;
|
|
301
314
|
this.context = Object.freeze({
|
|
302
|
-
run: (name, action, options) =>
|
|
315
|
+
run: (name, action, options) => {
|
|
316
|
+
const run = this.journalRun(name, action, options);
|
|
317
|
+
RUNS.add(run);
|
|
318
|
+
return run;
|
|
319
|
+
},
|
|
303
320
|
sleep: (durationMs) => this.sleep(durationMs),
|
|
304
321
|
promise: (name) => ({ get: () => this.promise(name) }),
|
|
305
322
|
request: () => ({ attemptCompletedSignal: this.controller.signal }),
|
|
@@ -396,6 +413,9 @@ class Attempt {
|
|
|
396
413
|
return (async () => {
|
|
397
414
|
try {
|
|
398
415
|
const value = await action();
|
|
416
|
+
// A Step that completes once its attempt ended, as a parallel sibling may, is lost with it.
|
|
417
|
+
if (this.done)
|
|
418
|
+
return this.block();
|
|
399
419
|
if (this.run.interruptions.delete(name)) {
|
|
400
420
|
// A crash after the effect: the run is attempted again, and the Step with it.
|
|
401
421
|
this.end({ kind: 'failed', error: new Interruption(name.replace(/^step:/u, '')) });
|
|
@@ -406,6 +426,8 @@ class Attempt {
|
|
|
406
426
|
return value;
|
|
407
427
|
}
|
|
408
428
|
catch (cause) {
|
|
429
|
+
if (this.done)
|
|
430
|
+
return this.block();
|
|
409
431
|
if (cause instanceof TerminalError) {
|
|
410
432
|
entry.settled = true;
|
|
411
433
|
entry.error = cause;
|
|
@@ -38,6 +38,11 @@ export declare function hasForbiddenGlobalCall(file: SourceFile): Array<{
|
|
|
38
38
|
}>;
|
|
39
39
|
/** The global a call reaches, as `name(…)` with no import or local binding, or `globalThis.name(…)`. */
|
|
40
40
|
export declare function globalCallName(file: SourceFile, node: ts.CallExpression): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The member path an expression reads from a global: `Date.now` when no import or local binding
|
|
43
|
+
* names `Date`, the same through `globalThis.Date.now`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function globalPath(file: SourceFile, input: ts.Expression): readonly string[] | undefined;
|
|
41
46
|
/**
|
|
42
47
|
* Resolve the path of `input` from the first parameter of `run`, or of a durable Step callback in
|
|
43
48
|
* it, through one immutable local alias, without following arbitrary helpers.
|
|
@@ -223,18 +223,26 @@ export function hasForbiddenGlobalCall(file) {
|
|
|
223
223
|
}
|
|
224
224
|
/** The global a call reaches, as `name(…)` with no import or local binding, or `globalThis.name(…)`. */
|
|
225
225
|
export function globalCallName(file, node) {
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
const path = globalPath(file, node.expression);
|
|
227
|
+
return path?.length === 1 ? path[0] : undefined;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The member path an expression reads from a global: `Date.now` when no import or local binding
|
|
231
|
+
* names `Date`, the same through `globalThis.Date.now`.
|
|
232
|
+
*/
|
|
233
|
+
export function globalPath(file, input) {
|
|
234
|
+
const expression = unwrap(input);
|
|
231
235
|
const chain = propertyChain(expression);
|
|
232
|
-
if (chain
|
|
236
|
+
if (!chain)
|
|
233
237
|
return undefined;
|
|
234
238
|
let root = expression;
|
|
235
239
|
while (ts.isPropertyAccessExpression(root))
|
|
236
240
|
root = unwrap(root.expression);
|
|
237
|
-
|
|
241
|
+
if (!ts.isIdentifier(root) || isLocallyBound(root, root.text))
|
|
242
|
+
return undefined;
|
|
243
|
+
if (chain[0] === 'globalThis')
|
|
244
|
+
return chain.length > 1 ? chain.slice(1) : undefined;
|
|
245
|
+
return importedSymbol(file, root) ? undefined : chain;
|
|
238
246
|
}
|
|
239
247
|
/**
|
|
240
248
|
* Resolve the path of `input` from the first parameter of `run`, or of a durable Step callback in
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { expressionType, propertyChain, unwrap, visit, visitOwnBody, } from '../../adapters/typescript/index.js';
|
|
3
|
-
import { ambiguity, calls, contextPath, globalCallName, production, propertyExpression, staticId, violation, } from './shared.js';
|
|
3
|
+
import { ambiguity, calls, contextPath, globalCallName, globalPath, production, propertyExpression, staticId, violation, } from './shared.js';
|
|
4
4
|
// `query` and `mutate` are the durable Step sugar over `run`; each is one Step too.
|
|
5
5
|
const STEP_METHODS = new Set(['run', 'query', 'mutate']);
|
|
6
6
|
// Members only a durable Workflow context has: using one proves the Workflow durable.
|
|
@@ -15,6 +15,14 @@ const DURABLE_CONTEXT_MEMBERS = new Set([
|
|
|
15
15
|
const AUTHORITIES = new Set(['caller', 'self']);
|
|
16
16
|
// Raw Kernel graph reads a Step's `kernel` offers beside `mutate`.
|
|
17
17
|
const KERNEL_READS = new Set(['query', 'get', 'getOrThrow', 'neighbors']);
|
|
18
|
+
// Global reads a replay answers anew: the clock and randomness.
|
|
19
|
+
const REPLAY_VALUES = new Set([
|
|
20
|
+
'Date.now',
|
|
21
|
+
'Math.random',
|
|
22
|
+
'crypto.randomUUID',
|
|
23
|
+
'crypto.getRandomValues',
|
|
24
|
+
'performance.now',
|
|
25
|
+
]);
|
|
18
26
|
// Array methods that run their callback once per element.
|
|
19
27
|
const ITERATING_METHODS = new Set([
|
|
20
28
|
'every',
|
|
@@ -191,14 +199,31 @@ export const workflowRules = [
|
|
|
191
199
|
return evidence;
|
|
192
200
|
},
|
|
193
201
|
},
|
|
202
|
+
{
|
|
203
|
+
id: 'FNC-REPLAY-VALUES',
|
|
204
|
+
ruleRevision: 'ddadc9e08be8faa36c38fd47d82d6c7815f68ffcac8806af958b13df3872b995',
|
|
205
|
+
evaluate(project) {
|
|
206
|
+
const evidence = [];
|
|
207
|
+
for (const workflow of durableWorkflows(project, evidence)) {
|
|
208
|
+
for (const { node, name } of replayValues(workflow.file, workflow.run)) {
|
|
209
|
+
evidence.push(violation(workflow.file, node, `Durable Workflow reads ${name} outside its steps; a replay reads another value and can take another path. Read it in a step.`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return evidence;
|
|
213
|
+
},
|
|
214
|
+
},
|
|
194
215
|
];
|
|
216
|
+
/** The `step.run` callbacks of every Workflow its source proves durable. */
|
|
217
|
+
function durableSteps(project, evidence) {
|
|
218
|
+
return durableWorkflows(project, evidence).flatMap(({ steps }) => steps);
|
|
219
|
+
}
|
|
195
220
|
/**
|
|
196
|
-
*
|
|
221
|
+
* Every Workflow its source proves durable, with its `step.run` callbacks: it uses a member only the
|
|
197
222
|
* durable context has, `step.query` or `step.mutate`, or a Step callback that receives
|
|
198
|
-
* capabilities. An inline Workflow never retries a step and is not inspected.
|
|
223
|
+
* capabilities. An inline Workflow never replays nor retries a step and is not inspected.
|
|
199
224
|
*/
|
|
200
|
-
function
|
|
201
|
-
const
|
|
225
|
+
function durableWorkflows(project, evidence) {
|
|
226
|
+
const workflows = [];
|
|
202
227
|
for (const file of production(project, 'functions')) {
|
|
203
228
|
const helpers = localFunctions(file.source);
|
|
204
229
|
for (const definition of workflowDefinitions(file)) {
|
|
@@ -232,10 +257,55 @@ function durableSteps(project, evidence) {
|
|
|
232
257
|
runs.push({ file, call: node, label: id ?? 'with a dynamic id', callback });
|
|
233
258
|
});
|
|
234
259
|
if (durable)
|
|
235
|
-
|
|
260
|
+
workflows.push({ file, run, steps: runs });
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
|
-
return
|
|
263
|
+
return workflows;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Reads of the clock and randomness in the code a replay runs again: the run's own body, and the
|
|
267
|
+
* functions it invokes there at once, immediately invoked or as an iterating Array callback. Step
|
|
268
|
+
* callbacks and every other nested function run at a time the source does not prove, and are
|
|
269
|
+
* skipped.
|
|
270
|
+
*/
|
|
271
|
+
function replayValues(file, run) {
|
|
272
|
+
const found = [];
|
|
273
|
+
const walk = (node) => {
|
|
274
|
+
if (ts.isFunctionLike(node) && !invokedAtOnce(node))
|
|
275
|
+
return;
|
|
276
|
+
const name = replayValue(file, node);
|
|
277
|
+
if (name)
|
|
278
|
+
found.push({ node, name });
|
|
279
|
+
node.forEachChild(walk);
|
|
280
|
+
};
|
|
281
|
+
if (run.body)
|
|
282
|
+
walk(run.body);
|
|
283
|
+
return found;
|
|
284
|
+
}
|
|
285
|
+
/** The clock or random value `node` reads from a global, as the rule names it. */
|
|
286
|
+
function replayValue(file, node) {
|
|
287
|
+
if (ts.isNewExpression(node)) {
|
|
288
|
+
const path = globalPath(file, node.expression);
|
|
289
|
+
return path?.join('.') === 'Date' && (node.arguments?.length ?? 0) === 0
|
|
290
|
+
? 'new Date()'
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
if (!ts.isCallExpression(node))
|
|
294
|
+
return undefined;
|
|
295
|
+
const name = globalPath(file, node.expression)?.join('.');
|
|
296
|
+
// `Date` called as a function answers the current time, whatever it receives.
|
|
297
|
+
if (name === 'Date')
|
|
298
|
+
return 'Date()';
|
|
299
|
+
return name !== undefined && REPLAY_VALUES.has(name) ? `${name}()` : undefined;
|
|
300
|
+
}
|
|
301
|
+
/** A function the code around it runs at once: immediately invoked, or an iterating Array callback. */
|
|
302
|
+
function invokedAtOnce(fn) {
|
|
303
|
+
if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn))
|
|
304
|
+
return false;
|
|
305
|
+
let site = fn;
|
|
306
|
+
while (ts.isParenthesizedExpression(site.parent))
|
|
307
|
+
site = site.parent;
|
|
308
|
+
return ((ts.isCallExpression(site.parent) && site.parent.expression === site) || iteratingCallback(fn));
|
|
239
309
|
}
|
|
240
310
|
/** Only a durable Step takes a declaration, `{ id, retry, timeoutMs }`, in place of its id. */
|
|
241
311
|
function declaredStep(argument) {
|