Skip to content

Add patch for partial updates without rewriting the full record #58

@mckalexee

Description

@mckalexee

Motivation

The only write API today is put, which requires the full model. The common case for needing partial updates is avoiding a read-before-write: you have the PK/SK and the new value of one field, and you want to write that field without first reading the row to assemble the rest. DynamoDB's UpdateItem supports this directly.

The challenge is that Facet manages synthetic key columns (PK/SK/GSI*PK/GSI*SK/facet/ttl), and a partial update has to keep those columns coherent with the patched fields. The design below makes that coherence the library's job, not the caller's.

Design philosophy

Four constraints, in priority order:

  1. No silent index desync. A patch must never leave a synthetic key inconsistent with its inputs.
  2. Type-stable across addIndex. Adding a GSI must not break existing patch call sites at compile time. Updating a single facet declaration shouldn't ripple type errors through every call site.
  3. No runtime throws on the default path. Adding a GSI must not silently turn working patches into production errors.
  4. Preserve the fast path (single round trip) whenever possible.

The natural design that satisfies all four: patch always succeeds when given a valid patch object; the library does whatever runtime work is needed (including a projected read) to keep synthetics correct. An option lets latency-sensitive code paths opt out of the implicit-read fallback and fail fast instead.

API

async patch(
  query: Pick<T, PK | SK> & Partial<T>,
  patch: Partial<Omit<T, PK | SK>>,
  options?: PatchOptions<T>,
): Promise<PatchSingleItemResponse<T>>;

type PatchOptions<T> = {
  condition?: ConditionExpression<T>;
  /**
   * How to handle a patch that touches a synthetic key's inputs but
   * doesn't supply all of them.
   * - 'auto' (default): projected GetItem to fetch missing inputs,
   *                     then UpdateItem. Two round trips.
   * - 'strict':         throw MissingPatchCoInputError. Fail fast,
   *                     no implicit read — useful for latency invariants.
   */
  resolveCoInputs?: 'auto' | 'strict';
};

interface PatchSingleItemResponse<T> {
  wasSuccessful: boolean;
  record: T;
  error?: unknown;
}

Mirrors the shape of get/delete/put. The patch parameter forbids identity fields (PK/SK), since changing identity is delete + put, not a patch.

UX examples

Common case — non-key field

await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { postTitle: 'Updated', viewCount: 42 },
);

Single UpdateItem. No synthetic columns are touched (none of their inputs changed).

Status field that participates in a GSI

// PostFacet has GSI1 (PK keys=['postStatus'], shard keys=['postId'])
// and GSI2 (PK keys=['pageId', 'postStatus']).
await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { postStatus: 'published' },
);

Single UpdateItem. GSI1PK and GSI2PK are recomputed and SET alongside postStatus — every input is in PK, SK, or the patch.

Co-input not in PK/SK or the patch — auto (default)

// GSI3SK keys=['sendAt', 'authorId']; authorId is on the model
// but not in any base PK/SK.
await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { sendAt: new Date() },
);

Two round trips: a projected GetItem for authorId only, then UpdateItem recomputing GSI3SK. Compiles and succeeds; cost is the extra round trip.

Same call with strict

await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { sendAt: new Date() },
  { resolveCoInputs: 'strict' },
);
// Throws MissingPatchCoInputError naming `authorId` and `GSI3SK`.

Or supply the co-input:

await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { sendAt: new Date(), authorId: post.authorId },
  { resolveCoInputs: 'strict' },
);
// Single round trip.

Conditional patch

await PostFacet.patch(
  { pageId: 'p1', postId: 'abc' },
  { postStatus: 'published' },
  { condition: ['postStatus', '=', 'draft'] },
);

Compiles via the existing ConditionExpression<T> plumbing in lib/condition.ts. Failed condition resolves as wasSuccessful: false rather than throwing, matching put's contract.

Runtime algorithm

For each call:

  1. Validate patch against assertNoReservedAttributes (existing helper in lib/facet.ts).
  2. Walk every registered key configuration (base PK, base SK, every GSI's PK and SK):
    • Inputs(K) = K.keys ∪ K.shard?.keys
    • If patch touches none of Inputs(K) → leave the synthetic column alone.
    • If patch touches some of Inputs(K) → require every input of K to be available in query ∪ patch.
  3. If any required input is missing:
    • auto: projected GetItem for exactly the missing inputs, using buildProjectionExpression from lib/projection.ts.
    • strict: throw MissingPatchCoInputError naming the field(s) and dependent key config.
  4. Build the UpdateExpression:
    • SET <patch field> = <value> for each patched non-key field.
    • SET <synthetic column> = <recomputed value> for each affected synthetic.
    • SET ttl = <epoch> if facet.ttl is in the patch (using the same Date/number/string normalization as Facet.in).
  5. Default-AND the user's options.condition (if any) with attribute_exists(PK) so a patch on a missing row fails fast rather than upserting a partial item the validator would later reject.
  6. UpdateItem with ReturnValues: 'ALL_NEW'. Pass the result through Facet.out so the configured validator runs on the post-update record (matches the read-side guarantee).
  7. On ConditionalCheckFailedException, return wasSuccessful: false with the error attached.

Touch points

  • New file lib/patch.ts — algorithm, MissingPatchCoInputError, PatchOptions, PatchSingleItemResponse.
  • lib/facet.tsFacet.patch method; reuse assertNoReservedAttributes.
  • lib/condition.ts — already exposes applyCondition. Need a way to AND-in attribute_exists(PK); the typed builder doesn't reach PK (not a keyof T), so the cleanest path is to append the clause to the compiled ConditionExpression string with a reserved name placeholder.
  • lib/projection.ts — already builds ProjectionExpression. Reuse for the fallback GetItem.

Out of scope (defer)

  • Atomic counters (ADD), list_append, if_not_exists, set add/remove. These are a richer DSL — separate design.
  • Patching identity fields (PK/SK). Still requires put.
  • Batch patching. BatchWriteItem doesn't support Update, so a batch path would have to use TransactWriteItems — couple to DynamoDB Transactions #25.

Why this design vs. alternatives

Three alternatives were considered and rejected against the four constraints above:

  1. Strict-by-default type gate (patch type excludes any field in any key config). Rejected: excludes the actual use case (status updates) and breaks compile on every addIndex that adds a key field.
  2. Co-input closure type (patch type encodes "if you touch a key field, you must supply all co-inputs"). Rejected: type-safe and never throws, but addIndex can break existing call sites at compile time when a new GSI introduces new co-inputs.
  3. Throw-on-missing-co-input by default (no static gate, hard runtime error). Rejected: stable types, but turns addIndex into a potential production-error trigger via patches that previously worked.

The chosen design gives up the compile-time guarantee of "no implicit reads" in exchange for type stability across addIndex. The escape hatch (resolveCoInputs: 'strict') is one option flag away for callers that want to enforce the no-implicit-read property locally.

Open questions

  • Naming: patch vs update. update matches the SDK verb but conflicts with REST/HTTP update semantics that often mean "replace". patch is RFC 5789 for partial updates and is unambiguous. Lean toward patch.
  • Telemetry on the fallback path: silent reads can mask performance regressions. A debug log/counter when the fallback fires (naming the triggering field and key config) is cheap to add later.
  • validateInput parity: put has an opt-in pre-write validator. For patch, the natural answer is "skip pre-write validation, run the validator on ALL_NEW" — the partial input isn't a T and so the existing validator can't run on it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    dynamodbDynamoDB correctness / docs-complianceenhancementNew feature or requesttypingTypeScript typing issue or improvement

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions