@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 +17 -3
- package/README.md +2 -2
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -0
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.aws.js +32 -2
- package/dist/index.cdk.d.ts +2 -2
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +18 -5
- package/dist/index.cdk.test.js +13 -0
- package/dist/index.mock.d.ts +7 -0
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +50 -9
- package/dist/index.test.js +126 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -3
- package/src/errors.ts +26 -0
- package/src/index.aws.ts +31 -2
- package/src/index.cdk.test.ts +14 -0
- package/src/index.cdk.ts +18 -5
- package/src/index.mock.ts +49 -10
- package/src/index.test.ts +150 -1
- package/src/version.ts +1 -1
package/DESIGN.md
CHANGED
|
@@ -51,6 +51,8 @@ This also follows the API's options-object convention (objects over positional p
|
|
|
51
51
|
|
|
52
52
|
`ifNotExists` and `ifFieldEquals` are mutually exclusive at the type level (discriminated union), as are `ifExists` and `ifFieldEquals`. DynamoDB's `ConditionExpression` could combine them, but the semantics are confusing — "create only if it doesn't exist AND the existing item's field equals X" is contradictory. The type system prevents this rather than silently picking one.
|
|
53
53
|
|
|
54
|
+
A conditional-failure `ApiError` (409) is flagged `retriable` only for a pure `ifFieldEquals` optimistic-lock check (a re-read and retry can succeed); an existence assertion (`ifNotExists`/`ifExists`) is not retriable (a blind retry fails identically). Both runtimes derive this from a single expression keyed on **value presence** (`ifFieldEquals !== undefined`) with the **existence assertion winning** — so even the type-forbidden combined case (only reachable outside the typed API) is not retriable, and mock and AWS produce identical `retriable` for identical inputs.
|
|
55
|
+
|
|
54
56
|
KVStore uses `ifValueEquals` (compare the entire value). DistributedTable uses `ifFieldEquals` (compare individual fields) because items are structured objects with multiple fields — comparing the entire item would be impractical and fragile.
|
|
55
57
|
|
|
56
58
|
### D-DT-5: `scan()` not `list()`
|
|
@@ -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: `
|
|
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-
|
|
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-
|
|
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:
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAwBrD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,sBAAsB;;;IAGlC;;;;;;;;;;;OAWG;;IAEH;;;;;;;;;;;;OAYG;;IAEH;;;;;;;;;OASG;;CAEM,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAIhE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,SAAS,EAAE,CAAC,GAAG,SAAS,GACtB,CAAC,GAAG,SAAS,CAQf;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;oCACb,MAAM,GAAG,SAAS;sCAChB,MAAM;mDAEO,MAAM;wDAED,MAAM,EAAE;;mCAI7B,MAAM;0CAEC,MAAM,aAAa,MAAM,YAAY,MAAM;CAG/D,CAAC;AAEX;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOvD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAC1C,IAAI,EAAE,kBAAkB,EACxB,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,GAAG,IAAI,EACd,GAAG,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBnB"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAwBrD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,sBAAsB;;;IAGlC;;;;;;;;;;;OAWG;;IAEH;;;;;;;;;;;;OAYG;;IAEH;;;;;;;;;OASG;;CAEM,CAAC;AAEX;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,KAAK,CAIhE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,QAAQ,CAMpF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1E,SAAS,EAAE,CAAC,GAAG,SAAS,GACtB,CAAC,GAAG,SAAS,CAQf;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;oCACb,MAAM,GAAG,SAAS;sCAChB,MAAM;mDAEO,MAAM;wDAED,MAAM,EAAE;;mCAI7B,MAAM;0CAEC,MAAM,aAAa,MAAM,YAAY,MAAM;CAG/D,CAAC;AAEX;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAOvD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CAAC,CAAC,EAC1C,IAAI,EAAE,kBAAkB,EACxB,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,IAAI,EAAE,CAAC,GAAG,IAAI,EACd,GAAG,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAC9B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBnB"}
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { createDefu } from 'defu';
|
|
4
|
+
import { ApiError } from '@aws-blocks/core';
|
|
4
5
|
/**
|
|
5
6
|
* @internal Right-biased deep merge for the `'coerce'` read path: overlay the
|
|
6
7
|
* schema-coerced item onto the raw stored item so schema output (filled defaults,
|
|
@@ -92,6 +93,30 @@ export function blocksError(name, message) {
|
|
|
92
93
|
err.name = name;
|
|
93
94
|
return err;
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* @internal Build the 409 ApiError for a conditional-write conflict. Maps to
|
|
98
|
+
* HTTP 409 (Conflict) so the JSON-RPC serializer emits code 409 instead of a
|
|
99
|
+
* generic 500, preserves the `ConditionalCheckFailed` name so `isBlocksError()`
|
|
100
|
+
* keeps matching on both server and client. Shared by the mock and AWS runtime
|
|
101
|
+
* so both produce an identically shaped 409; on AWS the caught DynamoDB
|
|
102
|
+
* `ConditionalCheckFailedException` is passed as `cause` (kept server-side by
|
|
103
|
+
* ApiError).
|
|
104
|
+
*
|
|
105
|
+
* `retriable` is scoped to the assertion kind: `true` only for optimistic-lock
|
|
106
|
+
* conflicts (`ifFieldEquals`), where a re-read and retry can succeed; `false`
|
|
107
|
+
* for existence/uniqueness assertions (`ifNotExists` on put, `ifExists` on
|
|
108
|
+
* delete), where a blind identical retry fails identically. DynamoDB collapses
|
|
109
|
+
* every conditional failure under one `ConditionalCheckFailedException` with no
|
|
110
|
+
* sub-reason, so the AWS runtime decides this from the condition the caller set
|
|
111
|
+
* on that specific put/delete call.
|
|
112
|
+
*/
|
|
113
|
+
export function conditionalCheckFailed(retriable, cause) {
|
|
114
|
+
return new ApiError('The conditional request failed', 409, {
|
|
115
|
+
name: DistributedTableErrors.ConditionalCheckFailed,
|
|
116
|
+
cause,
|
|
117
|
+
retriable,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
95
120
|
/**
|
|
96
121
|
* @internal Normalize a sort-key condition before it drives a query. Shared by
|
|
97
122
|
* the mock and AWS runtime so both treat the same inputs identically:
|
package/dist/index.aws.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,KAAK,EAA6C,MAAM,kBAAkB,CAAC;AACpF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACX,cAAc,EACd,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,aAAa,GACb,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACX,cAAc,EACd,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EAGb,QAAQ,EAER,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAgB,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAwBzD,qBAAa,gBAAgB,CAC5B,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,CACpF,SAAQ,KAAK;IAWqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAVlG,QAAQ,CAAC,MAAM,sBAAW;IAC1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,SAAS,CAAyB;IAE1C,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAkB5F,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAQ3C,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.aws.d.ts","sourceRoot":"","sources":["../src/index.aws.ts"],"names":[],"mappings":"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"}
|
package/dist/index.aws.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCom
|
|
|
5
5
|
import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
|
|
6
6
|
import { BB_NAME, BB_VERSION } from './version.js';
|
|
7
7
|
export { DistributedTableErrors } from './errors.js';
|
|
8
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
8
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
9
9
|
import { Logger } from '@aws-blocks/bb-logger';
|
|
10
10
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
11
|
// DynamoDB batch API limits and retry tuning.
|
|
@@ -75,6 +75,21 @@ export class DistributedTable extends Scope {
|
|
|
75
75
|
await this.docClient.send(new PutCommand(command));
|
|
76
76
|
}
|
|
77
77
|
catch (err) {
|
|
78
|
+
// A failed conditional write is a Conflict, not an
|
|
79
|
+
// InternalServerError: map DynamoDB's raw
|
|
80
|
+
// ConditionalCheckFailedException to an ApiError with status 409 so the
|
|
81
|
+
// JSON-RPC serializer emits code 409 instead of 500. Name is preserved
|
|
82
|
+
// for isBlocksError(), the driver error is kept as `cause`. DynamoDB
|
|
83
|
+
// collapses every conditional failure under one exception with no
|
|
84
|
+
// sub-reason, so retriability is derived from the conditions THIS call
|
|
85
|
+
// set, matching the mock: existence assertion wins — retriable only for
|
|
86
|
+
// a pure `ifFieldEquals` optimistic-lock check (value presence, so an
|
|
87
|
+
// explicit `undefined` is treated as absent) and NOT when `ifNotExists`
|
|
88
|
+
// is also set. Other errors (e.g. oversized items) still flow through
|
|
89
|
+
// remapItemTooLarge.
|
|
90
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
91
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifNotExists, err);
|
|
92
|
+
}
|
|
78
93
|
throw remapItemTooLarge(err);
|
|
79
94
|
}
|
|
80
95
|
}
|
|
@@ -87,7 +102,22 @@ export class DistributedTable extends Scope {
|
|
|
87
102
|
else if (options?.ifFieldEquals) {
|
|
88
103
|
this.applyFieldEqualsCondition(command, options.ifFieldEquals);
|
|
89
104
|
}
|
|
90
|
-
|
|
105
|
+
try {
|
|
106
|
+
await this.docClient.send(new DeleteCommand(command));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// A failed conditional delete is a Conflict, not an
|
|
110
|
+
// InternalServerError: map DynamoDB's raw
|
|
111
|
+
// ConditionalCheckFailedException to an ApiError with status 409 (see
|
|
112
|
+
// the put path above). Retriability is derived from the conditions THIS
|
|
113
|
+
// call set, matching the mock: existence assertion wins — retriable only
|
|
114
|
+
// for a pure `ifFieldEquals` optimistic-lock check (value presence) and
|
|
115
|
+
// NOT when `ifExists` is also set.
|
|
116
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
117
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifExists, err);
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
91
121
|
}
|
|
92
122
|
/**
|
|
93
123
|
* Query items by index, yielding matches as an async stream with automatic
|
package/dist/index.cdk.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BuildingBlockScope } from '@aws-blocks/core/cdk';
|
|
2
2
|
import type { ScopeParent } from '@aws-blocks/core';
|
|
3
3
|
import type { ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
4
4
|
export { DistributedTableErrors } from './errors.js';
|
|
5
5
|
export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef, ExternalKmsKeyRef } from './types.js';
|
|
6
|
-
export declare class DistributedTable<T = any> extends
|
|
6
|
+
export declare class DistributedTable<T = any> extends BuildingBlockScope {
|
|
7
7
|
options: any;
|
|
8
8
|
private table;
|
|
9
9
|
/**
|
package/dist/index.cdk.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,kBAAkB,EAAoC,MAAM,sBAAsB,CAAC;AAC5F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItE,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEnM,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,kBAAkB;IA2Bb,OAAO,EAAE,GAAG;IA1B/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD;;;;;;;;OAQG;IACH,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;gBAIxC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAqO/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
|
package/dist/index.cdk.js
CHANGED
|
@@ -1,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 {
|
|
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
|
|
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({
|
package/dist/index.cdk.test.js
CHANGED
|
@@ -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', {
|
package/dist/index.mock.d.ts
CHANGED
|
@@ -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;
|
package/dist/index.mock.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAA0B,MAAM,kBAAkB,CAAC;AAEjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAMpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EACX,cAAc,EACd,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,EACV,aAAa,GACb,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACX,cAAc,EACd,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,EAEjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,QAAQ,EAER,MAAM,YAAY,CAAC;AA4DpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,qBAAa,gBAAgB,CAC5B,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,CACpF,SAAQ,KAAK;IAWqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAVlG,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,IAAI,CAAiB;IAC7B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,cAAc,CAAqB;IAE3C,0FAA0F;IAC1F,SAAS,CAAC,GAAG,EAAE,WAAW,CAAC;gBAEf,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAe5F,GAAG,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;IAIjD;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAIf,GAAG,CAAC,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
|
package/dist/index.mock.js
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { BB_NAME, BB_VERSION } from './version.js';
|
|
8
8
|
export { DistributedTableErrors } from './errors.js';
|
|
9
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
9
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
|
|
10
10
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
11
11
|
const MAX_ITEM_BYTES = 400 * 1024;
|
|
12
12
|
async function validateSchema(schema, value) {
|
|
@@ -131,22 +131,31 @@ export class DistributedTable extends Scope {
|
|
|
131
131
|
throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
|
|
132
132
|
}
|
|
133
133
|
const keyStr = this.serializeKey(item);
|
|
134
|
+
// Existence assertion wins: `ifNotExists` makes a conflict non-retriable
|
|
135
|
+
// even if combined with an `ifFieldEquals` value check (value presence, so
|
|
136
|
+
// an explicit `undefined` is treated as absent). Only a pure `ifFieldEquals`
|
|
137
|
+
// optimistic-lock check is retriable. Shared derivation so mock and aws
|
|
138
|
+
// agree for identical inputs, incl. combined conditions.
|
|
139
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifNotExists;
|
|
134
140
|
if (options?.ifNotExists && this.data.has(keyStr)) {
|
|
135
|
-
throw
|
|
141
|
+
throw conditionalCheckFailed(retriable);
|
|
136
142
|
}
|
|
137
143
|
if (options?.ifFieldEquals) {
|
|
138
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
144
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
139
145
|
}
|
|
140
146
|
this.data.set(keyStr, item);
|
|
141
147
|
this.flushToDisk();
|
|
142
148
|
}
|
|
143
149
|
async delete(key, options) {
|
|
144
150
|
const keyStr = this.serializeKey(key);
|
|
151
|
+
// Existence assertion wins (see put): `ifExists` makes a conflict
|
|
152
|
+
// non-retriable even if combined with an `ifFieldEquals` value check.
|
|
153
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifExists;
|
|
145
154
|
if (options?.ifExists && !this.data.has(keyStr)) {
|
|
146
|
-
throw
|
|
155
|
+
throw conditionalCheckFailed(retriable);
|
|
147
156
|
}
|
|
148
157
|
if (options?.ifFieldEquals) {
|
|
149
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
158
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
150
159
|
}
|
|
151
160
|
this.data.delete(keyStr);
|
|
152
161
|
this.flushToDisk();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/index.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { test, describe } from 'node:test';
|
|
4
4
|
import { strict as assert } from 'node:assert';
|
|
5
5
|
import { DistributedTable, DistributedTableErrors } from './index.mock.js';
|
|
6
|
-
import { Scope } from '@aws-blocks/core';
|
|
6
|
+
import { ApiError, isBlocksError, Scope } from '@aws-blocks/core';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
// ── Schemas ─────────────────────────────────────────────────────────────────
|
|
9
9
|
const userSchema = z.object({
|
|
@@ -117,6 +117,89 @@ describe('DistributedTable', () => {
|
|
|
117
117
|
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => err.name === DistributedTableErrors.ConditionalCheckFailed);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
// ── OCC conflicts map to HTTP 409 (Conflict) ─────────────────────────────
|
|
121
|
+
// A conditional-write conflict must serialize to JSON-RPC code 409, not 500.
|
|
122
|
+
// The mock must throw an ApiError (status 409) that preserves the
|
|
123
|
+
// ConditionalCheckFailed name, matching the aws-runtime path. `retriable` is
|
|
124
|
+
// scoped to the assertion kind: true only for optimistic-lock ifFieldEquals
|
|
125
|
+
// conflicts (a re-read and retry can succeed), false for existence/uniqueness
|
|
126
|
+
// assertions (a blind retry fails identically).
|
|
127
|
+
describe('OCC conflicts map to 409', () => {
|
|
128
|
+
test('put ifNotExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
129
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
130
|
+
schema: userSchema,
|
|
131
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
132
|
+
});
|
|
133
|
+
const user = { userId: 'user1', email: 'test@example.com', name: 'Test', createdAt: 1000 };
|
|
134
|
+
await table.put(user);
|
|
135
|
+
await assert.rejects(() => table.put(user, { ifNotExists: true }), (err) => {
|
|
136
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
137
|
+
assert.equal(err.status, 409);
|
|
138
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
139
|
+
assert.equal(err.retriable, false);
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
test('put ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
144
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
145
|
+
schema: userSchema,
|
|
146
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
147
|
+
});
|
|
148
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
149
|
+
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => {
|
|
150
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
151
|
+
assert.equal(err.status, 409);
|
|
152
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
153
|
+
assert.equal(err.retriable, true);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
test('put ifNotExists + ifFieldEquals conflict is 409, not retriable (existence wins)', async () => {
|
|
158
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
159
|
+
schema: userSchema,
|
|
160
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
161
|
+
});
|
|
162
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
163
|
+
// The typed API forbids combining ifNotExists with ifFieldEquals; force
|
|
164
|
+
// the combined shape to verify the runtime derivation is existence-wins
|
|
165
|
+
// (not retriable) for parity with the aws path and bb-kv-store.
|
|
166
|
+
const combined = { ifNotExists: true, ifFieldEquals: { name: 'Wrong' } };
|
|
167
|
+
await assert.rejects(() => table.put({ userId: 'u1', email: 'a@b.com', name: 'Fail', createdAt: 1000 }, combined), (err) => {
|
|
168
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
169
|
+
assert.equal(err.status, 409);
|
|
170
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
171
|
+
assert.equal(err.retriable, false);
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
test('delete ifExists conflict is an ApiError with status 409, not retriable', async () => {
|
|
176
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
177
|
+
schema: userSchema,
|
|
178
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
179
|
+
});
|
|
180
|
+
await assert.rejects(() => table.delete({ userId: 'missing', createdAt: 1 }, { ifExists: true }), (err) => {
|
|
181
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
182
|
+
assert.equal(err.status, 409);
|
|
183
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
184
|
+
assert.equal(err.retriable, false);
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
test('delete ifFieldEquals conflict is an ApiError with status 409, retriable', async () => {
|
|
189
|
+
const table = new DistributedTable(testScope(), 'users', {
|
|
190
|
+
schema: userSchema,
|
|
191
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
192
|
+
});
|
|
193
|
+
await table.put({ userId: 'u1', email: 'a@b.com', name: 'Test', createdAt: 1000 });
|
|
194
|
+
await assert.rejects(() => table.delete({ userId: 'u1', createdAt: 1000 }, { ifFieldEquals: { name: 'Wrong' } }), (err) => {
|
|
195
|
+
assert.ok(err instanceof ApiError, `expected an ApiError, got ${err}`);
|
|
196
|
+
assert.equal(err.status, 409);
|
|
197
|
+
assert.ok(isBlocksError(err, DistributedTableErrors.ConditionalCheckFailed));
|
|
198
|
+
assert.equal(err.retriable, true);
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
120
203
|
// ── Conditional delete ──────────────────────────────────────────────────
|
|
121
204
|
describe('conditional delete', () => {
|
|
122
205
|
test('ifExists succeeds when item exists', async () => {
|
|
@@ -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
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-distributed-table",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"aws-blocks",
|
|
6
|
+
"dynamodb",
|
|
7
|
+
"nosql",
|
|
8
|
+
"database"
|
|
9
|
+
],
|
|
4
10
|
"repository": {
|
|
5
11
|
"type": "git",
|
|
6
12
|
"url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
|
|
@@ -39,8 +45,8 @@
|
|
|
39
45
|
"test": "node --test --test-concurrency=1 dist/index.test.js dist/parity.test.js dist/index.cdk.test.js"
|
|
40
46
|
},
|
|
41
47
|
"dependencies": {
|
|
42
|
-
"@aws-blocks/core": "^0.
|
|
43
|
-
"@aws-blocks/bb-logger": "^0.
|
|
48
|
+
"@aws-blocks/core": "^0.5.0",
|
|
49
|
+
"@aws-blocks/bb-logger": "^0.2.0",
|
|
44
50
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
45
51
|
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
46
52
|
"@standard-schema/spec": "^1.0.0",
|
package/src/errors.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createDefu } from 'defu';
|
|
5
5
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
6
|
+
import { ApiError } from '@aws-blocks/core';
|
|
6
7
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
7
8
|
import type { ReadValidationMode } from './types.js';
|
|
8
9
|
|
|
@@ -100,6 +101,31 @@ export function blocksError(name: string, message: string): Error {
|
|
|
100
101
|
return err;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* @internal Build the 409 ApiError for a conditional-write conflict. Maps to
|
|
106
|
+
* HTTP 409 (Conflict) so the JSON-RPC serializer emits code 409 instead of a
|
|
107
|
+
* generic 500, preserves the `ConditionalCheckFailed` name so `isBlocksError()`
|
|
108
|
+
* keeps matching on both server and client. Shared by the mock and AWS runtime
|
|
109
|
+
* so both produce an identically shaped 409; on AWS the caught DynamoDB
|
|
110
|
+
* `ConditionalCheckFailedException` is passed as `cause` (kept server-side by
|
|
111
|
+
* ApiError).
|
|
112
|
+
*
|
|
113
|
+
* `retriable` is scoped to the assertion kind: `true` only for optimistic-lock
|
|
114
|
+
* conflicts (`ifFieldEquals`), where a re-read and retry can succeed; `false`
|
|
115
|
+
* for existence/uniqueness assertions (`ifNotExists` on put, `ifExists` on
|
|
116
|
+
* delete), where a blind identical retry fails identically. DynamoDB collapses
|
|
117
|
+
* every conditional failure under one `ConditionalCheckFailedException` with no
|
|
118
|
+
* sub-reason, so the AWS runtime decides this from the condition the caller set
|
|
119
|
+
* on that specific put/delete call.
|
|
120
|
+
*/
|
|
121
|
+
export function conditionalCheckFailed(retriable: boolean, cause?: unknown): ApiError {
|
|
122
|
+
return new ApiError('The conditional request failed', 409, {
|
|
123
|
+
name: DistributedTableErrors.ConditionalCheckFailed,
|
|
124
|
+
cause,
|
|
125
|
+
retriable,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
103
129
|
/**
|
|
104
130
|
* @internal Normalize a sort-key condition before it drives a query. Shared by
|
|
105
131
|
* the mock and AWS runtime so both treat the same inputs identically:
|
package/src/index.aws.ts
CHANGED
|
@@ -47,7 +47,7 @@ import type {
|
|
|
47
47
|
TableKey,
|
|
48
48
|
ReadValidationMode,
|
|
49
49
|
} from './types.js';
|
|
50
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
50
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, conditionalCheckFailed, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
51
51
|
import type { KeyCondition, QueryOptions } from './types.js';
|
|
52
52
|
import { Logger } from '@aws-blocks/bb-logger';
|
|
53
53
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
@@ -129,6 +129,21 @@ export class DistributedTable<
|
|
|
129
129
|
try {
|
|
130
130
|
await this.docClient.send(new PutCommand(command));
|
|
131
131
|
} catch (err: unknown) {
|
|
132
|
+
// A failed conditional write is a Conflict, not an
|
|
133
|
+
// InternalServerError: map DynamoDB's raw
|
|
134
|
+
// ConditionalCheckFailedException to an ApiError with status 409 so the
|
|
135
|
+
// JSON-RPC serializer emits code 409 instead of 500. Name is preserved
|
|
136
|
+
// for isBlocksError(), the driver error is kept as `cause`. DynamoDB
|
|
137
|
+
// collapses every conditional failure under one exception with no
|
|
138
|
+
// sub-reason, so retriability is derived from the conditions THIS call
|
|
139
|
+
// set, matching the mock: existence assertion wins — retriable only for
|
|
140
|
+
// a pure `ifFieldEquals` optimistic-lock check (value presence, so an
|
|
141
|
+
// explicit `undefined` is treated as absent) and NOT when `ifNotExists`
|
|
142
|
+
// is also set. Other errors (e.g. oversized items) still flow through
|
|
143
|
+
// remapItemTooLarge.
|
|
144
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
145
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifNotExists, err);
|
|
146
|
+
}
|
|
132
147
|
throw remapItemTooLarge(err);
|
|
133
148
|
}
|
|
134
149
|
}
|
|
@@ -143,7 +158,21 @@ export class DistributedTable<
|
|
|
143
158
|
this.applyFieldEqualsCondition(command, options.ifFieldEquals);
|
|
144
159
|
}
|
|
145
160
|
|
|
146
|
-
|
|
161
|
+
try {
|
|
162
|
+
await this.docClient.send(new DeleteCommand(command));
|
|
163
|
+
} catch (err: unknown) {
|
|
164
|
+
// A failed conditional delete is a Conflict, not an
|
|
165
|
+
// InternalServerError: map DynamoDB's raw
|
|
166
|
+
// ConditionalCheckFailedException to an ApiError with status 409 (see
|
|
167
|
+
// the put path above). Retriability is derived from the conditions THIS
|
|
168
|
+
// call set, matching the mock: existence assertion wins — retriable only
|
|
169
|
+
// for a pure `ifFieldEquals` optimistic-lock check (value presence) and
|
|
170
|
+
// NOT when `ifExists` is also set.
|
|
171
|
+
if (err instanceof Error && err.name === DistributedTableErrors.ConditionalCheckFailed) {
|
|
172
|
+
throw conditionalCheckFailed(options?.ifFieldEquals !== undefined && !options?.ifExists, err);
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
147
176
|
}
|
|
148
177
|
|
|
149
178
|
/**
|
package/src/index.cdk.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
183
|
+
throw conditionalCheckFailed(retriable);
|
|
178
184
|
}
|
|
179
185
|
if (options?.ifFieldEquals) {
|
|
180
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
186
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
181
187
|
}
|
|
182
|
-
|
|
183
188
|
this.data.set(keyStr, item);
|
|
184
189
|
this.flushToDisk();
|
|
185
190
|
}
|
|
@@ -187,11 +192,14 @@ export class DistributedTable<
|
|
|
187
192
|
async delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void> {
|
|
188
193
|
const keyStr = this.serializeKey(key);
|
|
189
194
|
|
|
195
|
+
// Existence assertion wins (see put): `ifExists` makes a conflict
|
|
196
|
+
// non-retriable even if combined with an `ifFieldEquals` value check.
|
|
197
|
+
const retriable = options?.ifFieldEquals !== undefined && !options?.ifExists;
|
|
190
198
|
if (options?.ifExists && !this.data.has(keyStr)) {
|
|
191
|
-
throw
|
|
199
|
+
throw conditionalCheckFailed(retriable);
|
|
192
200
|
}
|
|
193
201
|
if (options?.ifFieldEquals) {
|
|
194
|
-
this.checkFieldEquals(keyStr, options.ifFieldEquals);
|
|
202
|
+
this.checkFieldEquals(keyStr, options.ifFieldEquals, retriable);
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
this.data.delete(keyStr);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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