From a90b20587ae173af64011ce07076291f2c708691 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Tue, 10 Nov 2026 17:23:35 +0100 Subject: [PATCH 1/2] Add runtime.earlyReturn Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + __tests__/__snapshots__/runtime.test.js.snap | 25 +++++++++++++ __tests__/helpers.js | 14 ++++--- __tests__/runtime.test.js | 39 ++++++++++++++++++++ index.js | 30 ++++++++++++++- 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 __tests__/__snapshots__/runtime.test.js.snap create mode 100644 __tests__/runtime.test.js diff --git a/README.md b/README.md index f048ce9..2bea9f0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This package provides an implementation for the `@aws-appsync/utils` package tha ## Changelog: +- v0.1.9: add `runtime.earlyReturn()`, importable from `@aws-appsync/utils`; it validates the `skipTo` return option and hands the value and target to the host, which decides which handlers still run - v0.1.8: `util.authType()` now reports `Lambda Authorization` for the identity of a Lambda-authorized Event API request, which carries what the authorizer returned as `handlerContext` rather than the `resolverContext` a GraphQL API uses - v0.1.7: complete the `@aws-appsync/utils/dynamodb` module with `query`, `sync`, the batch and transact helpers, the set helpers, `operations.remove` and `operations.updateListItem`, and the arguments the existing helpers dropped (`filter`, `projection`, `condition`, `consistentRead`, `_version` and the rest); add `util.transform.toSubscriptionFilter`; `util.dynamodb.fromS3ObjectJson` now parses instead of throwing - v0.1.6: add `util.authType()`, derived from the identity of the request; a host installs the request through the new `setResolverContext` export. `util.transform.toDynamoDBFilterExpression` and `toDynamoDBConditionExpression` now cover every operator, including `between`, `in`, `size` and `attributeType`, and nested `and`/`or`/`not`; `util.dynamodb.toDynamoDB` accepts `null` diff --git a/__tests__/__snapshots__/runtime.test.js.snap b/__tests__/__snapshots__/runtime.test.js.snap new file mode 100644 index 0000000..c124a58 --- /dev/null +++ b/__tests__/__snapshots__/runtime.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`runtime.earlyReturn accepts skipTo END 1`] = `"end"`; + +exports[`runtime.earlyReturn accepts skipTo NEXT 1`] = `"next"`; + +exports[`runtime.earlyReturn rejects an unknown skipTo 1`] = ` +{ + "message": "SkipTo only supports input \`END\` or \`NEXT\` ", +} +`; + +exports[`runtime.earlyReturn rejects options without skipTo 1`] = ` +{ + "message": "ReturnOptions parameter must contain a 'skipTo' key ", +} +`; + +exports[`runtime.earlyReturn returns null without a value 1`] = `null`; + +exports[`runtime.earlyReturn returns the value 1`] = ` +{ + "early": true, +} +`; diff --git a/__tests__/helpers.js b/__tests__/helpers.js index a63f4a8..8294387 100644 --- a/__tests__/helpers.js +++ b/__tests__/helpers.js @@ -3,7 +3,7 @@ import { fileURLToPath, pathToFileURL } from "url"; import { dirname, resolve } from "path"; import { AppSyncClient, EvaluateCodeCommand } from "@aws-sdk/client-appsync"; -import { util, setResolverContext } from ".."; +import { util, runtime, setResolverContext } from ".."; import * as ddb from "../dynamodb"; import * as rds from "../rds"; @@ -70,13 +70,13 @@ export const checkResolverValid = async (code, context, functionName) => { let result; if (process.env.TEST_TARGET === "AWS_CLOUD") { const fullCode = ` - import { util } from '@aws-appsync/utils'; + import { util, runtime } from '@aws-appsync/utils'; import * as rds from '@aws-appsync/utils/rds'; ` + code; result = await runResolverFunctionOnAWS(fullCode, context, functionName); } else { const fullCode = ` - import { util } from '${pathToFileURL(resolve(projectRoot, "index.js"))}'; + import { util, runtime } from '${pathToFileURL(resolve(projectRoot, "index.js"))}'; import * as rds from '${pathToFileURL(resolve(projectRoot, "rds/index.js"))}'; ` + code; const encodedJs = encodeURIComponent(fullCode); @@ -92,10 +92,14 @@ export const checkResolverValid = async (code, context, functionName) => { try { result = fn(context); } catch (e) { - if (e.name !== "AppSyncUserError") { + if (e.name === "AppSyncEarlyReturn") { + // what the host does with the value it returned early: on AWS it is the evaluation result + result = e.value ?? null; + } else if (e.name === "AppSyncUserError") { + result = {"message": e.message} + } else { throw e; } - result = {"message": e.message} } finally { setResolverContext(null); } diff --git a/__tests__/runtime.test.js b/__tests__/runtime.test.js new file mode 100644 index 0000000..4e29a45 --- /dev/null +++ b/__tests__/runtime.test.js @@ -0,0 +1,39 @@ +import { describe, test } from "@jest/globals"; + +import { checkResolverValid } from "./helpers"; + +const resolver = (body) => ` +export function request(ctx) { + ${body} +} + +export function response(ctx) { + return ctx.result; +} +`; + +describe("runtime.earlyReturn", () => { + test("returns the value", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn({ early: true }); return { late: true };`), {}, "request"); + }); + + test("returns null without a value", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn();`), {}, "request"); + }); + + test("accepts skipTo END", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn("end", { skipTo: "END" });`), {}, "request"); + }); + + test("accepts skipTo NEXT", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn("next", { skipTo: "NEXT" });`), {}, "request"); + }); + + test("rejects an unknown skipTo", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn("x", { skipTo: "BOGUS" });`), {}, "request"); + }); + + test("rejects options without skipTo", async () => { + await checkResolverValid(resolver(`runtime.earlyReturn("x", {});`), {}, "request"); + }); +}); diff --git a/index.js b/index.js index b1bd489..4fda064 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { toJsonObject } from './rds/index.js' -import { AppSyncUserError } from './errors.js' +import { AppSyncUserError, codeError } from './errors.js' export const dynamodbUtils = { toDynamoDB: function(value) { @@ -523,3 +523,31 @@ function toSubscriptionFilter(filter, ignoredFields, rules) { // a group that collected no filter at all is dropped, so an empty filter yields an empty list return { filterGroup: groups.filter((filters) => filters.length).map((filters) => ({ filters })) }; } + +// Unwinds the handler that called `runtime.earlyReturn`. The host recognises it by name, takes +// `value` as the handler's result, and uses `skipTo` to decide which handlers still run. +class AppSyncEarlyReturn extends Error { + constructor(value, skipTo) { + super("runtime.earlyReturn"); + this.name = "AppSyncEarlyReturn"; + this.value = value; + this.skipTo = skipTo; + } +} + +const SKIP_TO_TARGETS = ["END", "NEXT"]; + +export const runtime = { + earlyReturn: function(obj, returnOptions) { + if (returnOptions === undefined || returnOptions === null) { + throw new AppSyncEarlyReturn(obj, "NEXT"); + } + if (!Object.hasOwn(returnOptions, "skipTo")) { + throw codeError("ReturnOptions parameter must contain a 'skipTo' key "); + } + if (!SKIP_TO_TARGETS.includes(returnOptions.skipTo)) { + throw codeError("SkipTo only supports input `END` or `NEXT` "); + } + throw new AppSyncEarlyReturn(obj, returnOptions.skipTo); + }, +}; From 06b6bd1fa4e868c7e54edf3eb2317904936391d5 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Tue, 10 Nov 2026 18:41:26 +0100 Subject: [PATCH 2/2] chore: bump version to 0.1.9 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 10206f7..de0161a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@localstack/appsync-utils", - "version": "0.1.8", + "version": "0.1.9", "description": "Implementation of the AppSync utils helpers", "type": "module", "main": "index.js",