-
Notifications
You must be signed in to change notification settings - Fork 0
Composite keys
DynamoDB tables often use a partition-key / sort-key combination. The adapter's default keyFromPath only handles the single-field case:
(raw, adapter) => ({[adapter.keyFields[0].name]: raw})For composite keys you supply your own keyFromPath that parses the URL :key segment into a full key object.
The simplest approach is to pick a URL-safe delimiter and split on it:
import {Adapter} from 'dynamodb-toolkit';
import {createKoaAdapter} from 'dynamodb-toolkit-koa';
const orders = new Adapter({
client,
table: 'orders',
keyFields: ['customerId', 'orderId']
});
app.use(mount('/orders', createKoaAdapter(orders, {
keyFromPath: raw => {
const [customerId, orderId] = raw.split(':');
if (!customerId || !orderId) {
throw Object.assign(new Error('Key must be "customerId:orderId"'), {status: 400, code: 'BadKey'});
}
return {customerId, orderId};
}
})));Requests:
GET /orders/cust-42:ord-2026-01-7
PATCH /orders/cust-42:ord-2026-01-7
DELETE /orders/cust-42:ord-2026-01-7
Pick a delimiter that won't appear in your key values. : and | are URL-safe without encoding; / interferes with path segments; , interferes with query parsing.
Numeric partition keys:
keyFromPath: (raw, a) => {
const id = Number(raw);
if (!Number.isFinite(id) || !Number.isInteger(id) || id < 0) {
throw Object.assign(new Error('Numeric positive integer key expected'), {status: 400});
}
return {[a.keyFields[0].name]: id};
}UUID validation:
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
keyFromPath: (raw, a) => {
if (!UUID.test(raw)) {
throw Object.assign(new Error('UUID expected'), {status: 400, code: 'BadKey'});
}
return {[a.keyFields[0].name]: raw};
}When the partition key is known from elsewhere (auth token, header, path prefix), keyFromPath returns the full object assembled from multiple sources.
For example, a multi-tenant service where the tenant is derived from a subdomain and only the item ID is in the URL:
app.use(async (ctx, next) => {
ctx.state.tenantId = ctx.hostname.split('.')[0]; // `acme.example.com` → `acme`
await next();
});
app.use(mount('/items', createKoaAdapter(items, {
keyFromPath: raw => ({
tenantId: ctx.state.tenantId, // ← problem: `ctx` isn't in scope here
itemId: raw
})
})));keyFromPath does not receive the Koa Context. It runs per request but its signature is (rawKey, adapter) only. For context-dependent key building, wrap the middleware instead and rewrite ctx.path before delegating:
const base = createKoaAdapter(items, {
keyFromPath: raw => {
const [tenantId, itemId] = raw.split('|');
return {tenantId, itemId};
}
});
app.use(mount('/items', async (ctx, next) => {
// Rewrite the path so the single-segment key becomes "tenantId|itemId"
const m = ctx.path.match(/^\/([^/]+)$/);
if (m) ctx.path = '/' + encodeURIComponent(`${ctx.state.tenantId}|${m[1]}`);
await base(ctx, next);
}));This pattern keeps keyFromPath pure (no hidden context dependency) and puts the context-merging in a named middleware where it's easy to find.
POST / takes the full item in the body, so composite keys come through naturally:
curl -X POST /orders/ -H 'content-type: application/json' \
-d '{"customerId":"cust-42","orderId":"ord-7","total":99.95}'PUT /:key merges the URL key into the body — make sure your keyFromPath returns all key fields so they all end up in the stored item:
keyFromPath: raw => {
const [pk, sk] = raw.split(':');
return {pk, sk}; // both merged into body on PUT /pk:sk
}/-by-names?names=a,b,c works for composite keys the same way — each comma-separated entry goes through keyFromPath:
curl '/orders/-by-names?names=cust-42:ord-1,cust-42:ord-2,cust-7:ord-9'When the partition key is fixed (e.g. all items belong to one tenant / category) and only the sort key varies per request, skip keyFromPath for the prefix and use per-tenant filtering on the list routes via exampleFromContext.