@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.
@@ -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';
@@ -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 === null ||
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 journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(context.run(name, async () => {
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)).catch((cause) => {
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
- async awaitExecution(target) {
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) => this.journalRun(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 expression = unwrap(node.expression);
227
- if (ts.isIdentifier(expression)) {
228
- const name = expression.text;
229
- return importedSymbol(file, expression) || isLocallyBound(expression, name) ? undefined : name;
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?.length !== 2 || chain[0] !== 'globalThis')
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
- return ts.isIdentifier(root) && isLocallyBound(root, root.text) ? undefined : chain[1];
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
- * The `step.run` callbacks of every Workflow its source proves durable: it uses a member only the
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 durableSteps(project, evidence) {
201
- const steps = [];
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
- steps.push(...runs);
260
+ workflows.push({ file, run, steps: runs });
236
261
  }
237
262
  }
238
- return steps;
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) {