-
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 {createFetchAdapter} from 'dynamodb-toolkit-fetch';
const orders = new Adapter({
client,
table: 'orders',
keyFields: ['customerId', 'orderId']
});
const handler = createFetchAdapter(orders, {
mountPath: '/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, host), keyFromPath alone isn't enough — it only sees the URL segment. Two options:
Rewrite the Request URL before calling the adapter so the single-segment key carries both pieces. This keeps keyFromPath pure:
const inner = createFetchAdapter(items, {
mountPath: '/items',
keyFromPath: raw => {
const [tenantId, itemId] = raw.split('|');
return {tenantId, itemId};
}
});
export default {
async fetch(request) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/items\/([^/]+)(\/.*)?$/);
if (match) {
const tenantId = request.headers.get('x-tenant-id');
if (tenantId) {
url.pathname = `/items/${encodeURIComponent(`${tenantId}|${match[1]}`)}${match[2] || ''}`;
request = new Request(url, request);
}
}
return inner(request);
}
};For routes that don't carry a :key (i.e. GET /, DELETE /, PUT /-clone, PUT /-move), the partition-key scoping goes through exampleFromContext + your Adapter's prepareListInput hook. See Per-tenant filtering for the full pattern.
For item-level routes that need tenant scoping, combine Option A (URL rewriting) with a keyFields: ['tenantId', 'itemId'] Adapter.
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.
The rawKey argument to keyFromPath is already URL-decoded by matchRoute — you don't need to call decodeURIComponent yourself. Callers that want to include characters like # or ? in a key still need to percent-encode them client-side (encodeURIComponent(rawKey) before constructing the URL), but the value you receive is the decoded form.