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

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 request context

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:

Option A: wrap the handler to inject context into the URL

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

Option B: use exampleFromContext for list-level routes

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.

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.

URL-decoding

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.

Clone this wiki locally