@aws-blocks/bb-distributed-table 0.1.6 → 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()`
@@ -81,6 +83,10 @@ orders.query({
81
83
 
82
84
  The `order` field maps to DynamoDB's `ScanIndexForward` parameter (`'desc'` → `ScanIndexForward: false`). It defaults to `'asc'`.
83
85
 
86
+ **Ordering ties (GSI):** a GSI sort key need not be unique, so multiple items can share one sort-key value. The mock tie-breaks on the base-table primary key (partition key, then sort key) rather than on Map insertion order — otherwise results would depend on write order / disk-reload order. This **matches DynamoDB's observed ordering** for index rows with equal sort keys; AWS does not document a contractual guarantee for the tie order, so the mock is intentionally **deterministic even where DynamoDB's contract is unspecified** (a plus for reproducible tests — just don't rely on a specific tie order in production logic). Under `order: 'desc'` the whole index order, ties included, reverses.
87
+
88
+ **String-ordering boundary:** the mock compares sort/base keys with JavaScript `<`/`>` (UTF-16 code-unit order), which can differ from DynamoDB's UTF-8 byte order for non-ASCII string keys. This applies to both the index sort-key comparison and the base-key tie-break, and is a pre-existing property of the mock — surfaced here so the parity discussion is complete.
89
+
84
90
  ### D-DT-7: TTL via options field
85
91
 
86
92
  **Decision:** TTL is configured via `ttl: 'fieldName'` in the constructor options, not as a separate method or decorator.
@@ -100,7 +106,15 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
100
106
 
101
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.
102
108
 
103
- ### 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`
104
118
 
105
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.
106
120
 
@@ -123,7 +137,7 @@ A generic `ValidationException` is exactly the kind of catch-all bucket worth av
123
137
 
124
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.
125
139
 
126
- ### 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`
127
141
 
