Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Composite keys

Eugene Lazutkin edited this page Apr 23, 2026 · 2 revisions

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.

Two-part keys with a delimiter

The simplest approach is to pick a URL-safe delimiter and split on it:

import {Adapter} from 'dynamodb-toolkit';
import {createExpressAdapter} from 'dynamodb-toolkit-express';

const orders = new Adapter({
  client,
  table: 'orders',
  keyFields: ['customerId', 'orderId']
});

app.use('/orders', createExpressAdapter(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.

Type coercion

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};
}

Separate partition key from URL context

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((req, res, next) => {
  req.tenantId = req.hostname.split('.')[0];  // `acme.example.com` → `acme`
  next();
});

app.use('/items', createExpressAdapter(items, {
  keyFromPath: raw => ({
    tenantId: req.tenantId,   // ← problem: `req` isn't in scope here
    itemId: raw
  })
}));

keyFromPath does not receive the Express Request. It runs per request but its signature is (rawKey, adapter) only. For context-dependent key building, wrap the middleware instead and rewrite req.url before delegating:

const base = createExpressAdapter(items, {
  keyFromPath: raw => {
    const [tenantId, itemId] = raw.split('|');
    return {tenantId, itemId};
  }
});

app.use('/items', (req, res, next) => {
  // Rewrite the path so the single-segment key becomes "tenantId|itemId".
  // Express routes off req.url; updating it re-runs `req.path` derivation.
  const m = req.path.match(/^\/([^/]+)$/);
  if (m) req.url = '/' + encodeURIComponent(`${req.tenantId}|${m[1]}`);
  base(req, res, 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.

Exposing composite keys in collection operations

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'

Sort-key-only queries

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.

Clone this wiki locally