-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe: Provisioning workflow
The SQL-equivalent question this answers:
CREATE TABLE ... IF NOT EXISTS+ALTER TABLE ADD INDEX, driven from the same declaration you read and write with. In SQL the DDL is usually a separate.sqlfile applied by a migration tool; the schema it represents has to stay in sync with the code that queries it. DynamoDB offers the same split — IaC (Terraform / CDK / CloudFormation) manages the table, application code consumes it — but the toolkit'sAdapterdeclaration is already richer than DescribeTable's output (it carriesfilterable,searchable,versionField,createdAtField, marshalling choices). This recipe wires the Adapter declaration into a small, add-only provisioner that can create the table, extend it, and verify it — optionally carrying the non-DescribeTable metadata in a reserved row so drift is detectable end-to-end.Pattern: import
planTable/ensureTable/verifyTablefromdynamodb-toolkit/provisioning(or use the bundled CLI).planTableis read-only diagnostics;ensureTableapplies ADD-only changes (create, add GSI);verifyTablereports drift. WhendescriptorKeyis declared, the provisioning round also writes a JSON snapshot of the full declaration into a reserved row so verification covers the bits DescribeTable can't see.
Most DDB deployments already use IaC:
resource "aws_dynamodb_table" "events" {
name = "events"
billing_mode = "PAY_PER_REQUEST"
hash_key = "tenantId"
range_key = "-sk"
# ... GSI definitions, stream config, etc.
}That works. It handles permissions, cross-account, change-review, rollback. It covers everything DescribeTable can see: key schema, index shapes, billing mode, stream config. It does not see the adapter-level metadata: is there a versionField? A searchable allowlist? A createdAtField? Which marshalling helpers? These ride inside the JS declaration and the DynamoDB attribute storage.
Two concrete failure modes in a "IaC-only" deployment:
- Silent drift. IaC is updated to declare a new GSI, but the application's Adapter declaration isn't — or vice versa. The runtime still works (Queries against the missing GSI throw; Queries against an unexpected GSI ignore it), but you don't discover the mismatch until a hot-path query hits the gap.
-
No accountability for the toolkit's metadata. If
filterablechanged in the app but staging is still running the old version, the 422-vs-200 distinction you depend on is quietly different between environments.
The toolkit's provisioner isn't trying to replace IaC. It's a same-source-of-truth diagnostic — applying the JS declaration against the live table, surfacing exactly what's out of step, and optionally writing the non-DescribeTable metadata into a reserved row so the gap closes. Use it alongside IaC (as a CI verifier) or instead of IaC for smaller deployments.
import {planTable, ensureTable, verifyTable} from 'dynamodb-toolkit/provisioning';
// 1. Read-only diagnostics. No side effects.
const plan = await planTable(adapter);
// → {tableName: 'events', steps: [...], summary: ['Would CREATE table events\n + GSI ...']}
// 2. Apply the plan. Creates the table if absent, adds missing GSIs.
const {plan, executed, descriptorWritten} = await ensureTable(adapter);
// → plan: same shape as planTable's return
// executed: ['create:events', 'add-gsi:by-status']
// descriptorWritten: true (when descriptorKey declared)
// 3. Verify declaration matches the live table.
const {ok, diffs} = await verifyTable(adapter);
// → ok: true/false
// diffs: [{path, severity: 'error'|'warn', expected, actual}, ...]ensureTable is deliberately ADD-only. It never drops, never modifies in place, never renames. The full rule set:
| Situation | Plan step | Action |
|---|---|---|
| Table absent | create |
CreateTable with every declared GSI + LSI. |
| Declared GSI missing in table | add-gsi |
UpdateTable one GSI at a time (DDB rejects multiple GSI ops per call). |
| Declared LSI missing in table | skip-missing-lsi |
No-op. LSIs can only be declared at CreateTable; DDB rejects post-hoc creation. |
| Extra GSI in table, not declared | skip-extra-gsi |
No-op. Warns, never drops. |
| Extra LSI in table, not declared | skip-extra-lsi |
No-op. Warns. |
The "never drops" stance is the critical safety property. ensureTable won't surprise you with a destructive change. If you want to delete an index, do it with IaC or the AWS Console; the toolkit won't automate it.
// adapter.js — the same module your runtime imports
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
const client = DynamoDBDocumentClient.from(new DynamoDBClient({region: 'us-east-1'}), {
marshallOptions: {removeUndefinedValues: true}
});
export const adapter = new Adapter({
client,
table: 'events',
keyFields: ['tenantId', {name: 'eventId', type: 'string'}],
structuralKey: '-sk',
technicalPrefix: '-',
versionField: '-version',
createdAtField: '-createdAt',
billingMode: 'PAY_PER_REQUEST',
indices: {
'by-status-date': {
type: 'gsi',
pk: 'status',
sk: {name: 'createdAt', type: 'number'},
projection: 'all',
sparse: true
}
},
filterable: {
status: ['eq', 'in'],
createdAt: {ops: ['btw', 'ge', 'le'], type: 'number'}
},
descriptorKey: '__adapter__' // opt-in reserved row for the JSON snapshot
});# From the command line, against the file above:
npx dynamodb-toolkit plan-table ./adapter.js
npx dynamodb-toolkit ensure-table ./adapter.js
npx dynamodb-toolkit verify-table ./adapter.jsThe CLI imports the module, extracts the adapter from the default export (or a named adapter export), and dispatches. Any ESM-resolvable path works, including TypeScript modules under Node's native strip-types mode (Node 22.6+).
Run anywhere, anytime. Zero side effects; one DescribeTable call to read the live table.
const plan = await planTable(adapter);
// {
// tableName: 'events',
// steps: [
// {action: 'create', params: {TableName: 'events', ...}}
// ],
// summary: [
// 'Would CREATE table events',
// ' + GSI by-status-date (status:HASH, createdAt:RANGE)'
// ]
// }
console.log(plan.summary.join('\n'));Common uses:
-
Dry-run in CI. Diff the summary across
mainand feature branches to catch accidental index changes before they land. -
Pre-deploy sanity check. Print the plan before running
ensureTable. -
Drift detection against IaC. If IaC added a GSI but your Adapter declaration hasn't,
planTablesurfaces it as anadd-gsistep. Run the diff on every deploy and alert if the step set isn't empty.
Runs the plan. Every step is either a CreateTableCommand or an UpdateTableCommand; skip-* steps are no-ops.
const result = await ensureTable(adapter);
// {
// plan: {tableName, steps, summary},
// executed: ['create:events', 'add-gsi:by-status-date'],
// descriptorWritten: true // present only when descriptorKey declared
// }Semantics to remember:
-
GSI creation is async on the DDB side.
UpdateTablereturns immediately; the GSI backfills in the background (tens of minutes to hours for a large table). Queries against the new GSI throw until it'sACTIVE. The toolkit doesn't poll for ready — roll that into a deploy health check yourself, or runensureTableseparately from the release that starts querying the new index. -
Descriptor always writes on a successful run — including no-op runs. An IaC-managed table that already matches the declaration still picks up a fresh
__adapter__row, which makes the firstverifyTablepass meaningful. -
Idempotent. Running
ensureTabletwice on a matching table is a no-op; run count = 0 and the descriptor is refreshed.
Bootstrap paths:
-
Local dev against DynamoDB Local. Drop
ensureTable(adapter)into a test setup hook; spin up DDB Local once per run, create the table from the declaration, done. -
CI integration tests. Same shape.
@aws-sdk/client-dynamodbtargetshttp://localhost:8000(or wherever);ensureTableis the migration step. -
Greenfield prod. Works fine —
ensureTable+ a descriptor key carries enough metadata that you can forgo IaC for the table resource. Keep IaC for IAM / stream consumers / CloudWatch alarms. -
Brownfield prod (IaC already owns the table). Let IaC create the table. Call
ensureTablefrom a post-deploy Lambda anyway — it's a no-op on a matching schema, and it populates the descriptor row which unlocks the richerverifyTablechecks.
Compares the declaration against the live table. Checks two layers:
-
DescribeTable-visible schema. Base key schema, attribute definitions for declared keys, every declared GSI and LSI (key schema + projection), billing mode (if declared on the adapter), stream config (if declared). -
Descriptor row (optional). When
descriptorKeyis declared, reads the reserved row and diffs against the current declaration'sfilterable,searchable,versionField,createdAtField,technicalPrefix,relationships,typeLabels,typeDiscriminator.
const {ok, diffs} = await verifyTable(adapter);
// ok: true | false
// diffs: [
// {path: 'gsi.by-status-date.KeySchema', severity: 'error', expected: 'status:HASH,createdAt:RANGE', actual: 'status:HASH'},
// {path: 'gsi.by-legacy', severity: 'warn', expected: 'absent from declaration', actual: 'present in table'},
// {path: 'descriptor.filterable.status.0', severity: 'error', expected: 'eq', actual: 'in'}
// ]Diff severity levels:
-
error— declaration and live table disagree on something the application depends on. Declared-but-missing GSI, key type mismatch, descriptorfilterabledrift. -
warn— live table has something the declaration doesn't. Extra GSI, extra LSI, billing mode different from declared. Not load-bearing for the app's correctness, but flagged for cleanup.
Options:
| Option | Effect |
|---|---|
throwOnMismatch: true |
Throws TableVerificationFailed(tableName, diffs) when any error diff is present. Use in CI for fail-fast. |
requireDescriptor: true |
Treats absent descriptor as an error diff. Off by default so IaC-managed tables (never had a descriptor) don't fail verification. |
CI usage — fail the build on drift:
import {verifyTable} from 'dynamodb-toolkit/provisioning';
import {adapter} from './adapter.js';
try {
await verifyTable(adapter, {throwOnMismatch: true, requireDescriptor: true});
console.log('Table verified.');
} catch (err) {
console.error(err.message);
for (const d of err.diffs) console.error(` [${d.severity}] ${d.path}: expected ${JSON.stringify(d.expected)}, actual ${JSON.stringify(d.actual)}`);
process.exit(1);
}Or via CLI:
npx dynamodb-toolkit verify-table ./adapter.js --strict --require-descriptor
# Exit 0 → clean. Exit 1 → drift present.When descriptorKey: '__adapter__' is declared, ensureTable writes a JSON snapshot of the declaration at a reserved DB row:
Key: tenantId = '__adapter__', -sk = '__adapter__'
Item: {
tenantId: '__adapter__',
'-sk': '__adapter__',
__toolkit_descriptor__: '{"version":1,"generatedAt":"2026-04-23T12:00:00Z","table":"events",...}'
}
descriptorKey constraints:
- Must start with
technicalPrefixwhentechnicalPrefixis declared (keeps the descriptor inside the adapter-managed namespace). - The descriptor row is auto-filtered out of
getList/getListByParams/ every mass op — opt in withoptions.includeDescriptor: trueif you need to see it (debugging, migration tooling). -
getByKeyagainst the descriptor key does not auto-filter. If your app knows about the descriptor key, reads explicit. Simpler than adding read-side knowledge of the reserved row.
The snapshot carries:
-
keyFieldswith{name, type, width?}per entry. -
structuralKey(if declared). - Every
indicesentry (type, pk/sk shape, projection, sparse flag, indirect flag). -
typeLabels,typeDiscriminator,typeField. -
filterableallowlist (per-field ops + types). -
searchablefield list +searchablePrefix. -
versionField,createdAtField,technicalPrefix,relationships.
What it does not carry:
-
client(would serialize the AWS SDK instance — not portable). -
hooks(functions are not serializable). -
projectionFieldMap(intentional — this is a client-side read-path concern, not table shape).
Sparse predicates (sparse: {onlyWhen: fn}) serialize as {onlyWhen: '<function>'} — the descriptor records that a predicate exists but not its body. Drift in the predicate is caught by testing, not by verifyTable.
Three common placements for the ensureTable / verifyTable calls:
IaC (Terraform / CDK) creates the table + GSIs. On every deploy:
- Post-deploy hook runs
ensureTable(adapter). No schema changes (IaC already did them); descriptor row refreshes. - CI job runs
verifyTable(adapter, {throwOnMismatch: true, requireDescriptor: true})against a test table. Fails the build on any drift.
Cleanest when the ops team prefers IaC for the core table and wants drift detection on the application-side metadata.
No IaC for the table. Deployment:
- Release script runs
ensureTable(adapter). First run creates; subsequent runs add GSIs if any were declared since. - Same-release
verifyTable(adapter, {throwOnMismatch: true})as a health check after DDB settles.
Right for smaller deployments, OSS projects, or environments where the table resource isn't cross-cutting with other AWS resources.
Manual / one-off:
npx dynamodb-toolkit plan-table ./adapter.js # preview
npx dynamodb-toolkit ensure-table ./adapter.js # apply
npx dynamodb-toolkit verify-table ./adapter.js --strict # post-checkRight for local dev, integration-test setup, or any environment where a deploy pipeline is overkill.
dynamodb-toolkit/provisioning also exports the building blocks. Use these when you need behaviour the three top-level entry points don't cover (partial apply, custom plan transformations, descriptor maintenance without a full ensure):
| Export | Purpose |
|---|---|
extractDeclaration(adapterOrDeclaration) |
Pull the plain-object declaration out of an Adapter instance (or pass one directly). |
buildCreateTableInput(decl) |
The CreateTableCommand input for the declaration. Send yourself. |
buildAddGsiInput(decl, name, idx) |
The UpdateTableCommand input for one GSI addition. |
planAddOnly(decl, describeOutput) |
The diff/plan core. Pass a null describeOutput for "create from scratch." |
describeTable(client, tableName) |
DescribeTable wrapper returning null on ResourceNotFoundException. |
executePlan(client, plan) |
Run the plan. No descriptor write; call writeDescriptor after. |
diffTable(decl, live) |
The verify core. Returns the diff array without the descriptor comparison. |
buildDescriptorSnapshot(decl) / compareDescriptor(stored, decl)
|
Snapshot / diff the descriptor JSON. |
readDescriptor(decl) / writeDescriptor(decl)
|
Read / write the reserved row directly. |
Example — write the descriptor without running a plan (e.g., app already up, need to backfill the descriptor for an existing IaC-managed table):
import {writeDescriptor} from 'dynamodb-toolkit/provisioning';
await writeDescriptor(adapter); // overwrites the row; idempotent-
planTable— oneDescribeTablecall. No read/write capacity cost. Synchronous. -
ensureTable— oneDescribeTable, plus oneCreateTable(if absent) plus oneUpdateTableper missing GSI (if any).CreateTableis sync-ish on DDB's side;UpdateTablereturns immediately but GSI backfill is async and spans minutes-to-hours for populated tables. -
verifyTable— oneDescribeTable+ (whendescriptorKeyis declared) oneGetItemfor the descriptor row. Total cost < 1 RCU/call. -
writeDescriptor— onePutItemon the reserved row. ~1 WCU per call.
- You already run with
dynamodb-toolkit/provisioningat your disposal — it's in the tarball whether you use it or not. - You want the declaration to be the source of truth for what the table should look like.
- Drift between the app's understanding of
filterable/searchableand the deployed schema would be hard to notice otherwise. - You've got a CI step that can run JS and has DDB (local or real) access — the verifier is just one async call.
- Destructive changes are needed. Dropping a GSI, changing key types, renaming the table, switching billing mode mid-life — do it through IaC / console with a proper migration plan. The toolkit declines to automate destructive paths.
- Multi-region / multi-account coordination. Global Tables, KMS keys, replica configuration — IaC's territory.
- No JS runtime available in the deploy pipeline. The provisioner is an ESM module — needs Node (or Bun / Deno) to run. Drop to the raw SDK or IaC if your pipeline is bash-only.
-
Adapter: Constructor options — declaration fields the provisioner consumes (
keyFields,structuralKey,indices,billingMode,descriptorKey,provisionedThroughput,streamSpecification). - Concepts → Descriptor record — concept-level overview of the reserved row.
- Recipe: Keys-only GSI with runtime projection — index-design patterns the provisioner declares.
-
dev-docs/car-rental-feedback.mdin-repo — the feature-level design notes forplanTable/ensureTable/verifyTable.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History