128
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`.
129
143
 
@@ -149,7 +163,7 @@ Creates a single DynamoDB table:
149
163
  - **TTL:** Enabled via `TimeToLiveSpecification` when `options.ttl` is set
150
164
  - **Billing mode:** PAY_PER_REQUEST
151
165
  - **Table name:** Derived from `scope.fullId` (includes stack name for uniqueness)
152
- - **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)
153
167
  - **Permissions:** `grantReadWriteData` to the parent scope's handler automatically, plus explicit `dynamodb:Query` on `index/*`
154
168
 
155
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":"AAWA,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,17 +1,19 @@
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
+ import { LogGroup } from 'aws-cdk-lib/aws-logs';
7
9
  import { Provider } from 'aws-cdk-lib/custom-resources';
8
10
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
9
11
  import { Key } from 'aws-cdk-lib/aws-kms';
10
- import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
12
+ import { BuildingBlockScope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
11
13
  import { fileURLToPath } from 'node:url';
12
14
  import { dirname, join } from 'node:path';
13
15
  export { DistributedTableErrors } from './errors.js';
14
- export class DistributedTable extends Scope {
16
+ export class DistributedTable extends BuildingBlockScope {
15
17
  options;
16
18
  table;
17
19
  /**
@@ -37,7 +39,7 @@ export class DistributedTable extends Scope {
37
39
  return { __brand: 'ExternalKmsKeyRef', keyArn };
38
40
  }
39
41
  constructor(scope, id, options) {
40
- super(id, { parent: scope });
42
+ super(id, { parent: scope, vpc: { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] } });
41
43
  this.options = options;
42
44
  const config = options;
43
45
  if (config?.table) {
@@ -207,7 +209,7 @@ export class DistributedTable extends Scope {
207
209
  }));
208
210
  // Add GSI manager if indexes are defined
209
211
  if (config.indexes && Object.keys(config.indexes).length > 0) {
210
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
212
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
211
213
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
212
214
  const indexesWithTypes = {};
213
215
  for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
@@ -249,24 +251,35 @@ export class DistributedTable extends Scope {
249
251
  }
250
252
  // ── Shared GSI Manager Provider (one per stack) ─────────────────────────────
251
253
  const GSI_PROVIDER_KEY = Symbol.for('BLOCKS_GSI_MANAGER_PROVIDER');
252
- function getOrCreateGsiProvider(stack) {
254
+ function getOrCreateGsiProvider(stack, logRetention) {
253
255
  const existing = stack[GSI_PROVIDER_KEY];
254
256
  if (existing)
255
257
  return existing;
256
258
  const __dirname = dirname(fileURLToPath(import.meta.url));
257
259
  const tableArns = [];
258
260
  const sandboxTableArns = [];
261
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
262
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
263
+ // stack (logs are not durable state).
259
264
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
260
265
  runtime: DEFAULT_NODE_RUNTIME,
261
266
  handler: 'index.handler',
262
267
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
263
268
  timeout: Duration.minutes(15),
269
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
270
+ retention: logRetention,
271
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
272
+ }),
264
273
  });
265
274
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
266
275
  runtime: DEFAULT_NODE_RUNTIME,
267
276
  handler: 'index.isCompleteHandler',
268
277
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
269
278
  timeout: Duration.minutes(1),
279
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
280
+ retention: logRetention,
281
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
282
+ }),
270
283
  });
271
284
  // Production permissions — lazily resolved so ARNs accumulate as tables register
272
285
  gsiManagerLambda.addToRolePolicy(new PolicyStatement({
@@ -332,6 +332,19 @@ test('CDK: a per-block protection option overrides the stack defaults', () => {
332
332
  template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
333
333
  template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
334
334
  });
335
+ test('CDK: GSI manager Lambda log groups adopt defaults.logRetention', () => {
336
+ const { stack, parent } = setup(BlocksPresets.sandbox);
337
+ new DistributedTable(parent, 'users', {
338
+ schema: userSchema,
339
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
340
+ indexes: { byEmail: { partitionKey: 'email' } },
341
+ });
342
+ const template = Template.fromStack(stack);
343
+ // The GSI manager + isComplete Lambdas now own explicit log groups whose
344
+ // retention follows the stack-wide default (sandbox → one week), instead of
345
+ // AWS's infinite default.
346
+ template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
347
+ });
335
348
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
336
349
  const { stack, parent } = setup();
337
350
  new DistributedTable(parent, 'users', {
@@ -101,6 +101,13 @@ export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyC
101
101
  static fromExisting(tableName: string): ExternalTableRef;
102
102
  static fromKmsKey(keyArn: string): ExternalKmsKeyRef;
103
103
  private checkFieldEquals;
104
+ /**
105
+ * Deterministic tie-break for `query` ordering: compare two items by the
106
+ * base-table primary key (partition key, then sort key). Used when an index
107
+ * sort-key value is shared by multiple items, so results don't depend on Map
108
+ * insertion order. Returns a stable -1/0/1.
109
+ */
110
+ private compareByBaseKey;
104
111
  private serializeKey;
105
112
  private loadFromDisk;
106
113
  private flushToDisk;
@@ -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();
@@ -212,7 +221,14 @@ export class DistributedTable extends Scope {
212
221
  const dir = options.order === 'desc' ? -1 : 1;
213
222
  items.sort((a, b) => {
214
223
  const av = a[skField], bv = b[skField];
215
- return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
224
+ const primary = av < bv ? -1 : av > bv ? 1 : 0;
225
+ // Tie-break on the base-table primary key. On a GSI the sort key need
226
+ // not be unique, so equal sort-key values must NOT fall back to Map
227
+ // insertion order (which varies by write order / disk reload) — that's
228
+ // the mock-vs-DynamoDB divergence. DynamoDB orders index ties by the
229
+ // base-table key, and the whole index (ties included) reverses under
230
+ // `order: 'desc'`.
231
+ return (primary !== 0 ? primary : this.compareByBaseKey(a, b)) * dir;
216
232
  });
217
233
  }
218
234
  let count = 0;
