You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
No silent index desync. A patch must never leave a synthetic key inconsistent with its inputs.
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.
No runtime throws on the default path. Adding a GSI must not silently turn working patches into production errors.
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
asyncpatch(query: Pick<T,PK|SK>&Partial<T>,patch: Partial<Omit<T,PK|SK>>,options?: PatchOptions<T>,): Promise<PatchSingleItemResponse<T>>;typePatchOptions<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';};interfacePatchSingleItemResponse<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.
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']).awaitPostFacet.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.awaitPostFacet.patch({pageId: 'p1',postId: 'abc'},{sendAt: newDate()},);
Two round trips: a projected GetItem for authorId only, then UpdateItem recomputing GSI3SK. Compiles and succeeds; cost is the extra round trip.
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:
Validate patch against assertNoReservedAttributes (existing helper in lib/facet.ts).
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.
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.
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).
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.
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).
On ConditionalCheckFailedException, return wasSuccessful: false with the error attached.
Touch points
New file lib/patch.ts — algorithm, MissingPatchCoInputError, PatchOptions, PatchSingleItemResponse.
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:
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.
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.
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.
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'sUpdateItemsupports 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:
addIndex. Adding a GSI must not break existingpatchcall sites at compile time. Updating a single facet declaration shouldn't ripple type errors through every call site.The natural design that satisfies all four:
patchalways 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
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
Single
UpdateItem. No synthetic columns are touched (none of their inputs changed).Status field that participates in a GSI
Single
UpdateItem.GSI1PKandGSI2PKare recomputed andSETalongsidepostStatus— every input is in PK, SK, or the patch.Co-input not in PK/SK or the patch —
auto(default)Two round trips: a projected
GetItemforauthorIdonly, thenUpdateItemrecomputingGSI3SK. Compiles and succeeds; cost is the extra round trip.Same call with
strictOr supply the co-input:
Conditional patch
Compiles via the existing
ConditionExpression<T>plumbing inlib/condition.ts. Failed condition resolves aswasSuccessful: falserather than throwing, matchingput's contract.Runtime algorithm
For each call:
patchagainstassertNoReservedAttributes(existing helper inlib/facet.ts).Inputs(K) = K.keys ∪ K.shard?.keyspatchtouches none ofInputs(K)→ leave the synthetic column alone.patchtouches some ofInputs(K)→ require every input of K to be available inquery ∪ patch.auto: projectedGetItemfor exactly the missing inputs, usingbuildProjectionExpressionfromlib/projection.ts.strict: throwMissingPatchCoInputErrornaming the field(s) and dependent key config.UpdateExpression:SET <patch field> = <value>for each patched non-key field.SET <synthetic column> = <recomputed value>for each affected synthetic.SET ttl = <epoch>iffacet.ttlis in the patch (using the same Date/number/string normalization asFacet.in).options.condition(if any) withattribute_exists(PK)so a patch on a missing row fails fast rather than upserting a partial item the validator would later reject.UpdateItemwithReturnValues: 'ALL_NEW'. Pass the result throughFacet.outso the configuredvalidatorruns on the post-update record (matches the read-side guarantee).ConditionalCheckFailedException, returnwasSuccessful: falsewith the error attached.Touch points
lib/patch.ts— algorithm,MissingPatchCoInputError,PatchOptions,PatchSingleItemResponse.lib/facet.ts—Facet.patchmethod; reuseassertNoReservedAttributes.lib/condition.ts— already exposesapplyCondition. Need a way to AND-inattribute_exists(PK); the typed builder doesn't reachPK(not akeyof T), so the cleanest path is to append the clause to the compiledConditionExpressionstring with a reserved name placeholder.lib/projection.ts— already buildsProjectionExpression. Reuse for the fallbackGetItem.Out of scope (defer)
ADD),list_append,if_not_exists, set add/remove. These are a richer DSL — separate design.put.BatchWriteItemdoesn't supportUpdate, so a batch path would have to useTransactWriteItems— couple to DynamoDB Transactions #25.Why this design vs. alternatives
Three alternatives were considered and rejected against the four constraints above:
addIndexthat adds a key field.addIndexcan break existing call sites at compile time when a new GSI introduces new co-inputs.addIndexinto 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
patchvsupdate.updatematches the SDK verb but conflicts with REST/HTTPupdatesemantics that often mean "replace".patchis RFC 5789 for partial updates and is unambiguous. Lean towardpatch.validateInputparity:puthas an opt-in pre-write validator. Forpatch, the natural answer is "skip pre-write validation, run the validator onALL_NEW" — the partial input isn't aTand so the existingvalidatorcan't run on it.