Package-source copy · Generated into the site so runtime docs are searchable and linkable.
Core Patterns (TypeScript)
This document provides copy-pasteable patterns for the TableTheory TypeScript SDK.
Pattern: Define a DMS-friendly model
import { defineModel } from '@theory-cloud/tabletheory-ts';
// ✅ CORRECT: explicit key attributes + roles
export const User = defineModel({
name: 'User',
table: { name: 'users_contract' },
keys: {
partition: { attribute: 'PK', type: 'S' },
sort: { attribute: 'SK', type: 'S' },
},
attributes: [
{ attribute: 'PK', type: 'S', roles: ['pk'] },
{ attribute: 'SK', type: 'S', roles: ['sk'] },
{ attribute: 'nickname', type: 'S', optional: true, omit_empty: true },
{ attribute: 'version', type: 'N', roles: ['version'] },
],
});
Pattern: CRUD with idempotency
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { TheorydbClient } from '@theory-cloud/tabletheory-ts';
import { User } from './models/user';
const ddb = new DynamoDBClient({
region: 'us-east-1',
endpoint: process.env.DYNAMODB_ENDPOINT,
});
const db = new TheorydbClient(ddb).register(User);
// ✅ CORRECT: use `ifNotExists` for idempotent creates
await db.create(
'User',
{ PK: 'U#1', SK: 'PROFILE', nickname: 'Al' },
{ ifNotExists: true },
);
const item = await db.get('User', { PK: 'U#1', SK: 'PROFILE' });
await db.delete('User', { PK: 'U#1', SK: 'PROFILE' });
Pattern: Cursor pagination
const page1 = await db.query('User').partitionKey('U#1').limit(10).page();
const page2 = page1.cursor
? await db.query('User').partitionKey('U#1').cursor(page1.cursor).page()
: { items: [] };
For large partitions or scans, prefer the lazy async iterators so callers can stop early without fetching the remaining pages:
for await (const item of db
.query('User')
.partitionKey('U#1')
.limit(100)
.items()) {
if (shouldStop(item)) break; // no additional page is fetched after break
}
Pattern: Aggregations are client-side
db.query(...).sum(...), average(...), min(...), max(...), aggregate(...), countDistinct(...), and
groupBy(...).execute() are convenience helpers over all(): they follow every page and materialize every matching item
in memory before computing the result. Use them only for bounded result sets. When the planned native count() API lands,
prefer it for count-only paths.
Pattern: Exact number reads
TypeScript now unmarshals DynamoDB N/NS values to canonical decimal strings by default so large integers and
high-precision decimals round-trip exactly:
const db = new TheorydbClient(ddb).register(LedgerEntry);
const entry = await db.get('LedgerEntry', {
PK: 'LEDGER#1',
SK: 'ENTRY#1',
});
// entry.amount is the canonical DynamoDB decimal string, not a JavaScript Number.
If a consumer deliberately wants the historical lossy behavior for a bounded client, opt in explicitly:
const lossyDb = new TheorydbClient(ddb, {
numberUnmarshalMode: 'number',
}).register(LedgerEntry);
Pattern: Batch + Transactions
await db.batchWrite('User', {
puts: [
{ PK: 'U#1', SK: 'A' },
{ PK: 'U#1', SK: 'B' },
],
});
const got = await db.batchGet('User', [
{ PK: 'U#1', SK: 'A' },
{ PK: 'U#1', SK: 'B' },
]);
await db.transactWrite([
{
kind: 'put',
model: 'User',
item: { PK: 'U#1', SK: 'TX' },
ifNotExists: true,
},
{
kind: 'update',
model: 'User',
key: { PK: 'U#1', SK: 'TX' },
updateFn: (u) => {
u.setIfNotExists('WindowStart', undefined, '2026-01-23T00:00:00Z');
u.add('Count', 1);
u.conditionNotExists('Count').orCondition('Count', '<', 100);
},
},
]);
For models with encrypted attributes, transaction updates must use updateFn.
Raw updateExpression transaction updates are only supported for models without
encrypted attributes.
Pattern: Streams unmarshalling
import { unmarshalStreamRecord } from '@theory-cloud/tabletheory-ts';
const parsed = unmarshalStreamRecord(User, record);
Pattern: Deterministic unit tests (testkit)
import assert from 'node:assert/strict';
import { PutItemCommand } from '@aws-sdk/client-dynamodb';
import { TheorydbClient } from '@theory-cloud/tabletheory-ts';
import {
createMockDynamoDBClient,
fixedNow,
} from '@theory-cloud/tabletheory-ts/testkit';
const mock = createMockDynamoDBClient();
mock.when(PutItemCommand, async () => ({}));
const db = new TheorydbClient(mock.client, {
now: fixedNow('2026-01-16T00:00:00.000000000Z'),
}).register(User);
await db.create('User', { PK: 'U#1', SK: 'PROFILE' });
assert.equal(mock.calls.length, 1);
Pattern: Key design and GSI queries
Model keys are explicit in defineModel; do not infer access patterns from arbitrary fields. Keep PK/SK and GSI keys
stable, plaintext, and non-encrypted so DynamoDB can route requests.
export const UserByEmail = defineModel({
name: 'UserByEmail',
table: { name: 'users_contract' },
keys: {
partition: { attribute: 'PK', type: 'S' },
sort: { attribute: 'SK', type: 'S' },
},
indexes: [
{
name: 'gsi_email',
partition: { attribute: 'GSI1PK', type: 'S' },
sort: { attribute: 'GSI1SK', type: 'S' },
},
],
attributes: [
{ attribute: 'PK', type: 'S', roles: ['pk'] },
{ attribute: 'SK', type: 'S', roles: ['sk'] },
{ attribute: 'GSI1PK', type: 'S', roles: ['gsi1pk'] },
{ attribute: 'GSI1SK', type: 'S', roles: ['gsi1sk'] },
{ attribute: 'email', type: 'S' },
],
});
const page = await db
.query('UserByEmail')
.usingIndex('gsi_email')
.partitionKey('EMAIL#ada@example.com')
.limit(25)
.page();
Do not set consistentRead(true) on a GSI query; DynamoDB rejects strongly consistent GSI reads and TableTheory surfaces
that as ErrInvalidOperator.
Query optimizer explanation
QueryOptimizer.explain(builder.describe()) now runs the same condition-analysis shape as the Go selector: equality
conditions identify partition-key candidates, the first non-partition condition becomes the sort-key candidate, and indexes
are scored by partition match, sort-key operator, GSI isolation, and projection coverage. Use it as an explanation aid before
choosing .usingIndex(...); it does not mutate a builder or hide DynamoDB’s explicit access-pattern requirements.
import { QueryOptimizer } from '@theory-cloud/tabletheory-ts';
const optimizer = new QueryOptimizer();
const plan = optimizer.explain(
db
.scan('UserByEmail')
.filter('GSI1PK', '=', 'EMAIL#ada@example.com')
.describe(),
);
// plan.operation === 'Query' and plan.indexName === 'gsi_email' when the model declares that GSI.
Pattern: Fail-closed encryption
Encrypted attributes use an explicit EncryptionProvider. The runtime fails closed: if any registered model has an
encrypted attribute and no provider is configured, reads/writes fail instead of returning plaintext or skipping
decryption.
import { KMSClient } from '@aws-sdk/client-kms';
import {
AwsKmsEncryptionProvider,
TheorydbClient,
} from '@theory-cloud/tabletheory-ts';
const encryptedDb = new TheorydbClient(ddb, {
encryption: new AwsKmsEncryptionProvider(
new KMSClient({ region: 'us-east-1' }),
{
keyArn: process.env.TABLETHEORY_KMS_KEY_ARN!,
},
),
}).register(UserWithSecret);
For unit tests, inject the deterministic testkit provider rather than disabling encryption:
import { createDeterministicEncryptionProvider } from '@theory-cloud/tabletheory-ts/testkit';
const testDb = new TheorydbClient(mock.client, {
encryption: createDeterministicEncryptionProvider('unit-test-seed'),
}).register(UserWithSecret);
Pattern: Error handling by TableTheory code
Catch TheorydbError and branch on stable error codes instead of message text. Version conflicts include both
ErrVersionConflict and ErrConditionFailed for backward compatibility.
import {
hasTheorydbErrorCode,
isTheorydbError,
} from '@theory-cloud/tabletheory-ts';
try {
await db.update('User', staleItem, ['nickname']);
} catch (err) {
if (hasTheorydbErrorCode(err, 'ErrVersionConflict')) {
// Reload, merge, and retry from the current version.
} else if (isTheorydbError(err)) {
throw new Error(`TableTheory ${err.code}: ${err.message}`);
} else {
throw err;
}
}