@@ -280,21 +296,46 @@ export class DistributedTable extends Scope {
280
296
  return { __brand: 'ExternalKmsKeyRef', keyArn };
281
297
  }
282
298
  // ── Internal ────────────────────────────────────────────────────────────
283
- checkFieldEquals(keyStr, fields) {
299
+ checkFieldEquals(keyStr, fields, retriable) {
284
300
  const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
285
301
  if (entries.length === 0) {
286
302
  throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
287
303
  }
288
304
  const existing = this.data.get(keyStr);
289
305
  if (!existing) {
290
- 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);
291
309
  }
292
310
  for (const [field, value] of entries) {
293
311
  if (!deepEqual(existing[field], value)) {
294
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
312
+ // Compare-and-swap conflict; retriability decided by the caller.
313
+ throw conditionalCheckFailed(retriable);
295
314
  }
296
315
  }
297
316
  }
317
+ /**
318
+ * Deterministic tie-break for `query` ordering: compare two items by the
319
+ * base-table primary key (partition key, then sort key). Used when an index
320
+ * sort-key value is shared by multiple items, so results don't depend on Map
321
+ * insertion order. Returns a stable -1/0/1.
322
+ */
323
+ compareByBaseKey(a, b) {
324
+ const pk = this.keyConfig.partitionKey;
325
+ const ap = a[pk], bp = b[pk];
326
+ if (ap !== bp)
327
+ return ap < bp ? -1 : 1;
328
+ const sk = this.keyConfig.sortKey;
329
+ if (sk) {
330
+ const as = a[sk], bs = b[sk];
331
+ if (as !== bs)
332
+ return as < bs ? -1 : 1;
333
+ }
334
+ // Unreachable for distinct rows: every item is keyed by its serialized base
335
+ // primary key, so two different entries always differ in base PK or SK.
336
+ // (String comparisons above use JS UTF-16 order — see DESIGN.md D-DT-6.)
337
+ return 0;
338
+ }
298
339
  serializeKey(key) {
299
340
  const parts = [key[this.keyConfig.partitionKey]];
300
341
  if (this.keyConfig.sortKey)
@@ -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 () => {
@@ -724,4 +807,46 @@ describe('DistributedTable', () => {
724
807
  async () => { for await (const _ of table.query({ index: 'doesNotExist', where: { userId: { equals: 'u1' } } })) { } }, /Index 'doesNotExist' not found/);
725
808
  });
726
809
  });
