@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 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: `readValidation` `off | coerce | strict`, defaulting to `coerce`
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-10: Secure-by-default durability & encryption, sourced from stack `BlocksDefaults`
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-10)
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:
@@ -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:
@@ -1 +1 @@
1
- {"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"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"}
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
- await this.docClient.send(new DeleteCommand(command));
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
@@ -1,9 +1,9 @@
1
- import { Scope } from '@aws-blocks/core/cdk';
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 Scope {
6
+ export declare class DistributedTable<T = any> extends BuildingBlockScope {
7
7
  options: any;
8
8
  private table;
9
9
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,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,KAAK;IA2BA,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"}
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 { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
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 Scope {
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) {
@@ -1 +1 @@
1
- {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
@@ -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 blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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 blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
312
+ // Compare-and-swap conflict; retriability decided by the caller.
313
+ throw conditionalCheckFailed(retriable);
302
314
  }
303
315
  }
304
316
  }
@@ -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
@@ -1,3 +1,3 @@
1
1
  export declare const BB_NAME = "DistributedTable";
2
- export declare const BB_VERSION = "0.1.7";
2
+ export declare const BB_VERSION = "0.2.0";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'DistributedTable';
3
- export const BB_VERSION = '0.1.7';
3
+ export const BB_VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "@aws-blocks/bb-distributed-table",
3
- "version": "0.1.7",
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.4.0",
43
- "@aws-blocks/bb-logger": "^0.1.6",
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
- await this.docClient.send(new DeleteCommand(command));
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 { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
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 Scope {
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 blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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 blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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>): void {
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
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
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
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'DistributedTable';
3
- export const BB_VERSION = '0.1.7';
3
+ export const BB_VERSION = '0.2.0';