Skip to main content

Depot Lambda Client

The Depot Lambda Client is a library made to facilitate access to the Depot Lambda Gateway when the client code is a TypeScript library.

Find the depot-lambda-client NPM package here

It supports:

  • a fully-typed interface, that is able to work with hand-written types conforming to the schema known to Depot, or with types generated from the GraphQL representation of the Depot schemas
  • optional retries with exponential backoff, when the client code embeds a complex workflow where it is more advantageous to have an occasionally sleeping lambda than to increase the complexity of a step function
  • automatic conversions between in-memory types and the underlying JSON-safe representation. In particular, Date are translated to ISO 8601 strings that the underlying Depot engine understands.

How to use Depot Lambda Client

Requirements

  • Your client code is written in TypeScript, and should have access to the environment's Depot Gateway Lambda's ARN and your dataset ID (this is normally done via CDK-provisioned environment variables).
  • You have a description of your entity types in TypeScript. You can write them manually (aligned with the underlying Depot schema), or use the Depot GraphQL Tool to generate classes for you as part of your build process.
  • you have a IStageLogger instance from @stage-tech/stage-monitor-logging-client

Example

import { LambdaClient } from "@aws-sdk/client-lambda";
import { DepotLambdaClient } from '@stage-tech/depot-lambda-client'
import { IStageLogger } from "@stage-tech/stage-monitor-logging-client";
import { ConsoleLogger } from "typedoc/dist/lib/utils";

interface MyPet {
/* ... fields from Depot schema definition file via GraphQL or manually ... */
}

const lambdaClient = new LambdaClient({});
const logger = LoggerFactory.getConsoleLogger();

const petDepotLambdaClient = new DepotLambdaClient<MyPet>(
lambdaClient,
logger,
{
depotLambdaArn: 'arn:aws:.....:depot-XXXXXXXXX-depot-lambda-gateway', // get it from CDK, likely via an env var
datasetId: 'my-dataset-alias',
schema: "my.Pet", // should match <MyPet>
retryOptions: { // retryOptions is opt-in, if absent no retries in case of transient outages
maxRetries: 5,
initialDelayInSeconds: 1.2,
backoffFactor: 1.5
},
recordCodec: StandardRecordCodecs.DepotFields
});

async function example() {
const myPet = await petDepotLambdaClient.create({
/* TypeScript will require all mandatory properties, reject unknown properties if you put a constant here */
});
// myPet is of type MyPet & DepotFields
// so id, version, created, updated & hash are defined

await petDepotLambdaClient.patch(myPet.id, { /* someField: someNewValue */});
}
tip

The DepotLambdaClient<T> is only for instances of type T. If you need to also access another type U, then you should create a new instance DepotLambdaClient<U>. The DepotLambdaClient<?> object is thin, stateless and connectionless: there is no cost in having multiple instances around.

About the recordCodec property

The DepotLambdaClient<T> relies on TypeScript type-level computations to ensure that the type T & DepotFields is representable in JSON or that there exists a codec for each field which could otherwise not be safely represented in JSON.

By default, DepotFields includes two fields created and updated which are of type Date, which doesn't serde correctly into JSON.

The StandardRecordCodecs.DepotFields provides the codecs for these two fields. It can easily be extended when needed:

import { DepotLambdaClient } from "./depot-lambda-client";

interface Quadruped {
id?: string;
birthDate: Date;
legs: FourLegs;
}

interface FourLegs {
leftFore: Leg;
rightFore: Leg;
leftHind: Leg;
rightHind: Leg;
}

interface Leg {
clawsClippedOn?: Date;
}

const depotClient = new DepotLambdaClient<Mammal>(lambdaClient, logger, {
depotArn, dataset,
schema: 'Mammal',
recordCodec: StandardRecordCodecs.DepotFields, // WILL FAIL -- TypeScript will complain of missing fields
});

The recordCodec word will be underlined red, with the following annotation: &quot;Type of parameters are incompatible... [complains about id, version]&quot;

update the recordCodec line so that it reads: recordCodec: StandardRecordCodecs.DepotFields.with({})

At this point, the {} will be underlined red, with the following annotation: &quot;Type {} is missing ... birthDate, legs&quot;

Simply start to fill the missing fields. birthDate is "some form of" Date, we can just provide the standard Date codec here, which is FieldCodecs.Date.

The error message will now change to:

&quot;Type ... is missing: legs

legs is a struct (an interface); if there is a complaint, it means that at least one of the fields requires a codec. Let's find out; by providing a legs: FieldCodecs.record(RecordCodec.of({})): legs is fine, but it is missing some fields

Let's try to build a codec for the first leg, using the same process: creating a codec around the first leg

All four *leg fields require a codec, this is why legs required one. Since this is the same type, we can create one and reuse it:


const LegCodec = RecordCodec.of<PartOfRecordRequiringCodecs<Leg>>({
clawsTrimmedOn: FieldCodecs.Date,
});

const myDepotClient = new DepotLambdaClient<Quadruped>(new LambdaClient({}), logger, {
depotLambdaArn: "",
datasetId: "",
schema: "Mammal",
recordCodec: StandardRecordCodecs.DepotFields.with({
birthDate: FieldCodecs.Date,
legs: FieldCodecs.record(RecordCodec.of({
leftFore: LegCodec,
leftHind: LegCodec,
rightFore: LegCodec,
rightHind: LegCodec,
}))
})
});

Voilà! At this point the DepotLambdaClient<Quadruped> is able to safely communicate with the Depot Gateway Lambda, and you can communicate with it using high-level types rather than JSON of strings.

