@aws-blocks/bb-distributed-table 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +292 -0
  3. package/dist/errors.d.ts +111 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +135 -0
  6. package/dist/gsi-manager-lambda/index.js +221 -0
  7. package/dist/gsi-manager-lambda.d.ts +26 -0
  8. package/dist/gsi-manager-lambda.d.ts.map +1 -0
  9. package/dist/gsi-manager-lambda.js +244 -0
  10. package/dist/index.aws.d.ts +59 -0
  11. package/dist/index.aws.d.ts.map +1 -0
  12. package/dist/index.aws.js +310 -0
  13. package/dist/index.browser.d.ts +5 -0
  14. package/dist/index.browser.d.ts.map +1 -0
  15. package/dist/index.browser.js +7 -0
  16. package/dist/index.cdk.d.ts +27 -0
  17. package/dist/index.cdk.d.ts.map +1 -0
  18. package/dist/index.cdk.js +180 -0
  19. package/dist/index.cdk.test.d.ts +2 -0
  20. package/dist/index.cdk.test.d.ts.map +1 -0
  21. package/dist/index.cdk.test.js +93 -0
  22. package/dist/index.mock.d.ts +101 -0
  23. package/dist/index.mock.d.ts.map +1 -0
  24. package/dist/index.mock.js +301 -0
  25. package/dist/index.test.d.ts +2 -0
  26. package/dist/index.test.d.ts.map +1 -0
  27. package/dist/index.test.js +555 -0
  28. package/dist/parity.test.d.ts +2 -0
  29. package/dist/parity.test.d.ts.map +1 -0
  30. package/dist/parity.test.js +557 -0
  31. package/dist/types.d.ts +143 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +3 -0
  34. package/dist/version.d.ts +3 -0
  35. package/dist/version.d.ts.map +1 -0
  36. package/dist/version.js +3 -0
  37. package/package.json +49 -0
  38. package/src/errors.ts +145 -0
  39. package/src/gsi-manager-lambda.ts +305 -0
  40. package/src/index.aws.ts +400 -0
  41. package/src/index.browser.ts +8 -0
  42. package/src/index.cdk.test.ts +107 -0
  43. package/src/index.cdk.ts +220 -0
  44. package/src/index.mock.ts +363 -0
  45. package/src/index.test.ts +657 -0
  46. package/src/parity.test.ts +763 -0
  47. package/src/types.ts +163 -0
  48. package/src/version.ts +3 -0
