@sanity/runtime-cli 17.15.0 → 17.16.0-next.g05d3b7f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +19 -19
  2. package/dist/actions/blueprints/assets.d.ts +3 -3
  3. package/dist/actions/blueprints/assets.js +3 -3
  4. package/dist/actions/blueprints/blueprint.d.ts +20 -9
  5. package/dist/actions/blueprints/blueprint.js +25 -79
  6. package/dist/actions/blueprints/config.d.ts +1 -0
  7. package/dist/actions/blueprints/config.js +7 -1
  8. package/dist/actions/blueprints/pre-deploy.js +3 -3
  9. package/dist/actions/blueprints/process-assets.js +3 -2
  10. package/dist/baseCommands.js +3 -3
  11. package/dist/config.d.ts +1 -2
  12. package/dist/config.js +1 -2
  13. package/dist/cores/blueprints/doctor.js +2 -2
  14. package/dist/cores/blueprints/init.js +11 -2
  15. package/dist/cores/functions/logs.d.ts +7 -0
  16. package/dist/cores/functions/logs.js +17 -6
  17. package/dist/cores/functions/test.js +43 -9
  18. package/dist/server/app.js +4 -4
  19. package/dist/server/static/components/app.css +8 -1
  20. package/dist/utils/blueprints/group-resources.d.ts +2 -1
  21. package/dist/utils/blueprints/group-resources.js +3 -3
  22. package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -2
  23. package/dist/utils/blueprints/resolve-deployed-resources.js +4 -4
  24. package/dist/utils/bundles/prepare-asset.d.ts +3 -3
  25. package/dist/utils/bundles/prepare-asset.js +8 -7
  26. package/dist/utils/functions/child-process-wrapper.js +1 -1
  27. package/dist/utils/functions/child-runtime/durable.js +141 -23
  28. package/dist/utils/functions/child-runtime/getBatchResults.js +75 -0
  29. package/dist/utils/functions/child-runtime/invoke.d.ts +1 -0
  30. package/dist/utils/functions/child-runtime/invoke.js +26 -9
  31. package/dist/utils/functions/child-runtime/step-history.d.ts +15 -0
  32. package/dist/utils/functions/child-runtime/step-history.js +52 -0
  33. package/dist/utils/functions/extract-durable-config.js +1 -4
  34. package/dist/utils/functions/invoke/local.js +27 -11
  35. package/dist/utils/functions/invoke/nested.js +2 -1
  36. package/dist/utils/functions/invoke/resolve-invoke-type.d.ts +2 -2
  37. package/dist/utils/functions/invoke/resolve-invoke-type.js +2 -2
  38. package/dist/utils/functions/transpile/function.js +3 -0
  39. package/dist/utils/types.d.ts +24 -13
  40. package/dist/utils/types.js +1 -1
  41. package/oclif.manifest.json +1 -1
  42. package/package.json +3 -3
  43. package/dist/actions/blueprints/resolve.d.ts +0 -51
  44. package/dist/actions/blueprints/resolve.js +0 -52
@@ -45,7 +45,7 @@ async function deleteLogs({ name, externalId, auth, force, log, }) {
45
45
  spinner.succeed('Logs deleted');
46
46
  return { success: true };
47
47
  }