810
+ // ── Query: index sort-key ties are ordered deterministically ────────────
811
+ describe('query (index sort-key ties)', () => {
812
+ const cardSchema = z.object({ boardId: z.string(), cardId: z.string(), position: z.number() });
813
+ // Base key is (boardId, cardId); the GSI sorts by `position`, which is NOT
814
+ // unique — several cards can share a position. DynamoDB tie-breaks such
815
+ // index rows by the base-table key, so the mock must too (not by Map
816
+ // insertion order, which varies by write order / disk reload).
817
+ function cardTable() {
818
+ return new DistributedTable(testScope(), 'cards', {
819
+ schema: cardSchema,
820
+ key: { partitionKey: 'boardId', sortKey: 'cardId' },
821
+ indexes: { byPosition: { partitionKey: 'boardId', sortKey: 'position' } },
822
+ });
823
+ }
824
+ const q = (t, order) => collect(t.query({ index: 'byPosition', where: { boardId: { equals: 'b1' } }, ...(order ? { order } : {}) }));
825
+ test('ties on the index sort key order by the base-table key, regardless of write order', async () => {
826
+ const t1 = cardTable();
827
+ // All position=1; insert cardIds out of order.
828
+ for (const cardId of ['c3', 'c1', 'c2'])
829
+ await t1.put({ boardId: 'b1', cardId, position: 1 });
830
+ assert.deepEqual((await q(t1)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
831
+ // A different write order must yield the SAME result (deterministic).
832
+ const t2 = cardTable();
833
+ for (const cardId of ['c2', 'c3', 'c1'])
834
+ await t2.put({ boardId: 'b1', cardId, position: 1 });
835
+ assert.deepEqual((await q(t2)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
836
+ });
837
+ test('desc reverses ties too (whole index order flips)', async () => {
838
+ const t = cardTable();
839
+ for (const cardId of ['c1', 'c3', 'c2'])
840
+ await t.put({ boardId: 'b1', cardId, position: 1 });
841
+ assert.deepEqual((await q(t, 'desc')).map((c) => c.cardId), ['c3', 'c2', 'c1']);
842
+ });
843
+ test('primary order stays by index sort key; base key only breaks ties', async () => {
844
+ const t = cardTable();
845
+ await t.put({ boardId: 'b1', cardId: 'zzz', position: 1 });
846
+ await t.put({ boardId: 'b1', cardId: 'aaa', position: 2 });
847
+ await t.put({ boardId: 'b1', cardId: 'mmm', position: 1 });
848
+ // position asc first (1,1,2); within position=1, base key (cardId) breaks the tie.
849
+ assert.deepEqual((await q(t)).map((c) => [c.position, c.cardId]), [[1, 'mmm'], [1, 'zzz'], [2, 'aaa']]);
850
+ });
851
+ });
727
852
  });
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.6";
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.6';
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.6",
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.3.0",
43
- "@aws-blocks/bb-logger": "^0.1.5",
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
  /**
@@ -363,6 +363,20 @@ test('CDK: a per-block protection option overrides the stack defaults', () => {
363
363
  template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
364
364
  });
365
365
 
366
+ test('CDK: GSI manager Lambda log groups adopt defaults.logRetention', () => {
367
+ const { stack, parent } = setup(BlocksPresets.sandbox);
368
+ new DistributedTable(parent, 'users', {
369
+ schema: userSchema,
370
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
371
+ indexes: { byEmail: { partitionKey: 'email' } },
372
+ });
373
+ const template = Template.fromStack(stack);
374
+ // The GSI manager + isComplete Lambdas now own explicit log groups whose
375
+ // retention follows the stack-wide default (sandbox → one week), instead of
376
+ // AWS's infinite default.
377
+ template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
378
+ });
379
+
366
380
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
367
381
  const { stack, parent } = setup();
368
382
  new DistributedTable(parent, 'users', {
package/src/index.cdk.ts CHANGED
@@ -3,13 +3,15 @@
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
+ import { LogGroup, type RetentionDays } from 'aws-cdk-lib/aws-logs';
9
11
  import { Provider } from 'aws-cdk-lib/custom-resources';
10
12
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
11
13
  import { Key, type IKey } from 'aws-cdk-lib/aws-kms';
12
- import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
14
+ import { BuildingBlockScope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
13
15
  import type { ScopeParent } from '@aws-blocks/core';
14
16
  import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
15
17
  import { fileURLToPath } from 'node:url';
@@ -18,7 +20,7 @@ import { dirname, join } from 'node:path';
18
20
  export { DistributedTableErrors } from './errors.js';
19
21
  export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
20
22
 
21
- export class DistributedTable<T = any> extends Scope {
23
+ export class DistributedTable<T = any> extends BuildingBlockScope {
22
24
  private table: ITable;
23
25
 
24
26
  /**
@@ -46,7 +48,7 @@ export class DistributedTable<T = any> extends Scope {
46
48
  }
47
49
 
48
50
  constructor(scope: ScopeParent, id: string, public options: any) {
49
- super(id, { parent: scope });
51
+ super(id, { parent: scope, vpc: { gatewayEndpoints: [ec2.GatewayVpcEndpointAwsService.DYNAMODB] } });
50
52
 
51
53
  const config = options;
52
54
 
@@ -239,7 +241,7 @@ export class DistributedTable<T = any> extends Scope {
239
241
 
240
242
  // Add GSI manager if indexes are defined
241
243
  if (config.indexes && Object.keys(config.indexes).length > 0) {
242
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
244
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
243
245
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
244
246
 
245
247
  const indexesWithTypes: Record<string, any> = {};
@@ -293,7 +295,7 @@ interface SharedGsiProvider {
293
295
  addTableArn: (tableArn: string, isSandbox: boolean) => void;
294
296
  }
295
297
 
296
- function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
298
+ function getOrCreateGsiProvider(stack: cdk.Stack, logRetention: RetentionDays): SharedGsiProvider {
297
299
  const existing = (stack as any)[GSI_PROVIDER_KEY] as SharedGsiProvider | undefined;
298
300
  if (existing) return existing;
299
301
 
@@ -302,11 +304,18 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
302
304
  const tableArns: string[] = [];
303
305
  const sandboxTableArns: string[] = [];
304
306
 
307
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
308
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
309
+ // stack (logs are not durable state).
305
310
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
306
311
  runtime: DEFAULT_NODE_RUNTIME,
307
312
  handler: 'index.handler',
308
313
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
309
314
  timeout: Duration.minutes(15),
315
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
316
+ retention: logRetention,
317
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
318
+ }),
310
319
  });
311
320
 
312
321
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
@@ -314,6 +323,10 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
314
323
  handler: 'index.isCompleteHandler',
315
324
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
316
325
  timeout: Duration.minutes(1),
326
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
327
+ retention: logRetention,
328
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
329
+ }),
317
330
  });
318
331
 
319
332
  // Production permissions — lazily resolved so ARNs accumulate as tables register
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);
@@ -266,7 +274,14 @@ export class DistributedTable<
266
274
  const dir = options.order === 'desc' ? -1 : 1;
267
275
  items.sort((a, b) => {
268
276
  const av = (a as any)[skField], bv = (b as any)[skField];
269
- return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
277
+ const primary = av < bv ? -1 : av > bv ? 1 : 0;
278
+ // Tie-break on the base-table primary key. On a GSI the sort key need
279
+ // not be unique, so equal sort-key values must NOT fall back to Map
280
+ // insertion order (which varies by write order / disk reload) — that's
281
+ // the mock-vs-DynamoDB divergence. DynamoDB orders index ties by the
282
+ // base-table key, and the whole index (ties included) reverses under
283
+ // `order: 'desc'`.
284
+ return (primary !== 0 ? primary : this.compareByBaseKey(a, b)) * dir;
270
285
  });
271
286
  }
272
287
 
@@ -342,7 +357,7 @@ export class DistributedTable<
342
357
 
343
358
  // ── Internal ────────────────────────────────────────────────────────────
344
359
 
345
- private checkFieldEquals(keyStr: string, fields: Partial<T>): void {
360
+ private checkFieldEquals(keyStr: string, fields: Partial<T>, retriable: boolean): void {
346
361
  const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
347
362
  if (entries.length === 0) {
348
363
  throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
@@ -350,15 +365,39 @@ export class DistributedTable<
350
365
 
351
366
  const existing = this.data.get(keyStr);
352
367
  if (!existing) {
353
- 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);
354
371
  }
355
372
  for (const [field, value] of entries) {
356
373
  if (!deepEqual((existing as any)[field], value)) {
357
- throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
374
+ // Compare-and-swap conflict; retriability decided by the caller.
375
+ throw conditionalCheckFailed(retriable);
358
376
  }
359
377
  }
360
378
  }
361
379
 
380
+ /**
381
+ * Deterministic tie-break for `query` ordering: compare two items by the
382
+ * base-table primary key (partition key, then sort key). Used when an index
383
+ * sort-key value is shared by multiple items, so results don't depend on Map
384
+ * insertion order. Returns a stable -1/0/1.
385
+ */
386
+ private compareByBaseKey(a: T, b: T): number {
387
+ const pk = this.keyConfig.partitionKey;
388
+ const ap = (a as any)[pk], bp = (b as any)[pk];
389
+ if (ap !== bp) return ap < bp ? -1 : 1;
390
+ const sk = this.keyConfig.sortKey;
391
+ if (sk) {
392
+ const as = (a as any)[sk], bs = (b as any)[sk];
393
+ if (as !== bs) return as < bs ? -1 : 1;
394
+ }
395
+ // Unreachable for distinct rows: every item is keyed by its serialized base
396
+ // primary key, so two different entries always differ in base PK or SK.
397
+ // (String comparisons above use JS UTF-16 order — see DESIGN.md D-DT-6.)
398
+ return 0;
399
+ }
400
+
362
401
  private serializeKey(key: TableKey<T, K>): string {
363
402
  const parts = [(key as any)[this.keyConfig.partitionKey]];
364
403
  if (this.keyConfig.sortKey) parts.push((key as any)[this.keyConfig.sortKey]);
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', () => {
@@ -859,4 +963,49 @@ describe('DistributedTable', () => {
859
963
  );
860
964
  });
861
965
  });
