@aws-blocks/bb-distributed-table 0.1.2 → 0.1.3
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 +196 -0
- package/README.md +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/version.ts +1 -1
package/DESIGN.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# DistributedTable — Design
|
|
2
|
+
|
|
3
|
+
Design document for DistributedTable. For usage, see [README.md](./README.md).
|
|
4
|
+
|
|
5
|
+
**Package:** `@aws-blocks/bb-distributed-table`
|
|
6
|
+
**Type:** Primitive (new infrastructure)
|
|
7
|
+
**AWS Service:** DynamoDB (partition key + optional sort key + GSIs)
|
|
8
|
+
|
|
9
|
+
## Design Decisions
|
|
10
|
+
|
|
11
|
+
### D-DT-1: Key object over positional arguments
|
|
12
|
+
|
|
13
|
+
**Decision:** `get()`, `delete()`, `getBatch()`, and `deleteBatch()` accept a key object (`{ userId: 'alice', orderId: '001' }`) rather than positional arguments (`'alice', '001'`).
|
|
14
|
+
|
|
15
|
+
**Rationale:**
|
|
16
|
+
|
|
17
|
+
Every major DynamoDB library uses a key object pattern:
|
|
18
|
+
- **AWS SDK v3 DocumentClient:** `Key: { userId: 'alice', orderId: '001' }`
|
|
19
|
+
- **ElectroDB:** `.get({ cityId: 'Atlanta1', mallId: 'EastPointe' })`
|
|
20
|
+
- **DynamoDB-Toolbox v1:** `.key({ pokemonId: 'pikachu1' })`
|
|
21
|
+
|
|
22
|
+
DynamoDB keys are compound by nature — a partition key and optional sort key that together identify an item. An object communicates the field names at the call site, making the code self-documenting. Positional args like `get('alice', '001')` don't tell you which value is which.
|
|
23
|
+
|
|
24
|
+
The key type is a computed `TableKey<T, K>` — a `Required<Pick<T, KeyFields>>`, not `Partial<T>`. `Partial<T>` would be too loose: it makes non-key fields optional but present, and allows omitting required key fields entirely (`table.get({})` would compile). The computed type picks exactly the key fields and makes them all required. When a sort key is defined, it is required in the key object — you cannot accidentally omit it. The class carries a third generic `K extends TableKeyConfig<T>` so the literal key config is preserved and `TableKey` resolves correctly.
|
|
25
|
+
|
|
26
|
+
This also follows the API's options-object convention (objects over positional parameters) since the key is inherently a multi-field value.
|
|
27
|
+
|
|
28
|
+
### D-DT-2: Schema is required
|
|
29
|
+
|
|
30
|
+
**Decision:** `DistributedTableOptions.schema` is required, not optional.
|
|
31
|
+
|
|
32
|
+
**Rationale:** DistributedTable's key configuration references field names from the schema (`partitionKey: 'userId'`). The schema is what makes the key type-safe — without it, there's no way to validate that key field names exist in the item type. Unlike KVStore (which stores opaque values by string key), DistributedTable operates on structured items where the schema is integral to the type system.
|
|
33
|
+
|
|
34
|
+
### D-DT-3: StandardSchemaV1 over Zod-specific types
|
|
35
|
+
|
|
36
|
+
**Decision:** Accept `StandardSchemaV1` from `@standard-schema/spec` instead of Zod-specific structural types.
|
|
37
|
+
|
|
38
|
+
**Rationale:** Building Blocks accept any StandardSchemaV1 implementation. This avoids vendor lock-in to Zod and lets customers use Valibot, ArkType, or any other conforming library. The `@standard-schema/spec` package is types-only (zero runtime). Validation uses `schema['~standard'].validate()`.
|
|
39
|
+
|
|
40
|
+
### D-DT-4: Conditional operations — ifNotExists, ifExists, ifFieldEquals
|
|
41
|
+
|
|
42
|
+
**Decision:** Support `ifNotExists` on put, `ifExists` on delete, and `ifFieldEquals` on both put and delete.
|
|
43
|
+
|
|
44
|
+
**Rationale:** DistributedTable backs structured application data that often requires coordination — idempotent creates, optimistic locking, and guarded deletes. These map directly to DynamoDB's `ConditionExpression` capabilities:
|
|
45
|
+
|
|
46
|
+
- `ifNotExists` → `attribute_not_exists(pk)` — protects create-only operations from overwriting existing items.
|
|
47
|
+
- `ifExists` → `attribute_exists(pk)` — ensures you're deleting something that's actually there (useful for audit trails, cascading deletes).
|
|
48
|
+
- `ifFieldEquals` → `#field = :value` — optimistic locking / compare-and-swap. Check that a field (e.g., `status`, `version`, `updatedAt`) matches an expected value before writing. This is the DynamoDB equivalent of `UPDATE ... WHERE version = ?` in SQL.
|
|
49
|
+
|
|
50
|
+
`ifFieldEquals` accepts `Partial<T>`, so multiple fields can be checked in a single condition (AND semantics). All condition failures throw with `error.name = 'ConditionalCheckFailedException'`, matching the AWS SDK error name.
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
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
|
+
|
|
56
|
+
### D-DT-5: `scan()` not `list()`
|
|
57
|
+
|
|
58
|
+
**Decision:** The full-table enumeration method is named `scan()`, not `list()`.
|
|
59
|
+
|
|
60
|
+
**Rationale:** `list` is an avoided verb because it understates the cost of a full table enumeration. `scan` communicates that every item is read and scales with total data size. The name is borrowed directly from DynamoDB to reinforce the cost implication.
|
|
61
|
+
|
|
62
|
+
### D-DT-6: Single options object for query
|
|
63
|
+
|
|
64
|
+
**Decision:** `query(options)` takes a single options object with `index`, `where`, `limit`, and `order` fields. Omitting `index` queries the primary key.
|
|
65
|
+
|
|
66
|
+
**Rationale:** A single options object is consistent with the rest of the API. The `index` field determines which key config applies to `where` — when present, `where` is typed against the GSI's key config; when absent, it's typed against the table's primary key. This is implemented as a discriminated union on `index`:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// GSI query — where is typed against byDate's key config
|
|
70
|
+
orders.query({
|
|
71
|
+
index: 'byDate',
|
|
72
|
+
where: { userId: { equals: 'alice' }, createdAt: { greaterThan: 1000 } },
|
|
73
|
+
order: 'desc',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Primary key query — where is typed against the table's primary key
|
|
77
|
+
orders.query({
|
|
78
|
+
where: { userId: { equals: 'alice' }, orderId: { beginsWith: '2024-' } },
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `order` field maps to DynamoDB's `ScanIndexForward` parameter (`'desc'` → `ScanIndexForward: false`). It defaults to `'asc'`.
|
|
83
|
+
|
|
84
|
+
### D-DT-7: TTL via options field
|
|
85
|
+
|
|
86
|
+
**Decision:** TTL is configured via `ttl: 'fieldName'` in the constructor options, not as a separate method or decorator.
|
|
87
|
+
|
|
88
|
+
**Rationale:** DynamoDB TTL is a table-level setting that designates one attribute as the expiration timestamp. It's a static configuration concern, not a per-item operation, so it belongs in the constructor options alongside `key` and `indexes`. The field must exist in the schema and should contain a Unix epoch timestamp in seconds. DynamoDB automatically deletes expired items in the background (typically within 48 hours of expiration).
|
|
89
|
+
|
|
90
|
+
### D-DT-8: Split `InvalidQuery` and `ItemTooLarge` rather than one `Validation` bucket
|
|
91
|
+
|
|
92
|
+
**Decision:** Pre-flight input failures surface as two intent-revealing error names — `InvalidQueryException` and `ItemTooLargeException` — instead of a single generic `ValidationException` that mirrors DynamoDB's wire name.
|
|
93
|
+
|
|
94
|
+
**Rationale:** These are two genuinely different failure modes that an earlier revision collapsed under one catch:
|
|
95
|
+
|
|
96
|
+
- **`InvalidQuery`** — the request *shape* is wrong: a missing `where` clause, a partition key not given as `{ equals: value }`, an unknown index, more than one sort-key condition, or an empty `ifFieldEquals`. Every one of these is a **caller bug** — something the caller fixes by correcting the call. There's no value in branching on which specific shape error occurred at runtime, so they share one name.
|
|
97
|
+
- **`ItemTooLarge`** — an item exceeds the 400 KB per-item limit. This is **not necessarily a caller bug**: the size of a given record may be outside the caller's control (user-supplied content, accumulated history). A caller may legitimately want to branch on it — skip the item, split it, or store a reference instead — which it cannot do if the only signal is a generic name plus message text.
|
|
98
|
+
|
|
99
|
+
A generic `ValidationException` is exactly the kind of catch-all bucket worth avoiding, and `BatchIncomplete` already establishes the Blocks-specific-name pattern in this same package. Splitting into intent-revealing names lets a customer write `isBlocksError(e, DistributedTableErrors.ItemTooLarge)` and reliably tell "this item is too big" from "my query is malformed" without string-matching the message.
|
|
100
|
+
|
|
101
|
+
**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
|
+
|
|
103
|
+
## Infrastructure (CDK)
|
|
104
|
+
|
|
105
|
+
Creates a single DynamoDB table:
|
|
106
|
+
|
|
107
|
+
- **Partition key:** Configurable name and type via `options.key.partitionKey`
|
|
108
|
+
- **Sort key:** Configurable name and type via `options.key.sortKey` (optional)
|
|
109
|
+
- **Global secondary indexes:** Managed by a custom resource (see below)
|
|
110
|
+
- **TTL:** Enabled via `TimeToLiveSpecification` when `options.ttl` is set
|
|
111
|
+
- **Billing mode:** PAY_PER_REQUEST
|
|
112
|
+
- **Table name:** Derived from `scope.fullId` (includes stack name for uniqueness)
|
|
113
|
+
- **Removal policy:** DESTROY (sandbox), configurable for production
|
|
114
|
+
- **Permissions:** `grantReadWriteData` to the parent scope's handler automatically, plus explicit `dynamodb:Query` on `index/*`
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
### GSI Management Custom Resource
|
|
119
|
+
|
|
120
|
+
DynamoDB only allows one GSI change per `UpdateTable` call, and each change can take minutes to hours on large tables (DynamoDB must backfill the index). A standard CDK `Table` construct cannot express multi-GSI changes in a single deployment. DistributedTable uses a CloudFormation custom resource with the CDK `Provider` framework's async pattern to manage GSIs declaratively.
|
|
121
|
+
|
|
122
|
+
**Architecture:**
|
|
123
|
+
|
|
124
|
+
- **`onEvent` handler** — Invoked once per CloudFormation Create/Update/Delete. Compares the table's current GSIs against the desired state. If already matching, returns immediately. Otherwise, initiates the first GSI change and returns `IN_PROGRESS`. For sandbox deployments, takes a fast path (see below).
|
|
125
|
+
- **`isCompleteHandler`** — Polled by the Provider framework every 10 seconds (configurable via `queryInterval`), up to a 2-hour total timeout. On each poll:
|
|
126
|
+
1. If the table is busy (a GSI is still creating/deleting), returns `IsComplete: false`.
|
|
127
|
+
2. If the table is idle and matches the desired state, returns `IsComplete: true`.
|
|
128
|
+
3. If the table is idle but doesn't match, initiates the next GSI change and returns `IsComplete: false`.
|
|
129
|
+
|
|
130
|
+
**Ordering:** Creations are performed before deletions when possible. This ensures new access patterns are available before old ones are removed. The one exception is schema-mismatched GSIs — if a desired GSI has the same name as an existing GSI but different key schema, the old one must be deleted first because DynamoDB doesn't support in-place GSI modification.
|
|
131
|
+
|
|
132
|
+
**IAM policies are split by environment:**
|
|
133
|
+
|
|
134
|
+
| Permission | Production | Sandbox |
|
|
135
|
+
|------------|-----------|---------|
|
|
136
|
+
| `dynamodb:DescribeTable` | ✅ | ✅ |
|
|
137
|
+
| `dynamodb:UpdateTable` | ✅ | ✅ |
|
|
138
|
+
| `dynamodb:DeleteTable` | ❌ | ✅ |
|
|
139
|
+
| `dynamodb:CreateTable` | ❌ | ✅ |
|
|
140
|
+
| `dynamodb:Scan` | ❌ | ✅ |
|
|
141
|
+
| `dynamodb:BatchWriteItem` | ❌ | ✅ |
|
|
142
|
+
|
|
143
|
+
Production deployments can only add/remove GSIs via `UpdateTable`. The GSI manager lambda cannot drop or recreate the table, scan its data, or batch-write items. This prevents accidental data loss from a misconfigured custom resource.
|
|
144
|
+
|
|
145
|
+
### Sandbox Deployment Model
|
|
146
|
+
|
|
147
|
+
Sandbox deployments use a **drop-and-recreate** fast path that bypasses the sequential one-at-a-time GSI limitation:
|
|
148
|
+
|
|
149
|
+
1. Scan all items from the existing table (backup to memory)
|
|
150
|
+
2. Delete the table
|
|
151
|
+
3. Wait for deletion to complete
|
|
152
|
+
4. Create a new table with all desired GSIs defined upfront (DynamoDB allows multiple GSIs at table creation time)
|
|
153
|
+
5. Wait for the table and all GSIs to become ACTIVE
|
|
154
|
+
6. Restore all items via `BatchWriteItem`
|
|
155
|
+
|
|
156
|
+
This is dramatically faster than sequential GSI creation (seconds vs. minutes/hours) but **destroys and recreates the table**. It is only used when `SandboxMode` is `true` (set by the CDK layer based on the `sandboxMode` context variable). The IAM policy for production deployments does not grant the permissions needed for this path, so it cannot execute in production even if `SandboxMode` were accidentally set.
|
|
157
|
+
|
|
158
|
+
⚠️ **Data loss risk in sandbox:** If the Lambda times out during step 1 (scan) on a very large table, or if the restore in step 6 fails partway through, data may be lost. This is acceptable for sandbox (ephemeral dev environments) but is why the fast path is never used in production.
|
|
159
|
+
|
|
160
|
+
## Serialization
|
|
161
|
+
|
|
162
|
+
Items are stored as DynamoDB JSON (marshalled via `@aws-sdk/lib-dynamodb` DocumentClient). The type parameter `T` is inferred from the StandardSchemaV1 schema at compile time. Runtime validation occurs on `put` and `putBatch` before writing. Both mock and AWS runtime use the same validation path (`schema['~standard'].validate()`).
|
|
163
|
+
|
|
164
|
+
## Mock Implementation
|
|
165
|
+
|
|
166
|
+
- Data stored in `.bb-data/{scope.fullId}/data.json` via `getMockDataDir()` from core.
|
|
167
|
+
- Data persists across dev server restarts. Customers can wipe with `rm -rf .bb-data`.
|
|
168
|
+
- Index queries implemented via in-memory filtering over the full dataset.
|
|
169
|
+
- Conditional write/delete failures throw with `error.name = 'ConditionalCheckFailedException'`.
|
|
170
|
+
- Schema validation on `put()` and `putBatch()`; throws with `error.name = 'ValidationFailedException'`.
|
|
171
|
+
- Validates 400 KB serialized item size limit.
|
|
172
|
+
- TTL is accepted in options but not enforced — items are not auto-deleted locally.
|
|
173
|
+
- `getBatch`/`putBatch`/`deleteBatch` always process every entry in one pass — the
|
|
174
|
+
in-memory store never returns `UnprocessedKeys`/`UnprocessedItems`, so the AWS
|
|
175
|
+
runtime's retry loop and `BatchIncomplete` exhaustion error have no mock equivalent
|
|
176
|
+
(see parity gaps below).
|
|
177
|
+
- `ifFieldEquals` compares values with an order-independent structural deep-equal.
|
|
178
|
+
Object/Map keys are compared as a set (DynamoDB Maps are an unordered collection
|
|
179
|
+
of name-value pairs), while arrays remain order-sensitive (DynamoDB Lists are
|
|
180
|
+
ordered). The unordered-Map equality of `=` in a DynamoDB condition expression
|
|
181
|
+
was confirmed against real DynamoDB: storing `{ role: 'admin', level: 5 }` and
|
|
182
|
+
issuing a conditional `put` with `ifFieldEquals: { level: 5, role: 'admin' }`
|
|
183
|
+
(keys reversed) passes the condition, so the mock's order-independent compare
|
|
184
|
+
matches AWS.
|
|
185
|
+
|
|
186
|
+
### Mock vs AWS Behavior Differences
|
|
187
|
+
|
|
188
|
+
| Behavior Difference | Impact | Mitigation |
|
|
189
|
+
|------------|--------|------------|
|
|
190
|
+
| No throughput limits | Code that would be throttled in AWS succeeds locally | Document the gap; recommend sandbox testing for throughput-sensitive flows |
|
|
191
|
+
| Batch retry exhaustion (`BatchIncomplete`) is AWS-runtime only | Under sustained throttling, AWS batch ops retry with backoff and throw `DistributedTableErrors.BatchIncomplete` once `MAX_BATCH_ATTEMPTS` is reached; the mock never throttles so this path is unreachable locally | Error name and message are single-sourced in `errors.ts` so catch-site handling (`isBlocksError(e, DistributedTableErrors.BatchIncomplete)`) is identical regardless of backend. Exercise throttling/backoff behavior in sandbox |
|
|
192
|
+
| No item size limit enforcement beyond 400 KB check | Edge cases around DynamoDB marshalling overhead | Mock validates serialized JSON size, which is a close approximation |
|
|
193
|
+
| Immediate consistency (vs eventual for GSIs) | GSI reads always reflect the latest write locally | No mitigation — eventual consistency is inherently non-deterministic. Document the gap; recommend sandbox testing |
|
|
194
|
+
| No IAM enforcement | Permission errors only surface in AWS | No mitigation at mock level — IAM is handled by CDK grants automatically |
|
|
195
|
+
| In-memory index queries vs DynamoDB index reads | Index query performance characteristics differ; no GSI throughput throttling | No mitigation — correctness is preserved. Performance testing requires sandbox |
|
|
196
|
+
| TTL not enforced locally | Items with expired TTL remain in mock data | Document the gap; test TTL behavior in sandbox |
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Structured data storage backed by DynamoDB with secondary indexes and rich query
|
|
|
6
6
|
|
|
7
7
|
**When NOT to use:** If you only need single-key lookups, use `KVStore`. If you need full SQL (joins, aggregations), use `Database`.
|
|
8
8
|
|
|
9
|
+
> Design & mock parity details: [DESIGN.md](./DESIGN.md)
|
|
10
|
+
|
|
9
11
|
## API
|
|
10
12
|
|
|
11
13
|
```typescript
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-distributed-table",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"author": "Amazon Web Services",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@aws-blocks/core": "^0.1.2",
|
|
34
|
-
"@aws-blocks/bb-logger": "^0.1.
|
|
34
|
+
"@aws-blocks/bb-logger": "^0.1.2",
|
|
35
35
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
36
36
|
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
37
37
|
"@standard-schema/spec": "^1.0.0"
|
package/src/version.ts
CHANGED