@sanity/runtime-cli 17.16.0 → 17.17.0

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 (29) 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/pre-deploy.js +3 -3
  5. package/dist/actions/blueprints/process-assets.js +3 -2
  6. package/dist/config.js +4 -1
  7. package/dist/cores/functions/logs.d.ts +7 -0
  8. package/dist/cores/functions/logs.js +29 -16
  9. package/dist/cores/functions/test.js +15 -4
  10. package/dist/server/app.js +2 -2
  11. package/dist/server/static/vendor/vendor.bundle.js +21 -42
  12. package/dist/utils/blueprints/group-resources.d.ts +2 -1
  13. package/dist/utils/blueprints/group-resources.js +3 -3
  14. package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -2
  15. package/dist/utils/blueprints/resolve-deployed-resources.js +4 -4
  16. package/dist/utils/bundles/prepare-asset.d.ts +3 -3
  17. package/dist/utils/bundles/prepare-asset.js +46 -12
  18. package/dist/utils/functions/child-runtime/durable.js +141 -23
  19. package/dist/utils/functions/child-runtime/getBatchResults.js +75 -0
  20. package/dist/utils/functions/child-runtime/step-history.d.ts +15 -0
  21. package/dist/utils/functions/child-runtime/step-history.js +52 -0
  22. package/dist/utils/functions/invoke/local.js +19 -8
  23. package/dist/utils/functions/invoke/resolve-invoke-type.d.ts +2 -2
  24. package/dist/utils/functions/invoke/resolve-invoke-type.js +2 -2
  25. package/dist/utils/functions/transpile/function.js +3 -0
  26. package/dist/utils/types.d.ts +21 -13
  27. package/dist/utils/types.js +1 -1
  28. package/oclif.manifest.json +1 -1
  29. package/package.json +5 -5
@@ -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>;
@@ -5,6 +5,7 @@ import path, { basename, dirname, join } from 'node:path';
5
5
  import { pipeline } from 'node:stream/promises';
6
6
  import { createGzip } from 'node:zlib';
7
7
  import { buildApp, buildStudio, compareDependencyVersions, } from '@sanity/cli-build/_internal/build';
8
+ import { buildStudioSchemasAndManifests } from '@sanity/cli-build/_internal/extract';
8
9
  import { getLocalPackageVersion } from '@sanity/cli-core';
9
10
  import { pack } from 'tar-fs';
