jharlow/zod-to-dynamodb-onetable-schema(opens in a new tab)
Language: TypeScript
Auto-generate `dynamodb-onetable` model schemas using `zod`, with best-in-class autocomplete
- stars
- 3
- watchers
- 1
- forks
- 2
- open issues
- 4
- Last updated
- 12/31/2024
Language: TypeScript
Auto-generate `dynamodb-onetable` model schemas using `zod`, with best-in-class autocomplete
Auto-generate dynamodb-onetable model schemas using zod, with best-in-class autocomplete
zod objects into dynamo-onetable model schemaszod schemas into dynamo-onetable model field schemasdynamo-onetable via type-fu 🥋logger.debug if you provide a Winston instancezod@^3.23.8 and dynamo-onetable@^2.7.5dyanmodb-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:
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.
npm i zod-to-dynamodb-onetable-schema
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: "hello@example.com", 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.
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: "hello@example.com", status: "unverified", }; await accountModel.create(newAccount); const storedAccount = await accountModel.get(newAccount); expect(newAccount).toMatchObject(storedAccount);
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.
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");
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):
gh repo clone jharlow/zod-to-dynamodb-onetable-schema
dynamodb-local on your machinedocker run -d -p 8000:8000 amazon/dynamodb-local
pnpm install
pnpm testpnpm pr-checks