48
- async function streamLogs({ name, externalId, auth, log, }) {
48
+ async function streamLogs({ name, externalId, auth, utc, log, }) {
49
49
  const spinner = log.ora(`Setting up streaming logs session for function ${niceId(name)}`).start();
50
50
  try {
51
51
  spinner.stop();
@@ -58,7 +58,7 @@ async function streamLogs({ name, externalId, auth, log, }) {
58
58
  alreadyOpened = true;
59
59
  };
60
60
  const renderLog = (logEntry) => {
61
- log(formatLog(logEntry, true));
61
+ log(formatLog(logEntry, utc));
62
62
  };
63
63
  streamLogsAction(externalId, auth, renderLog, onOpen, (error) => log.error(`${styleText('red', 'Error:')} ${error}`), log);
64
64
  // Return a special key for streaming mode
@@ -121,10 +121,7 @@ async function getLogs({ name, externalId, auth, limit, json, utc, log, }) {
121
121
  */
122
122
  function formatLog(entry, utc) {
123
123
  const { time, level, message, operationName, attempt, executionArn } = entry;
124
- const date = new Date(time);
125
- const [dateString, timeString] = utc
126
- ? date.toISOString().slice(0, 19).split('T')
127
- : [date.toLocaleDateString(), date.toLocaleTimeString()];
124
+ const [dateString, timeString] = formatTimestamp(new Date(time), utc);
128
125
  const durableAnnotation = durableLogAnnotation({ operationName, attempt, executionArn });
129
126
  return [
130
127
  styleText('bold', dateString),
@@ -134,6 +131,20 @@ function formatLog(entry, utc) {
134
131
  message,
135
132
  ].join(' ');
136
133
  }
134
+ /**
135
+ * Formats a date as `[YYYY-MM-DD, HH:mm:ss]` in either UTC or local time.
136
+ * The shape is identical in both cases so `--utc` only changes the timezone.
137
+ * @param date
138
+ * @param utc
139
+ */
140
+ export function formatTimestamp(date, utc) {
141
+ if (utc)
142
+ return date.toISOString().slice(0, 19).split('T');
143
+ const pad = (n) => n.toString().padStart(2, '0');
144
+ const dateString = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
145
+ const timeString = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
146
+ return [dateString, timeString];
147
+ }
137
148
  /**
138
149
  * Log annotation for durable functions.
139
150
  * @param operationName
@@ -1,7 +1,7 @@
1
1
  import { dirname } from 'node:path';
2
2
  import { testAction } from '../../actions/functions/test.js';
3
3
  import config from '../../config.js';
4
- import { STEP_PHASE_GLYPH } from '../../constants.js';
4
+ import { SANITY_FUNCTION_DURABLE, STEP_PHASE_GLYPH } from '../../constants.js';
5
5
  import { resolveDeployedResources } from '../../utils/blueprints/resolve-deployed-resources.js';
6
6
  import { createDurableJsonCollector } from '../../utils/functions/durable-logs.js';
7
7
  import { findFunctionInResources, getFunctionNames } from '../../utils/functions/find.js';
@@ -55,7 +55,7 @@ export async function functionTestCore(options) {
55
55
  apiHost: config.apiUrl,
56
56
  token: config.token || undefined,
57
57
  withUserToken,
58
- resources: resolvedResources,
58
+ resolvedResources,
59
59
  blueprintFileDir: dirname(blueprint.fileInfo.blueprintFilePath),
60
60
  log,
61
61
  };
@@ -88,15 +88,40 @@ export async function functionTestCore(options) {
88
88
  * surfaced after the spinner resolves, including failed ones — a downstream
89
89
  * error is fire-and-forget (it doesn't reject the caller) but is still shown.
90
90
  */
91
- const runNested = async (nestedName, nestedPayload, ctx, depth, callStack) => {
91
+ const runNested = async (nestedName, nestedPayload, ctx, depth, callStack, metadata) => {
92
92
  const order = ++nestedOrder;
93
93
  const nestedResource = findFunctionInResources(blueprint.resources, nestedName);
94
- const result = await runFunction(nestedResource, { payload: nestedPayload, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested });
95
- nestedRuns.push({ order, name: nestedName, logs: result.logs, error: result.error });
96
- return result;
94
+ const isLiveDurable = metadata?.origin === 'step.delegate' && nestedResource.type === SANITY_FUNCTION_DURABLE;
95
+ if (isLiveDurable) {
96
+ log(`↳ BEGIN: invoke('${nestedName}')`);
97
+ }
98
+ try {
99
+ const result = await runFunction(nestedResource, { payload: nestedPayload, event: 'nested' }, { ...ctx, resources: resolvedResources }, { depth, callStack, onNestedInvoke: runNested, onStepEvent });
100
+ if (result.error && isLiveDurable) {
101
+ log(result.error.stack || result.error.message);
102
+ }
103
+ nestedRuns.push({
104
+ order,
105
+ name: nestedName,
106
+ logs: result.logs,
107
+ error: result.error,
108
+ isLiveDurable,
109
+ });
110
+ return result;
111
+ }
112
+ finally {
113
+ if (isLiveDurable) {
114
+ log(`↳ END: invoke('${nestedName}')`);
115
+ }
116
+ }
97
117
  };
98
118
  const logNestedRuns = () => {
99
- for (const run of [...nestedRuns].sort((a, b) => a.order - b.order)) {
119
+ // Remove buffered logs for durable runs
120
+ const runs = plan.isDurable ? nestedRuns.splice(0) : [...nestedRuns];
121
+ for (const run of runs.sort((a, b) => a.order - b.order)) {
122
+ // Skip if is Durable so log ordering is retained
123
+ if (run.isLiveDurable)
124
+ continue;
100
125
  log(`↳ BEGIN: invoke('${run.name}')`);
101
126
  // Show any logs received
102
127
  if (run.logs?.trim()) {
@@ -132,10 +157,19 @@ export async function functionTestCore(options) {
132
157
  if (event.kind === 'delegate') {
133
158
  logNestedRuns();
134
159
  }
135
- const glyph = STEP_PHASE_GLYPH[event.phase] ?? '•';
160
+ const willRetry = event.phase === 'error' && typeof event.retryDelayMs === 'number';
161
+ const glyph = willRetry ? '⟳' : (STEP_PHASE_GLYPH[event.phase] ?? '•');
136
162
  const duration = typeof event.durationMs === 'number' ? ` (${Math.round(event.durationMs)}ms)` : '';
163
+ const retriesPerformed = (event.attempt ?? 1) - 1;
164
+ let retryNote = '';
165
+ if (willRetry) {
166
+ retryNote = ` retry ${event.attempt} of ${event.maxAttempts}`;
167
+ }
168
+ else if (event.phase === 'error' && retriesPerformed > 0) {
169
+ retryNote = ` failed after ${retriesPerformed} retries`;
170
+ }
137
171
  const detail = event.phase === 'error' && event.error ? ` ${event.error.message}` : '';
138
- lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}`);
172
+ lines.push(` ${glyph} ${event.name} [${event.kind}]${duration}${detail}${retryNote}`);
139
173
  }
140
174
  return lines;
141
175
  }
@@ -1,14 +1,14 @@
1
1
  import { existsSync, readFileSync, watchFile } from 'node:fs';
2
2
  import * as http from 'node:http';
3
3
  import { dirname } from 'node:path';
4
+ import { resolveStackConfig } from '@sanity/blueprints/resolve';
4
5
  import { default as mime } from 'mime-types';
5
6
  import { WebSocket, WebSocketServer } from 'ws';
6
7
  import { findBlueprintFile, loadAndParseBlueprint } from '../actions/blueprints/blueprint.js';
7
8
  import { readConfigFile } from '../actions/blueprints/config.js';
8
- import { resolveIds } from '../actions/blueprints/resolve.js';
9
9
  import config from '../config.js';
10
10
  import { isRecord } from '../utils/is-record.js';
11
- import { isEventType, isGroqContextOptions, } from '../utils/types.js';
11
+ import { isEventType, } from '../utils/types.js';
12
12
  import { handleInvokeRequest } from './handlers/invoke.js';
13
13
  const app = (host, port, validateResources, executionOptions) => {
14
14
  // Send a JSON envelope to every connected UI client.
@@ -47,7 +47,7 @@ const app = (host, port, validateResources, executionOptions) => {
47
47
  const blueprint = await loadAndParseBlueprint(undefined, { validateResources });
48
48
  const { parsedBlueprint, fileInfo, module: blueprintModule } = blueprint;
49
49
  const blueprintConfig = readConfigFile(fileInfo.blueprintFilePath);
50
- const { projectId, organizationId } = resolveIds({
50
+ const { projectId, organizationId } = resolveStackConfig({
51
51
  module: blueprintModule,
52
52
  config: blueprintConfig,
53
53
  });
@@ -75,7 +75,7 @@ const app = (host, port, validateResources, executionOptions) => {
75
75
  const { data, func: functionName, metadata, } = parseInvokeRequest(Buffer.concat(body), dirname(fileInfo.blueprintFilePath));
76
76
  const { context, event } = data;
77
77
  // replace user token if required
78
- if (isGroqContextOptions(context)) {
78
+ if ('clientOptions' in context) {
79
79
  if (context.clientOptions.token) {
80
80
  context.clientOptions.token = config.token || undefined;
81
81
  }
@@ -191,7 +191,7 @@ body {
191
191
  .border-right {
192
192
  border-right-width: 1px;
193
193
  border-right-style: solid;
194
- border-top-color: var(--card-border-color);
194
+ border-right-color: var(--card-border-color);
195
195
  }
196
196
 
197
197
  .hr-border {
@@ -413,6 +413,13 @@ ol[type='content'] li:hover:not(.selected:not(.selected)) {
413
413
  border-right: none;
414
414
  }
415
415
 
416
+ fieldset {
417
+ & :is(input:not([role='switch']), select, textarea) {
418
+ border: 1px solid light-dark(var(--gray-200), var(--gray-800)) !important;
419
+ border-radius: var(--radius-2);
420
+ }
421
+ }
422
+
416
423
  & :is(input:not([is='switch']), select, textarea) {
417
424
  border: 1px solid light-dark(var(--gray-200), var(--gray-800)) !important;
418
425
  border-radius: var(--radius-2);
@@ -1,8 +1,9 @@
1
1
  import type { BlueprintResource } from '@sanity/blueprints';
2
+ import type { ResolvedResource } from '../types.js';
2
3
  export type GroupedBlueprintResource = {
3
4
  id: string;
4
5
  name: string;
5
6
  type: string;
6
7
  };
7
8
  export type GroupedBlueprintResources = Record<string, GroupedBlueprintResource[]>;
8
- export declare function groupBlueprintResources(blueprintResources: BlueprintResource[]): GroupedBlueprintResources;
9
+ export declare function groupBlueprintResources(blueprintResources: ResolvedResource<BlueprintResource>[]): GroupedBlueprintResources;
@@ -8,7 +8,8 @@ const BLUEPRINT_TYPE_TO_KEY = {
8
8
  };
9
9
  export function groupBlueprintResources(blueprintResources) {
10
10
  const grouped = {};
11
- for (const resource of blueprintResources) {
11
+ for (const resolvedResource of blueprintResources) {
12
+ const resource = resolvedResource.resource;
12
13
  if (resource.type === 'sanity.access.robot')
13
14
  continue;
14
15
  const key = (resource.type?.startsWith('sanity.function.')
@@ -18,9 +19,8 @@ export function groupBlueprintResources(blueprintResources) {
18
19
  continue;
19
20
  if (!grouped[key])
20
21
  grouped[key] = [];
21
- const externalId = resource.externalId;
22
22
  grouped[key].push({
23
- id: externalId || 'not-deployed',
23
+ id: resolvedResource.externalId || 'not-deployed',
24
24
  name: resource.name,
25
25
  type: resource.type,
26
26
  });
@@ -1,5 +1,5 @@
1
1
  import type { BlueprintResource } from '@sanity/blueprints';
2
- import type { AuthParams } from '../types.js';
2
+ import type { AuthParams, ResolvedResource } from '../types.js';
3
3
  interface ResolveOptions {
4
4
  resources: BlueprintResource[];
5
5
  stackId?: string;
@@ -12,5 +12,5 @@ interface ResolveOptions {
12
12
  * resources that aren't yet deployed are preserved without an externalId.
13
13
  * Any failure to fetch the stack falls back to the raw local resources.
14
14
  */
15
- export declare function resolveDeployedResources({ resources, stackId, auth, fetchFn, }: ResolveOptions): Promise<BlueprintResource[]>;
15
+ export declare function resolveDeployedResources({ resources, stackId, auth, fetchFn, }: ResolveOptions): Promise<ResolvedResource<BlueprintResource>[]>;
16
16
  export {};
@@ -7,11 +7,11 @@ import { getStack } from '../../actions/blueprints/stacks.js';
7
7
  */
8
8
  export async function resolveDeployedResources({ resources, stackId, auth, fetchFn, }) {
9
9
  if (!stackId || !auth?.token || !auth.scopeType || !auth.scopeId)
10
- return resources;
10
+ return resources.map((r) => ({ resource: r }));
11
11
  try {
12
12
  const response = await getStack({ stackId, auth, fetchFn });
13
13
  if (!response.ok || !Array.isArray(response.stack?.resources))
14
- return resources;
14
+ return resources.map((r) => ({ resource: r }));
15
15
  const deployedByKey = new Map();
16
16
  for (const deployed of response.stack.resources) {
17
17
  if (deployed.externalId) {
@@ -20,10 +20,10 @@ export async function resolveDeployedResources({ resources, stackId, auth, fetch
20
20
  }
21
21
  return resources.map((resource) => {
22
22
  const externalId = deployedByKey.get(`${resource.type}:${resource.name}`);
23
- return externalId ? { ...resource, externalId } : resource;
23
+ return { resource, externalId };
24
24
  });
25
25
  }
26
26
  catch {
27
- return resources;
27
+ return resources.map((r) => ({ resource: r }));
28
28
  }
29
29
  }
@@ -1,7 +1,7 @@
1
1
  import type { BlueprintApplicationResource, BlueprintInstallationConfigResource, BlueprintStudioResource } from '@sanity/blueprints';
2
2
  import { type BuildStudioEventListener } from '@sanity/cli-build/_internal/build';
3
3
  import type { Output } from '@sanity/cli-core';
4
- import type { AssetPrepResult } from '../types.js';
4
+ import type { AssetPrepResult, ResolvedResource } from '../types.js';
5
5
  export type BundleResource = BlueprintStudioResource | BlueprintApplicationResource | BlueprintInstallationConfigResource;
6
6
  type BuildContext<R extends BundleResource> = {
7
7
  resource: R;
@@ -28,9 +28,9 @@ export declare const BUNDLE_SPECS: {
28
28
  build: typeof buildInstallationConfigBundle;
29
29
  };
30
30
  };
31
- export declare function prepareBundleAsset<R extends BundleResource>({ blueprintFileDir, resource, }: {
31
+ export declare function prepareBundleAsset<R extends BundleResource>({ blueprintFileDir, resolvedResource, }: {
32
32
  blueprintFileDir: string;
33
- resource: R;
33
+ resolvedResource: ResolvedResource<R>;
34
34
  }): Promise<AssetPrepResult>;
35
35
  declare function buildStudioBundle({ resource, appId, workDir, outDir, output, eventListener, }: BuildContext<BlueprintStudioResource>): Promise<BuildResult>;
36
36
  declare function buildApplicationBundle({ resource, appId, workDir, outDir, output, eventListener, }: BuildContext<BlueprintApplicationResource>): Promise<BuildResult>;
@@ -12,10 +12,11 @@ export const BUNDLE_SPECS = {
12
12
  'sanity.application': { requiresAppId: true, build: buildApplicationBundle },
13
13
  'sanity.installation.config': { requiresAppId: false, build: buildInstallationConfigBundle },
14
14
  };
15
- export async function prepareBundleAsset({ blueprintFileDir, resource, }) {
15
+ export async function prepareBundleAsset({ blueprintFileDir, resolvedResource, }) {
16
+ const resource = resolvedResource.resource;
16
17
  // The lookup can't prove it yields the spec matching this R, so it's asserted once here.
17
18
  const spec = BUNDLE_SPECS[resource.type];
18
- const appId = getAppId(resource);
19
+ const appId = getAppId(resolvedResource);
19
20
  if (spec.requiresAppId && !appId) {
20
21
  return {
21
22
  success: false,
@@ -260,12 +261,12 @@ async function getInstalledSanityVersion(workDir) {
260
261
  return undefined;
261
262
  }
262
263
  }
263
- function getAppId(resource) {
264
- if ('externalId' in resource && typeof resource.externalId === 'string' && resource.externalId) {
265
- return resource.externalId;
264
+ function getAppId(resolvedResource) {
265
+ if (resolvedResource.externalId) {
266
+ return resolvedResource.externalId;
266
267
  }
267
- else if (resource.lifecycle?.ownershipAction?.type === 'attach') {
268
- return resource.lifecycle.ownershipAction.id;
268
+ else if (resolvedResource.resource.lifecycle?.ownershipAction?.type === 'attach') {
269
+ return resolvedResource.resource.lifecycle.ownershipAction.id;
269
270
  }
270
271
  return undefined;
271
272
  }
@@ -86,7 +86,7 @@ process.on('message', async (data) => {
86
86
  {
87
87
  emit: (e) => process.send(JSON.stringify({type: 'step-event', event: e})),
88
88
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, noWait ? 0 : ms)),
89
- invoke: invoker.invoke,
89
+ invoke: invoker.delegate,
90
90
  },
91
91
  )
92
92
  } else {
@@ -1,5 +1,7 @@
1
1
  import {performance} from 'node:perf_hooks'
2
2
  import {inspect} from 'node:util'
3
+ import {getBatchResults} from './getBatchResults.js'
4
+ import {createStepHistory} from './step-history.js'
3
5
 
4
6
  /**
5
7
  *
@@ -23,10 +25,13 @@ const formatArgs = (args) =>
23
25
  * @param handler - callback being run
24
26
  * @param context - data to send to the operation
25
27
  * @param event - the step being run
28
+ * @param [branchName] - the name of the branch being run
26
29
  * @param io
27
30
  */
28
- export async function runDurable(handler, {context, event}, io) {
31
+ export async function runDurable(handler, {context, event, branchName}, io) {
32
+ const history = createStepHistory()
29
33
  let currentStep = null
34
+
30
35
  const streamLog = (level, args) => {
31
36
  const date = new Date()
32
37
  const timestamp = `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`
@@ -41,7 +46,6 @@ export async function runDurable(handler, {context, event}, io) {
41
46
  })
42
47
  }
43
48
 
44
- // @todo: need to throw error for use of `console.log` in a durable function
45
49
  const logger = {
46
50
  log: (...a) => streamLog('info', a),
47
51
  info: (...a) => streamLog('info', a),
@@ -49,24 +53,57 @@ export async function runDurable(handler, {context, event}, io) {
49
53
  error: (...a) => streamLog('error', a),
50
54
  }
51
55
 
52
- const track = async (name, kind, produce) => {
53
- io.emit({name, kind, phase: 'start'})
54
- const startedAt = performance.now()
56
+ const emitFailure = (pending) => {
57
+ history.settle(pending)
58
+ io.emit(pending.event)
59
+ }
60
+
61
+ const settleUnretried = () => {
62
+ const pending = history.getPending()
63
+ if (pending !== null) {
64
+ emitFailure(pending)
65
+ }
66
+ }
67
+
68
+ const track = async (name, kind, produce, retry) => {
69
+ settleUnretried()
70
+ const {index, saved} = history.nextStep()
71
+
72
+ if (saved?.phase === 'complete') {
73
+ return saved.output
74
+ }
75
+
76
+ const maxAttempts = retry?.maxAttempts ?? 1
77
+ const retries = Math.max((retry?.maxAttempts ?? 1) - 1, 0)
78
+ const waitingOnRetry = typeof saved?.retryDelayMs === 'number'
79
+
80
+ if (saved?.phase === 'error' && !waitingOnRetry) {
81
+ throw saved.output
82
+ }
83
+
84
+ const attempt = (saved?.attempt ?? 0) + 1
55
85
  const prev = currentStep
56
86
  currentStep = name
57
87
 
88
+ io.emit({name, kind, phase: 'start'})
89
+ const startedAt = performance.now()
90
+
58
91
  try {
59
- const value = await produce()
60
- io.emit({
92
+ const value = await produce(attempt)
93
+ const completeEvent = {
61
94
  name,
62
95
  kind,
63
96
  phase: 'complete',
64
97
  output: value,
65
98
  durationMs: performance.now() - startedAt,
66
- })
99
+ }
100
+
101
+ history.recordSuccess(index, completeEvent)
102
+ io.emit(completeEvent)
103
+
67
104
  return value
68
105
  } catch (err) {
69
- io.emit({
106
+ const errorEvent = {
70
107
  name,
71
108
  kind,
72
109
  phase: 'error',
@@ -76,20 +113,81 @@ export async function runDurable(handler, {context, event}, io) {
76
113
  stack: err?.stack ?? '',
77
114
  },
78
115
  durationMs: performance.now() - startedAt,
116
+ attempt,
117
+ retries,
118
+ maxAttempts,
119
+ }
120
+
121
+ const retryPending = history.recordFailure(index, {
122
+ event: errorEvent,
123
+ error: err,
124
+ delayMs: retry?.delay ? durationToMs(retry.delay) : 0,
79
125
  })
126
+
127
+ if (!retryPending) {
128
+ io.emit(errorEvent)
129
+ }
130
+
80
131
  throw err
81
132
  } finally {
82
133
  currentStep = prev
83
134
  }
84
135
  }
85
136
 
137
+ const FANOUT_STATUS = {
138
+ STARTED: 'STARTED',
139
+ ALL_COMPLETED: 'ALL_COMPLETED',
140
+ FAILURE_TOLERANCE_EXCEEDED: 'FAILURE_TOLERANCE_EXCEEDED',
141
+ SUCCEEDED: 'SUCCEEDED',
142
+ FAILED: 'FAILED',
143
+ }
144
+
86
145
  const step = {
87
- run: ({name, handler: fn}) => track(name || '', 'run', () => fn({logger, attempt: 1})),
88
- wait: ({name, duration}) =>
89
- track(name || '', 'wait', async () => {
90
- await io.wait(durationToMs(duration))
91
- return undefined
92
- }),
146
+ run: ({name, handler: fn, retry}) =>
147
+ track(name || '', 'run', (attempt) => fn({logger, attempt}), retry),
148
+ delegate: ({name, handler, event, retry}) =>
149
+ track(
150
+ name || '',
151
+ 'delegate',
152
+ () => {
153
+ const fn = typeof handler === 'string' ? handler : handler?.name
154
+ if (!fn) {
155
+ throw new Error('No function name provided for delegation')
156
+ }
157
+ return io.invoke(fn, event?.data ?? {})
158
+ },
159
+ retry,
160
+ ),
161
+ fanout: async ({name, branches}) => {
162
+ if (
163
+ !Array.isArray(branches) ||
164
+ Array.from(branches).some((branch) => typeof branch !== 'function')
165
+ ) {
166
+ throw new TypeError('fanout branches must be an array of functions')
167
+ }
168
+
169
+ const outcomes = branches.map((_, index) => ({index, status: FANOUT_STATUS.STARTED}))
170
+ return new Promise((resolve, reject) => {
171
+ const execs = branches.map(async (branch, index) => {
172
+ try {
173
+ const branchName = `${name || 'fanout'} / branch ${index}`
174
+ const result = await runDurable(branch, {context, event, branchName}, io)
175
+ outcomes[index] = {index, status: FANOUT_STATUS.SUCCEEDED, result}
176
+ } catch (err) {
177
+ const error = err instanceof Error ? err : new Error(String(err))
178
+ outcomes[index] = {index, status: FANOUT_STATUS.FAILED, error}
179
+ }
180
+ })
181
+
182
+ Promise.allSettled(execs)
183
+ .then(() => {
184
+ resolve(getBatchResults(outcomes, FANOUT_STATUS.ALL_COMPLETED))
185
+ })
186
+ .catch(() => {
187
+ reject(reject)
188
+ })
189
+ })
190
+ },
93
191
  waitForCondition: ({name, initial, poller, next}) =>
94
192
  track(name || '', 'wait-for-condition', async () => {
95
193
  let state = initial
@@ -107,15 +205,35 @@ export async function runDurable(handler, {context, event}, io) {
107
205
  }
108
206
  return state
109
207
  }),
110
- delegate: ({name, handler, event}) =>
111
- track(name || '', 'delegate', () => {
112
- const fn = typeof handler === 'string' ? handler : handler?.name
113
- if (!fn) {
114
- throw new Error('No function name provided for delegation')
115
- }
116
- return io.invoke(fn, event)
208
+ wait: ({name, duration}) =>
209
+ track(name || '', 'wait', async () => {
210
+ await io.wait(durationToMs(duration))
211
+ return undefined
117
212
  }),
118
213
  }
119
214
 
120
- return handler({context, event, step, logger})
215
+ let result
216
+ let done = false
217
+ const handlerArgs = branchName !== undefined ? {step, logger} : {context, event, step, logger}
218
+ while (!done) {
219
+ history.startRun()
220
+ try {
221
+ result = await handler(handlerArgs)
222
+ settleUnretried()
223
+ done = true
224
+ } catch (err) {
225
+ const pending = history.getPending()
226
+ if (pending === null) {
227
+ throw err
228
+ }
229
+ if (err?.name === 'UnrecoverableError') {
230
+ emitFailure(pending)
231
+ throw err
232
+ }
233
+ io.emit({...pending.event, retryDelayMs: pending.delayMs})
234
+ await io.wait(pending.delayMs)
235
+ }
236
+ }
237
+
238
+ return result
121
239
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Maps to runtime APIs from @sanity/functions
3
+ * @param outcomes - array of outcomes from the batch execution, each outcome should have a status and result or error
4
+ * @param reason - a string indicating the reason for the batch result, e.g., 'ALL_COMPLETED' or 'FAILED'
5
+ */
6
+ export const getBatchResults = (outcomes, reason) => {
7
+ /**
8
+ * Returns an array of all outcomes, regardless of their status.
9
+ */
10
+ const all = outcomes.map((outcome) => ({...outcome}))
11
+
12
+ /**
13
+ * Returns an array of outcomes that have failed (status 'FAILED').
14
+ */
15
+ const failed = () =>
16
+ all.filter((outcome) => outcome.error !== undefined && outcome.status === 'FAILED')
17
+
18
+ /**
19
+ * Returns an array of outcomes that have succeeded (status 'SUCCEEDED').
20
+ */
21
+ const succeeded = () =>
22
+ all.filter((outcome) => outcome.result !== undefined && outcome.status === 'SUCCEEDED')
23
+
24
+ /**
25
+ * Returns an array of outcomes that are still in progress (status 'STARTED').
26
+ */
27
+ const started = () => all.filter((outcome) => outcome.status === 'STARTED')
28
+
29
+ /**
30
+ * Returns the count of successful outcomes (status 'SUCCEEDED')
31
+ */
32
+ const successCount = all.filter((item) => item.status === 'SUCCEEDED').length
33
+
34
+ /**
35
+ * Returns the count of failed outcomes (status 'FAILED')
36
+ */
37
+ const failureCount = all.filter((item) => item.status === 'FAILED').length
38
+
39
+ /**
40
+ * Returns an array of results from successful outcomes (status 'SUCCEEDED').
41
+ */
42
+ const getResults = () => succeeded().map((item) => item.result)
43
+
44
+ /**
45
+ * Returns an array of errors from failed outcomes (status 'FAILED').
46
+ */
47
+ const getErrors = () => failed().map((item) => item.error)
48
+
49
+ /**
50
+ * Throws the first error found in the failed outcomes (status 'FAILED').
51
+ */
52
+ const throwIfError = () => {
53
+ const failure = all.find((outcome) => outcome.status === 'FAILED')
54
+ if (failure?.error) {
55
+ throw failure.error
56
+ }
57
+ }
58
+
59
+ return {
60
+ all,
61
+ status: failureCount > 0 ? 'FAILED' : 'SUCCEEDED',
62
+ hasFailure: failureCount > 0,
63
+ failed,
64
+ succeeded,
65
+ started,
66
+ reason,
67
+ successCount,
68
+ failureCount,
69
+ startedCount: started().length,
70
+ totalCount: all.length,
71
+ getResults,
72
+ getErrors,
73
+ throwIfError,
74
+ }
75
+ }
@@ -1,4 +1,5 @@
1
1
  export function createInvoker(): {
2
2
  invoke: (name: string, payload?: Record<string, unknown>) => Promise<unknown>
3
+ delegate: (name: string, payload?: Record<string, unknown>) => Promise<unknown>
3
4
  handleResponse: (msg: unknown) => void
4
5
  }