tip

the PartOfRecordRequiringCodecs<Leg> bit is a little not obvious, but it instructs the compiler to extract just the fields that really need a codec to be representable in JSON.

It isn't necessary in the inline expression as the compiler is then able to infer it (there is "inference pressure" to infer PartOfRecordRequiringCodecs<Quadruped> in the DepotLambdaClient constructor, which propagates into the graph structure

info

the PartOfRecordRequiringCodecs<T> mapped type is built in a way that strips the optionality, undefinability, nullability, and arrayness of the fields.

In a nutshell:

let x: PartOfRecordRequiringCodecs<{
aString: string;
date: Date;
optDate?: Date;
optDateOrUndefOrNull: Date | undefined | null; // e.g. Maybe<Date>
arrayOfMaybeDates: (Date | undefined | null)[] | undefined | null;
}>;

/* x is of type: {
date: Date;
optDate: Date;
optDateOrUndefNull: Date;
arrayOfMaybeDates: Date;
} */

Testing

Combining jest, aws-sdk-client-mock and depot-local, you can stand up a realistic test that lets you validate that your code using depot-lambda-client:

import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
import {
DepotApi,
DepotLocal,
EnvironmentBuilder,
loadIntoDepotViaBatch,
LocationBuilder,
proxyToDepotApiMap
} from "@stage-tech/depot-local";
import { MergedSchemas } from "@stage-tech/depot-schema";
import { LoggerFactory } from "@stage-tech/stage-monitor-logging-client";
import { mockClient } from "aws-sdk-client-mock";
import {
DepotFields,
DepotLambdaClient,
DepotLambdaListOnlyClient,
PartOfRecordRequiringCodecs,
RecordCodec,
StandardRecordCodecs
} from "@stage-tech/depot-lambda-client";

jest.setTimeout(360_000);

const DepotLambdaMock = mockClient(LambdaClient);

describe("sample test using DepotLambdaClient + Mock + DepotLocal", () => {

it("should do simple creation when everything's good", async () =>
await withContext(async (context) => {
const myPet = await context.myPetDepotClient.create({
/* TypeScript will require all mandatory properties, reject unknown properties if you put a constant here */
});
expect(myPet.version).toBeGreaterThanOrEqual(1);
}));


it("should retrieve objects even with simulated disruption", async () =>
await withContext(async (context) => {
await context.myPetDepotClient.create({
id: "pet007",
});

DepotLambdaMock.reset()
.on(InvokeCommand)
.resolvesOnce(dynamoDbErrorResponse)
.resolvesOnce(dynamoDbErrorResponse)
.resolvesOnce(dynamoDbErrorResponse)
.callsFake(proxyToDepotApiMap({[DATASET_ALIAS]: myPetDepotApi}));

// this will show that the Depot Lambda Client will hit three failures before
// finally resuming successfully

const myPet = await context.myPetDepotClient.get("pet007");
expect(myPet.version).toBeGreaterThanOrEqual(1);
}));
});

const depotLocal = new DepotLocal({
depot: {},
dynamodb: {}
});

beforeAll(async () => {
await depotLocal.start();
})

afterAll(async () => {
await depotLocal.stop();
})

const MyTestNamespace: MergedSchemas = {
// build it the way you want, reading a Depot Package or manually like this:
[MyPetSchemaName]: MyPetSchema
};

function withContext<T>(action: (context: TestContext) => Promise<T>): Promise<T> {
const myPetDepotApi = depotLocal
.deploy(new EnvironmentBuilder(MyTestNamespace).location(LocationBuilder.dynamoDbWithTestScan()).build())
.api(0);

DepotLambdaMock.reset()
.on(InvokeCommand)
.callsFake(proxyToDepotApiMap({[DATASET_ALIAS]: myPetDepotApi}));

const myPetDepotClient = new DepotLambdaClient<MyPet>(new LambdaClient({}), LoggerFactory.getConsoleLogger(), {
depotLambdaArn: "unit-test",
datasetId: myPetDepotApi.datasetId,
schema: MyPetSchemaName,
retryOptions: {
maxRetries: 5,
initialDelayInSeconds: 0.25, // shorter than in deployed code
backoffFactor: 1.01 // really small backoff factor
},
recordCodec: StandardRecordCodecs.DepotFields
});

return action({myPetDepotApi, myPetDepotClient});
}

const dynamoDbErrorResponse = {
StatusCode: 200,
Payload: textEncoder.encode(
`{
"status": {
"code": 500,
"message": "Internal Server Error"
},
"data": {
"code": "UnknownError",
"message": "Internal server error (Service: DynamoDb, Status Code: 500, Request ID: HHB5VAOJ87RCUBAMGCMFFL2LJNVV4KQNSO5AEMVJF66Q9ASUAAJG)"
}
}`
)
};

When to use depot-lambda-client, when to prefer alternatives

Prefer using depot-lambda-client when...Prefer direct depot gateway lambda calls when...Prefer GraphQL when...
**access is controlled via IAM **, originates from within our AWS accounts**access is controlled via IAM **, originates from within our AWS accountsanonymous or personal access, from anywhere on the Internet
shallow navigation on most or all fields of a schemashallow navigation on most or all fieldsdeep, narrow navigation within the object graph
many related operations in a short-lived process that is best represented as TypeScript source codesingle trivially simple operations where making a step function Task is no more complicated than setting up a lambda
Batch operations on dozens of individually-identified items at a timepotentially complex, multi-object mutations
list operations aligned with an index on Dynamo-only Locations