@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,59 @@
1
+ import { Scope } from '@aws-blocks/core';
2
+ import type { ScopeParent } from '@aws-blocks/core';
3
+ export { DistributedTableErrors } from './errors.js';
4
+ export type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, TableKey, PartitionKeyCondition, SortKeyCondition, KeyCondition, QueryOptions, ScanOptions, PutOptions, DeleteOptions, } from './types.js';
5
+ import type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, ScanOptions, PutOptions, DeleteOptions, TableKey } from './types.js';
6
+ import type { QueryOptions } from './types.js';
7
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
8
+ export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyConfig<T>, Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>> extends Scope {
9
+ options: DistributedTableOptions<T, K, Indexes>;
10
+ readonly bbName = "DistributedTable";
11
+ private schema;
12
+ private keyConfig;
13
+ private indexes;
14
+ private docClient;
15
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
16
+ protected log: ChildLogger;
17
+ constructor(scope: ScopeParent, id: string, options: DistributedTableOptions<T, K, Indexes>);
18
+ get(key: TableKey<T, K>): Promise<T | null>;
19
+ put(item: T, options?: PutOptions<T>): Promise<void>;
20
+ delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void>;
21
+ /**
22
+ * Query items by index, yielding matches as an async stream with automatic
23
+ * pagination over DynamoDB's `LastEvaluatedKey`.
24
+ *
25
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
26
+ * the `where` clause is missing, the partition key is not given as
27
+ * `{ equals: value }`, or more than one sort-key condition is supplied
28
+ * (DynamoDB allows only one per query — use `between` for ranges).
29
+ */
30
+ query(options: QueryOptions<T, K, Indexes>): AsyncIterable<T>;
31
+ scan(options?: ScanOptions): AsyncIterable<T>;
32
+ getBatch(keys: TableKey<T, K>[]): Promise<(T | null)[]>;
33
+ putBatch(items: T[]): Promise<void>;
34
+ deleteBatch(keys: TableKey<T, K>[]): Promise<void>;
35
+ static fromExisting(tableName: string): ExternalTableRef;
36
+ private validateItem;
37
+ private buildKey;
38
+ private backoff;
39
+ /**
40
+ * Run a DynamoDB batch operation, resubmitting any unprocessed entries with
41
+ * exponential backoff. DynamoDB batch APIs can succeed partially (HTTP 200 with
42
+ * UnprocessedKeys/UnprocessedItems) under throttling or the 16 MB response cap,
43
+ * so the leftovers must be retried by the caller.
44
+ *
45
+ * If entries remain unprocessed after MAX_BATCH_ATTEMPTS, this throws a
46
+ * BatchIncomplete error rather than returning quietly: for writes/deletes a
47
+ * silent return would drop data, and for reads it would be indistinguishable
48
+ * from a missing item. Callers should back off and resubmit.
49
+ * See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
50
+ *
51
+ * @param operation Human-readable operation name used in the exhaustion error.
52
+ * @param initial Entries to submit on the first attempt.
53
+ * @param send Performs one batch call and returns the entries DynamoDB did not process.
54
+ */
55
+ private retryUnprocessed;
56
+ private applyFieldEqualsCondition;
57
+ private buildQueryCommand;
58
+ }
59
+ //# sourceMappingURL=index.aws.d.ts.map
@@ -0,0 +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,gBAAgB,EAChB,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,WAAW,EACX,UAAU,EACV,aAAa,EAGb,QAAQ,EACR,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;IAUqC,OAAO,EAAE,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IATlG,QAAQ,CAAC,MAAM,sBAAW;IAC1B,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,SAAS,CAAI;IACrB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,SAAS,CAAyB;IAE1C,2FAA2F;IAC3F,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;IAc5F,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;IAkBpD,MAAM,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAa5E;;;;;;;;OAQG;IACI,KAAK,CACX,OAAO,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,GAClC,aAAa,CAAC,CAAC,CAAC;IAsCZ,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,CAAC,CAAC;IAoB9C,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;IAsBvD,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBnC,WAAW,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBxD,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;YAM1C,YAAY;IAQ1B,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,OAAO;IAWf;;;;;;;;;;;;;;;OAeG;YACW,gBAAgB;IAkB9B,OAAO,CAAC,yBAAyB;IAkBjC,OAAO,CAAC,iBAAiB;CA4CzB"}
@@ -0,0 +1,310 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
4
+ import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCommand, ScanCommand, BatchGetCommand, BatchWriteCommand, } from '@aws-sdk/lib-dynamodb';
5
+ import { Scope, registerSdkIdentifiers, getSdkIdentifiers } from '@aws-blocks/core';
6
+ import { BB_NAME, BB_VERSION } from './version.js';
7
+ export { DistributedTableErrors } from './errors.js';
8
+ import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge } from './errors.js';
9
+ import { Logger } from '@aws-blocks/bb-logger';
10
+ // ── Helpers ─────────────────────────────────────────────────────────────────
11
+ // DynamoDB batch API limits and retry tuning.
12
+ // BatchGetItem accepts up to 100 keys per call; BatchWriteItem up to 25 requests.
13
+ // Both can return UnprocessedKeys/UnprocessedItems on partial success (e.g. throttling
14
+ // or the 16 MB response cap), which the caller must resubmit with backoff.
15
+ // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
16
+ const BATCH_GET_MAX_KEYS = 100;
17
+ const BATCH_WRITE_MAX_REQUESTS = 25;
18
+ const MAX_BATCH_ATTEMPTS = 5;
19
+ const BASE_BACKOFF_MS = 50;
20
+ const MAX_BACKOFF_MS = 5000;
21
+ /** Split an array into chunks of at most `size` elements. */
22
+ function chunked(items, size) {
23
+ const chunks = [];
24
+ for (let i = 0; i < items.length; i += size) {
25
+ chunks.push(items.slice(i, i + size));
26
+ }
27
+ return chunks;
28
+ }
29
+ export class DistributedTable extends Scope {
30
+ options;
31
+ bbName = BB_NAME;
32
+ schema;
33
+ keyConfig;
34
+ indexes;
35
+ docClient;
36
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
37
+ log;
38
+ constructor(scope, id, options) {
39
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
40
+ this.options = options;
41
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
42
+ const tableName = options.table?.tableName ?? this.fullId.substring(0, 255);
43
+ this.schema = options.schema;
44
+ this.keyConfig = options.key;
45
+ this.indexes = (options.indexes ?? {});
46
+ const client = new DynamoDBClient({
47
+ customUserAgent: this.buildUserAgentChain(),
48
+ });
49
+ this.docClient = DynamoDBDocumentClient.from(client);
50
+ registerSdkIdentifiers(this.fullId, { tableName });
51
+ }
52
+ async get(key) {
53
+ const result = await this.docClient.send(new GetCommand({
54
+ TableName: getSdkIdentifiers(this).tableName,
55
+ Key: this.buildKey(key),
56
+ }));
57
+ return result.Item ?? null;
58
+ }
59
+ async put(item, options) {
60
+ await this.validateItem(item);
61
+ const command = { TableName: getSdkIdentifiers(this).tableName, Item: item };
62
+ if (options?.ifNotExists) {
63
+ command.ConditionExpression = 'attribute_not_exists(#pk)';
64
+ command.ExpressionAttributeNames = { '#pk': this.keyConfig.partitionKey };
65
+ }
66
+ else if (options?.ifFieldEquals) {
67
+ this.applyFieldEqualsCondition(command, options.ifFieldEquals);
68
+ }
69
+ try {
70
+ await this.docClient.send(new PutCommand(command));
71
+ }
72
+ catch (err) {
73
+ throw remapItemTooLarge(err);
74
+ }
75
+ }
76
+ async delete(key, options) {
77
+ const command = { TableName: getSdkIdentifiers(this).tableName, Key: this.buildKey(key) };
78
+ if (options?.ifExists) {
79
+ command.ConditionExpression = 'attribute_exists(#pk)';
80
+ command.ExpressionAttributeNames = { '#pk': this.keyConfig.partitionKey };
81
+ }
82
+ else if (options?.ifFieldEquals) {
83
+ this.applyFieldEqualsCondition(command, options.ifFieldEquals);
84
+ }
85
+ await this.docClient.send(new DeleteCommand(command));
86
+ }
87
+ /**
88
+ * Query items by index, yielding matches as an async stream with automatic
89
+ * pagination over DynamoDB's `LastEvaluatedKey`.
90
+ *
91
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
92
+ * the `where` clause is missing, the partition key is not given as
93
+ * `{ equals: value }`, or more than one sort-key condition is supplied
94
+ * (DynamoDB allows only one per query — use `between` for ranges).
95
+ */
96
+ async *query(options) {
97
+ const indexConfig = options.index ? this.indexes[options.index] : this.keyConfig;
98
+ if (!indexConfig)
99
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.indexNotFound(options.index));
100
+ const pkField = indexConfig.partitionKey;
101
+ const skField = indexConfig.sortKey;
102
+ if (!options.where) {
103
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.whereRequired(pkField));
104
+ }
105
+ const pkValue = options.where[pkField]?.equals;
106
+ if (pkValue === undefined) {
107
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.partitionKeyEqualsRequired(pkField));
108
+ }
109
+ // Normalize the sort-key condition before building the query: a present-but-
110
+ // empty condition ({} / all-undefined) becomes "no filter" (otherwise we'd
111
+ // register #sk in ExpressionAttributeNames with no clause and DynamoDB would
112
+ // reject it), and more than one condition is rejected before issuing a call.
113
+ const skCondition = skField
114
+ ? normalizeSortKeyCondition(options.where[skField])
115
+ : undefined;
116
+ let lastEvaluatedKey;
117
+ let count = 0;
118
+ do {
119
+ const command = this.buildQueryCommand(options.index, pkField, pkValue, skField, skCondition, lastEvaluatedKey, options);
120
+ const result = await this.docClient.send(command);
121
+ for (const item of result.Items ?? []) {
122
+ yield item;
123
+ if (options.limit && ++count >= options.limit)
124
+ return;
125
+ }
126
+ lastEvaluatedKey = result.LastEvaluatedKey;
127
+ } while (lastEvaluatedKey);
128
+ }
129
+ async *scan(options) {
130
+ let lastEvaluatedKey;
131
+ let count = 0;
132
+ do {
133
+ const result = await this.docClient.send(new ScanCommand({
134
+ TableName: getSdkIdentifiers(this).tableName,
135
+ ExclusiveStartKey: lastEvaluatedKey,
136
+ Limit: options?.limit,
137
+ }));
138
+ for (const item of result.Items ?? []) {
139
+ yield item;
140
+ if (options?.limit && ++count >= options.limit)
141
+ return;
142
+ }
143
+ lastEvaluatedKey = result.LastEvaluatedKey;
144
+ } while (lastEvaluatedKey);
145
+ }
146
+ async getBatch(keys) {
147
+ const results = new Map();
148
+ const tableName = getSdkIdentifiers(this).tableName;
149
+ for (const chunk of chunked(keys, BATCH_GET_MAX_KEYS)) {
150
+ await this.retryUnprocessed('getBatch', chunk.map(k => this.buildKey(k)), async (pendingKeys) => {
151
+ const resp = await this.docClient.send(new BatchGetCommand({
152
+ RequestItems: { [tableName]: { Keys: pendingKeys } },
153
+ }));
154
+ for (const item of resp.Responses?.[tableName] ?? []) {
155
+ results.set(JSON.stringify(this.buildKey(item)), item);
156
+ }
157
+ return resp.UnprocessedKeys?.[tableName]?.Keys;
158
+ });
159
+ }
160
+ return keys.map(key => results.get(JSON.stringify(this.buildKey(key))) ?? null);
161
+ }
162
+ async putBatch(items) {
163
+ for (const item of items)
164
+ await this.validateItem(item);
165
+ const tableName = getSdkIdentifiers(this).tableName;
166
+ for (const chunk of chunked(items, BATCH_WRITE_MAX_REQUESTS)) {
167
+ await this.retryUnprocessed('putBatch', chunk.map(item => ({ PutRequest: { Item: item } })), async (requests) => {
168
+ try {
169
+ const resp = await this.docClient.send(new BatchWriteCommand({
170
+ RequestItems: { [tableName]: requests },
171
+ }));
172
+ return resp.UnprocessedItems?.[tableName];
173
+ }
174
+ catch (err) {
175
+ throw remapItemTooLarge(err);
176
+ }
177
+ });
178
+ }
179
+ }
180
+ async deleteBatch(keys) {
181
+ const tableName = getSdkIdentifiers(this).tableName;
182
+ for (const chunk of chunked(keys, BATCH_WRITE_MAX_REQUESTS)) {
183
+ await this.retryUnprocessed('deleteBatch', chunk.map(key => ({ DeleteRequest: { Key: this.buildKey(key) } })), async (requests) => {
184
+ const resp = await this.docClient.send(new BatchWriteCommand({
185
+ RequestItems: { [tableName]: requests },
186
+ }));
187
+ return resp.UnprocessedItems?.[tableName];
188
+ });
189
+ }
190
+ }
191
+ static fromExisting(tableName) {
192
+ return { __brand: 'ExternalTableRef', tableName };
193
+ }
194
+ // ── Internal ────────────────────────────────────────────────────────────
195
+ async validateItem(item) {
196
+ const result = this.schema['~standard'].validate(item);
197
+ const resolved = result instanceof Promise ? await result : result;
198
+ if (resolved.issues) {
199
+ throw blocksError(DistributedTableErrors.ValidationFailed, resolved.issues[0].message);
200
+ }
201
+ }
202
+ buildKey(key) {
203
+ const result = { [this.keyConfig.partitionKey]: key[this.keyConfig.partitionKey] };
204
+ if (this.keyConfig.sortKey)
205
+ result[this.keyConfig.sortKey] = key[this.keyConfig.sortKey];
206
+ return result;
207
+ }
208
+ backoff(attempt) {
209
+ // Exponential backoff with equal jitter: keep half the delay as a floor and
210
+ // randomise the other half. Full jitter (random * cap) can collapse to ~0ms
211
+ // and lets concurrent callers re-collide; equal jitter preserves a minimum
212
+ // spacing while still de-synchronising retries under shared throttling.
213
+ // See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
214
+ const capped = Math.min(BASE_BACKOFF_MS * Math.pow(2, attempt), MAX_BACKOFF_MS);
215
+ const ms = capped / 2 + Math.random() * (capped / 2);
216
+ return new Promise(resolve => setTimeout(resolve, ms));
217
+ }
218
+ /**
219
+ * Run a DynamoDB batch operation, resubmitting any unprocessed entries with
220
+ * exponential backoff. DynamoDB batch APIs can succeed partially (HTTP 200 with
221
+ * UnprocessedKeys/UnprocessedItems) under throttling or the 16 MB response cap,
222
+ * so the leftovers must be retried by the caller.
223
+ *
224
+ * If entries remain unprocessed after MAX_BATCH_ATTEMPTS, this throws a
225
+ * BatchIncomplete error rather than returning quietly: for writes/deletes a
226
+ * silent return would drop data, and for reads it would be indistinguishable
227
+ * from a missing item. Callers should back off and resubmit.
228
+ * See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
229
+ *
230
+ * @param operation Human-readable operation name used in the exhaustion error.
231
+ * @param initial Entries to submit on the first attempt.
232
+ * @param send Performs one batch call and returns the entries DynamoDB did not process.
233
+ */
234
+ async retryUnprocessed(operation, initial, send) {
235
+ let pending = initial;
236
+ for (let attempt = 0; attempt < MAX_BATCH_ATTEMPTS && pending && pending.length > 0; attempt++) {
237
+ if (attempt > 0)
238
+ await this.backoff(attempt);
239
+ pending = await send(pending);
240
+ }
241
+ if (pending && pending.length > 0) {
242
+ throw blocksError(DistributedTableErrors.BatchIncomplete, DistributedTableMessages.batchIncomplete(operation, pending.length, MAX_BATCH_ATTEMPTS));
243
+ }
244
+ }
245
+ applyFieldEqualsCondition(command, fields) {
246
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
247
+ if (entries.length === 0) {
248
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
249
+ }
250
+ const conditions = [];
251
+ const names = {};
252
+ const values = {};
253
+ entries.forEach(([field, value], i) => {
254
+ conditions.push(`#field${i} = :val${i}`);
255
+ names[`#field${i}`] = field;
256
+ values[`:val${i}`] = value;
257
+ });
258
+ command.ConditionExpression = conditions.join(' AND ');
259
+ command.ExpressionAttributeNames = names;
260
+ command.ExpressionAttributeValues = values;
261
+ }
262
+ buildQueryCommand(indexName, pkField, pkValue, skField, skCondition, exclusiveStartKey, options) {
263
+ let expr = '#pk = :pkval';
264
+ const names = { '#pk': pkField };
265
+ const values = { ':pkval': pkValue };
266
+ if (skField && skCondition) {
267
+ names['#sk'] = skField;
268
+ if ('equals' in skCondition && skCondition.equals !== undefined) {
269
+ expr += ' AND #sk = :skval';
270
+ values[':skval'] = skCondition.equals;
271
+ }
272
+ else if ('greaterThan' in skCondition && skCondition.greaterThan !== undefined) {
273
+ expr += ' AND #sk > :skval';
274
+ values[':skval'] = skCondition.greaterThan;
275
+ }
276
+ else if ('greaterThanOrEqual' in skCondition && skCondition.greaterThanOrEqual !== undefined) {
277
+ expr += ' AND #sk >= :skval';
278
+ values[':skval'] = skCondition.greaterThanOrEqual;
279
+ }
280
+ else if ('lessThan' in skCondition && skCondition.lessThan !== undefined) {
281
+ expr += ' AND #sk < :skval';
282
+ values[':skval'] = skCondition.lessThan;
283
+ }
284
+ else if ('lessThanOrEqual' in skCondition && skCondition.lessThanOrEqual !== undefined) {
285
+ expr += ' AND #sk <= :skval';
286
+ values[':skval'] = skCondition.lessThanOrEqual;
287
+ }
288
+ else if ('between' in skCondition && skCondition.between) {
289
+ expr += ' AND #sk BETWEEN :skval1 AND :skval2';
290
+ values[':skval1'] = skCondition.between[0];
291
+ values[':skval2'] = skCondition.between[1];
292
+ }
293
+ else if ('beginsWith' in skCondition && skCondition.beginsWith !== undefined) {
294
+ expr += ' AND begins_with(#sk, :skval)';
295
+ values[':skval'] = skCondition.beginsWith;
296
+ }
297
+ }
298
+ return new QueryCommand({
299
+ TableName: getSdkIdentifiers(this).tableName,
300
+ IndexName: indexName || undefined,
301
+ KeyConditionExpression: expr,
302
+ ExpressionAttributeNames: names,
303
+ ExpressionAttributeValues: values,
304
+ ExclusiveStartKey: exclusiveStartKey,
305
+ Limit: options?.limit,
306
+ ScanIndexForward: options?.order === 'desc' ? false : undefined,
307
+ });
308
+ }
309
+ }
310
+ // ── Query input helper type ─────────────────────────────────────────────────
@@ -0,0 +1,5 @@
1
+ export declare class DistributedTable {
2
+ constructor(...args: any[]);
3
+ }
4
+ export { DistributedTableErrors } from './errors.js';
5
+ //# sourceMappingURL=index.browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.browser.d.ts","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAIA,qBAAa,gBAAgB;gBAChB,GAAG,IAAI,EAAE,GAAG,EAAE;CAC1B;AACD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,7 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Browser stub - DistributedTable runs server-side only
4
+ export class DistributedTable {
5
+ constructor(...args) { }
6
+ }
7
+ export { DistributedTableErrors } from './errors.js';
@@ -0,0 +1,27 @@
1
+ import { Scope } from '@aws-blocks/core/cdk';
2
+ import type { ScopeParent } from '@aws-blocks/core';
3
+ import type { ExternalTableRef } from './types.js';
4
+ export { DistributedTableErrors } from './errors.js';
5
+ export type { DistributedTableOptions, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
6
+ export declare class DistributedTable<T = any> extends Scope {
7
+ options: any;
8
+ private table;
9
+ /**
10
+ * Reference an existing DynamoDB table instead of provisioning a new one.
11
+ * Mirrors the same factory exposed by the runtime build so the same code
12
+ * works in both contexts. The customer is responsible for ensuring the
13
+ * pre-existing table already has any required GSIs configured — Blocks
14
+ * will not modify the table when this factory is used.
15
+ */
16
+ static fromExisting(tableName: string): ExternalTableRef;
17
+ constructor(scope: ScopeParent, id: string, options: any);
18
+ get(..._args: unknown[]): never;
19
+ put(..._args: unknown[]): never;
20
+ delete(..._args: unknown[]): never;
21
+ query(..._args: unknown[]): never;
22
+ scan(..._args: unknown[]): never;
23
+ getBatch(..._args: unknown[]): never;
24
+ putBatch(..._args: unknown[]): never;
25
+ deleteBatch(..._args: unknown[]): never;
26
+ }
27
+ //# sourceMappingURL=index.cdk.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAInD,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE5J,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,KAAK;IAcA,OAAO,EAAE,GAAG;IAb/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;gBAI5C,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAmG/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"}
@@ -0,0 +1,180 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
4
+ import * as cdk from 'aws-cdk-lib';
5
+ import { CustomResource, Duration } from 'aws-cdk-lib';
6
+ import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
7
+ import { Provider } from 'aws-cdk-lib/custom-resources';
8
+ import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
9
+ import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, join } from 'node:path';
12
+ export { DistributedTableErrors } from './errors.js';
13
+ export class DistributedTable extends Scope {
14
+ options;
15
+ table;
16
+ /**
17
+ * Reference an existing DynamoDB table instead of provisioning a new one.
18
+ * Mirrors the same factory exposed by the runtime build so the same code
19
+ * works in both contexts. The customer is responsible for ensuring the
20
+ * pre-existing table already has any required GSIs configured — Blocks
21
+ * will not modify the table when this factory is used.
22
+ */
23
+ static fromExisting(tableName) {
24
+ return { __brand: 'ExternalTableRef', tableName };
25
+ }
26
+ constructor(scope, id, options) {
27
+ super(id, { parent: scope });
28
+ this.options = options;
29
+ const config = options;
30
+ if (config?.table) {
31
+ // `fromExisting`: don't provision; bind to the pre-existing table by name
32
+ // and grant the runtime Lambda read/write + index query access.
33
+ // We deliberately skip the GSI custom resource — the customer owns the
34
+ // table's index lifecycle when they bring their own.
35
+ this.table = Table.fromTableName(this, 'table', config.table.tableName);
36
+ this.table.grantReadWriteData(this.handler);
37
+ this.handler.addToRolePolicy(new PolicyStatement({
38
+ actions: ['dynamodb:Query'],
39
+ resources: [`${this.table.tableArn}/index/*`],
40
+ }));
41
+ return;
42
+ }
43
+ const tableName = this.fullId.substring(0, 255);
44
+ const isSandbox = this.node.tryGetContext('sandboxMode') === 'true';
45
+ // Probe the schema's validate() to determine if a key field is numeric.
46
+ // Sends a test value of 0 for the field — if validation doesn't flag it,
47
+ // the field accepts numbers. Uses only the StandardSchemaV1 interface.
48
+ const isNumericField = (fieldName) => {
49
+ const probe = { [fieldName]: 0 };
50
+ const result = config.schema['~standard'].validate(probe);
51
+ // validate may return sync or async; at synth time schemas are sync
52
+ if (result && 'issues' in result && result.issues) {
53
+ return !result.issues.some((i) => i.path?.length === 1 && i.path[0] === fieldName);
54
+ }
55
+ return true; // no issues for this field → numeric
56
+ };
57
+ const getDdbType = (fieldName) => isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
58
+ this.table = new Table(this, 'table', {
59
+ tableName,
60
+ partitionKey: {
61
+ name: config.key.partitionKey,
62
+ type: getDdbType(config.key.partitionKey),
63
+ },
64
+ sortKey: config.key.sortKey ? {
65
+ name: config.key.sortKey,
66
+ type: getDdbType(config.key.sortKey),
67
+ } : undefined,
68
+ billingMode: BillingMode.PAY_PER_REQUEST,
69
+ timeToLiveAttribute: config.ttl || undefined,
70
+ });
71
+ this.table.grantReadWriteData(this.handler);
72
+ // Explicit index query permissions
73
+ this.handler.addToRolePolicy(new PolicyStatement({
74
+ actions: ['dynamodb:Query'],
75
+ resources: [`${this.table.tableArn}/index/*`],
76
+ }));
77
+ // Add GSI manager if indexes are defined
78
+ if (config.indexes && Object.keys(config.indexes).length > 0) {
79
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
80
+ gsiProvider.addTableArn(this.table.tableArn, isSandbox);
81
+ const indexesWithTypes = {};
82
+ for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
83
+ indexesWithTypes[indexName] = {
84
+ partitionKey: indexConfig.partitionKey,
85
+ sortKey: indexConfig.sortKey,
86
+ partitionKeyType: getDdbType(indexConfig.partitionKey) === AttributeType.NUMBER ? 'N' : 'S',
87
+ sortKeyType: indexConfig.sortKey
88
+ ? (getDdbType(indexConfig.sortKey) === AttributeType.NUMBER ? 'N' : 'S')
89
+ : undefined,
90
+ };
91
+ }
92
+ const gsiResource = new CustomResource(this, 'gsi-resource', {
93
+ serviceToken: gsiProvider.serviceToken,
94
+ properties: {
95
+ TableName: this.table.tableName,
96
+ Indexes: indexesWithTypes,
97
+ SandboxMode: isSandbox ? 'true' : 'false',
98
+ Version: '3',
99
+ },
100
+ });
101
+ gsiResource.node.addDependency(this.table);
102
+ }
103
+ }
104
+ // ── Runtime methods are not available during CDK synth ────────────────
105
+ // Under `--conditions=cdk` a DistributedTable resolves to this construct,
106
+ // which only provisions infrastructure. The data methods live in the runtime
107
+ // build; calling them at module top-level (which runs during synth) would
108
+ // otherwise fail with a cryptic `X is not a function`. These stubs turn that
109
+ // into an actionable message.
110
+ get(..._args) { return synthGuard('DistributedTable', 'get'); }
111
+ put(..._args) { return synthGuard('DistributedTable', 'put'); }
112
+ delete(..._args) { return synthGuard('DistributedTable', 'delete'); }
113
+ query(..._args) { return synthGuard('DistributedTable', 'query'); }
114
+ scan(..._args) { return synthGuard('DistributedTable', 'scan'); }
115
+ getBatch(..._args) { return synthGuard('DistributedTable', 'getBatch'); }
116
+ putBatch(..._args) { return synthGuard('DistributedTable', 'putBatch'); }
117
+ deleteBatch(..._args) { return synthGuard('DistributedTable', 'deleteBatch'); }
118
+ }
119
+ // ── Shared GSI Manager Provider (one per stack) ─────────────────────────────
120
+ const GSI_PROVIDER_KEY = Symbol.for('BLOCKS_GSI_MANAGER_PROVIDER');
121
+ function getOrCreateGsiProvider(stack) {
122
+ const existing = stack[GSI_PROVIDER_KEY];
123
+ if (existing)
124
+ return existing;
125
+ const __dirname = dirname(fileURLToPath(import.meta.url));
126
+ const tableArns = [];
127
+ const sandboxTableArns = [];
128
+ const gsiManagerLambda = new LambdaFunction(stack, 'KitGsiManager', {
129
+ runtime: DEFAULT_NODE_RUNTIME,
130
+ handler: 'index.handler',
131
+ code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
132
+ timeout: Duration.minutes(15),
133
+ });
134
+ const gsiIsCompleteLambda = new LambdaFunction(stack, 'KitGsiIsComplete', {
135
+ runtime: DEFAULT_NODE_RUNTIME,
136
+ handler: 'index.isCompleteHandler',
137
+ code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
138
+ timeout: Duration.minutes(1),
139
+ });
140
+ // Production permissions — lazily resolved so ARNs accumulate as tables register
141
+ gsiManagerLambda.addToRolePolicy(new PolicyStatement({
142
+ actions: ['dynamodb:DescribeTable', 'dynamodb:UpdateTable'],
143
+ resources: cdk.Lazy.list({ produce: () => tableArns }),
144
+ }));
145
+ gsiIsCompleteLambda.addToRolePolicy(new PolicyStatement({
146
+ actions: ['dynamodb:DescribeTable', 'dynamodb:UpdateTable'],
147
+ resources: cdk.Lazy.list({ produce: () => tableArns }),
148
+ }));
149
+ // Sandbox permissions — only added if any table requests sandbox mode
150
+ let sandboxPolicyAdded = false;
151
+ const provider = new Provider(stack, 'KitGsiProvider', {
152
+ onEventHandler: gsiManagerLambda,
153
+ isCompleteHandler: gsiIsCompleteLambda,
154
+ queryInterval: Duration.seconds(10),
155
+ totalTimeout: Duration.hours(2),
156
+ });
157
+ const shared = {
158
+ serviceToken: provider.serviceToken,
159
+ addTableArn: (tableArn, isSandbox) => {
160
+ tableArns.push(tableArn);
161
+ if (isSandbox) {
162
+ sandboxTableArns.push(tableArn);
163
+ if (!sandboxPolicyAdded) {
164
+ sandboxPolicyAdded = true;
165
+ gsiManagerLambda.addToRolePolicy(new PolicyStatement({
166
+ actions: [
167
+ 'dynamodb:DeleteTable',
168
+ 'dynamodb:CreateTable',
169
+ 'dynamodb:Scan',
170
+ 'dynamodb:BatchWriteItem',
171
+ ],
172
+ resources: cdk.Lazy.list({ produce: () => sandboxTableArns }),
173
+ }));
174
+ }
175
+ }
176
+ },
177
+ };
178
+ stack[GSI_PROVIDER_KEY] = shared;
179
+ return shared;
180
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.cdk.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cdk.test.d.ts","sourceRoot":"","sources":["../src/index.cdk.test.ts"],"names":[],"mappings":""}