{"_id":"@aaron_dyke/dynamo-schema","name":"@aaron_dyke/dynamo-schema","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@aaron_dyke/dynamo-schema","version":"1.0.0","description":"Type-safe DynamoDB schema validation and modeling library for TypeScript","type":"module","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./adapters/sdk-v2":{"types":"./dist/adapters/sdk-v2.d.ts","import":"./dist/adapters/sdk-v2.js"},"./adapters/sdk-v3":{"types":"./dist/adapters/sdk-v3.d.ts","import":"./dist/adapters/sdk-v3.js"},"./adapters/sdk-v2-doc":{"types":"./dist/adapters/sdk-v2-doc.d.ts","import":"./dist/adapters/sdk-v2-doc.js"},"./adapters/sdk-v3-doc":{"types":"./dist/adapters/sdk-v3-doc.d.ts","import":"./dist/adapters/sdk-v3-doc.js"}},"scripts":{"prepare":"tsc","build":"tsc","type-check":"tsc --noEmit","test":"vitest run","test:watch":"vitest"},"repository":{"type":"git","url":"git+https://github.com/AaronDyke/dynamo-schema.git"},"keywords":["dynamodb","schema","validation","typescript","standard-schema"],"author":"","license":"ISC","bugs":{"url":"https://github.com/AaronDyke/dynamo-schema/issues"},"homepage":"https://github.com/AaronDyke/dynamo-schema#readme","devDependencies":{"typescript":"^5.9.3","vitest":"^3.0.0","zod":"^3.24.0"},"peerDependencies":{"@aws-sdk/client-dynamodb":"^3.0.0","@aws-sdk/lib-dynamodb":"^3.0.0","aws-sdk":"^2.0.0"},"peerDependenciesMeta":{"@aws-sdk/client-dynamodb":{"optional":true},"@aws-sdk/lib-dynamodb":{"optional":true},"aws-sdk":{"optional":true}},"_id":"@aaron_dyke/dynamo-schema@1.0.0","gitHead":"87762ea96139128bc2a650ddb66bfeeb4edd3bb9","_nodeVersion":"22.21.1","_npmVersion":"10.9.4","dist":{"integrity":"sha512-CMT9fbbqXTyJxup7pf/2+lWlz+n3UBmraIJ4RtU+acSB+m9Zky5Mmdro0jO7nLiyyU1NgSDkzEVtdH+ANiCD+Q==","shasum":"d4ac35ee21dac6122cb8802444ef37a968444069","tarball":"https://registry.npmjs.org/@aaron_dyke/dynamo-schema/-/dynamo-schema-1.0.0.tgz","fileCount":294,"unpackedSize":875040,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIHkrc+1rHSkVE2XmWTwRXtWfSZBxU0OWBwV1kF7l7JPzAiEAwy2cJ4DwgBETRe0n3LFpM4dFeVa1u/q1RNQW9KaoLjI="}]},"_npmUser":{"name":"aaron_dyke","email":"aarond.prod@gmail.com"},"directories":{},"maintainers":[{"name":"aaron_dyke","email":"aarond.prod@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/dynamo-schema_1.0.0_1773017852942_0.9617125107257978"},"_hasShrinkwrap":false}},"time":{"created":"2026-03-09T00:57:32.853Z","1.0.0":"2026-03-09T00:57:33.192Z","modified":"2026-03-09T00:57:33.474Z"},"maintainers":[{"name":"aaron_dyke","email":"aarond.prod@gmail.com"}],"description":"Type-safe DynamoDB schema validation and modeling library for TypeScript","homepage":"https://github.com/AaronDyke/dynamo-schema#readme","keywords":["dynamodb","schema","validation","typescript","standard-schema"],"repository":{"type":"git","url":"git+https://github.com/AaronDyke/dynamo-schema.git"},"bugs":{"url":"https://github.com/AaronDyke/dynamo-schema/issues"},"license":"ISC","readme":"# dynamo-schema\n\nType-safe DynamoDB schema validation and modeling for TypeScript. Works with any [Standard Schema](https://standardschema.dev) compatible validation library (Zod, Valibot, ArkType, etc.).\n\n## Features\n\n- **Standard Schema compatible** -- bring your own validation library (Zod, Valibot, ArkType, or any Standard Schema V1 implementation)\n- **Full type inference** -- entity types are inferred from your schemas, keys are validated at compile time\n- **Single-table design** -- define multiple entities on the same table with different key patterns and indexes\n- **Template keys** -- use `\"USER#{{userId}}\"` patterns or simple field references for partition and sort keys\n- **All DynamoDB operations** -- Put, Get, Delete, Update, Query, Scan, BatchWrite, BatchGet, TransactWrite, TransactGet\n- **Type-safe update builder** -- chainable, immutable expression builder with autocomplete on attribute names\n- **TTL support** -- configure a TTL attribute on the table, auto-inject expiry on `put`, auto-refresh on `update`, and remove TTL from a specific item\n- **Lifecycle hooks** -- attach cross-cutting behavior (`beforePut`, `beforeUpdate`, `afterGet`, `beforeDelete`) to any entity for audit logging, soft-delete, auto-timestamps, and more\n- **Runtime validation** -- validates inputs/outputs through Standard Schema at runtime (configurable)\n- **SDK flexible** -- supports AWS SDK v2 and v3, both raw DynamoDB client and DocumentClient\n- **Zero runtime dependencies** -- marshalling, validation wrappers, and Standard Schema types are all self-contained\n- **Result-based error handling** -- all operations return `Result<T, DynamoError>` instead of throwing\n\n## Installation\n\n```bash\nnpm install dynamo-schema\n```\n\nYou also need your chosen schema library and AWS SDK:\n\n```bash\n# Schema library (pick one)\nnpm install zod\n# or: npm install valibot\n# or: npm install arktype\n\n# AWS SDK v3 (recommended)\nnpm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb\n\n# Or AWS SDK v2\nnpm install aws-sdk\n```\n\n## Quick Start\n\n```typescript\nimport { defineTable, defineEntity, createClient } from \"dynamo-schema\";\nimport { createSDKv3DocAdapter } from \"dynamo-schema/adapters/sdk-v3-doc\";\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport {\n  DynamoDBDocumentClient,\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n} from \"@aws-sdk/lib-dynamodb\";\nimport { z } from \"zod\";\n\n// 1. Define your table\nconst table = defineTable({\n  tableName: \"MainTable\",\n  partitionKey: { name: \"pk\", definition: \"pk\" },\n  sortKey: { name: \"sk\", definition: \"sk\" },\n});\n\n// 2. Define your entity with a Zod schema (or any Standard Schema)\nconst userSchema = z.object({\n  userId: z.string(),\n  email: z.string().email(),\n  name: z.string(),\n  age: z.number().int().positive(),\n});\n\nconst userEntity = defineEntity({\n  name: \"User\",\n  schema: userSchema,\n  table,\n  partitionKey: \"USER#{{userId}}\",\n  sortKey: \"PROFILE\",\n});\n\n// 3. Create the client\nconst ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));\nconst adapter = createSDKv3DocAdapter(ddbClient, {\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n});\nconst client = createClient({ adapter });\n\n// 4. Get a type-safe entity client\nconst users = client.entity(userEntity);\n\n// 5. Use it\nconst putResult = await users.put({\n  userId: \"123\",\n  email: \"alice@example.com\",\n  name: \"Alice\",\n  age: 30,\n});\n\nif (putResult.success) {\n  console.log(\"User created\");\n} else {\n  console.error(putResult.error.message);\n}\n```\n\n---\n\n## Core Concepts\n\n### Defining a Table\n\n`defineTable()` creates an immutable table definition describing your DynamoDB table's key structure and indexes.\n\n```typescript\nimport { defineTable } from \"dynamo-schema\";\n\nconst table = defineTable({\n  tableName: \"MainTable\",\n  partitionKey: { name: \"pk\", definition: \"pk\" },\n  sortKey: { name: \"sk\", definition: \"sk\" },\n  indexes: {\n    gsi1: {\n      type: \"GSI\",\n      indexName: \"GSI1\",\n      partitionKey: { name: \"gsi1pk\", definition: \"gsi1pk\" },\n      sortKey: { name: \"gsi1sk\", definition: \"gsi1sk\" },\n    },\n    gsi2: {\n      type: \"GSI\",\n      indexName: \"GSI2\",\n      partitionKey: { name: \"gsi2pk\", definition: \"gsi2pk\" },\n    },\n    lsi1: {\n      type: \"LSI\",\n      indexName: \"LSI1\",\n      partitionKey: { name: \"pk\", definition: \"pk\" },\n      sortKey: { name: \"lsi1sk\", definition: \"lsi1sk\" },\n    },\n  },\n});\n```\n\n**`KeyAttribute` properties:**\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `name` | `string` | The DynamoDB attribute name (e.g., `\"pk\"`, `\"gsi1pk\"`) |\n| `definition` | `string` | A key template or field reference |\n| `type` | `\"S\" \\| \"N\" \\| \"B\"` | Optional. The DynamoDB attribute type |\n\n### Defining an Entity\n\n`defineEntity()` binds a Standard Schema to a table with key mappings. The library validates at compile time that all template fields exist in the schema's output type.\n\n```typescript\nimport { defineEntity } from \"dynamo-schema\";\nimport { z } from \"zod\";\n\nconst userSchema = z.object({\n  userId: z.string(),\n  email: z.string().email(),\n  name: z.string(),\n  age: z.number(),\n  role: z.enum([\"admin\", \"user\"]),\n});\n\nconst userEntity = defineEntity({\n  name: \"User\",\n  schema: userSchema,\n  table,\n  partitionKey: \"USER#{{userId}}\",       // template key\n  sortKey: \"PROFILE\",                     // static sort key\n  indexes: {\n    gsi1: {\n      partitionKey: \"{{role}}\",           // index partition key\n      sortKey: \"USER#{{userId}}\",         // index sort key\n    },\n  },\n});\n```\n\n**Key definition formats:**\n\n| Format | Example | Description |\n|--------|---------|-------------|\n| Template | `\"USER#{{userId}}\"` | Substitutes `userId` field from entity data |\n| Multi-field template | `\"{{orgId}}#{{date}}\"` | Combines multiple fields |\n| Static value | `\"PROFILE\"` | Uses the literal string as-is |\n| Simple field | `\"userId\"` | Uses the field value directly (no `{{}}` needed when the entire key is one field) |\n\n### TTL Configuration\n\nTTL is configured in two places: the **table** (which attribute DynamoDB uses for expiry) and the **entity** (how that attribute is managed automatically).\n\n#### Table-level TTL\n\nTell DynamoDB which attribute holds the expiry timestamp. This attribute must be a `Number` type in DynamoDB and TTL must be enabled on the table in AWS.\n\n```typescript\nconst table = defineTable({\n  tableName: \"MainTable\",\n  partitionKey: { name: \"pk\", definition: \"pk\" },\n  sortKey: { name: \"sk\", definition: \"sk\" },\n  ttl: { attributeName: \"expiresAt\" },\n});\n```\n\n#### Entity-level TTL behavior\n\nControl automatic TTL injection per entity:\n\n```typescript\nconst sessionEntity = defineEntity({\n  name: \"Session\",\n  schema: sessionSchema,\n  table,\n  partitionKey: \"SESSION#{{sessionId}}\",\n  sortKey: \"METADATA\",\n  ttl: {\n    // Auto-inject this TTL value on every put (30 days from now)\n    defaultTtlSeconds: 60 * 60 * 24 * 30,\n    // Refresh the TTL on every update (sliding expiry)\n    autoUpdateTtlSeconds: 60 * 60 * 24 * 30,\n  },\n});\n```\n\nBoth fields are optional and independent. For example, you can set `autoUpdateTtlSeconds` without `defaultTtlSeconds` if you want sliding expiry on updates but not automatic injection on creation.\n\nThe TTL value injected is always `Math.floor(Date.now() / 1000) + <seconds>` (Unix epoch seconds, as required by DynamoDB).\n\n### Type Inference\n\nThe library infers TypeScript types from your schema definitions:\n\n```typescript\nimport type { InferEntityType, EntityKeyInput } from \"dynamo-schema\";\n\n// Infer the entity's data type from the schema\ntype User = InferEntityType<typeof userEntity>;\n// => { userId: string; email: string; name: string; age: number; role: \"admin\" | \"user\" }\n\n// Infer the key input type (fields needed to identify an item)\ntype UserKey = EntityKeyInput<typeof userEntity>;\n// => { readonly userId: string }\n```\n\n---\n\n## Operations\n\nAll operations return `Result<T, DynamoError>`. Check `result.success` to determine if the operation succeeded.\n\n### Put\n\nWrites an item to the table. The item is validated against the entity schema before writing. If the entity has `ttl.defaultTtlSeconds` configured, the TTL attribute is automatically injected.\n\n```typescript\nconst result = await users.put({\n  userId: \"123\",\n  email: \"alice@example.com\",\n  name: \"Alice\",\n  age: 30,\n  role: \"user\",\n});\n\nif (!result.success) {\n  // result.error.type is \"validation\" | \"key\" | \"marshalling\" | \"dynamo\"\n  console.error(result.error.type, result.error.message);\n}\n```\n\nIf the entity has a `defaultTtlSeconds` configured, the TTL attribute is computed and written automatically — you do not need to include it in your data:\n\n```typescript\n// Entity configured with ttl: { defaultTtlSeconds: 3600 }\n// The \"expiresAt\" attribute is injected automatically (now + 1 hour)\nawait sessions.put({ sessionId: \"abc\", userId: \"123\" });\n```\n\n**Options:**\n\n```typescript\nawait users.put(data, {\n  condition: \"attribute_not_exists(pk)\",           // condition expression\n  expressionNames: { \"#pk\": \"pk\" },                // expression attribute names\n  expressionValues: {},                             // expression attribute values\n  skipValidation: true,                             // skip runtime schema validation\n});\n```\n\n### Get\n\nRetrieves a single item by key. Returns `undefined` if not found.\n\n```typescript\nconst result = await users.get({ userId: \"123\" });\n\nif (result.success) {\n  if (result.data) {\n    console.log(result.data.name);  // fully typed as User\n  } else {\n    console.log(\"User not found\");\n  }\n}\n```\n\n**Options:**\n\n```typescript\nawait users.get({ userId: \"123\" }, {\n  consistentRead: true,\n  projection: [\"name\", \"email\"],    // only return these attributes\n});\n```\n\n### Delete\n\nDeletes an item by key.\n\n```typescript\nconst result = await users.delete({ userId: \"123\" });\n```\n\n**Options:**\n\n```typescript\nawait users.delete({ userId: \"123\" }, {\n  condition: \"#role <> :admin\",\n  expressionNames: { \"#role\": \"role\" },\n  expressionValues: { \":admin\": \"admin\" },\n});\n```\n\n### Update\n\nUpdates an item using a type-safe expression builder. The builder provides autocomplete on attribute names and type checks values.\n\n```typescript\nconst result = await users.update(\n  { userId: \"123\" },\n  (u) => u\n    .set(\"name\", \"Alice Smith\")          // SET name = \"Alice Smith\"\n    .set(\"age\", 31)                      // SET age = 31\n    .setIfNotExists(\"createdAt\", \"2024-01-01\") // SET createdAt = if_not_exists(createdAt, \"2024-01-01\")\n    .remove(\"temporaryField\")            // REMOVE temporaryField\n    .add(\"loginCount\", 1)               // ADD loginCount 1\n);\n```\n\n**Builder methods:**\n\n| Method | DynamoDB Action | Description |\n|--------|----------------|-------------|\n| `.set(path, value)` | `SET` | Set an attribute to a value |\n| `.setIfNotExists(path, value)` | `SET` | Set an attribute only if it does not already exist (uses `if_not_exists`) |\n| `.remove(path)` | `REMOVE` | Remove an attribute |\n| `.add(path, value)` | `ADD` | Add to a number or add elements to a set |\n| `.delete(path, value)` | `DELETE` | Remove elements from a set |\n\n**`setIfNotExists` example — initializing fields on first update:**\n\n```typescript\n// Set createdAt on first update, never overwrite it on subsequent updates.\n// Set updatedAt unconditionally on every update.\nawait users.update(\n  { userId: \"123\" },\n  (u) => u\n    .setIfNotExists(\"createdAt\", new Date().toISOString())\n    .set(\"updatedAt\", new Date().toISOString())\n    .set(\"name\", \"Alice Smith\"),\n);\n// Produces:\n// SET #sne0_createdAt = if_not_exists(#sne0_createdAt, :sne0_createdAt),\n//     #s0_updatedAt = :s0_updatedAt,\n//     #s1_name = :s1_name\n```\n\n**Update with condition:**\n\n```typescript\nawait users.update(\n  { userId: \"123\" },\n  (u) => u.set(\"email\", \"newemail@example.com\"),\n  {\n    condition: \"#age > :minAge\",\n    expressionNames: { \"#age\": \"age\" },\n    expressionValues: { \":minAge\": 18 },\n  },\n);\n```\n\n**TTL auto-refresh on update:**\n\nIf the entity has `ttl.autoUpdateTtlSeconds` configured, a `SET` action for the TTL attribute is automatically appended to every update expression (sliding expiry). To suppress this for a specific update, pass `skipAutoTtl: true`:\n\n```typescript\n// Entity configured with ttl: { autoUpdateTtlSeconds: 3600 }\n\n// Normal update — TTL is automatically refreshed to now + 1 hour\nawait sessions.update({ sessionId: \"abc\" }, (u) => u.set(\"lastSeen\", Date.now()));\n\n// Administrative update — TTL is NOT refreshed\nawait sessions.update(\n  { sessionId: \"abc\" },\n  (u) => u.set(\"flagged\", true),\n  { skipAutoTtl: true },\n);\n```\n\n### Remove TTL\n\nRemoves the TTL attribute from an existing item, preventing it from expiring. Requires the entity's table to have a `ttl` config.\n\n```typescript\nconst result = await sessions.removeTtl({ sessionId: \"abc\" });\n\nif (result.success) {\n  console.log(\"Session will no longer expire\");\n} else {\n  // result.error.type === \"validation\" if table has no TTL configured\n  console.error(result.error.message);\n}\n```\n\n### Query\n\nQueries items by partition key with optional sort key conditions.\n\n```typescript\nconst result = await users.query({\n  partitionKey: { userId: \"123\" },\n  sortKeyCondition: { beginsWith: \"PROFILE\" },\n});\n\nif (result.success) {\n  for (const user of result.data.items) {\n    console.log(user.name);  // typed as User\n  }\n\n  // Pagination\n  if (result.data.lastKey) {\n    const nextPage = await users.query({\n      partitionKey: { userId: \"123\" },\n      options: { startKey: result.data.lastKey },\n    });\n  }\n}\n```\n\n**Sort key conditions:**\n\n| Condition | Example | DynamoDB Expression |\n|-----------|---------|-------------------|\n| `eq` | `{ eq: \"PROFILE\" }` | `sk = :sk` |\n| `lt` | `{ lt: \"ORDER#2024\" }` | `sk < :sk` |\n| `lte` | `{ lte: \"ORDER#2024\" }` | `sk <= :sk` |\n| `gt` | `{ gt: \"ORDER#2024\" }` | `sk > :sk` |\n| `gte` | `{ gte: \"ORDER#2024\" }` | `sk >= :sk` |\n| `between` | `{ between: [\"ORDER#2024-01\", \"ORDER#2024-12\"] }` | `sk BETWEEN :skLo AND :skHi` |\n| `beginsWith` | `{ beginsWith: \"ORDER#\" }` | `begins_with(sk, :sk)` |\n\n**Query options:**\n\n```typescript\nawait users.query({\n  partitionKey: { userId: \"123\" },\n  sortKeyCondition: { beginsWith: \"ORDER#\" },\n  options: {\n    indexName: \"GSI1\",                        // query a secondary index\n    filter: \"#status = :active\",              // raw filter expression (legacy)\n    expressionNames: { \"#status\": \"status\" },\n    expressionValues: { \":active\": \"active\" },\n    limit: 10,                                // max items per page\n    scanIndexForward: false,                  // reverse order\n    consistentRead: true,\n    projection: [\"name\", \"email\"],\n    startKey: previousResult.data.lastKey,    // pagination\n  },\n});\n```\n\n### Scan\n\nScans all items in a table or index.\n\n```typescript\nconst result = await users.scan();\n\nif (result.success) {\n  console.log(`Found ${result.data.count} items`);\n  for (const user of result.data.items) {\n    console.log(user.name);\n  }\n}\n```\n\n**Scan with filter:**\n\n```typescript\nawait users.scan({\n  filter: \"#age > :minAge\",\n  expressionNames: { \"#age\": \"age\" },\n  expressionValues: { \":minAge\": 21 },\n  limit: 100,\n  indexName: \"GSI1\",\n});\n```\n\n---\n\n## Filter / Condition Expression Builder\n\nThe `createFilterBuilder<T>()` function provides a type-safe, composable API for building DynamoDB filter expressions (in query/scan) and condition expressions (in put/delete/update).\n\nIt automatically handles reserved word aliasing, `:value` placeholder injection, and `AND`/`OR`/`NOT` composition — making it impossible to write a malformed expression.\n\n### Basic usage\n\n```typescript\nimport { createFilterBuilder, compileFilterNode } from \"dynamo-schema\";\n\ntype User = { userId: string; status: string; age: number; email: string; verifiedAt?: string };\n\nconst f = createFilterBuilder<User>();\n\n// Build a filter node (immutable, composable)\nconst filter = f.and(\n  f.eq(\"status\", \"active\"),\n  f.gt(\"age\", 18),\n  f.beginsWith(\"email\", \"admin@\"),\n  f.attributeExists(\"verifiedAt\"),\n);\n\n// Pass directly to query/scan options\nconst result = await users.query({\n  partitionKey: { userId: \"123\" },\n  options: { filter },\n});\n```\n\n### Inline callback syntax\n\nThe `filter` and `condition` options also accept an inline callback. The callback receives an untyped builder (any string key is accepted):\n\n```typescript\nawait users.query({\n  partitionKey: { userId: \"123\" },\n  options: {\n    filter: (f) => f.and(\n      f.eq(\"status\", \"active\"),\n      f.gt(\"age\", 18),\n    ),\n  },\n});\n```\n\n### Available operators\n\n| Method | DynamoDB | Notes |\n|--------|----------|-------|\n| `f.eq(attr, value)` | `attr = :v` | |\n| `f.ne(attr, value)` | `attr <> :v` | |\n| `f.lt(attr, value)` | `attr < :v` | |\n| `f.lte(attr, value)` | `attr <= :v` | |\n| `f.gt(attr, value)` | `attr > :v` | |\n| `f.gte(attr, value)` | `attr >= :v` | |\n| `f.between(attr, lo, hi)` | `attr BETWEEN :lo AND :hi` | |\n| `f.beginsWith(attr, prefix)` | `begins_with(attr, :v)` | |\n| `f.contains(attr, value)` | `contains(attr, :v)` | |\n| `f.attributeExists(attr)` | `attribute_exists(attr)` | No value |\n| `f.attributeNotExists(attr)` | `attribute_not_exists(attr)` | No value |\n| `f.attributeType(attr, type)` | `attribute_type(attr, :v)` | type: `S`, `N`, `B`, etc. |\n| `f.and(...conds)` | `(c1 AND c2 ...)` | |\n| `f.or(...conds)` | `(c1 OR c2 ...)` | |\n| `f.not(cond)` | `NOT (cond)` | |\n\n### Condition expressions in put / delete / update\n\nThe same `FilterInput` type is accepted for `condition` in put, delete, and update:\n\n```typescript\n// Put: only if item does not exist\nawait users.put(newUser, {\n  condition: (f) => f.attributeNotExists(\"userId\"),\n});\n\n// Delete: only if version matches\nawait users.delete({ userId: \"123\" }, {\n  condition: (f) => f.eq(\"version\", 5),\n});\n\n// Update: only if status is still \"active\"\nawait users.update(\n  { userId: \"123\" },\n  (b) => b.set(\"name\", \"Alice\"),\n  { condition: (f) => f.eq(\"status\", \"active\") },\n);\n```\n\n### Manual compilation\n\nIf you need access to the compiled expression parts directly (e.g., for custom logic):\n\n```typescript\nimport { createFilterBuilder, compileFilterNode } from \"dynamo-schema\";\n\nconst f = createFilterBuilder<User>();\nconst compiled = compileFilterNode(\n  f.and(f.eq(\"status\", \"active\"), f.gt(\"age\", 18)),\n);\n\nconsole.log(compiled.expression);\n// → \"(#f0 = :f0 AND #f1 > :f1)\"\n\nconsole.log(compiled.expressionAttributeNames);\n// → { \"#f0\": \"status\", \"#f1\": \"age\" }\n\nconsole.log(compiled.expressionAttributeValues);\n// → { \":f0\": \"active\", \":f1\": 18 }\n```\n\n### Backward compatibility\n\nRaw expression strings still work everywhere — the builder is fully additive:\n\n```typescript\n// Legacy raw string (still works)\nawait users.scan({\n  filter: \"#status = :s\",\n  expressionNames: { \"#status\": \"status\" },\n  expressionValues: { \":s\": \"active\" },\n});\n\n// New builder-based filter (recommended)\nawait users.scan({\n  filter: (f) => f.eq(\"status\", \"active\"),\n});\n```\n\n---\n\n## Batch Operations\n\n### Batch Write\n\nWrites or deletes multiple items across entities. Automatically chunks into groups of 25 (the DynamoDB limit) and retries `UnprocessedItems` with exponential backoff (default: 3 retries at 100ms, 200ms, 400ms).\n\n```typescript\nconst result = await client.batchWrite([\n  {\n    type: \"put\",\n    entity: userEntity,\n    data: { userId: \"1\", email: \"alice@example.com\", name: \"Alice\", age: 30, role: \"user\" },\n  },\n  {\n    type: \"put\",\n    entity: userEntity,\n    data: { userId: \"2\", email: \"bob@example.com\", name: \"Bob\", age: 25, role: \"admin\" },\n  },\n  {\n    type: \"delete\",\n    entity: userEntity,\n    keyInput: { userId: \"old-user\" },\n  },\n]);\n```\n\n**Custom retry options:**\n\n```typescript\nawait client.batchWrite(requests, {\n  retryOptions: {\n    maxAttempts: 6,    // 1 initial + 5 retries (default: 4)\n    baseDelayMs: 200,  // 200ms → 400ms → 800ms ... (default: 100)\n    maxDelayMs: 10000, // cap at 10s (default: 5000)\n  },\n});\n```\n\nIf items remain unprocessed after all attempts, the operation returns a `DynamoError` (rather than silently discarding them).\n\n### Batch Get\n\nRetrieves multiple items across entities. Automatically chunks into groups of 100 (the DynamoDB limit) and retries `UnprocessedKeys` with exponential backoff (default: 3 retries at 100ms, 200ms, 400ms).\n\n```typescript\nconst result = await client.batchGet([\n  {\n    entity: userEntity,\n    keys: [\n      { userId: \"1\" },\n      { userId: \"2\" },\n      { userId: \"3\" },\n    ],\n    consistentRead: true,\n  },\n]);\n\nif (result.success) {\n  // Responses are grouped by entity name\n  const users = result.data.responses[\"User\"];\n  for (const user of users ?? []) {\n    console.log(user);\n  }\n}\n```\n\n**Custom retry options:**\n\n```typescript\nawait client.batchGet(requests, {\n  retryOptions: {\n    maxAttempts: 5,    // 1 initial + 4 retries (default: 4)\n    baseDelayMs: 150,  // 150ms → 300ms → 600ms ... (default: 100)\n    maxDelayMs: 3000,  // cap at 3s (default: 5000)\n  },\n});\n```\n\nIf keys remain unprocessed after all attempts, the operation returns a `DynamoError`.\n\n**Retry defaults summary:**\n\n| Setting | Default | Meaning |\n|---------|---------|---------|\n| `maxAttempts` | `4` | 1 initial + 3 retries |\n| `baseDelayMs` | `100` | 100ms → 200ms → 400ms |\n| `maxDelayMs` | `5000` | Maximum delay cap |\n\n---\n\n## Transactions\n\n### Transact Write\n\nExecutes up to 100 write operations atomically. Supports put, delete, update, and condition checks.\n\n```typescript\nconst result = await client.transactWrite([\n  {\n    type: \"put\",\n    entity: userEntity,\n    data: { userId: \"123\", email: \"alice@example.com\", name: \"Alice\", age: 30, role: \"user\" },\n    condition: \"attribute_not_exists(pk)\",\n  },\n  {\n    type: \"update\",\n    entity: orderEntity,\n    keyInput: { userId: \"123\", orderId: \"order-1\" },\n    builderFn: (u) => u.set(\"status\", \"confirmed\"),\n  },\n  {\n    type: \"delete\",\n    entity: cartEntity,\n    keyInput: { userId: \"123\" },\n  },\n  {\n    type: \"conditionCheck\",\n    entity: inventoryEntity,\n    keyInput: { productId: \"prod-1\" },\n    condition: \"#stock > :zero\",\n    expressionNames: { \"#stock\": \"stock\" },\n    expressionValues: { \":zero\": 0 },\n  },\n]);\n```\n\n### Transact Get\n\nRetrieves up to 100 items atomically. Results are returned in the same order as the requests.\n\n```typescript\nconst result = await client.transactGet([\n  { entity: userEntity, keyInput: { userId: \"123\" } },\n  { entity: orderEntity, keyInput: { userId: \"123\", orderId: \"order-1\" } },\n]);\n\nif (result.success) {\n  const [user, order] = result.data.items;\n  // user and order are Record<string, unknown> | undefined\n}\n```\n\n---\n\n## Single-Table Design\n\nThe library is designed for single-table patterns where multiple entity types share one DynamoDB table.\n\n```typescript\nimport { defineTable, defineEntity, createClient } from \"dynamo-schema\";\nimport { z } from \"zod\";\n\n// One table for everything\nconst table = defineTable({\n  tableName: \"AppTable\",\n  partitionKey: { name: \"pk\", definition: \"pk\" },\n  sortKey: { name: \"sk\", definition: \"sk\" },\n  indexes: {\n    gsi1: {\n      type: \"GSI\",\n      indexName: \"GSI1\",\n      partitionKey: { name: \"gsi1pk\", definition: \"gsi1pk\" },\n      sortKey: { name: \"gsi1sk\", definition: \"gsi1sk\" },\n    },\n  },\n});\n\n// User entity\nconst userEntity = defineEntity({\n  name: \"User\",\n  schema: z.object({\n    userId: z.string(),\n    email: z.string(),\n    name: z.string(),\n  }),\n  table,\n  partitionKey: \"USER#{{userId}}\",\n  sortKey: \"PROFILE\",\n  indexes: {\n    gsi1: {\n      partitionKey: \"{{email}}\",\n      sortKey: \"USER#{{userId}}\",\n    },\n  },\n});\n\n// Order entity (same table, different key pattern)\nconst orderEntity = defineEntity({\n  name: \"Order\",\n  schema: z.object({\n    userId: z.string(),\n    orderId: z.string(),\n    total: z.number(),\n    status: z.enum([\"pending\", \"shipped\", \"delivered\"]),\n    createdAt: z.string(),\n  }),\n  table,\n  partitionKey: \"USER#{{userId}}\",\n  sortKey: \"ORDER#{{orderId}}\",\n  indexes: {\n    gsi1: {\n      partitionKey: \"{{status}}\",\n      sortKey: \"{{createdAt}}\",\n    },\n  },\n});\n\n// Create entity clients\nconst client = createClient({ adapter });\nconst users = client.entity(userEntity);\nconst orders = client.entity(orderEntity);\n\n// Query all orders for a user\nconst userOrders = await orders.query({\n  partitionKey: { userId: \"123\" },\n  sortKeyCondition: { beginsWith: \"ORDER#\" },\n});\n\n// Query all pending orders across users via GSI\nconst pendingOrders = await orders.query({\n  partitionKey: { status: \"pending\" },\n  options: { indexName: \"GSI1\", scanIndexForward: false },\n});\n\n// Mix entity types in batch/transact operations\nawait client.transactWrite([\n  {\n    type: \"put\",\n    entity: userEntity,\n    data: { userId: \"456\", email: \"bob@example.com\", name: \"Bob\" },\n  },\n  {\n    type: \"put\",\n    entity: orderEntity,\n    data: {\n      userId: \"456\",\n      orderId: \"order-1\",\n      total: 99.99,\n      status: \"pending\",\n      createdAt: new Date().toISOString(),\n    },\n  },\n]);\n```\n\n---\n\n## SDK Adapters\n\n### AWS SDK v3 with DocumentClient (recommended)\n\n```typescript\nimport { createSDKv3DocAdapter } from \"dynamo-schema/adapters/sdk-v3-doc\";\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport {\n  DynamoDBDocumentClient,\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n} from \"@aws-sdk/lib-dynamodb\";\n\nconst ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));\n\nconst adapter = createSDKv3DocAdapter(ddbClient, {\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n});\n```\n\n### AWS SDK v3 with raw DynamoDB client\n\nUse this when you need full control over AttributeValue marshalling. The library handles marshalling/unmarshalling automatically.\n\n```typescript\nimport { createSDKv3Adapter } from \"dynamo-schema/adapters/sdk-v3\";\nimport {\n  DynamoDBClient,\n  PutItemCommand, GetItemCommand, DeleteItemCommand, UpdateItemCommand,\n  QueryCommand, ScanCommand, BatchWriteItemCommand, BatchGetItemCommand,\n  TransactWriteItemsCommand, TransactGetItemsCommand,\n} from \"@aws-sdk/client-dynamodb\";\n\nconst ddbClient = new DynamoDBClient({});\n\nconst adapter = createSDKv3Adapter(ddbClient, {\n  PutItemCommand, GetItemCommand, DeleteItemCommand, UpdateItemCommand,\n  QueryCommand, ScanCommand, BatchWriteItemCommand, BatchGetItemCommand,\n  TransactWriteItemsCommand, TransactGetItemsCommand,\n});\n```\n\n### AWS SDK v2 with DocumentClient\n\n```typescript\nimport { createSDKv2DocAdapter } from \"dynamo-schema/adapters/sdk-v2-doc\";\nimport AWS from \"aws-sdk\";\n\nconst docClient = new AWS.DynamoDB.DocumentClient();\nconst adapter = createSDKv2DocAdapter(docClient);\n```\n\n### AWS SDK v2 with raw DynamoDB\n\n```typescript\nimport { createSDKv2Adapter } from \"dynamo-schema/adapters/sdk-v2\";\nimport AWS from \"aws-sdk\";\n\nconst ddb = new AWS.DynamoDB();\nconst adapter = createSDKv2Adapter(ddb);\n```\n\n### Custom Adapters\n\nYou can implement the `SDKAdapter` interface to create adapters for testing or other DynamoDB-compatible services:\n\n```typescript\nimport type { SDKAdapter } from \"dynamo-schema\";\n\nconst mockAdapter: SDKAdapter = {\n  isRaw: false,\n  putItem: async (input) => ({ attributes: undefined }),\n  getItem: async (input) => ({ item: undefined }),\n  deleteItem: async (input) => ({ attributes: undefined }),\n  updateItem: async (input) => ({ attributes: undefined }),\n  query: async (input) => ({ items: [], count: 0 }),\n  scan: async (input) => ({ items: [], count: 0 }),\n  batchWriteItem: async (requests) => ({ unprocessedItems: [] }),\n  batchGetItem: async (requests) => ({ responses: {}, unprocessedKeys: [] }),\n  transactWriteItems: async (items) => {},\n  transactGetItems: async (items) => ({ items: [] }),\n};\n```\n\n---\n\n## Lifecycle Hooks\n\nEntity lifecycle hooks let you attach cross-cutting behavior — audit logging, auto-timestamps, soft-delete, access control — to any entity **without wrapping every call manually**.\n\nHooks are defined in `defineEntity` and are **run by default** on every matching operation. Pass `skipHooks: true` in any operation's options to bypass all hooks for that specific call.\n\n### Available hooks\n\n| Hook | Operation | When it runs | Can abort? |\n|------|-----------|--------------|-----------|\n| `beforePut` | `put` | After schema validation, before key building | Yes — throw to abort |\n| `beforeUpdate` | `update` | After builder runs, before TTL injection | Yes — throw to abort |\n| `afterGet` | `get` | After item is fetched and unmarshalled | Yes — throw to abort |\n| `beforeDelete` | `delete` | Before the DynamoDB call | Yes — throw to abort |\n\n### Auto-inject timestamps\n\n```typescript\nimport { defineEntity } from \"dynamo-schema\";\n\nconst UserEntity = defineEntity({\n  name: \"User\",\n  schema: UserSchema,\n  table: UserTable,\n  partitionKey: \"USER#{{userId}}\",\n  sortKey: \"PROFILE\",\n  hooks: {\n    // Stamp updatedAt on every write\n    beforePut: (item) => ({ ...item, updatedAt: Date.now() }),\n\n    // Stamp updatedAt on every update expression\n    beforeUpdate: (_key, actions) => ({\n      ...actions,\n      sets: [...actions.sets, { path: \"updatedAt\", value: Date.now() }],\n    }),\n  },\n});\n```\n\n### Soft-delete with `beforeDelete`\n\n```typescript\nconst OrderEntity = defineEntity({\n  name: \"Order\",\n  schema: OrderSchema,\n  table: OrderTable,\n  partitionKey: \"ORDER#{{orderId}}\",\n  hooks: {\n    // Prevent hard deletes — direct callers to a safer API\n    beforeDelete: (_key) => {\n      throw new Error(\"Orders cannot be deleted. Call cancelOrder() instead.\");\n    },\n  },\n});\n\n// This will fail with type \"hook\" rather than hitting DynamoDB\nconst result = await orders.delete({ orderId: \"ord-1\" });\nif (!result.success && result.error.type === \"hook\") {\n  console.error(result.error.message);\n}\n```\n\n### Transform results with `afterGet`\n\n```typescript\nconst ProductEntity = defineEntity({\n  name: \"Product\",\n  schema: ProductSchema,\n  table: ProductTable,\n  partitionKey: \"PRODUCT#{{productId}}\",\n  hooks: {\n    // Provide a default when the item does not exist\n    afterGet: (item) => item ?? { productId: \"unknown\", name: \"Unknown Product\", price: 0 },\n  },\n});\n```\n\n### Async hooks\n\nEvery hook can be synchronous or asynchronous — both are fully supported:\n\n```typescript\nconst AuditedEntity = defineEntity({\n  name: \"AuditedItem\",\n  schema: ItemSchema,\n  table: ItemTable,\n  partitionKey: \"ITEM#{{itemId}}\",\n  hooks: {\n    beforeDelete: async (key) => {\n      // Perform an async audit log write before allowing the delete\n      await auditLog.record(\"delete\", key);\n    },\n  },\n});\n```\n\n### Skipping hooks for a single call\n\nPass `skipHooks: true` to any operation to bypass all hooks for that specific call:\n\n```typescript\n// Administrative bulk import — skip hooks for performance\nawait users.put(rawUser, { skipHooks: true });\n\n// Bypass soft-delete protection for an admin hard-delete\nawait orders.delete({ orderId: \"ord-1\" }, { skipHooks: true });\n\n// Skip afterGet transformation to get the raw stored item\nconst raw = await users.get({ userId: \"u1\" }, { skipHooks: true });\n```\n\n### Hook errors\n\nWhen a hook throws, the operation is aborted and the error is returned as a `DynamoError` with `type: \"hook\"`. The original thrown value is preserved in `error.cause`.\n\n```typescript\nconst result = await orders.delete({ orderId: \"ord-1\" });\nif (!result.success) {\n  if (result.error.type === \"hook\") {\n    // A lifecycle hook aborted the operation\n    console.error(\"Hook blocked operation:\", result.error.message);\n    console.error(\"Original error:\", result.error.cause);\n  }\n}\n```\n\n---\n\n## Error Handling\n\nAll operations return `Result<T, DynamoError>` instead of throwing exceptions.\n\n```typescript\nimport type { Result, DynamoError } from \"dynamo-schema\";\n\nconst result = await users.put(userData);\n\nif (result.success) {\n  // result.data is the success value (void for put)\n} else {\n  // result.error is a DynamoError\n  switch (result.error.type) {\n    case \"validation\":\n      // Schema validation failed\n      console.error(\"Invalid data:\", result.error.message);\n      break;\n    case \"key\":\n      // Key building failed (missing template fields)\n      console.error(\"Key error:\", result.error.message);\n      break;\n    case \"marshalling\":\n      // Marshalling/unmarshalling failed\n      console.error(\"Marshalling error:\", result.error.message);\n      break;\n    case \"hook\":\n      // A lifecycle hook aborted the operation\n      console.error(\"Hook error:\", result.error.message);\n      console.error(\"Cause:\", result.error.cause);\n      break;\n    case \"dynamo\":\n      // DynamoDB service error\n      console.error(\"DynamoDB error:\", result.error.message);\n      console.error(\"Cause:\", result.error.cause);\n      break;\n  }\n}\n```\n\n**`DynamoError` type reference:**\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `type` | `\"validation\" \\| \"key\" \\| \"marshalling\" \\| \"hook\" \\| \"dynamo\"` | The category of error |\n| `message` | `string` | Human-readable error message |\n| `cause` | `unknown` | Optional underlying error |\n\n**Result utilities:**\n\n```typescript\nimport { ok, err, mapResult, flatMapResult } from \"dynamo-schema\";\n\n// Map over a successful result\nconst mapped = mapResult(result, (user) => user.name);\n\n// Chain operations that return Results\nconst chained = flatMapResult(result, (user) =>\n  user.age >= 18 ? ok(user) : err(new Error(\"Must be 18+\")),\n);\n```\n\n---\n\n## Validation\n\nRuntime validation is **enabled by default**. Every `put` operation validates the data against the entity schema before writing to DynamoDB.\n\n### Disabling validation\n\n```typescript\n// Disable for the entire client\nconst client = createClient({ adapter, validation: false });\n\n// Or disable per operation\nawait users.put(data, { skipValidation: true });\n```\n\n### Table Validation\n\n`validateTable()` compares your local `defineTable()` definition against the actual DynamoDB table in AWS. It calls `DescribeTable` through the SDK adapter and reports mismatches in key names, key types, indexes, and table status.\n\nThis is useful for catching drift between your code and your deployed table — for example during CI, deployment scripts, or application startup.\n\n**Basic usage:**\n\n```typescript\nimport { defineTable, validateTable } from \"dynamo-schema\";\nimport { createSDKv3DocAdapter } from \"dynamo-schema/adapters/sdk-v3-doc\";\nimport { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport {\n  DynamoDBDocumentClient,\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n} from \"@aws-sdk/lib-dynamodb\";\n\n// 1. Define your table locally\nconst table = defineTable({\n  tableName: \"MainTable\",\n  partitionKey: { name: \"pk\", definition: \"pk\" },\n  sortKey: { name: \"sk\", definition: \"sk\" },\n  indexes: {\n    gsi1: {\n      type: \"GSI\",\n      indexName: \"GSI1\",\n      partitionKey: { name: \"gsi1pk\", definition: \"gsi1pk\" },\n      sortKey: { name: \"gsi1sk\", definition: \"gsi1sk\" },\n    },\n  },\n});\n\n// 2. Create the adapter\nconst ddbClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));\nconst adapter = createSDKv3DocAdapter(ddbClient, {\n  PutCommand, GetCommand, DeleteCommand, UpdateCommand,\n  QueryCommand, ScanCommand, BatchWriteCommand, BatchGetCommand,\n  TransactWriteCommand, TransactGetCommand,\n});\n\n// 3. Validate the table\nconst result = await validateTable(table, adapter);\n```\n\n**Handling the result:**\n\n`validateTable` returns `Result<TableValidationResult, DynamoError>`. The `TableValidationResult` contains a `valid` boolean and an array of `issues`, each with a severity level.\n\n```typescript\nif (!result.success) {\n  // The DescribeTable API call itself failed (e.g. table not found, permissions)\n  console.error(\"Failed to describe table:\", result.error.message);\n} else if (!result.data.valid) {\n  // The table exists but doesn't match the local definition\n  console.log(`Table \"${result.data.tableName}\" has validation errors:`);\n\n  for (const issue of result.data.issues) {\n    // issue.severity: \"error\" | \"warning\" | \"info\"\n    // issue.path:     location of the mismatch (e.g. \"partitionKey\", \"indexes.gsi1.sortKey\")\n    // issue.message:  human-readable description\n    // issue.expected: what the local definition expects (optional)\n    // issue.actual:   what AWS returned (optional)\n    console.log(`  [${issue.severity}] ${issue.path}: ${issue.message}`);\n    if (issue.expected) console.log(`    expected: ${issue.expected}`);\n    if (issue.actual)   console.log(`    actual:   ${issue.actual}`);\n  }\n} else {\n  console.log(`Table \"${result.data.tableName}\" matches the local definition.`);\n}\n```\n\n**What gets validated:**\n\n| Check | Severity | Description |\n|-------|----------|-------------|\n| Table status | `warning` | Reports if the table status is not `\"ACTIVE\"` |\n| Partition key name | `error` | Local `partitionKey.name` must match the AWS HASH key |\n| Partition key type | `error` | If `partitionKey.type` is set locally, it must match AWS |\n| Sort key presence | `error` | Both sides must agree on whether a sort key exists |\n| Sort key name | `error` | Local `sortKey.name` must match the AWS RANGE key |\n| Sort key type | `error` | If `sortKey.type` is set locally, it must match AWS |\n| Index existence | `error` | Every locally defined index must exist in AWS |\n| Index type | `error` | A local GSI must be a GSI in AWS (not an LSI), and vice versa |\n| Index key names/types | `error` | Index partition and sort key names and types must match |\n| GSI status | `warning` | Reports if a GSI status is not `\"ACTIVE\"` |\n| Extra AWS indexes | `info` | Indexes in AWS that are not defined locally are reported |\n| TTL attribute empty | `error` | `ttl.attributeName` must not be empty or whitespace |\n| TTL conflicts with partition key | `error` | `ttl.attributeName` must not be the same as the partition key name |\n| TTL conflicts with sort key | `error` | `ttl.attributeName` must not be the same as the sort key name |\n\n**Using in CI or startup checks:**\n\n```typescript\n// Fail fast if the table schema has drifted\nconst assertTableValid = async (table: TableDefinition, adapter: SDKAdapter) => {\n  const result = await validateTable(table, adapter);\n\n  if (!result.success) {\n    throw new Error(`Cannot validate table: ${result.error.message}`);\n  }\n\n  const errors = result.data.issues.filter((i) => i.severity === \"error\");\n  if (errors.length > 0) {\n    const summary = errors\n      .map((e) => `  ${e.path}: ${e.message}`)\n      .join(\"\\n\");\n    throw new Error(\n      `Table \"${result.data.tableName}\" schema mismatch:\\n${summary}`,\n    );\n  }\n};\n```\n\n**`TableValidationResult` type reference:**\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `tableName` | `string` | The table name that was validated |\n| `tableStatus` | `string` | The AWS table status (e.g. `\"ACTIVE\"`) |\n| `valid` | `boolean` | `true` if no `\"error\"` severity issues were found |\n| `issues` | `readonly TableValidationIssue[]` | All issues found during validation |\n\n**`TableValidationIssue` type reference:**\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `severity` | `\"error\" \\| \"warning\" \\| \"info\"` | How critical the issue is |\n| `path` | `string` | Dot-separated path to the mismatched property |\n| `message` | `string` | Human-readable description of the issue |\n| `expected` | `string \\| undefined` | What the local definition expects |\n| `actual` | `string \\| undefined` | What AWS returned |\n\n### Using with different schema libraries\n\nThe library works with any [Standard Schema V1](https://standardschema.dev) compliant validation library.\n\n**With Zod:**\n\n```typescript\nimport { z } from \"zod\";\n\nconst schema = z.object({\n  userId: z.string(),\n  email: z.string().email(),\n  tags: z.array(z.string()),\n});\n```\n\n**With Valibot:**\n\n```typescript\nimport * as v from \"valibot\";\n\nconst schema = v.object({\n  userId: v.string(),\n  email: v.pipe(v.string(), v.email()),\n  tags: v.array(v.string()),\n});\n```\n\n**With ArkType:**\n\n```typescript\nimport { type } from \"arktype\";\n\nconst schema = type({\n  userId: \"string\",\n  email: \"string\",\n  tags: \"string[]\",\n});\n```\n\n---\n\n## Marshalling\n\nThe library includes self-contained marshalling for converting between JavaScript values and DynamoDB's AttributeValue format. This is used automatically when you use a raw DynamoDB adapter (`isRaw: true`), but you can also use it directly:\n\n```typescript\nimport { marshallItem, marshallValue, unmarshallItem, unmarshallValue } from \"dynamo-schema\";\n\n// Marshall a JS object to DynamoDB format\nconst result = marshallItem({ name: \"Alice\", age: 30, active: true });\nif (result.success) {\n  console.log(result.data);\n  // { name: { S: \"Alice\" }, age: { N: \"30\" }, active: { BOOL: true } }\n}\n\n// Unmarshall DynamoDB format back to JS\nconst item = unmarshallItem({\n  name: { S: \"Alice\" },\n  age: { N: \"30\" },\n  active: { BOOL: true },\n});\nif (item.success) {\n  console.log(item.data);\n  // { name: \"Alice\", age: 30, active: true }\n}\n```\n\n**Type mapping:**\n\n| JavaScript Type | DynamoDB Type |\n|----------------|---------------|\n| `string` | `S` |\n| `number` / `bigint` | `N` |\n| `boolean` | `BOOL` |\n| `null` / `undefined` | `NULL` |\n| `Uint8Array` | `B` |\n| `Set<string>` | `SS` |\n| `Set<number>` | `NS` |\n| `Set<Uint8Array>` | `BS` |\n| `Array` | `L` |\n| Plain object | `M` |\n\n---\n\n## API Reference\n\n### Factory Functions\n\n| Function | Description |\n|----------|-------------|\n| `defineTable(config)` | Creates an immutable table definition |\n| `defineEntity(config)` | Creates an immutable entity definition with compile-time key validation |\n| `createClient(config)` | Creates a DynamoDB client from an SDK adapter |\n| `createUpdateBuilder<T>()` | Creates a standalone update expression builder |\n| `validateTable(table, adapter)` | Validates a local table definition against the actual AWS table |\n\n### DynamoClient Methods\n\n| Method | Description |\n|--------|-------------|\n| `client.entity(entityDef)` | Returns a type-safe `EntityClient` for the entity |\n| `client.batchWrite(requests, options?)` | Batch write with auto-chunking (25 items) + exponential backoff retry |\n| `client.batchGet(requests, options?)` | Batch get with auto-chunking (100 items) + exponential backoff retry |\n| `client.transactWrite(requests, options?)` | Atomic write transaction (up to 100 items) |\n| `client.transactGet(requests)` | Atomic get transaction (up to 100 items) |\n\n### EntityClient Methods\n\n| Method | Description |\n|--------|-------------|\n| `entity.put(data, options?)` | Write an item (validates against schema, auto-injects TTL if configured) |\n| `entity.get(key, options?)` | Get an item by key |\n| `entity.delete(key, options?)` | Delete an item by key |\n| `entity.update(key, builderFn, options?)` | Update with type-safe expression builder (auto-refreshes TTL if configured) |\n| `entity.query(input)` | Query by partition key with sort key conditions |\n| `entity.scan(options?)` | Scan table or index |\n| `entity.removeTtl(key)` | Remove the TTL attribute from an item so it never expires |\n\n### Type Utilities\n\n| Type | Description |\n|------|-------------|\n| `InferEntityType<E>` | Infer the TypeScript type from an entity definition |\n| `EntityKeyInput<E>` | Infer the key input type for an entity |\n| `EntityKeyFields<E>` | Union of field names in the entity's key templates |\n| `ExtractTemplateFields<T>` | Extract field names from a template string type |\n| `TtlConfig` | Table-level TTL config: `{ attributeName: string }` |\n| `EntityTtlConfig` | Entity-level TTL behavior: `{ defaultTtlSeconds?, autoUpdateTtlSeconds? }` |\n| `EntityHooks<T>` | Lifecycle hooks for an entity: `{ beforePut?, beforeUpdate?, afterGet?, beforeDelete? }` |\n| `Result<T, E>` | Success/failure discriminated union |\n| `DynamoError` | Error type with `type`, `message`, and `cause` |\n\n---\n\n## License\n\nISC\n","readmeFilename":"README.md","_rev":"1-b1a364cd231a65a9c5777d3e6562f6db"}