zod-to-dynamodb-onetable-schema
v1.0.1
Published
Auto-generate `dynamodb-onetable` model schemas using `zod`, with best-in-class autocomplete
Maintainers
Readme
💍 zod-to-dynamodb-onetable-schema
Auto-generate dynamodb-onetable model schemas using zod, with best-in-class autocomplete
Overview
- Convert
zodobjects intodynamo-onetablemodel schemas - Convert
zodschemas intodynamo-onetablemodel field schemas - Get dynamic autocomplete as you expect from
dynamo-onetablevia type-fu 🥋 - Un-representable data-types cause errors, un-representable checks will notify you via
logger.debugif you provide a Winston instance - Compatible with
zod@^3.23.8anddynamo-onetable@^2.7.5
Rationale
dyanmodb-onetable provides a fantastic API for building and interacting with DynamoDB single-table designs. In using it, I've come to appreciate a couple of areas where I wanted something slightly different:
- The validation option offers a single regex pattern per field (and we all know how regex goes)
- Defining the schema can be tricky because using the supplied types clobbers the library's ability to infer your specific models
Enter, zod, which excels at providing a flexible schema-building API and parsing data. This library aims to bridge the two, giving you all the benefits of dynamodb-onetable while delegating model schema building and parsing to zod, which has proven itself as a capable library for those jobs.
Install
npm i zod-to-dynamodb-onetable-schemaQuick start
Say you have an existing 'Account' schema using zod in your application code:
import { z } from "zod";
const accountSchema = z.object({
id: z.string().uuid(),
email: z.string(),
status: z.enum(["verified", "unverified"]),
});Defining a Account model is now easy. We'll extend it to include our table's indexes and pass it to zodOneModelSchema.
import { zodOneModelSchema } from "zod-to-dynamodb-onetable-schema";
import { Table } from "dynamodb-onetable";
const accountRecordSchema = accountSchema.extend({
pk: z.literal("${_type}#${id}"), // 👈 more about this later
sk: z.literal("${_type}#"),
});
const table = new Table({
// other fields collapsed,
schema: {
indexes: { primary: { hash: "pk", sort: "sk" } },
models: { Account: zodOneModelSchema(accountRecordSchema) },
},
});We can now use our new Account model...
const accountModel = table.getModel("Account");
const newAccount: z.infer<typeof accountSchema> = {
id: uuidv4(),
email: "[email protected]",
status: "unverified",
};
await accountModel.create(newAccount);
const storedAccount = await accountModel.get(newAccount);
expect(newAccount).toEqual(storedAccount);Notice we didn't need to specify the pk or pk? That's because Table handles it for us when we use z.literal() with OneTable's value template syntax. The typing is smart enough to identify that these values can be automatically extracted from your entity data and aren't needed.
A deeper dive
Explicitly setting indexes
If you don't want to use z.literal() and OneTable's value template syntax, you can set your indexes using z.string() and z.number() as you would expect.
import { Table } from "dynamodb-onetable";
import { zodOneModelSchema } from "zod-to-dynamodb-onetable-schema";
import { z } from "zod";
const accountRecordSchema = z.object({
pk: z.string(),
sk: z.string(),
id: z.string().uuid(),
email: z.string(),
status: z.enum(["verified", "unverified"]),
});
const table = new Table({
// other fields collapsed,
schema: {
indexes: { primary: { hash: "pk", sort: "sk" } },
models: { Account: zodOneModelSchema(accountRecordSchema) },
},
});
const accountModel = table.getModel("Account");
const newAccount: z.infer<typeof accountRecordSchema> = {
pk: "Account#1",
sk: "Account",
id: "1",
email: "[email protected]",
status: "unverified",
};
await accountModel.create(newAccount);
const storedAccount = await accountModel.get(newAccount);
expect(newAccount).toMatchObject(storedAccount);Mixing OneTable schema syntax with zod schemas
This library also supports partial zod schema definition via the zodOneFieldSchema export. In this example, we add a complex schema using the zod API to a nested attribute.
import { Table } from "dynamodb-onetable";
import { zodOneFieldSchema } from "zod-to-dynamodb-onetable-schema";
const table = new Table({
// other fields collapsed,
schema: {
indexes: { primary: { hash: "pk", sort: "sk" } },
models: {
Account: {
pk: { type: String, required: true },
sk: { type: String, required: true },
account: {
type: "object",
required: true,
schema: {
id: { type: String, required: true },
// 👇 utilize our zod converter
emails: zodOneFieldSchema(
z.array(
z.object({
email: z.string().email(),
isVerified: z.boolean(),
}),
),
),
},
},
},
},
},
});Thanks to the type-fu 🥋 of ZodToOneField, even nesting our converter like this will still leave you with best-in-class autocomplete in the Table instance.
Decoupling the schema from Table
You might get to a point where you want to have multiple Table instances, at which point you'll want to have one source of truth for your schema. Likewise, you might want to inject your Table while still getting full autocomplete.
In short, the answer is to use Table<typeof oneTableSchema> as your injectable table where oneTableSchema satisfies OneSchema!
import { OneSchema, Table } from "dynamodb-onetable";
import { z } from "zod";
import { zodOneModelSchema } from "../src";
const accountSchema = z.object({
id: z.string().uuid(),
email: z.string(),
status: z.enum(["verified", "unverified"]),
});
type Account = z.infer<typeof accountSchema>;
interface AccountStore {
getAccount: (accountId: string) => Promise<Account | null>;
}
const accountRecordSchema = accountSchema.extend({
pk: z.literal("${_type}#${id}"),
sk: z.literal("${_type}#"),
});
const oneTableSchema = {
// other attributes collapsed
indexes: { primary: { hash: "pk", sort: "sk" } },
models: { Account: zodOneModelSchema(accountRecordSchema) },
} satisfies OneSchema;
class AccountOneTableStore implements AccountStore {
constructor(private readonly table: Table<typeof oneTableSchema>) {}
async getAccount(accountId: string): Promise<Account | null> {
try {
const data = await this.table.getModel("Account").get({ id: accountId });
return accountSchema.parse(data);
} catch (err) {
console.info("Account could not be found in OneTable", { err });
return null;
}
}
}
const table = new Table({
// other attributes collapsed
schema: oneTableSchema,
});
const accountStore = new AccountOneTableStore(table);
const account = await accountStore.get("test-id");Contributing
I appreciate any contributions, issues or discussions. My aim is to make contributing quick and easy.
Please note that PR quality checks enforce a 100% code coverage rate and will test your code against a local version of DynamoDB. Passing these requirements are essential to getting a merge/release. For new code, at least some tests should interface with an instance of Table that interacts with a local DynamoDB instance. An example of this test type is at tests/zodOneModelSchema.spec.ts.
Here's a quick start to getting this repo running on your own machine (assumes you already have gh, node, pnpm and docker installed):
- Clone the repo to your own machine
gh repo clone jharlow/zod-to-dynamodb-onetable-schema- Start an instance of
dynamodb-localon your machine
docker run -d -p 8000:8000 amazon/dynamodb-local- Install dependencies
pnpm install- You can now execute the test suite and develop 🙌
pnpm test- Before pushing, check your work will pass checks:
pnpm pr-checks