966
+
967
+ // ── Query: index sort-key ties are ordered deterministically ────────────
968
+ describe('query (index sort-key ties)', () => {
969
+ const cardSchema = z.object({ boardId: z.string(), cardId: z.string(), position: z.number() });
970
+ // Base key is (boardId, cardId); the GSI sorts by `position`, which is NOT
971
+ // unique — several cards can share a position. DynamoDB tie-breaks such
972
+ // index rows by the base-table key, so the mock must too (not by Map
973
+ // insertion order, which varies by write order / disk reload).
974
+ function cardTable() {
975
+ return new DistributedTable(testScope(), 'cards', {
976
+ schema: cardSchema,
977
+ key: { partitionKey: 'boardId', sortKey: 'cardId' },
978
+ indexes: { byPosition: { partitionKey: 'boardId', sortKey: 'position' } },
979
+ });
980
+ }
981
+ const q = (t: ReturnType<typeof cardTable>, order?: 'asc' | 'desc') =>
982
+ collect(t.query({ index: 'byPosition', where: { boardId: { equals: 'b1' } }, ...(order ? { order } : {}) }));
983
+
984
+ test('ties on the index sort key order by the base-table key, regardless of write order', async () => {
985
+ const t1 = cardTable();
986
+ // All position=1; insert cardIds out of order.
987
+ for (const cardId of ['c3', 'c1', 'c2']) await t1.put({ boardId: 'b1', cardId, position: 1 });
988
+ assert.deepEqual((await q(t1)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
989
+
990
+ // A different write order must yield the SAME result (deterministic).
991
+ const t2 = cardTable();
992
+ for (const cardId of ['c2', 'c3', 'c1']) await t2.put({ boardId: 'b1', cardId, position: 1 });
993
+ assert.deepEqual((await q(t2)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
994
+ });
995
+
996
+ test('desc reverses ties too (whole index order flips)', async () => {
997
+ const t = cardTable();
998
+ for (const cardId of ['c1', 'c3', 'c2']) await t.put({ boardId: 'b1', cardId, position: 1 });
999
+ assert.deepEqual((await q(t, 'desc')).map((c) => c.cardId), ['c3', 'c2', 'c1']);
1000
+ });
1001
+
1002
+ test('primary order stays by index sort key; base key only breaks ties', async () => {
1003
+ const t = cardTable();
1004
+ await t.put({ boardId: 'b1', cardId: 'zzz', position: 1 });
1005
+ await t.put({ boardId: 'b1', cardId: 'aaa', position: 2 });
1006
+ await t.put({ boardId: 'b1', cardId: 'mmm', position: 1 });
1007
+ // position asc first (1,1,2); within position=1, base key (cardId) breaks the tie.
1008
+ assert.deepEqual((await q(t)).map((c) => [c.position, c.cardId]), [[1, 'mmm'], [1, 'zzz'], [2, 'aaa']]);
1009
+ });
1010
+ });
862
1011
  });
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.6';
3
+ export const BB_VERSION = '0.2.0';