Skip to content

Recipe: Provisioning workflow

Eugene Lazutkin edited this page Apr 23, 2026 · 1 revision

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 .sql file 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's Adapter declaration is already richer than DescribeTable's output (it carries filterable, 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 / verifyTable from dynamodb-toolkit/provisioning (or use the bundled CLI). planTable is read-only diagnostics; ensureTable applies ADD-only changes (create, add GSI); verifyTable reports drift. When descriptorKey is 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.

Why a toolkit-native provisioner at all

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 filterable changed 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.

Three entry points

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.

The minimal pattern

// 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.js

The 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+).

planTable — read-only diagnostics

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 main and 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, planTable surfaces it as an add-gsi step. Run the diff on every deploy and alert if the step set isn't empty.

ensureTable — ADD-only apply

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. UpdateTable returns immediately; the GSI backfills in the background (tens of minutes to hours for a large table). Queries against the new GSI throw until it's ACTIVE. The toolkit doesn't poll for ready — roll that into a deploy health check yourself, or run ensureTable separately 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 first verifyTable pass meaningful.
  • Idempotent. Running ensureTable twice 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-dynamodb targets http://localhost:8000 (or wherever); ensureTable is 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 ensureTable from a post-deploy Lambda anyway — it's a no-op on a matching schema, and it populates the descriptor row which unlocks the richer verifyTable checks.

verifyTable — drift detection

Compares the declaration against the live table. Checks two layers:

  1. 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).
  2. Descriptor row (optional). When descriptorKey is declared, reads the reserved row and diffs against the current declaration's filterable, 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, descriptor filterable drift.
  • 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.

The descriptor record

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 technicalPrefix when technicalPrefix is declared (keeps the descriptor inside the adapter-managed namespace).
  • The descriptor row is auto-filtered out of getList / getListByParams / every mass op — opt in with options.includeDescriptor: true if you need to see it (debugging, migration tooling).
  • getByKey against 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:

  • keyFields with {name, type, width?} per entry.
  • structuralKey (if declared).
  • Every indices entry (type, pk/sk shape, projection, sparse flag, indirect flag).
  • typeLabels, typeDiscriminator, typeField.
  • filterable allowlist (per-field ops + types).
  • searchable field 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.

Deployment patterns

Three common placements for the ensureTable / verifyTable calls:

A — IaC owns the table; toolkit owns the descriptor

IaC (Terraform / CDK) creates the table + GSIs. On every deploy:

  1. Post-deploy hook runs ensureTable(adapter). No schema changes (IaC already did them); descriptor row refreshes.
  2. 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.

B — Toolkit owns everything

No IaC for the table. Deployment:

  1. Release script runs ensureTable(adapter). First run creates; subsequent runs add GSIs if any were declared since.
  2. 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.

C — CLI-only, no deploy automation

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-check

Right for local dev, integration-test setup, or any environment where a deploy pipeline is overkill.

Lower-level primitives

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

Cost and runtime

  • planTable — one DescribeTable call. No read/write capacity cost. Synchronous.
  • ensureTable — one DescribeTable, plus one CreateTable (if absent) plus one UpdateTable per missing GSI (if any). CreateTable is sync-ish on DDB's side; UpdateTable returns immediately but GSI backfill is async and spans minutes-to-hours for populated tables.
  • verifyTable — one DescribeTable + (when descriptorKey is declared) one GetItem for the descriptor row. Total cost < 1 RCU/call.
  • writeDescriptor — one PutItem on the reserved row. ~1 WCU per call.

When this pattern fits

  • You already run with dynamodb-toolkit/provisioning at 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 / searchable and 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.

When it doesn't fit

  • 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.

Related

Clone this wiki locally