10
11
  export const BUNDLE_SPECS = {
@@ -12,10 +13,11 @@ export const BUNDLE_SPECS = {
12
13
  'sanity.application': { requiresAppId: true, build: buildApplicationBundle },
13
14
  'sanity.installation.config': { requiresAppId: false, build: buildInstallationConfigBundle },
14
15
  };
15
- export async function prepareBundleAsset({ blueprintFileDir, resource, }) {
16
+ export async function prepareBundleAsset({ blueprintFileDir, resolvedResource, }) {
17
+ const resource = resolvedResource.resource;
16
18
  // The lookup can't prove it yields the spec matching this R, so it's asserted once here.
17
19
  const spec = BUNDLE_SPECS[resource.type];
18
- const appId = getAppId(resource);
20
+ const appId = getAppId(resolvedResource);
19
21
  if (spec.requiresAppId && !appId) {
20
22
  return {
21
23
  success: false,
@@ -104,8 +106,15 @@ export async function prepareBundleAsset({ blueprintFileDir, resource, }) {
104
106
  }
105
107
  }
106
108
  async function buildStudioBundle({ resource, appId, workDir, outDir, output, eventListener, }) {
109
+ // this has been checked before we got here but this helps the type checker
110
+ if (!appId)
111
+ throw new Error(`Unable to determine appId for ${resource.type} resource '${resource.name}'`);
112
+ // currently we only support hard-coded project, support for dynamic project id to be added
113
+ const projectId = resource.project;
114
+ if (!projectId || projectId.startsWith('$.'))
115
+ throw new Error(`Unable to determine project for ${resource.type} resource '${resource.name}'`);
107
116
  let mismatchedPackages;
108
- await buildStudio({
117
+ const { mfManifest } = await buildStudio({
109
118
  appId,
110
119
  autoUpdatesEnabled: resource.autoUpdates.enabled,
111
120
  checkAppId() { },
@@ -117,10 +126,12 @@ async function buildStudioBundle({ resource, appId, workDir, outDir, output, eve
117
126
  determineBasePath() {
118
127
  return resource.basePath ?? '/';
119
128
  },
120
- // a studio is a standalone app, not a workbench module-federation app
121
129
  isApp: false,
122
130
  isBlueprints: true,
123
- isWorkbenchApp: false,
131
+ // federated so the build emits mf-manifest.json for Brett; like `sanity build` for a
132
+ // `defineApplication` studio, this drops the auto-updates import map
133
+ isWorkbenchApp: true,
134
+ workbenchAppId: appId,
124
135
  minify: resource.minify ?? true,
125
136
  outDir,
126
137
  output,
@@ -134,6 +145,21 @@ async function buildStudioBundle({ resource, appId, workDir, outDir, output, eve
134
145
  workDir,
135
146
  eventListener,
136
147
  });
148
+ if (mfManifest) {
149
+ resource.mfManifest = mfManifest;
150
+ }
151
+ const { schemaInputs } = await buildStudioSchemasAndManifests(appId, {
152
+ configPath: resource.root,
153
+ isExternal: false,
154
+ outPath: outDir,
155
+ projectId,
156
+ schemaRequired: false,
157
+ verbose: false,
158
+ workDir,
159
+ });
160
+ if (schemaInputs) {
161
+ resource.schemaInputs = schemaInputs;
162
+ }
137
163
  return { mismatchedPackages };
138
164
  }
139
165
  async function buildApplicationBundle({ resource, appId, workDir, outDir, output, eventListener, }) {
@@ -142,7 +168,7 @@ async function buildApplicationBundle({ resource, appId, workDir, outDir, output
142
168
  if (extraWindow) {
143
169
  throw new Error(`Application resource '${resource.name}' declares more than one window view; at most one is allowed`);
144
170
  }
145
- await buildApp({
171
+ const { mfManifest } = await buildApp({
146
172
  ...sharedBuildAppOptions({ appId, workDir, outDir, output, eventListener }),
147
173
  appId,
148
174
  appTitle: resource.title,
@@ -153,11 +179,14 @@ async function buildApplicationBundle({ resource, appId, workDir, outDir, output
153
179
  webWorkers: resource.webWorkers,
154
180
  },
155
181
  });
182
+ if (mfManifest) {
183
+ resource.mfManifest = mfManifest;
184
+ }
156
185
  // a workbench app never auto-updates, so no dependency mismatches are compared
157
186
  return {};
158
187
  }
159
188
  async function buildInstallationConfigBundle({ resource, workDir, outDir, output, eventListener, }) {
160
- await buildApp({
189
+ const { mfManifest } = await buildApp({
161
190
  ...sharedBuildAppOptions({ appId: undefined, workDir, outDir, output, eventListener }),
162
191
  appId: undefined,
163
192
  appTitle: undefined,
@@ -165,6 +194,9 @@ async function buildInstallationConfigBundle({ resource, workDir, outDir, output
165
194
  workbenchAppId: undefined,
166
195
  exposes: { config: { appType: resource.appType, fields: resource.fields } },
167
196
  });
197
+ if (mfManifest) {
198
+ resource.mfManifest = mfManifest;
199
+ }
168
200
  return {};
169
201
  }
170
202
  // Options a workbench build (application or installation config) always sets the
@@ -247,6 +279,8 @@ function createSilentOutput() {
247
279
  return undefined;
248
280
  },
249
281
  log() { },
282
+ logDiagnostic(_message) { },
283
+ logJson(_json) { },
250
284
  warn(input) {
251
285
  return input;
252
286
  },
@@ -260,12 +294,12 @@ async function getInstalledSanityVersion(workDir) {
260
294
  return undefined;
261
295
  }
262
296
  }
263
- function getAppId(resource) {
264
- if ('externalId' in resource && typeof resource.externalId === 'string' && resource.externalId) {
265
- return resource.externalId;
297
+ function getAppId(resolvedResource) {
298
+ if (resolvedResource.externalId) {
299
+ return resolvedResource.externalId;
266
300
  }
267
- else if (resource.lifecycle?.ownershipAction?.type === 'attach') {
268
- return resource.lifecycle.ownershipAction.id;
301
+ else if (resolvedResource.resource.lifecycle?.ownershipAction?.type === 'attach') {
302
+ return resolvedResource.resource.lifecycle.ownershipAction.id;
269
303
  }
270
304
  return undefined;
271
305
  }
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ import type {DurableStepEvent} from './types'
2
+
3
+ type PendingRetry = {index: number; event: DurableStepEvent; delayMs: number}
4
+
5
+ export function createStepHistory(): {
6
+ startRun: () => void
7
+ nextStep: () => {index: number; saved: DurableStepEvent | undefined}
8
+ recordSuccess: (index: number, event: DurableStepEvent) => void
9
+ recordFailure: (
10
+ index: number,
11
+ failure: {event: DurableStepEvent; error: unknown; delayMs: number},
12
+ ) => boolean
13
+ getPending: () => PendingRetry | null
14
+ settle: (pending: PendingRetry) => void
15
+ }
@@ -0,0 +1,52 @@
1
+ const createStepHistory = () => {
2
+ const history = new Map([])
3
+ let cursor = 0
4
+ let pending = null
5
+
6
+ const startRun = () => {
7
+ cursor = 0
8
+ }
9
+
10
+ const nextStep = () => {
11
+ const index = cursor
12
+ cursor++
13
+ return {index, saved: history.get(index)}
14
+ }
15
+
16
+ const recordSuccess = (index, event) => {
17
+ history.set(index, event)
18
+ }
19
+
20
+ const recordFailure = (index, {event, error, delayMs}) => {
21
+ const retryable = event.attempt <= event.retries && error?.name !== 'UnrecoverableError'
22
+
23
+ if (retryable) {
24
+ history.set(index, {...event, output: error, retryDelayMs: delayMs})
25
+ pending = {index, event, delayMs}
26
+ } else {
27
+ history.set(index, {...event, output: error})
28
+ }
29
+ return retryable
30
+ }
31
+
32
+ const getPending = () => {
33
+ const item = pending
34
+ pending = null
35
+ return item
36
+ }
37
+
38
+ const settle = ({index, event}) => {
39
+ history.set(index, {...event, output: history.get(index).output})
40
+ }
41
+
42
+ return {
43
+ startRun,
44
+ nextStep,
45
+ recordSuccess,
46
+ recordFailure,
47
+ getPending,
48
+ settle,
49
+ }
50
+ }
51
+
52
+ export {createStepHistory}
@@ -64,10 +64,11 @@ export default async function invoke(resource, payload, context, options) {
64
64
  // Apply the GROQ rule before preparing a build so a filtered-out event skips
65
65
  // the (potentially expensive) transpile entirely.
66
66
  if (isGroqInvocation(resource)) {
67
- if (!isGroqContextOptions(context)) {
67
+ if (!('clientOptions' in context)) {
68
68
  throw new Error('GROQ-based functions require a context with clientOptions');
69
69
  }
70
- const { projectId, dataset } = context.clientOptions;
70
+ const projectId = 'projectId' in context.clientOptions ? context.clientOptions.projectId : undefined;
71
+ const dataset = 'dataset' in context.clientOptions ? context.clientOptions.dataset : undefined;
71
72
  if ('before' in payload) {
72
73
  filteredData = await applyGroqRule(resource, payload, projectId, dataset);
73
74
  const filter = resource.event && 'filter' in resource.event ? resource.event.filter : '';
@@ -173,12 +174,22 @@ export default async function invoke(resource, payload, context, options) {
173
174
  // of `nested.error`. The error isn't lost: it travels back in the returned
174
175
  // result and is logged/streamed to the UI by the caller (app/test core).
175
176
  if (child && !child.killed) {
176
- const response = {
177
- type: 'invoke-response',
178
- id,
179
- ok: true,
180
- result: { json: nested.json },
181
- };
177
+ // step.delegate errors need to be surfaced to the caller for retry behavior and for the durable step to be marked as failed.
178
+ const origin = metadata?.origin || undefined;
179
+ const error = nested.error || undefined;
180
+ const response = origin === 'step.delegate' && error
181
+ ? {
182
+ type: 'invoke-response',
183
+ id,
184
+ ok: false,
185
+ error,
186
+ }
187
+ : {
188
+ type: 'invoke-response',
189
+ id,
190
+ ok: true,
191
+ result: { json: nested.json },
192
+ };
182
193
  child.send(JSON.stringify(response));
183
194
  }
184
195
  }
@@ -1,6 +1,6 @@
1
1
  import type { BlueprintResource } from '@sanity/blueprints';
2
2
  import type { Logger } from '../../logger.js';
3
- import type { EventType, FunctionGroqResource, FunctionResource, FunctionTestFlags, InvokeContextOptions, InvokePayloadOptions } from '../../types.js';
3
+ import type { EventType, FunctionGroqResource, FunctionResource, FunctionTestFlags, InvokeContextOptions, InvokePayloadOptions, ResolvedResource } from '../../types.js';
4
4
  import buildPayload from '../build-payload.js';
5
5
  import { fetchAsset, fetchDocument } from '../fetch-document.js';
6
6
  export type InvocationPlan = {
@@ -23,7 +23,7 @@ export type InputContext = {
23
23
  apiHost: string;
24
24
  token?: string;
25
25
  withUserToken?: boolean;
26
- resources: BlueprintResource[];
26
+ resolvedResources: ResolvedResource<BlueprintResource>[];
27
27
  blueprintFileDir: string;
28
28
  log: Logger;
29
29
  };
@@ -51,7 +51,7 @@ export const buildContext = (plan, input) => {
51
51
  if (plan.kind === 'plain') {
52
52
  return {
53
53
  clientOptions: { ...withToken },
54
- resources: input.resources,
54
+ resources: input.resolvedResources,
55
55
  blueprintFileDir: input.blueprintFileDir,
56
56
  };
57
57
  }
@@ -69,7 +69,7 @@ export const buildContext = (plan, input) => {
69
69
  '',
70
70
  functionResourceType: 'project',
71
71
  functionResourceId: input.projectId || '',
72
- resources: input.resources,
72
+ resources: input.resolvedResources,
73
73
  blueprintFileDir: input.blueprintFileDir,
74
74
  };
75
75
  };