@@ -0,0 +1,400 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
5
+ import {
6
+ DynamoDBDocumentClient,
7
+ GetCommand,
8
+ PutCommand,
9
+ DeleteCommand,
10
+ QueryCommand,
11
+ ScanCommand,
12
+ BatchGetCommand,
13
+ BatchWriteCommand,
14
+ } from '@aws-sdk/lib-dynamodb';
15
+ import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
16
+ import type { ScopeParent } from '@aws-blocks/core';
17
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
18
+ import { BB_NAME, BB_VERSION } from './version.js';
19
+
20
+ export { DistributedTableErrors } from './errors.js';
21
+ export type {
22
+ TableKeyConfig,
23
+ DistributedTableOptions,
24
+ ExternalTableRef,
25
+ TableKey,
26
+ PartitionKeyCondition,
27
+ SortKeyCondition,
28
+ KeyCondition,
29
+ QueryOptions,
30
+ ScanOptions,
31
+ PutOptions,
32
+ DeleteOptions,
33
+ } from './types.js';
34
+
35
+ import type {
36
+ TableKeyConfig,
37
+ DistributedTableOptions,
38
+ ExternalTableRef,
39
+ ScanOptions,
40
+ PutOptions,
41
+ DeleteOptions,
42
+ PartitionKeyCondition,
43
+ SortKeyCondition,
44
+ TableKey,
45
+ } from './types.js';
46
+ import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge } from './errors.js';
47
+ import type { KeyCondition, QueryOptions } from './types.js';
48
+ import { Logger } from '@aws-blocks/bb-logger';
49
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
50
+
51
+ // ── Helpers ─────────────────────────────────────────────────────────────────
52
+
53
+ // DynamoDB batch API limits and retry tuning.
54
+ // BatchGetItem accepts up to 100 keys per call; BatchWriteItem up to 25 requests.
55
+ // Both can return UnprocessedKeys/UnprocessedItems on partial success (e.g. throttling
56
+ // or the 16 MB response cap), which the caller must resubmit with backoff.
57
+ // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
58
+ const BATCH_GET_MAX_KEYS = 100;
59
+ const BATCH_WRITE_MAX_REQUESTS = 25;
60
+ const MAX_BATCH_ATTEMPTS = 5;
61
+ const BASE_BACKOFF_MS = 50;
62
+ const MAX_BACKOFF_MS = 5000;
63
+
64
+ /** Split an array into chunks of at most `size` elements. */
65
+ function chunked<U>(items: U[], size: number): U[][] {
66
+ const chunks: U[][] = [];
67
+ for (let i = 0; i < items.length; i += size) {
68
+ chunks.push(items.slice(i, i + size));
69
+ }
70
+ return chunks;
71
+ }
72
+
73
+ export class DistributedTable<
74
+ T,
75
+ K extends TableKeyConfig<T> = TableKeyConfig<T>,
76
+ Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>,
77
+ > extends Scope {
78
+ readonly bbName = BB_NAME;
79
+ private schema: StandardSchemaV1<T>;
80
+ private keyConfig: K;
81
+ private indexes: Indexes;
82
+ private docClient: DynamoDBDocumentClient;
83
+
84
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
85
+ protected log: ChildLogger;
86
+
87
+ constructor(scope: ScopeParent, id: string, public options: DistributedTableOptions<T, K, Indexes>) {
88
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
89
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
90
+ const tableName = options.table?.tableName ?? this.fullId.substring(0, 255);
91
+ this.schema = options.schema;
92
+ this.keyConfig = options.key;
93
+ this.indexes = (options.indexes ?? {}) as Indexes;
94
+ const client = new DynamoDBClient({
95
+ customUserAgent: this.buildUserAgentChain(),
96
+ });
97
+ this.docClient = DynamoDBDocumentClient.from(client);
98
+ registerSdkIdentifiers(this.fullId, { tableName });
99
+ }
100
+
101
+ async get(key: TableKey<T, K>): Promise<T | null> {
102
+ const result = await this.docClient.send(new GetCommand({
103
+ TableName: getSdkIdentifiers(this).tableName,
104
+ Key: this.buildKey(key),
105
+ }));
106
+ return (result.Item as T) ?? null;
107
+ }
108
+
109
+ async put(item: T, options?: PutOptions<T>): Promise<void> {
110
+ await this.validateItem(item);
111
+ const command: any = { TableName: getSdkIdentifiers(this).tableName, Item: item };
112
+
113
+ if (options?.ifNotExists) {
114
+ command.ConditionExpression = 'attribute_not_exists(#pk)';
115
+ command.ExpressionAttributeNames = { '#pk': this.keyConfig.partitionKey };
116
+ } else if (options?.ifFieldEquals) {
117
+ this.applyFieldEqualsCondition(command, options.ifFieldEquals);
118
+ }
119
+
120
+ try {
121
+ await this.docClient.send(new PutCommand(command));
122
+ } catch (err: unknown) {
123
+ throw remapItemTooLarge(err);
124
+ }
125
+ }
126
+
127
+ async delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void> {
128
+ const command: any = { TableName: getSdkIdentifiers(this).tableName, Key: this.buildKey(key) };
129
+
130
+ if (options?.ifExists) {
131
+ command.ConditionExpression = 'attribute_exists(#pk)';
132
+ command.ExpressionAttributeNames = { '#pk': this.keyConfig.partitionKey };
133
+ } else if (options?.ifFieldEquals) {
134
+ this.applyFieldEqualsCondition(command, options.ifFieldEquals);
135
+ }
136
+
137
+ await this.docClient.send(new DeleteCommand(command));
138
+ }
139
+
140
+ /**
141
+ * Query items by index, yielding matches as an async stream with automatic
142
+ * pagination over DynamoDB's `LastEvaluatedKey`.
143
+ *
144
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
145
+ * the `where` clause is missing, the partition key is not given as
146
+ * `{ equals: value }`, or more than one sort-key condition is supplied
147
+ * (DynamoDB allows only one per query — use `between` for ranges).
148
+ */
149
+ async *query(
150
+ options: QueryOptions<T, K, Indexes>,
151
+ ): AsyncIterable<T> {
152
+ const indexConfig = options.index ? this.indexes[options.index as keyof Indexes] : this.keyConfig;
153
+ if (!indexConfig) throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.indexNotFound(options.index));
154
+
155
+ const pkField = indexConfig.partitionKey;
156
+ const skField = indexConfig.sortKey;
157
+
158
+ if (!options.where) {
159
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.whereRequired(pkField));
160
+ }
161
+ const pkValue = (options.where as any)[pkField]?.equals;
162
+ if (pkValue === undefined) {
163
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.partitionKeyEqualsRequired(pkField));
164
+ }
165
+ // Normalize the sort-key condition before building the query: a present-but-
166
+ // empty condition ({} / all-undefined) becomes "no filter" (otherwise we'd
167
+ // register #sk in ExpressionAttributeNames with no clause and DynamoDB would
168
+ // reject it), and more than one condition is rejected before issuing a call.
169
+ const skCondition = skField
170
+ ? normalizeSortKeyCondition((options.where as any)[skField] as SortKeyCondition<any> | undefined)
171
+ : undefined;
172
+
173
+ let lastEvaluatedKey: Record<string, any> | undefined;
174
+ let count = 0;
175
+
176
+ do {
177
+ const command = this.buildQueryCommand(options.index, pkField, pkValue, skField, skCondition, lastEvaluatedKey, options);
178
+ const result = await this.docClient.send(command);
179
+
180
+ for (const item of result.Items ?? []) {
181
+ yield item as T;
182
+ if (options.limit && ++count >= options.limit) return;
183
+ }
184
+
185
+ lastEvaluatedKey = result.LastEvaluatedKey;
186
+ } while (lastEvaluatedKey);
187
+ }
188
+
189
+ async *scan(options?: ScanOptions): AsyncIterable<T> {
190
+ let lastEvaluatedKey: Record<string, any> | undefined;
191
+ let count = 0;
192
+
193
+ do {
194
+ const result = await this.docClient.send(new ScanCommand({
195
+ TableName: getSdkIdentifiers(this).tableName,
196
+ ExclusiveStartKey: lastEvaluatedKey,
197
+ Limit: options?.limit,
198
+ }));
199
+
200
+ for (const item of result.Items ?? []) {
201
+ yield item as T;
202
+ if (options?.limit && ++count >= options.limit) return;
203
+ }
204
+
205
+ lastEvaluatedKey = result.LastEvaluatedKey;
206
+ } while (lastEvaluatedKey);
207
+ }
208
+
209
+ async getBatch(keys: TableKey<T, K>[]): Promise<(T | null)[]> {
210
+ const results = new Map<string, T>();
211
+ const tableName = getSdkIdentifiers(this).tableName;
212
+
213
+ for (const chunk of chunked(keys, BATCH_GET_MAX_KEYS)) {
214
+ await this.retryUnprocessed(
215
+ 'getBatch',
216
+ chunk.map(k => this.buildKey(k)),
217
+ async pendingKeys => {
218
+ const resp = await this.docClient.send(new BatchGetCommand({
219
+ RequestItems: { [tableName]: { Keys: pendingKeys } },
220
+ }));
221
+ for (const item of resp.Responses?.[tableName] ?? []) {
222
+ results.set(JSON.stringify(this.buildKey(item as any)), item as T);
223
+ }
224
+ return resp.UnprocessedKeys?.[tableName]?.Keys as Record<string, any>[] | undefined;
225
+ },
226
+ );
227
+ }
228
+ return keys.map(key => results.get(JSON.stringify(this.buildKey(key))) ?? null);
229
+ }
230
+
231
+ async putBatch(items: T[]): Promise<void> {
232
+ for (const item of items) await this.validateItem(item);
233
+ const tableName = getSdkIdentifiers(this).tableName;
234
+
235
+ for (const chunk of chunked(items, BATCH_WRITE_MAX_REQUESTS)) {
236
+ await this.retryUnprocessed(
237
+ 'putBatch',
238
+ chunk.map(item => ({ PutRequest: { Item: item as any } })),
239
+ async requests => {
240
+ try {
241
+ const resp = await this.docClient.send(new BatchWriteCommand({
242
+ RequestItems: { [tableName]: requests },
243
+ }));
244
+ return resp.UnprocessedItems?.[tableName] as any[] | undefined;
245
+ } catch (err: unknown) {
246
+ throw remapItemTooLarge(err);
247
+ }
248
+ },
249
+ );
250
+ }
251
+ }
252
+
253
+ async deleteBatch(keys: TableKey<T, K>[]): Promise<void> {
254
+ const tableName = getSdkIdentifiers(this).tableName;
255
+
256
+ for (const chunk of chunked(keys, BATCH_WRITE_MAX_REQUESTS)) {
257
+ await this.retryUnprocessed(
258
+ 'deleteBatch',
259
+ chunk.map(key => ({ DeleteRequest: { Key: this.buildKey(key) } })),
260
+ async requests => {
261
+ const resp = await this.docClient.send(new BatchWriteCommand({
262
+ RequestItems: { [tableName]: requests },
263
+ }));
264
+ return resp.UnprocessedItems?.[tableName] as any[] | undefined;
265
+ },
266
+ );
267
+ }
268
+ }
269
+
270
+ static fromExisting(tableName: string): ExternalTableRef {
271
+ return { __brand: 'ExternalTableRef' as const, tableName };
272
+ }
273
+
274
+ // ── Internal ────────────────────────────────────────────────────────────
275
+
276
+ private async validateItem(item: T): Promise<void> {
277
+ const result = this.schema['~standard'].validate(item);
278
+ const resolved = result instanceof Promise ? await result : result;
279
+ if (resolved.issues) {
280
+ throw blocksError(DistributedTableErrors.ValidationFailed, resolved.issues[0].message);
281
+ }
282
+ }
283
+
284
+ private buildKey(key: TableKey<T, K>): Record<string, any> {
285
+ const result: Record<string, any> = { [this.keyConfig.partitionKey]: (key as any)[this.keyConfig.partitionKey] };
286
+ if (this.keyConfig.sortKey) result[this.keyConfig.sortKey] = (key as any)[this.keyConfig.sortKey];
287
+ return result;
288
+ }
289
+
290
+ private backoff(attempt: number): Promise<void> {
291
+ // Exponential backoff with equal jitter: keep half the delay as a floor and
292
+ // randomise the other half. Full jitter (random * cap) can collapse to ~0ms
293
+ // and lets concurrent callers re-collide; equal jitter preserves a minimum
294
+ // spacing while still de-synchronising retries under shared throttling.
295
+ // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
296
+ const capped = Math.min(BASE_BACKOFF_MS * Math.pow(2, attempt), MAX_BACKOFF_MS);
297
+ const ms = capped / 2 + Math.random() * (capped / 2);
298
+ return new Promise(resolve => setTimeout(resolve, ms));
299
+ }
300
+
301
+ /**
302
+ * Run a DynamoDB batch operation, resubmitting any unprocessed entries with
303
+ * exponential backoff. DynamoDB batch APIs can succeed partially (HTTP 200 with
304
+ * UnprocessedKeys/UnprocessedItems) under throttling or the 16 MB response cap,
305
+ * so the leftovers must be retried by the caller.
306
+ *
307
+ * If entries remain unprocessed after MAX_BATCH_ATTEMPTS, this throws a
308
+ * BatchIncomplete error rather than returning quietly: for writes/deletes a
309
+ * silent return would drop data, and for reads it would be indistinguishable
310
+ * from a missing item. Callers should back off and resubmit.
311
+ * See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
312
+ *
313
+ * @param operation Human-readable operation name used in the exhaustion error.
314
+ * @param initial Entries to submit on the first attempt.
315
+ * @param send Performs one batch call and returns the entries DynamoDB did not process.
316
+ */
317
+ private async retryUnprocessed<E>(
318
+ operation: string,
319
+ initial: E[],
320
+ send: (pending: E[]) => Promise<E[] | undefined>,
321
+ ): Promise<void> {
322
+ let pending: E[] | undefined = initial;
323
+ for (let attempt = 0; attempt < MAX_BATCH_ATTEMPTS && pending && pending.length > 0; attempt++) {
324
+ if (attempt > 0) await this.backoff(attempt);
325
+ pending = await send(pending);
326
+ }
327
+ if (pending && pending.length > 0) {
328
+ throw blocksError(
329
+ DistributedTableErrors.BatchIncomplete,
330
+ DistributedTableMessages.batchIncomplete(operation, pending.length, MAX_BATCH_ATTEMPTS),
331
+ );
332
+ }
333
+ }
334
+
335
+ private applyFieldEqualsCondition(command: any, fields: Partial<T>): void {
336
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
337
+ if (entries.length === 0) {
338
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
339
+ }
340
+ const conditions: string[] = [];
341
+ const names: Record<string, string> = {};
342
+ const values: Record<string, any> = {};
343
+ entries.forEach(([field, value], i) => {
344
+ conditions.push(`#field${i} = :val${i}`);
345
+ names[`#field${i}`] = field;
346
+ values[`:val${i}`] = value;
347
+ });
348
+ command.ConditionExpression = conditions.join(' AND ');
349
+ command.ExpressionAttributeNames = names;
350
+ command.ExpressionAttributeValues = values;
351
+ }
352
+
353
+ private buildQueryCommand(
354
+ indexName: string | undefined,
355
+ pkField: string,
356
+ pkValue: any,
357
+ skField: string | undefined,
358
+ skCondition: SortKeyCondition<any> | undefined,
359
+ exclusiveStartKey?: Record<string, any>,
360
+ options?: { limit?: number; order?: 'asc' | 'desc' },
361
+ ): QueryCommand {
362
+ let expr = '#pk = :pkval';
363
+ const names: Record<string, string> = { '#pk': pkField };
364
+ const values: Record<string, any> = { ':pkval': pkValue };
365
+
366
+ if (skField && skCondition) {
367
+ names['#sk'] = skField;
368
+ if ('equals' in skCondition && skCondition.equals !== undefined) {
369
+ expr += ' AND #sk = :skval'; values[':skval'] = skCondition.equals;
370
+ } else if ('greaterThan' in skCondition && skCondition.greaterThan !== undefined) {
371
+ expr += ' AND #sk > :skval'; values[':skval'] = skCondition.greaterThan;
372
+ } else if ('greaterThanOrEqual' in skCondition && skCondition.greaterThanOrEqual !== undefined) {
373
+ expr += ' AND #sk >= :skval'; values[':skval'] = skCondition.greaterThanOrEqual;
374
+ } else if ('lessThan' in skCondition && skCondition.lessThan !== undefined) {
375
+ expr += ' AND #sk < :skval'; values[':skval'] = skCondition.lessThan;
376
+ } else if ('lessThanOrEqual' in skCondition && skCondition.lessThanOrEqual !== undefined) {
377
+ expr += ' AND #sk <= :skval'; values[':skval'] = skCondition.lessThanOrEqual;
378
+ } else if ('between' in skCondition && skCondition.between) {
379
+ expr += ' AND #sk BETWEEN :skval1 AND :skval2';
380
+ values[':skval1'] = skCondition.between[0]; values[':skval2'] = skCondition.between[1];
381
+ } else if ('beginsWith' in skCondition && skCondition.beginsWith !== undefined) {
382
+ expr += ' AND begins_with(#sk, :skval)'; values[':skval'] = skCondition.beginsWith;
383
+ }
384
+ }
385
+
386
+ return new QueryCommand({
387
+ TableName: getSdkIdentifiers(this).tableName,
388
+ IndexName: indexName || undefined,
389
+ KeyConditionExpression: expr,
390
+ ExpressionAttributeNames: names,
391
+ ExpressionAttributeValues: values,
392
+ ExclusiveStartKey: exclusiveStartKey,
393
+ Limit: options?.limit,
394
+ ScanIndexForward: options?.order === 'desc' ? false : undefined,
395
+ });
396
+ }
397
+ }
398
+
399
+ // ── Query input helper type ─────────────────────────────────────────────────
400
+
@@ -0,0 +1,8 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // Browser stub - DistributedTable runs server-side only
5
+ export class DistributedTable {
6
+ constructor(...args: any[]) {}
7
+ }
8
+ export { DistributedTableErrors } from './errors.js';
@@ -0,0 +1,107 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * CDK-side regression tests for DistributedTable.
6
+ *
7
+ * History: DistributedTable.fromExisting was advertised in the runtime build
8
+ * but the CDK constructor unconditionally provisioned a new DynamoDB table
9
+ * AND the static factory was missing entirely from the CDK class. These
10
+ * tests pin the fix and ensure GSI custom resources are NOT created when
11
+ * binding to an external table.
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert';
15
+ import * as cdk from 'aws-cdk-lib';
16
+ import type { Construct } from 'constructs';
17
+ import { Template } from 'aws-cdk-lib/assertions';
18
+ import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
19
+ import { z } from 'zod';
20
+ import { DistributedTable } from './index.cdk.js';
21
+
22
+ const userSchema = z.object({
23
+ userId: z.string(),
24
+ email: z.string(),
25
+ createdAt: z.number(),
26
+ });
27
+
28
+ class StubBlocksStack extends cdk.Stack {
29
+ public readonly handler: cdk.aws_lambda.Function;
30
+ public readonly id: string;
31
+ constructor(scope: Construct, id: string) {
32
+ super(scope, id);
33
+ this.id = id;
34
+ (globalThis as any).CURRENT_BLOCKS_STACK = this;
35
+ this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
36
+ runtime: DEFAULT_NODE_RUNTIME,
37
+ handler: 'index.handler',
38
+ code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
39
+ });
40
+ }
41
+ }
42
+
43
+ function setup(): { stack: StubBlocksStack; parent: Scope } {
44
+ const app = new cdk.App();
45
+ const stack = new StubBlocksStack(app, 'TestStack');
46
+ const parent = new Scope('app');
47
+ return { stack, parent };
48
+ }
49
+
50
+ test('CDK: default DistributedTable provisions a DynamoDB table', () => {
51
+ const { stack, parent } = setup();
52
+ new DistributedTable(parent, 'users', {
53
+ schema: userSchema,
54
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
55
+ });
56
+ const template = Template.fromStack(stack);
57
+ template.resourceCountIs('AWS::DynamoDB::Table', 1);
58
+ });
59
+
60
+ test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
61
+ const { stack, parent } = setup();
62
+ new DistributedTable(parent, 'users', {
63
+ schema: userSchema,
64
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
65
+ table: DistributedTable.fromExisting('preexisting-users-table'),
66
+ });
67
+ const template = Template.fromStack(stack);
68
+ template.resourceCountIs('AWS::DynamoDB::Table', 0);
69
+ });
70
+
71
+ test('CDK: DistributedTable.fromExisting with indexes does NOT provision the GSI custom resource', () => {
72
+ const { stack, parent } = setup();
73
+ new DistributedTable(parent, 'users', {
74
+ schema: userSchema,
75
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
76
+ indexes: {
77
+ byEmail: { partitionKey: 'email' },
78
+ },
79
+ table: DistributedTable.fromExisting('preexisting-users-table'),
80
+ });
81
+ const template = Template.fromStack(stack);
82
+ // The GSI manager is realized as a Provider (Lambda + custom resource).
83
+ // `fromExisting` must opt out of touching indexes — the customer owns
84
+ // the existing table's index lifecycle.
85
+ template.resourceCountIs('AWS::CloudFormation::CustomResource', 0);
86
+ });
87
+
88
+ test('CDK: DistributedTable.fromExisting returns a branded ref', () => {
89
+ const ref = DistributedTable.fromExisting('foo');
90
+ assert.strictEqual(ref.tableName, 'foo');
91
+ assert.strictEqual(ref.__brand, 'ExternalTableRef');
92
+ });
93
+
94
+ test('CDK: calling a runtime data method throws an actionable error (not a cryptic TypeError)', () => {
95
+ const { parent } = setup();
96
+ const table = new DistributedTable(parent, 'users', {
97
+ schema: userSchema,
98
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
99
+ }) as any;
100
+ for (const method of ['get', 'put', 'delete', 'query', 'scan', 'getBatch', 'putBatch', 'deleteBatch']) {
101
+ assert.throws(
102
+ () => table[method]('k'),
103
+ /cannot be called during CDK synth/,
104
+ `${method}() should throw the actionable synth-time error`,
105
+ );
106
+ }
107
+ });