@aws-blocks/bb-distributed-table 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +13 -3
- package/README.md +2 -2
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -0
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.aws.js +32 -2
- package/dist/index.cdk.d.ts +2 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +4 -3
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +20 -8
- package/dist/index.test.js +84 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -3
- package/src/errors.ts +26 -0
- package/src/index.aws.ts +31 -2
- package/src/index.cdk.ts +4 -3
- package/src/index.mock.ts +20 -9
- package/src/index.test.ts +105 -1
- package/src/version.ts +1 -1
package/DESIGN.md
CHANGED
|
@@ -51,6 +51,8 @@ This also follows the API's options-object convention (objects over positional p
|
|
|
51
51
|
|
|
52
52
|
`ifNotExists` and `ifFieldEquals` are mutually exclusive at the type level (discriminated union), as are `ifExists` and `ifFieldEquals`. DynamoDB's `ConditionExpression` could combine them, but the semantics are confusing — "create only if it doesn't exist AND the existing item's field equals X" is contradictory. The type system prevents this rather than silently picking one.
|
|
53
53
|
|
|
54
|
+
A conditional-failure `ApiError` (409) is flagged `retriable` only for a pure `ifFieldEquals` optimistic-lock check (a re-read and retry can succeed); an existence assertion (`ifNotExists`/`ifExists`) is not retriable (a blind retry fails identically). Both runtimes derive this from a single expression keyed on **value presence** (`ifFieldEquals !== undefined`) with the **existence assertion winning** — so even the type-forbidden combined case (only reachable outside the typed API) is not retriable, and mock and AWS produce identical `retriable` for identical inputs.
|
|
55
|
+
|
|
54
56
|
KVStore uses `ifValueEquals` (compare the entire value). DistributedTable uses `ifFieldEquals` (compare individual fields) because items are structured objects with multiple fields — comparing the entire item would be impractical and fragile.
|
|
55
57
|
|
|
56
58
|
### D-DT-5: `scan()` not `list()`
|
|
@@ -104,7 +106,15 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
|
|
|
104
106
|
|
|
105
107
|
**Mock/AWS parity:** the mock checks serialized byte length client-side and throws `ItemTooLarge` directly. On AWS, DynamoDB raises a generic `ValidationException` for an oversized item; the runtime narrows on the size-specific message (`size has exceeded`) and re-maps only that case to `ItemTooLarge`. Other `ValidationException` causes (malformed expressions, type mismatches) propagate unchanged. Both layers therefore surface the same `error.name`, and the shared message lives in `errors.ts` (`DistributedTableMessages.itemTooLarge`) so the two stay byte-for-byte aligned.
|
|
106
108
|
|
|
107
|
-
### D-DT-9: `
|
|
109
|
+
### D-DT-9: `retriable` on a 409 conflict is scoped to optimistic-lock (`ifFieldEquals`) only
|
|
110
|
+
|
|
111
|
+
**Decision:** A `ConditionalCheckFailedException` maps to an `ApiError` with status **409**, but `retriable` is **true only for `ifFieldEquals` (optimistic-lock) conflicts** and **false for `ifNotExists`/`ifExists` (existence/uniqueness) assertions**.
|
|
112
|
+
|
|
113
|
+
**Rationale:** A value/field-equals conflict is genuinely optimistic-concurrency — a re-read and retry can succeed. An `ifNotExists`/`ifExists` failure is an existence assertion: a blind identical retry fails identically, so flagging it retriable is misleading. DynamoDB collapses every conditional failure under one `ConditionalCheckFailedException` with no sub-reason, so the AWS runtime decides retriability per-operation from the condition the caller set on that specific `put`/`delete`, matching the mock branch-for-branch. `error.name` and status (409) are unchanged.
|
|
114
|
+
|
|
115
|
+
Note `retriable` marks the conflict *kind* (optimistic-lock `ifFieldEquals` vs existence assertion), **not** a guarantee that a retry will succeed: an `ifFieldEquals` conflict against a **missing** row is still flagged retriable, yet a blind retry fails identically — because DynamoDB collapses missing-vs-stale into one indistinguishable exception, and the mock deliberately matches that for parity.
|
|
116
|
+
|
|
117
|
+
### D-DT-10: `readValidation` — `off | coerce | strict`, defaulting to `coerce`
|
|
108
118
|
|
|
109
119
|
> The **default choice** (`'coerce'` over `'off'`) is recorded as a cross-cutting architectural decision in [`docs/DECISIONS.md` D-015](../../docs/DECISIONS.md#d-015-distributedtable-reads-default-to-readvalidation-coerce-not-off). This section covers the per-BB mechanics.
|
|
110
120
|
|
|
@@ -127,7 +137,7 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
|
|
|
127
137
|
|
|
128
138
|
**Mock/AWS parity:** both layers call the same `applyReadValidation()` helper in `errors.ts`, so all three modes (coerced output, raw-fallback + warn, strict throw) behave identically. `null` (a missing item) passes through untouched in every mode, preserving not-found semantics.
|
|
129
139
|
|
|
130
|
-
### D-DT-
|
|
140
|
+
### D-DT-11: Secure-by-default durability & encryption, sourced from stack `BlocksDefaults`
|
|
131
141
|
|
|
132
142
|
Durability posture is resolved from the stack-wide `BlocksDefaults` (`BlocksPresets` in `@aws-blocks/core/cdk`), the shared infrastructure-defaults mechanism (introduced in #302): `defaults.removalPolicy`, `defaults.deletionProtection`, and `defaults.pointInTimeRecovery`. Under `BlocksPresets.production` a table defaults to PITR **on**, deletion protection **on**, and `RemovalPolicy.RETAIN`; under `BlocksPresets.sandbox` all three flip off/`DESTROY` so throwaway stacks stay cheap and `sandbox:destroy` is a one-command teardown. SSE defaults to the AWS-managed KMS key. Every default is overridable per table via `protection`, `pointInTimeRecovery`, and `encryption`; a per-block option always wins (`option ?? scope.defaults.field`). Each field is read **independently** from `defaults` — deletion protection and PITR are never derived from `removalPolicy`.
|
|
133
143
|
|
|
@@ -153,7 +163,7 @@ Creates a single DynamoDB table:
|
|
|
153
163
|
- **TTL:** Enabled via `TimeToLiveSpecification` when `options.ttl` is set
|
|
154
164
|
- **Billing mode:** PAY_PER_REQUEST
|
|
155
165
|
- **Table name:** Derived from `scope.fullId` (includes stack name for uniqueness)
|
|
156
|
-
- **Durability & encryption:** Secure-by-default in production — PITR, deletion protection, SSE-KMS, and `RemovalPolicy.RETAIN` (see D-DT-
|
|
166
|
+
- **Durability & encryption:** Secure-by-default in production — PITR, deletion protection, SSE-KMS, and `RemovalPolicy.RETAIN` (see D-DT-11)
|
|
157
167
|
- **Permissions:** `grantReadWriteData` to the parent scope's handler automatically, plus explicit `dynamodb:Query` on `index/*`
|
|
158
168
|
|
|
159
169
|
Attribute types are inferred from the schema at synth time. The CDK layer probes the schema's `StandardSchemaV1.validate()` method with a test value of `0` for each key field — if the field accepts it without issues, it's numeric (`AttributeType.NUMBER`), otherwise string (`AttributeType.STRING`). This is schema-library-agnostic and uses only the standard validation interface.
|
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ await table.delete(key, { ifExists: true });
|
|
|
129
129
|
await table.delete(key, { ifFieldEquals: { status: 'archived' } });
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
All condition failures throw with `error.name === DistributedTableErrors.ConditionalCheckFailed`.
|
|
132
|
+
All condition failures throw with `error.name === DistributedTableErrors.ConditionalCheckFailed`. They serialize to JSON-RPC **409 (Conflict)** over the wire (not 500), in both the mock and AWS runtime. Only `ifFieldEquals` optimistic-lock conflicts are flagged retriable; `ifNotExists` / `ifExists` existence assertions are **not** retriable (a blind retry fails identically).
|
|
133
133
|
|
|
134
134
|
> **No partial update:** There is no `update()` or `patch()` method. To change a field, do a read-modify-write — `get()` the item, mutate it, then `put()` the full item back. For safe concurrent updates, pass `{ ifFieldEquals: { version: <previous> } }` to `put()` so the write fails (via `ConditionalCheckFailed`) if another writer changed the item in the meantime (optimistic locking).
|
|
135
135
|
|
|
@@ -178,7 +178,7 @@ Errors thrown by DistributedTable carry an `error.name` you can match with `isBl
|
|
|
178
178
|
|
|
179
179
|
| Constant | `error.name` | Thrown when |
|
|
180
180
|
|----------|--------------|-------------|
|
|
181
|
-
| `DistributedTableErrors.ConditionalCheckFailed` | `ConditionalCheckFailedException` | An `ifNotExists` / `ifExists` / `ifFieldEquals` condition failed. |
|
|
181
|
+
| `DistributedTableErrors.ConditionalCheckFailed` | `ConditionalCheckFailedException` | An `ifNotExists` / `ifExists` / `ifFieldEquals` condition failed. Serializes to HTTP **409 (Conflict)**; retriable only for `ifFieldEquals` optimistic-lock conflicts (not `ifNotExists` / `ifExists`). |
|
|
182
182
|
| `DistributedTableErrors.ValidationFailed` | `ValidationFailedException` | An item failed the configured `schema` validation on `put()` / `putBatch()`. |
|
|
183
183
|
| `DistributedTableErrors.InvalidQuery` | `InvalidQueryException` | The query/condition shape is wrong: missing `where`, partition key not given as `{ equals }`, unknown index, multiple sort-key conditions, or an empty `ifFieldEquals`. A caller bug. |
|
|
184
184
|
| `DistributedTableErrors.ItemTooLarge` | `ItemTooLargeException` | A `put`/`putBatch` item exceeds DynamoDB's 400 KB per-item size limit. |
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import { ApiError } from '@aws-blocks/core';
|
|
2
3
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
3
4
|
import type { ReadValidationMode } from './types.js';
|
|
4
5
|
/**
|
|
@@ -67,6 +68,24 @@ export declare const DistributedTableErrors: {
|
|
|
67
68
|
* produce identically shaped errors.
|
|
68
69
|
*/
|
|
69
70
|
export declare function blocksError(name: string, message: string): Error;
|
|
71
|
+
/**
|
|
72
|
+
* @internal Build the 409 ApiError for a conditional-write conflict. Maps to
|
|
73
|
+
* HTTP 409 (Conflict) so the JSON-RPC serializer emits code 409 instead of a
|
|
74
|
+
* generic 500, preserves the `ConditionalCheckFailed` name so `isBlocksError()`
|
|
75
|
+
* keeps matching on both server and client. Shared by the mock and AWS runtime
|
|
76
|
+
* so both produce an identically shaped 409; on AWS the caught DynamoDB
|
|
77
|
+
* `ConditionalCheckFailedException` is passed as `cause` (kept server-side by
|
|
78
|
+
* ApiError).
|
|
79
|
+
*
|
|
80
|
+
* `retriable` is scoped to the assertion kind: `true` only for optimistic-lock
|
|
81
|
+
* conflicts (`ifFieldEquals`), where a re-read and retry can succeed; `false`
|
|
82
|
+
* for existence/uniqueness assertions (`ifNotExists` on put, `ifExists` on
|
|
83
|
+
* delete), where a blind identical retry fails identically. DynamoDB collapses
|
|
84
|
+
* every conditional failure under one `ConditionalCheckFailedException` with no
|
|
85
|
+
* sub-reason, so the AWS runtime decides this from the condition the caller set
|
|
86
|
+
* on that specific put/delete call.
|
|
87
|
+
*/
|
|
88
|
+
export declare function conditionalCheckFailed(retriable: boolean, cause?: unknown): ApiError;
|
|
70
89
|
/**
|
|
71
90
|
* @internal Normalize a sort-key condition before it drives a query. Shared by
|
|
72
91
|
* the mock and AWS runtime so both treat the same inputs identically:
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAwBrD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,sBAAsB;;;IAGlC;;;;;;;;;;;OAWG;;IAEH;;;;;;;;;;;;OAYG;;IAEH;;;;;;;;;OASG;;CAEM,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAIhE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,SAAS,EAAE,CAAC,GAAG,SAAS,GACtB,CAAC,GAAG,SAAS,CAQf;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;oCACb,MAAM,GAAG,SAAS;sCAChB,MAAM;mDAEO,MAAM;wDAED,MAAM,EAAE;;mCAI7B,MAAM;0CAEC,MAAM,aAAa,MAAM,YAAY,MAAM;CAG/D,CAAC;AAEX;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOvD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAC1C,IAAI,EAAE,kBAAkB,EACxB,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,GAAG,IAAI,EACd,GAAG,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBnB"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAwBrD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,sBAAsB;;;IAGlC;;;;;;;;;;;OAWG;;IAEH;;;;;;;;;;;;OAYG;;IAEH;;;;;;;;;OASG;;CAEM,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAIhE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,CAMpF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,SAAS,EAAE,CAAC,GAAG,SAAS,GACtB,CAAC,GAAG,SAAS,CAQf;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;oCACb,MAAM,GAAG,SAAS;sCAChB,MAAM;mDAEO,MAAM;wDAED,MAAM,EAAE;;mCAI7B,MAAM;0CAEC,MAAM,aAAa,MAAM,YAAY,MAAM;CAG/D,CAAC;AAEX;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOvD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAC1C,IAAI,EAAE,kBAAkB,EACxB,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,GAAG,IAAI,EACd,GAAG,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBnB"}
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { createDefu } from 'defu';
|
|
4
|
+
import { ApiError } from '@aws-blocks/core';
|
|
4
5
|
/**
|
|
5
6
|
* @internal Right-biased deep merge for the `'coerce'` read path: overlay the
|
|
6
7
|
* schema-coerced item onto the raw stored item so schema output (filled defaults,
|
|
@@ -92,6 +93,30 @@ export function blocksError(name, message) {
|
|
|
92
93
|
err.name = name;
|
|
93
94
|
return err;
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* @internal Build the 409 ApiError for a conditional-write conflict. Maps to
|
|
98
|
+
* HTTP 409 (Conflict) so the JSON-RPC serializer emits code 409 instead of a
|
|
99
|
+
* generic 500, preserves the `ConditionalCheckFailed` name so `isBlocksError()`
|
|
100
|
+
* keeps matching on both server and client. Shared by the mock and AWS runtime
|
|
101
|
+
* so both produce an identically shaped 409; on AWS the caught DynamoDB
|
|
102
|
+
* `ConditionalCheckFailedException` is passed as `cause` (kept server-side by
|
|
103
|
+
* ApiError).
|
|
104
|
+
*
|
|
105
|
+
* `retriable` is scoped to the assertion kind: `true` only for optimistic-lock
|
|
106
|
+
* conflicts (`ifFieldEquals`), where a re-read and retry can succeed; `false`
|
|
107
|
+
* for existence/uniqueness assertions (`ifNotExists` on put, `ifExists` on
|
|
108
|
+
* delete), where a blind identical retry fails identically. DynamoDB collapses
|
|
109
|
+
* every conditional failure under one `ConditionalCheckFailedException` with no
|
|
110
|
+
* sub-reason, so the AWS runtime decides this from the condition the caller set
|
|
111
|
+
* on that specific put/delete call.
|
|
112
|
+
*/
|
|
113
|
+
export function conditionalCheckFailed(retriable, cause) {
|
|
114
|
+
return new ApiError('The conditional request failed', 409, {
|
|
115
|
+
name: DistributedTableErrors.ConditionalCheckFailed,
|
|
116
|
+
cause,
|
|
117
|
+
retriable,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
95
120
|
/**
|
|
96
121
|
* @internal Normalize a sort-key condition before it drives a query. Shared by
|
|
97
122
|
* the mock and AWS runtime so both treat the same inputs identically:
|
package/dist/index.aws.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACX,cAAc,EACd,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,aAAa,GACb,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACX,cAAc,EACd,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EAGb,QAAQ,EAER,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAgB,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAwBzD,qBAAa,gBAAgB,CAC5B,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,CACpF,SAAQ,KAAK;IAWqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAVlG,QAAQ,CAAC,MAAM,sBAAW;IAC1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAyB;IAE1C,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAkB5F,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAQ3C,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"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"}
|
package/dist/index.aws.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCom
|
|
|
5
5
|
import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
|
|
6
6
|
import { BB_NAME, BB_VERSION } from './version.js';
|
|
7
7
|
export { DistributedTableErrors } from './errors.js';
|
|
8
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
8
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
9
9
|
import { Logger } from '@aws-blocks/bb-logger';
|
|
10
10
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
11
|
// DynamoDB batch API limits and retry tuning.
|
|
@@ -75,6 +75,21 @@ export class DistributedTable extends Scope {
|
|
|
75
75
|
await this.docClient.send(new PutCommand(command));
|
|
76
76
|
}
|
|
77
77
|
catch (err) {
|
|
78
|
+
// A failed conditional write is a Conflict, not an
|
|
79
|
+
// InternalServerError: map DynamoDB's raw
|
|
80
|
+
// ConditionalCheckFailedException to an ApiError with status 409 so the
|
|
81
|
+
// JSON-RPC serializer emits code 409 instead of 500. Name is preserved
|
|
82
|
+
// for isBlocksError(), the driver error is kept as `cause`. DynamoDB
|
|
83
|
+
// collapses every conditional failure under one exception with no
|
|
84
|
+
// sub-reason, so retriability is derived from the conditions THIS call
|
|
85
|
+
// set, matching the mock: existence assertion wins — retriable only for
|
|
86
|
+
// a pure `ifFieldEquals` optimistic-lock check (value presence, so an
|
|
87
|
+
// explicit `undefined` is treated as absent) and NOT when `ifNotExists`
|
|
88
|
+
// is also set. Other errors (e.g. oversized items) still flow through
|
|
89
|
+
// remapItemTooLarge.
|
|
90
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
91
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifNotExists, err);
|
|
92
|
+
}
|
|
78
93
|
throw remapItemTooLarge(err);
|
|
79
94
|
}
|
|
80
95
|
}
|
|
@@ -87,7 +102,22 @@ export class DistributedTable extends Scope {
|
|
|
87
102
|
else if (options?.ifFieldEquals) {
|
|
88
103
|
this.applyFieldEqualsCondition(command, options.ifFieldEquals);
|
|
89
104
|
}
|
|
90
|
-
|
|
105
|
+
try {
|
|
106
|
+
await this.docClient.send(new DeleteCommand(command));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// A failed conditional delete is a Conflict, not an
|
|
110
|
+
// InternalServerError: map DynamoDB's raw
|
|
111
|
+
// ConditionalCheckFailedException to an ApiError with status 409 (see
|
|
112
|
+
// the put path above). Retriability is derived from the conditions THIS
|
|
113
|
+
// call set, matching the mock: existence assertion wins — retriable only
|
|
114
|
+
// for a pure `ifFieldEquals` optimistic-lock check (value presence) and
|
|
115
|
+
// NOT when `ifExists` is also set.
|
|
116
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
117
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifExists, err);
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
91
121
|
}
|
|
92
122
|
/**
|
|
93
123
|
* Query items by index, yielding matches as an async stream with automatic
|
package/dist/index.cdk.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BuildingBlockScope } from '@aws-blocks/core/cdk';
|
|
2
2
|
import type { ScopeParent } from '@aws-blocks/core';
|
|
3
3
|
import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
4
4
|
export { DistributedTableErrors } from './errors.js';
|
|
5
5
|
export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
6
|
-
export declare class DistributedTable<T = any> extends
|
|
6
|
+
export declare class DistributedTable<T = any> extends BuildingBlockScope {
|
|
7
7
|
options: any;
|
|
8
8
|
private table;
|
|
9
9
|
/**
|
package/dist/index.cdk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,kBAAkB,EAAoC,MAAM,sBAAsB,CAAC;AAC5F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItE,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEnM,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,kBAAkB;IA2Bb,OAAO,EAAE,GAAG;IA1B/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD;;;;;;;;OAQG;IACH,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;gBAIxC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAqO/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
|
package/dist/index.cdk.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { Table, AttributeType, BillingMode, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
|
|
4
|
+
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
4
5
|
import * as cdk from 'aws-cdk-lib';
|
|
5
6
|
import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
|
|
6
7
|
import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
|
|
@@ -8,11 +9,11 @@ import { LogGroup } from 'aws-cdk-lib/aws-logs';
|
|
|
8
9
|
import { Provider } from 'aws-cdk-lib/custom-resources';
|
|
9
10
|
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
|
|
10
11
|
import { Key } from 'aws-cdk-lib/aws-kms';
|
|
11
|
-
import {
|
|
12
|
+
import { BuildingBlockScope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { dirname, join } from 'node:path';
|
|
14
15
|
export { DistributedTableErrors } from './errors.js';
|
|
15
|
-
export class DistributedTable extends
|
|
16
|
+
export class DistributedTable extends BuildingBlockScope {
|
|
16
17
|
options;
|
|
17
18
|
table;
|
|
18
19
|
/**
|
|
@@ -38,7 +39,7 @@ export class DistributedTable extends Scope {
|
|
|
38
39
|
return { __brand: 'ExternalKmsKeyRef', keyArn };
|
|
39
40
|
}
|
|
40
41
|
constructor(scope, id, options) {
|
|
41
|
-
super(id, { parent: scope });
|
|
42
|
+
super(id, { parent: scope, vpc: { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] } });
|
|
42
43
|
this.options = options;
|
|
43
44
|
const config = options;
|
|
44
45
|
if (config?.table) {
|
package/dist/index.mock.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAMpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACX,cAAc,EACd,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,aAAa,GACb,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACX,cAAc,EACd,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EAEjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,QAAQ,EAER,MAAM,YAAY,CAAC;AA4DpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,gBAAgB,CAC5B,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,CACpF,SAAQ,KAAK;IAWqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAVlG,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,IAAI,CAAiB;IAC7B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,cAAc,CAAqB;IAE3C,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAe5F,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAIjD;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAIf,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
|
package/dist/index.mock.js
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { BB_NAME, BB_VERSION } from './version.js';
|
|
8
8
|
export { DistributedTableErrors } from './errors.js';
|
|
9
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
9
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
10
10
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
11
|
const MAX_ITEM_BYTES = 400 * 1024;
|
|
12
12
|
async function validateSchema(schema, value) {
|
|
@@ -131,22 +131,31 @@ export class DistributedTable extends Scope {
|
|
|
131
131
|
throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
|
|
132
132
|
}
|
|
133
133
|
const keyStr = this.serializeKey(item);
|
|
134
|
+
// Existence assertion wins: `ifNotExists` makes a conflict non-retriable
|
|
135
|
+
// even if combined with an `ifFieldEquals` value check (value presence, so
|
|
136
|
+
// an explicit `undefined` is treated as absent). Only a pure `ifFieldEquals`
|
|
137
|
+
// optimistic-lock check is retriable. Shared derivation so mock and aws
|
|
138
|
+
// agree for identical inputs, incl. combined conditions.
|
|
139
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifNotExists;
|
|
134
140
|
if (options?.ifNotExists && this.data.has(keyStr)) {
|
|
135
|
-
throw
|
|
141
|
+
throw conditionalCheckFailed(retriable);
|
|
136
142
|
}
|
|
137
143
|
if (options?.ifFieldEquals) {
|
|
138
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
144
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
139
145
|
}
|
|
140
146
|
this.data.set(keyStr, item);
|
|
141
147
|
this.flushToDisk();
|
|
142
148
|
}
|
|
143
149
|
async delete(key, options) {
|
|
144
150
|
const keyStr = this.serializeKey(key);
|
|
151
|
+
// Existence assertion wins (see put): `ifExists` makes a conflict
|
|
152
|
+
// non-retriable even if combined with an `ifFieldEquals` value check.
|
|
153
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifExists;
|
|
145
154
|
if (options?.ifExists && !this.data.has(keyStr)) {
|
|
146
|
-
throw
|
|
155
|
+
throw conditionalCheckFailed(retriable);
|
|
147
156
|
}
|
|
148
157
|
if (options?.ifFieldEquals) {
|
|
149
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
158
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
150
159
|
}
|
|
151
160
|
this.data.delete(keyStr);
|
|
152
161
|
this.flushToDisk();
|
|
@@ -287,18 +296,21 @@ export class DistributedTable extends Scope {
|
|
|
287
296
|
return { __brand: 'ExternalKmsKeyRef', keyArn };
|
|
288
297
|
}
|
|
289
298
|
// ── Internal ────────────────────────────────────────────────────────────
|
|
290
|
-
checkFieldEquals(keyStr, fields) {
|
|
299
|
+
checkFieldEquals(keyStr, fields, retriable) {
|
|
291
300
|
const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
|
|
292
301
|
if (entries.length === 0) {
|
|
293
302
|
throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
|
|
294
303
|
}
|
|
295
304
|
const existing = this.data.get(keyStr);
|
|
296
305
|
if (!existing) {
|
|
297
|
-
|
|
306
|
+
// Compare-and-swap conflict; retriability is decided by the caller —
|
|
307
|
+
// existence assertion wins, so not retriable even if combined.
|
|
308
|
+
throw conditionalCheckFailed(retriable);
|
|
298
309
|
}
|
|
299
310
|
for (const [field, value] of entries) {
|
|
300
311
|
if (!deepEqual(existing[field], value)) {
|
|
301
|
-
|
|
312
|
+
// Compare-and-swap conflict; retriability decided by the caller.
|
|
313
|
+
throw conditionalCheckFailed(retriable);
|
|
302
314
|
}
|
|
303
315
|
}
|
|
304
316
|
}
|
package/dist/index.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { test, describe } from 'node:test';
|
|
4
4
|
import { strict as assert } from 'node:assert';
|
|
5
5
|
import { DistributedTable, DistributedTableErrors } from './index.mock.js';
|
|
6
|
-
import { Scope } from '@aws-blocks/core';
|
|
6
|
+
import { ApiError, isBlocksError, Scope } from '@aws-blocks/core';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
// ── Schemas ─────────────────────────────────────────────────────────────────
|
|
9
9
|
const userSchema = z.object({
|
|
@@ -117,6 +117,89 @@ describe('DistributedTable', () => {
|
|
|
117
117
|
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => err.name === DistributedTableErrors.ConditionalCheckFailed);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
// ── OCC conflicts map to HTTP 409 (Conflict) ─────────────────────────────
|
|
121
|
+
// A conditional-write conflict must serialize to JSON-RPC code 409, not 500.
|
|
122
|
+
// The mock must throw an ApiError (status 409) that preserves the
|
|
123
|
+
// ConditionalCheckFailed name, matching the aws-runtime path. `retriable` is
|
|
124
|
+
// scoped to the assertion kind: true only for optimistic-lock ifFieldEquals
|
|
125
|
+
// conflicts (a re-read and retry can succeed), false for existence/uniqueness
|
|
126
|
+
// assertions (a blind retry fails identically).
|
|
127
|
+
describe('OCC conflicts map to 409', () => {
|
|
128
|
+
test('put ifNotExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
129
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
130
|
+
schema: userSchema,
|
|
131
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
132
|
+
});
|
|
133
|
+
const user = { userId: 'user1', email: 'test@example.com', name: 'Test', createdAt: 1000 };
|
|
134
|
+
await table.put(user);
|
|
135
|
+
await assert.rejects(() => table.put(user, { ifNotExists: true }), (err) => {
|
|
136
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
137
|
+
assert.equal(err.status, 409);
|
|
138
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
139
|
+
assert.equal(err.retriable, false);
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
test('put ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
144
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
145
|
+
schema: userSchema,
|
|
146
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
147
|
+
});
|
|
148
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
149
|
+
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => {
|
|
150
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
151
|
+
assert.equal(err.status, 409);
|
|
152
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
153
|
+
assert.equal(err.retriable, true);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
test('put ifNotExists + ifFieldEquals conflict is 409, not retriable (existence wins)', async () => {
|
|
158
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
159
|
+
schema: userSchema,
|
|
160
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
161
|
+
});
|
|
162
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
163
|
+
// The typed API forbids combining ifNotExists with ifFieldEquals; force
|
|
164
|
+
// the combined shape to verify the runtime derivation is existence-wins
|
|
165
|
+
// (not retriable) for parity with the aws path and bb-kv-store.
|
|
166
|
+
const combined = { ifNotExists: true, ifFieldEquals: { name: 'Wrong' } };
|
|
167
|
+
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, combined), (err) => {
|
|
168
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
169
|
+
assert.equal(err.status, 409);
|
|
170
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
171
|
+
assert.equal(err.retriable, false);
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
test('delete ifExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
176
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
177
|
+
schema: userSchema,
|
|
178
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
179
|
+
});
|
|
180
|
+
await assert.rejects(() => table.delete({ userId: 'missing', createdAt: 1 }, { ifExists: true }), (err) => {
|
|
181
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
182
|
+
assert.equal(err.status, 409);
|
|
183
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
184
|
+
assert.equal(err.retriable, false);
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
test('delete ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
189
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
190
|
+
schema: userSchema,
|
|
191
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
192
|
+
});
|
|
193
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
194
|
+
await assert.rejects(() => table.delete({ userId: 'u1', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => {
|
|
195
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
196
|
+
assert.equal(err.status, 409);
|
|
197
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
198
|
+
assert.equal(err.retriable, true);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
120
203
|
// ── Conditional delete ──────────────────────────────────────────────────
|
|
121
204
|
describe('conditional delete', () => {
|
|
122
205
|
test('ifExists succeeds when item exists', async () => {
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-distributed-table",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"aws-blocks",
|
|
6
|
+
"dynamodb",
|
|
7
|
+
"nosql",
|
|
8
|
+
"database"
|
|
9
|
+
],
|
|
4
10
|
"repository": {
|
|
5
11
|
"type": "git",
|
|
6
12
|
"url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
|
|
@@ -39,8 +45,8 @@
|
|
|
39
45
|
"test": "node --test --test-concurrency=1 dist/index.test.js dist/parity.test.js dist/index.cdk.test.js"
|
|
40
46
|
},
|
|
41
47
|
"dependencies": {
|
|
42
|
-
"@aws-blocks/core": "^0.
|
|
43
|
-
"@aws-blocks/bb-logger": "^0.
|
|
48
|
+
"@aws-blocks/core": "^0.5.0",
|
|
49
|
+
"@aws-blocks/bb-logger": "^0.2.0",
|
|
44
50
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
45
51
|
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
46
52
|
"@standard-schema/spec": "^1.0.0",
|
package/src/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createDefu } from 'defu';
|
|
5
5
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
6
|
+
import { ApiError } from '@aws-blocks/core';
|
|
6
7
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
7
8
|
import type { ReadValidationMode } from './types.js';
|
|
8
9
|
|
|
@@ -100,6 +101,31 @@ export function blocksError(name: string, message: string): Error {
|
|
|
100
101
|
return err;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* @internal Build the 409 ApiError for a conditional-write conflict. Maps to
|
|
106
|
+
* HTTP 409 (Conflict) so the JSON-RPC serializer emits code 409 instead of a
|
|
107
|
+
* generic 500, preserves the `ConditionalCheckFailed` name so `isBlocksError()`
|
|
108
|
+
* keeps matching on both server and client. Shared by the mock and AWS runtime
|
|
109
|
+
* so both produce an identically shaped 409; on AWS the caught DynamoDB
|
|
110
|
+
* `ConditionalCheckFailedException` is passed as `cause` (kept server-side by
|
|
111
|
+
* ApiError).
|
|
112
|
+
*
|
|
113
|
+
* `retriable` is scoped to the assertion kind: `true` only for optimistic-lock
|
|
114
|
+
* conflicts (`ifFieldEquals`), where a re-read and retry can succeed; `false`
|
|
115
|
+
* for existence/uniqueness assertions (`ifNotExists` on put, `ifExists` on
|
|
116
|
+
* delete), where a blind identical retry fails identically. DynamoDB collapses
|
|
117
|
+
* every conditional failure under one `ConditionalCheckFailedException` with no
|
|
118
|
+
* sub-reason, so the AWS runtime decides this from the condition the caller set
|
|
119
|
+
* on that specific put/delete call.
|
|
120
|
+
*/
|
|
121
|
+
export function conditionalCheckFailed(retriable: boolean, cause?: unknown): ApiError {
|
|
122
|
+
return new ApiError('The conditional request failed', 409, {
|
|
123
|
+
name: DistributedTableErrors.ConditionalCheckFailed,
|
|
124
|
+
cause,
|
|
125
|
+
retriable,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/**
|
|
104
130
|
* @internal Normalize a sort-key condition before it drives a query. Shared by
|
|
105
131
|
* the mock and AWS runtime so both treat the same inputs identically:
|
package/src/index.aws.ts
CHANGED
|
@@ -47,7 +47,7 @@ import type {
|
|
|
47
47
|
TableKey,
|
|
48
48
|
ReadValidationMode,
|
|
49
49
|
} from './types.js';
|
|
50
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
50
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
51
51
|
import type { KeyCondition, QueryOptions } from './types.js';
|
|
52
52
|
import { Logger } from '@aws-blocks/bb-logger';
|
|
53
53
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
@@ -129,6 +129,21 @@ export class DistributedTable<
|
|
|
129
129
|
try {
|
|
130
130
|
await this.docClient.send(new PutCommand(command));
|
|
131
131
|
} catch (err: unknown) {
|
|
132
|
+
// A failed conditional write is a Conflict, not an
|
|
133
|
+
// InternalServerError: map DynamoDB's raw
|
|
134
|
+
// ConditionalCheckFailedException to an ApiError with status 409 so the
|
|
135
|
+
// JSON-RPC serializer emits code 409 instead of 500. Name is preserved
|
|
136
|
+
// for isBlocksError(), the driver error is kept as `cause`. DynamoDB
|
|
137
|
+
// collapses every conditional failure under one exception with no
|
|
138
|
+
// sub-reason, so retriability is derived from the conditions THIS call
|
|
139
|
+
// set, matching the mock: existence assertion wins — retriable only for
|
|
140
|
+
// a pure `ifFieldEquals` optimistic-lock check (value presence, so an
|
|
141
|
+
// explicit `undefined` is treated as absent) and NOT when `ifNotExists`
|
|
142
|
+
// is also set. Other errors (e.g. oversized items) still flow through
|
|
143
|
+
// remapItemTooLarge.
|
|
144
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
145
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifNotExists, err);
|
|
146
|
+
}
|
|
132
147
|
throw remapItemTooLarge(err);
|
|
133
148
|
}
|
|
134
149
|
}
|
|
@@ -143,7 +158,21 @@ export class DistributedTable<
|
|
|
143
158
|
this.applyFieldEqualsCondition(command, options.ifFieldEquals);
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
|
|
161
|
+
try {
|
|
162
|
+
await this.docClient.send(new DeleteCommand(command));
|
|
163
|
+
} catch (err: unknown) {
|
|
164
|
+
// A failed conditional delete is a Conflict, not an
|
|
165
|
+
// InternalServerError: map DynamoDB's raw
|
|
166
|
+
// ConditionalCheckFailedException to an ApiError with status 409 (see
|
|
167
|
+
// the put path above). Retriability is derived from the conditions THIS
|
|
168
|
+
// call set, matching the mock: existence assertion wins — retriable only
|
|
169
|
+
// for a pure `ifFieldEquals` optimistic-lock check (value presence) and
|
|
170
|
+
// NOT when `ifExists` is also set.
|
|
171
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
172
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifExists, err);
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
147
176
|
}
|
|
148
177
|
|
|
149
178
|
/**
|
package/src/index.cdk.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { Construct } from 'constructs';
|
|
5
5
|
import { Table, type ITable, AttributeType, BillingMode, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
|
|
6
|
+
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
6
7
|
import * as cdk from 'aws-cdk-lib';
|
|
7
8
|
import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
|
|
8
9
|
import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
|
|
@@ -10,7 +11,7 @@ import { LogGroup, type RetentionDays } from 'aws-cdk-lib/aws-logs';
|
|
|
10
11
|
import { Provider } from 'aws-cdk-lib/custom-resources';
|
|
11
12
|
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
|
|
12
13
|
import { Key, type IKey } from 'aws-cdk-lib/aws-kms';
|
|
13
|
-
import {
|
|
14
|
+
import { BuildingBlockScope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
14
15
|
import type { ScopeParent } from '@aws-blocks/core';
|
|
15
16
|
import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,7 +20,7 @@ import { dirname, join } from 'node:path';
|
|
|
19
20
|
export { DistributedTableErrors } from './errors.js';
|
|
20
21
|
export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
21
22
|
|
|
22
|
-
export class DistributedTable<T = any> extends
|
|
23
|
+
export class DistributedTable<T = any> extends BuildingBlockScope {
|
|
23
24
|
private table: ITable;
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -47,7 +48,7 @@ export class DistributedTable<T = any> extends Scope {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
constructor(scope: ScopeParent, id: string, public options: any) {
|
|
50
|
-
super(id, { parent: scope });
|
|
51
|
+
super(id, { parent: scope, vpc: { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] } });
|
|
51
52
|
|
|
52
53
|
const config = options;
|
|
53
54
|
|
package/src/index.mock.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type {
|
|
|
38
38
|
TableKey,
|
|
39
39
|
ReadValidationMode,
|
|
40
40
|
} from './types.js';
|
|
41
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
41
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
42
42
|
|
|
43
43
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
44
44
|
|
|
@@ -173,13 +173,18 @@ export class DistributedTable<
|
|
|
173
173
|
|
|
174
174
|
const keyStr = this.serializeKey(item as any);
|
|
175
175
|
|
|
176
|
+
// Existence assertion wins: `ifNotExists` makes a conflict non-retriable
|
|
177
|
+
// even if combined with an `ifFieldEquals` value check (value presence, so
|
|
178
|
+
// an explicit `undefined` is treated as absent). Only a pure `ifFieldEquals`
|
|
179
|
+
// optimistic-lock check is retriable. Shared derivation so mock and aws
|
|
180
|
+
// agree for identical inputs, incl. combined conditions.
|
|
181
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifNotExists;
|
|
176
182
|
if (options?.ifNotExists && this.data.has(keyStr)) {
|
|
177
|
-
throw
|
|
183
|
+
throw conditionalCheckFailed(retriable);
|
|
178
184
|
}
|
|
179
185
|
if (options?.ifFieldEquals) {
|
|
180
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
186
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
181
187
|
}
|
|
182
|
-
|
|
183
188
|
this.data.set(keyStr, item);
|
|
184
189
|
this.flushToDisk();
|
|
185
190
|
}
|
|
@@ -187,11 +192,14 @@ export class DistributedTable<
|
|
|
187
192
|
async delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void> {
|
|
188
193
|
const keyStr = this.serializeKey(key);
|
|
189
194
|
|
|
195
|
+
// Existence assertion wins (see put): `ifExists` makes a conflict
|
|
196
|
+
// non-retriable even if combined with an `ifFieldEquals` value check.
|
|
197
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifExists;
|
|
190
198
|
if (options?.ifExists && !this.data.has(keyStr)) {
|
|
191
|
-
throw
|
|
199
|
+
throw conditionalCheckFailed(retriable);
|
|
192
200
|
}
|
|
193
201
|
if (options?.ifFieldEquals) {
|
|
194
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
202
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
this.data.delete(keyStr);
|
|
@@ -349,7 +357,7 @@ export class DistributedTable<
|
|
|
349
357
|
|
|
350
358
|
// ── Internal ────────────────────────────────────────────────────────────
|
|
351
359
|
|
|
352
|
-
private checkFieldEquals(keyStr: string, fields: Partial<T
|
|
360
|
+
private checkFieldEquals(keyStr: string, fields: Partial<T>, retriable: boolean): void {
|
|
353
361
|
const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
|
|
354
362
|
if (entries.length === 0) {
|
|
355
363
|
throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
|
|
@@ -357,11 +365,14 @@ export class DistributedTable<
|
|
|
357
365
|
|
|
358
366
|
const existing = this.data.get(keyStr);
|
|
359
367
|
if (!existing) {
|
|
360
|
-
|
|
368
|
+
// Compare-and-swap conflict; retriability is decided by the caller —
|
|
369
|
+
// existence assertion wins, so not retriable even if combined.
|
|
370
|
+
throw conditionalCheckFailed(retriable);
|
|
361
371
|
}
|
|
362
372
|
for (const [field, value] of entries) {
|
|
363
373
|
if (!deepEqual((existing as any)[field], value)) {
|
|
364
|
-
|
|
374
|
+
// Compare-and-swap conflict; retriability decided by the caller.
|
|
375
|
+
throw conditionalCheckFailed(retriable);
|
|
365
376
|
}
|
|
366
377
|
}
|
|
367
378
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { test, describe } from 'node:test';
|
|
5
5
|
import { strict as assert } from 'node:assert';
|
|
6
6
|
import { DistributedTable, DistributedTableErrors } from './index.mock.js';
|
|
7
|
-
import { Scope } from '@aws-blocks/core';
|
|
7
|
+
import { ApiError, isBlocksError, Scope } from '@aws-blocks/core';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
|
|
10
10
|
// ── Schemas ─────────────────────────────────────────────────────────────────
|
|
@@ -143,6 +143,110 @@ describe('DistributedTable', () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
// ── OCC conflicts map to HTTP 409 (Conflict) ─────────────────────────────
|
|
147
|
+
// A conditional-write conflict must serialize to JSON-RPC code 409, not 500.
|
|
148
|
+
// The mock must throw an ApiError (status 409) that preserves the
|
|
149
|
+
// ConditionalCheckFailed name, matching the aws-runtime path. `retriable` is
|
|
150
|
+
// scoped to the assertion kind: true only for optimistic-lock ifFieldEquals
|
|
151
|
+
// conflicts (a re-read and retry can succeed), false for existence/uniqueness
|
|
152
|
+
// assertions (a blind retry fails identically).
|
|
153
|
+
|
|
154
|
+
describe('OCC conflicts map to 409', () => {
|
|
155
|
+
test('put ifNotExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
156
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
157
|
+
schema: userSchema,
|
|
158
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
159
|
+
});
|
|
160
|
+
const user = { userId: 'user1', email: 'test@example.com', name: 'Test', createdAt: 1000 };
|
|
161
|
+
await table.put(user);
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
() => table.put(user, { ifNotExists: true }),
|
|
164
|
+
(err: unknown) => {
|
|
165
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
166
|
+
assert.equal(err.status, 409);
|
|
167
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
168
|
+
assert.equal(err.retriable, false);
|
|
169
|
+
return true;
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('put ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
175
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
176
|
+
schema: userSchema,
|
|
177
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
178
|
+
});
|
|
179
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
180
|
+
await assert.rejects(
|
|
181
|
+
() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }),
|
|
182
|
+
(err: unknown) => {
|
|
183
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
184
|
+
assert.equal(err.status, 409);
|
|
185
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
186
|
+
assert.equal(err.retriable, true);
|
|
187
|
+
return true;
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('put ifNotExists + ifFieldEquals conflict is 409, not retriable (existence wins)', async () => {
|
|
193
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
194
|
+
schema: userSchema,
|
|
195
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
196
|
+
});
|
|
197
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
198
|
+
// The typed API forbids combining ifNotExists with ifFieldEquals; force
|
|
199
|
+
// the combined shape to verify the runtime derivation is existence-wins
|
|
200
|
+
// (not retriable) for parity with the aws path and bb-kv-store.
|
|
201
|
+
const combined = { ifNotExists: true, ifFieldEquals: { name: 'Wrong' } } as unknown as Parameters<typeof table.put>[1];
|
|
202
|
+
await assert.rejects(
|
|
203
|
+
() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, combined),
|
|
204
|
+
(err: unknown) => {
|
|
205
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
206
|
+
assert.equal(err.status, 409);
|
|
207
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
208
|
+
assert.equal(err.retriable, false);
|
|
209
|
+
return true;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('delete ifExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
215
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
216
|
+
schema: userSchema,
|
|
217
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
218
|
+
});
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
() => table.delete({ userId: 'missing', createdAt: 1 }, { ifExists: true }),
|
|
221
|
+
(err: unknown) => {
|
|
222
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
223
|
+
assert.equal(err.status, 409);
|
|
224
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
225
|
+
assert.equal(err.retriable, false);
|
|
226
|
+
return true;
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('delete ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
232
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
233
|
+
schema: userSchema,
|
|
234
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
235
|
+
});
|
|
236
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
237
|
+
await assert.rejects(
|
|
238
|
+
() => table.delete({ userId: 'u1', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }),
|
|
239
|
+
(err: unknown) => {
|
|
240
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
241
|
+
assert.equal(err.status, 409);
|
|
242
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
243
|
+
assert.equal(err.retriable, true);
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
146
250
|
// ── Conditional delete ──────────────────────────────────────────────────
|
|
147
251
|
|
|
148
252
|
describe('conditional delete', () => {
|
package/src/version.ts
CHANGED