zod-to-dynamodb-onetable-schema
v1.0.0
Published
Auto-generate `dynamodb-onetable` model schemas using `zod`, with best-in-class autocomplete
Downloads
496
Maintainers
Readme
💍 zod-to-dynamodb-onetable-schema
Auto-generate dynamodb-onetable
model schemas using zod
, with best-in-class autocomplete
Overview
- Convert
zod
objects intodynamo-onetable
model schemas - Convert
zod
schemas intodynamo-onetable
model field schemas - Get dynamic autocomplete as you expect from
dynamo-onetable
via type-fu 🥋 - Un-representable data-types cause errors, un-representable checks will notify you via
logger.debug
if you provide a Winston instance - Zero dependencies - compatible with
zod@^3.23.8
anddynamo-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-schema
Quick 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-local
on 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