@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.
- package/README.md +19 -19
- package/dist/actions/blueprints/assets.d.ts +3 -3
- package/dist/actions/blueprints/assets.js +3 -3
- package/dist/actions/blueprints/blueprint.d.ts +20 -9
- package/dist/actions/blueprints/blueprint.js +25 -79
- package/dist/actions/blueprints/config.d.ts +1 -0
- package/dist/actions/blueprints/config.js +7 -1
- package/dist/actions/blueprints/pre-deploy.js +3 -3
- package/dist/actions/blueprints/process-assets.js +3 -2
- package/dist/baseCommands.js +3 -3
- package/dist/config.d.ts +1 -2
- package/dist/config.js +1 -2
- package/dist/cores/blueprints/doctor.js +2 -2
- package/dist/cores/blueprints/init.js +11 -2
- package/dist/cores/functions/logs.d.ts +7 -0
- package/dist/cores/functions/logs.js +17 -6
- package/dist/cores/functions/test.js +43 -9
- package/dist/server/app.js +4 -4
- package/dist/server/static/components/app.css +8 -1
- package/dist/utils/blueprints/group-resources.d.ts +2 -1
- package/dist/utils/blueprints/group-resources.js +3 -3
- package/dist/utils/blueprints/resolve-deployed-resources.d.ts +2 -2
- package/dist/utils/blueprints/resolve-deployed-resources.js +4 -4
- package/dist/utils/bundles/prepare-asset.d.ts +3 -3
- package/dist/utils/bundles/prepare-asset.js +8 -7
- package/dist/utils/functions/child-process-wrapper.js +1 -1
- package/dist/utils/functions/child-runtime/durable.js +141 -23
- package/dist/utils/functions/child-runtime/getBatchResults.js +75 -0
- package/dist/utils/functions/child-runtime/invoke.d.ts +1 -0
- package/dist/utils/functions/child-runtime/invoke.js +26 -9
- package/dist/utils/functions/child-runtime/step-history.d.ts +15 -0
- package/dist/utils/functions/child-runtime/step-history.js +52 -0
- package/dist/utils/functions/extract-durable-config.js +1 -4
- package/dist/utils/functions/invoke/local.js +27 -11
- package/dist/utils/functions/invoke/nested.js +2 -1
- package/dist/utils/functions/invoke/resolve-invoke-type.d.ts +2 -2
- package/dist/utils/functions/invoke/resolve-invoke-type.js +2 -2
- package/dist/utils/functions/transpile/function.js +3 -0
- package/dist/utils/types.d.ts +24 -13
- package/dist/utils/types.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +3 -3
- package/dist/actions/blueprints/resolve.d.ts +0 -51
- 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,
|
|
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
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/server/app.js
CHANGED
|
@@ -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,
|
|
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 } =
|
|
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 (
|
|
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-
|
|
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
|
|
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
|
|
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,
|
|
31
|
+
export declare function prepareBundleAsset<R extends BundleResource>({ blueprintFileDir, resolvedResource, }: {
|
|
32
32
|
blueprintFileDir: string;
|
|
33
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
264
|
-
if (
|
|
265
|
-
return
|
|
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.
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
111
|
-
track(name || '', '
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
+
}
|