@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,143 @@
1
+ /**
2
+ * Shared types for DistributedTable. Imported by mock, aws, and browser entry points.
3
+ * This file has zero runtime dependencies — types only.
4
+ */
5
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
6
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
7
+ export interface TableKeyConfig<T> {
8
+ /** Attribute name used as the partition key. Must be a field in the schema. */
9
+ partitionKey: keyof T & string;
10
+ /** Attribute name used as the sort key. Must be a field in the schema. Optional. */
11
+ sortKey?: keyof T & string;
12
+ }
13
+ export interface DistributedTableOptions<T, K extends TableKeyConfig<T> = TableKeyConfig<T>, Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>> {
14
+ /** StandardSchemaV1 schema for runtime validation and type inference. Required. */
15
+ schema: StandardSchemaV1<T>;
16
+ /** Primary key configuration. */
17
+ key: K;
18
+ /** Global secondary index definitions. Optional. */
19
+ indexes?: Indexes;
20
+ /**
21
+ * Enable DynamoDB Time-to-Live (TTL) on the specified attribute.
22
+ * The attribute must be a field in the schema and should contain a Unix
23
+ * epoch timestamp (in seconds). DynamoDB automatically deletes items
24
+ * whose TTL attribute value is older than the current time.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * const sessions = new DistributedTable(scope, 'sessions', {
29
+ * schema: sessionSchema,
30
+ * key: { partitionKey: 'sessionId' },
31
+ * ttl: 'expiresAt',
32
+ * });
33
+ * ```
34
+ */
35
+ ttl?: keyof T & string;
36
+ /** Wrap an existing table instead of creating one. */
37
+ table?: ExternalTableRef;
38
+ /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
39
+ logger?: ChildLogger;
40
+ }
41
+ export interface ExternalTableRef {
42
+ readonly __brand: 'ExternalTableRef';
43
+ readonly tableName: string;
44
+ }
45
+ /**
46
+ * Picks exactly the key fields from T and makes them required.
47
+ * Non-key fields are excluded.
48
+ */
49
+ export type TableKey<T, K extends TableKeyConfig<T> = TableKeyConfig<T>> = K extends {
50
+ sortKey: infer SK extends keyof T & string;
51
+ } ? Required<Pick<T, K['partitionKey'] | SK>> : Required<Pick<T, K['partitionKey']>>;
52
+ /** Partition key condition — DynamoDB requires exact match on PK in a Query. */
53
+ export type PartitionKeyCondition<V> = {
54
+ equals: V;
55
+ };
56
+ /** Sort key condition — supports range queries, beginsWith (strings only). */
57
+ export type SortKeyCondition<V> = {
58
+ equals?: V;
59
+ greaterThan?: V;
60
+ greaterThanOrEqual?: V;
61
+ lessThan?: V;
62
+ lessThanOrEqual?: V;
63
+ between?: [V, V];
64
+ beginsWith?: V extends string ? string : never;
65
+ };
66
+ /**
67
+ * Query input for a given index. The partition key field is required (equals only).
68
+ * The sort key field is optional with rich conditions. No other fields appear.
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * // Index: { partitionKey: 'userId', sortKey: 'createdAt' }
73
+ * // T: { userId: string; createdAt: number; name: string }
74
+ * // KeyCondition = { userId: { equals: string }; createdAt?: SortKeyCondition<number> }
75
+ * ```
76
+ */
77
+ export type KeyCondition<T, K extends TableKeyConfig<T>> = K extends {
78
+ sortKey: infer SK extends keyof T & string;
79
+ } ? {
80
+ [P in K['partitionKey']]: PartitionKeyCondition<T[P]>;
81
+ } & {
82
+ [P in SK]?: SortKeyCondition<T[P]>;
83
+ } : {
84
+ [P in K['partitionKey']]: PartitionKeyCondition<T[P]>;
85
+ };
86
+ /**
87
+ * Query options using named parameters. The `where` clause provides key
88
+ * conditions, `index` selects a GSI (omit for primary key), and `limit`
89
+ * and `order` control result size and sort direction.
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // Primary key query (no index)
94
+ * for await (const item of table.query({ where: { userId: { equals: 'u1' } } })) { ... }
95
+ *
96
+ * // GSI query with limit and reverse order
97
+ * for await (const item of table.query({
98
+ * index: 'byTimestamp',
99
+ * where: { userId: { equals: 'u1' }, timestamp: { greaterThan: 1000 } },
100
+ * limit: 10,
101
+ * order: 'desc',
102
+ * })) { ... }
103
+ * ```
104
+ */
105
+ export type QueryOptions<T, K extends TableKeyConfig<T>, Indexes extends Record<string, TableKeyConfig<T>>> = {
106
+ [Name in string & keyof Indexes]: {
107
+ /** GSI to query. Omit to query the primary key. */
108
+ index: Name;
109
+ /** Key conditions for the query. */
110
+ where: KeyCondition<T, Indexes[Name]>;
111
+ /** Maximum number of items to return. */
112
+ limit?: number;
113
+ /** Sort order. Defaults to 'asc'. */
114
+ order?: 'asc' | 'desc';
115
+ };
116
+ }[string & keyof Indexes] | {
117
+ index?: undefined;
118
+ /** Key conditions for the primary key query. */
119
+ where: KeyCondition<T, K>;
120
+ /** Maximum number of items to return. */
121
+ limit?: number;
122
+ /** Sort order. Defaults to 'asc'. */
123
+ order?: 'asc' | 'desc';
124
+ };
125
+ export interface ScanOptions {
126
+ /** Maximum number of items to return. */
127
+ limit?: number;
128
+ }
129
+ export type PutOptions<T> = {
130
+ ifNotExists: true;
131
+ ifFieldEquals?: never;
132
+ } | {
133
+ ifNotExists?: never;
134
+ ifFieldEquals: Partial<T>;
135
+ } | Record<string, never>;
136
+ export type DeleteOptions<T> = {
137
+ ifExists: true;
138
+ ifFieldEquals?: never;
139
+ } | {
140
+ ifExists?: never;
141
+ ifFieldEquals: Partial<T>;
142
+ } | Record<string, never>;
143
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export {};
@@ -0,0 +1,3 @@
1
+ export declare const BB_NAME = "DistributedTable";
2
+ export declare const BB_VERSION = "0.1.0";
3
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,OAAO,qBAAqB,CAAC;AAC1C,eAAO,MAAM,UAAU,UAAU,CAAC"}
@@ -0,0 +1,3 @@
1
+ // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
+ export const BB_NAME = 'DistributedTable';
3
+ export const BB_VERSION = '0.1.0';
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@aws-blocks/bb-distributed-table",
3
+ "version": "0.1.0",
4
+ "author": "Amazon Web Services",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "DESIGN.md",
11
+ "src",
12
+ "LICENSE"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "browser": "./dist/index.browser.js",
17
+ "cdk": {
18
+ "types": "./dist/index.cdk.d.ts",
19
+ "default": "./dist/index.cdk.js"
20
+ },
21
+ "aws-runtime": "./dist/index.aws.js",
22
+ "types": "./dist/index.mock.d.ts",
23
+ "default": "./dist/index.mock.js"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "prebuild": "node ../../scripts/generate-version.mjs DistributedTable",
28
+ "build": "tsc --build && npm run build:lambda",
29
+ "build:lambda": "esbuild src/gsi-manager-lambda.ts --bundle --platform=node --target=node24 --outfile=dist/gsi-manager-lambda/index.js --format=cjs --external:@aws-sdk/*",
30
+ "test": "node --test dist/index.test.js dist/parity.test.js dist/index.cdk.test.js"
31
+ },
32
+ "dependencies": {
33
+ "@aws-blocks/core": "^0.1.0",
34
+ "@aws-blocks/bb-logger": "^0.1.0",
35
+ "@aws-sdk/client-dynamodb": "^3.0.0",
36
+ "@aws-sdk/lib-dynamodb": "^3.0.0",
37
+ "@standard-schema/spec": "^1.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "esbuild": "^0.25.0",
42
+ "typescript": "^5.3.0",
43
+ "zod": "^4.1.12"
44
+ },
45
+ "peerDependencies": {
46
+ "aws-cdk-lib": "^2.257.0",
47
+ "constructs": "^10.6.0"
48
+ }
49
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,145 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Typed error constants for DistributedTable. Use with `isBlocksError()` in catch blocks.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { isBlocksError } from '@aws-blocks/core';
10
+ * import { DistributedTableErrors } from '@aws-blocks/bb-distributed-table';
11
+ *
12
+ * try {
13
+ * await table.put(item, { ifNotExists: true });
14
+ * } catch (e: unknown) {
15
+ * if (isBlocksError(e, DistributedTableErrors.ConditionalCheckFailed)) {
16
+ * // item already exists
17
+ * }
18
+ * throw e;
19
+ * }
20
+ * ```
21
+ */
22
+ export const DistributedTableErrors = {
23
+ ConditionalCheckFailed: 'ConditionalCheckFailedException',
24
+ ValidationFailed: 'ValidationFailedException',
25
+ /**
26
+ * The query or condition shape is invalid and was rejected before reaching
27
+ * DynamoDB: a missing `where` clause, a partition key not given as
28
+ * `{ equals: value }`, an unknown index, more than one sort-key condition, or
29
+ * an empty `ifFieldEquals`. These are all caller bugs — something the caller
30
+ * can fix by correcting the call. Catchable via
31
+ * `isBlocksError(e, DistributedTableErrors.InvalidQuery)`.
32
+ *
33
+ * Kept distinct from {@link ItemTooLarge} (a runtime data condition) so a
34
+ * customer can tell "my query is wrong" from "this item is too big" by name
35
+ * alone rather than string-matching the message.
36
+ */
37
+ InvalidQuery: 'InvalidQueryException',
38
+ /**
39
+ * An item exceeds DynamoDB's 400 KB per-item size limit. Unlike an invalid
40
+ * query, this is not necessarily a caller bug — the size of a given item may
41
+ * be outside the caller's control — so callers may want to branch on it
42
+ * (skip, split, or store a reference instead). Catchable via
43
+ * `isBlocksError(e, DistributedTableErrors.ItemTooLarge)`.
44
+ *
45
+ * The mock checks serialized byte length client-side and throws this directly.
46
+ * On AWS, DynamoDB raises a generic `ValidationException` for oversized items;
47
+ * the runtime detects the size-specific message and re-maps it to this name so
48
+ * both layers are catchable with the same code. Other `ValidationException`
49
+ * causes (malformed expressions, type mismatches) propagate as-is.
50
+ */
51
+ ItemTooLarge: 'ItemTooLargeException',
52
+ /**
53
+ * A batch operation could not complete all entries within the retry budget.
54
+ * DynamoDB batch APIs return UnprocessedKeys/UnprocessedItems (HTTP 200) under
55
+ * sustained throttling; when retries are exhausted we surface this so callers
56
+ * can back off and resubmit rather than silently losing writes or mistaking a
57
+ * throttled read for a missing item.
58
+ *
59
+ * The in-memory mock never throttles, so it never produces this error — the
60
+ * constant is shared purely so catch-site handling is identical across both.
61
+ */
62
+ BatchIncomplete: 'BatchIncompleteException',
63
+ } as const;
64
+
65
+ /**
66
+ * @internal Build an Error whose `name` carries the typed error code (so callers
67
+ * can match it with `isBlocksError`). Shared by the mock and AWS runtime so both
68
+ * produce identically shaped errors.
69
+ */
70
+ export function blocksError(name: string, message: string): Error {
71
+ const err = new Error(`${name}: ${message}`);
72
+ err.name = name;
73
+ return err;
74
+ }
75
+
76
+ /**
77
+ * @internal Normalize a sort-key condition before it drives a query. Shared by
78
+ * the mock and AWS runtime so both treat the same inputs identically:
79
+ *
80
+ * - **Zero defined fields** (`undefined`, or a present-but-empty `{}` /
81
+ * `{ createdAt: undefined }`) → returns `undefined`, i.e. "no sort-key filter,
82
+ * query the whole partition". A present-but-empty object would otherwise
83
+ * diverge: the mock's per-item matcher accepts everything (returns the whole
84
+ * partition) while the AWS runtime registers `#sk` in `ExpressionAttributeNames`
85
+ * with no clause that uses it, which DynamoDB rejects with `ValidationException`.
86
+ * - **Exactly one defined field** → returns the condition unchanged.
87
+ * - **More than one defined field** → throws `InvalidQuery`, because DynamoDB allows
88
+ * only one sort-key condition per `KeyConditionExpression` (use `between` for ranges).
89
+ *
90
+ * @throws {DistributedTableErrors.InvalidQuery} If more than one sort-key field is defined.
91
+ */
92
+ export function normalizeSortKeyCondition<C extends Record<string, unknown>>(
93
+ condition: C | undefined,
94
+ ): C | undefined {
95
+ if (!condition) return undefined;
96
+ const definedKeys = Object.keys(condition).filter(k => condition[k] !== undefined);
97
+ if (definedKeys.length === 0) return undefined;
98
+ if (definedKeys.length > 1) {
99
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.multipleSortKeyConditions(definedKeys));
100
+ }
101
+ return condition;
102
+ }
103
+
104
+ /**
105
+ * @internal Validation messages shared by the mock and AWS runtime. Centralised
106
+ * here so the two implementations stay byte-for-byte in lockstep — parity tests
107
+ * assert the same wording against both.
108
+ */
109
+ export const DistributedTableMessages = {
110
+ indexNotFound: (index: string | undefined) => `Index '${index}' not found`,
111
+ whereRequired: (pkField: string) =>
112
+ `query() requires a 'where' clause with partition key field '${pkField}'`,
113
+ partitionKeyEqualsRequired: (pkField: string) =>
114
+ `query() requires '${pkField}: { equals: value }' in the where clause (partition key must be an exact match)`,
115
+ multipleSortKeyConditions: (conditionKeys: string[]) =>
116
+ `Only one sort key condition is allowed per query (DynamoDB limitation). ` +
117
+ `Got: ${conditionKeys.join(', ')}. Use "between" for range queries.`,
118
+ emptyIfFieldEquals: 'ifFieldEquals must contain at least one field with a non-undefined value',
119
+ itemTooLarge: (bytes: number) =>
120
+ `Item size has exceeded the maximum allowed size of 400 KB (got ${bytes} bytes)`,
121
+ batchIncomplete: (operation: string, remaining: number, attempts: number) =>
122
+ `${operation} did not complete: ${remaining} entr${remaining === 1 ? 'y' : 'ies'} still unprocessed ` +
123
+ `after ${attempts} attempts (DynamoDB throttling or response-size limits). Retry with backoff.`,
124
+ } as const;
125
+
126
+ /**
127
+ * @internal Re-map DynamoDB's generic `ValidationException` to the intent-revealing
128
+ * `ItemTooLarge` name when (and only when) it was raised for an oversized item.
129
+ *
130
+ * DynamoDB raises a single `ValidationException` for many unrelated conditions, so
131
+ * we narrow on the size-specific message ("size has exceeded") before re-mapping —
132
+ * other `ValidationException` causes (malformed expressions, type mismatches) are
133
+ * left untouched and propagate as-is. This mirrors the mock's client-side size
134
+ * check so both layers are catchable with `isBlocksError(e, ItemTooLarge)`. The
135
+ * original DynamoDB error is preserved as `cause` (kept server-side per D-003) so
136
+ * its stack and requestId remain available for debugging.
137
+ */
138
+ export function remapItemTooLarge(err: unknown): unknown {
139
+ if (err instanceof Error && err.name === 'ValidationException' && /size has exceeded/i.test(err.message)) {
140
+ const remapped = new Error(err.message, { cause: err });
141
+ remapped.name = DistributedTableErrors.ItemTooLarge;
142
+ return remapped;
143
+ }
144
+ return err;
145
+ }
@@ -0,0 +1,305 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ DynamoDBClient,
6
+ DescribeTableCommand,
7
+ UpdateTableCommand,
8
+ DeleteTableCommand,
9
+ CreateTableCommand,
10
+ ScanCommand,
11
+ BatchWriteItemCommand,
12
+ BillingMode,
13
+ ScalarAttributeType,
14
+ KeyType,
15
+ } from '@aws-sdk/client-dynamodb';
16
+
17
+ const dynamodb = new DynamoDBClient({});
18
+
19
+ interface IndexConfig {
20
+ partitionKey: string;
21
+ sortKey?: string;
22
+ partitionKeyType?: 'S' | 'N' | 'B';
23
+ sortKeyType?: 'S' | 'N' | 'B';
24
+ }
25
+
26
+ interface CfnEvent {
27
+ RequestType: 'Create' | 'Update' | 'Delete';
28
+ PhysicalResourceId?: string;
29
+ ResourceProperties: {
30
+ TableName: string;
31
+ Indexes: Record<string, IndexConfig>;
32
+ SandboxMode?: string;
33
+ };
34
+ }
35
+
36
+ // ── Helpers ─────────────────────────────────────────────────────────────────
37
+
38
+ async function describeTable(tableName: string) {
39
+ const result = await dynamodb.send(new DescribeTableCommand({ TableName: tableName }));
40
+ return result.Table!;
41
+ }
42
+
43
+ function getCurrentGSIs(table: any): Record<string, { pk: string; sk?: string }> {
44
+ const gsis: Record<string, { pk: string; sk?: string }> = {};
45
+ for (const gsi of table.GlobalSecondaryIndexes ?? []) {
46
+ const pk = gsi.KeySchema?.find((k: any) => k.KeyType === 'HASH')?.AttributeName;
47
+ const sk = gsi.KeySchema?.find((k: any) => k.KeyType === 'RANGE')?.AttributeName;
48
+ if (pk) gsis[gsi.IndexName!] = { pk, sk };
49
+ }
50
+ return gsis;
51
+ }
52
+
53
+ function gsiMatchesDesired(table: any, desired: Record<string, IndexConfig>): boolean {
54
+ const current = getCurrentGSIs(table);
55
+ const currentNames = Object.keys(current);
56
+ const desiredNames = Object.keys(desired);
57
+
58
+ if (currentNames.length !== desiredNames.length) return false;
59
+ for (const name of desiredNames) {
60
+ if (!current[name]) return false;
61
+ if (current[name].pk !== desired[name].partitionKey) return false;
62
+ if (current[name].sk !== desired[name].sortKey) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function isTableBusy(table: any): boolean {
68
+ if (table.TableStatus !== 'ACTIVE') return true;
69
+ for (const gsi of table.GlobalSecondaryIndexes ?? []) {
70
+ if (gsi.IndexStatus !== 'ACTIVE') return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ // ── Sandbox fast path ───────────────────────────────────────────────────────
76
+
77
+ async function recreateTableWithIndexes(tableName: string, desired: Record<string, IndexConfig>) {
78
+ console.log('⚠️ SANDBOX MODE: Recreating table with all GSIs (fast path)');
79
+
80
+ const table = await describeTable(tableName);
81
+
82
+ // Backup data
83
+ const items: any[] = [];
84
+ let lastKey: any;
85
+ do {
86
+ const result = await dynamodb.send(new ScanCommand({ TableName: tableName, ExclusiveStartKey: lastKey }));
87
+ items.push(...(result.Items ?? []));
88
+ lastKey = result.LastEvaluatedKey;
89
+ } while (lastKey);
90
+ console.log(`Backed up ${items.length} items`);
91
+
92
+ // Delete table
93
+ await dynamodb.send(new DeleteTableCommand({ TableName: tableName }));
94
+ while (true) {
95
+ try {
96
+ await dynamodb.send(new DescribeTableCommand({ TableName: tableName }));
97
+ await new Promise(r => setTimeout(r, 3000));
98
+ } catch (e: any) {
99
+ if (e.name === 'ResourceNotFoundException') break;
100
+ throw e;
101
+ }
102
+ }
103
+
104
+ // Collect all attribute definitions needed for keys
105
+ const usedAttrs = new Set<string>();
106
+ table.KeySchema!.forEach((k: any) => usedAttrs.add(k.AttributeName!));
107
+ for (const cfg of Object.values(desired)) {
108
+ usedAttrs.add(cfg.partitionKey);
109
+ if (cfg.sortKey) usedAttrs.add(cfg.sortKey);
110
+ }
111
+
112
+ const existingAttrMap = new Map<string, string>();
113
+ for (const attr of table.AttributeDefinitions ?? []) {
114
+ existingAttrMap.set(attr.AttributeName!, attr.AttributeType!);
115
+ }
116
+
117
+ const attrDefs = [...usedAttrs].map(name => ({
118
+ AttributeName: name,
119
+ AttributeType: (existingAttrMap.get(name) ??
120
+ Object.values(desired).find(c => c.partitionKey === name)?.partitionKeyType ??
121
+ Object.values(desired).find(c => c.sortKey === name)?.sortKeyType ??
122
+ ScalarAttributeType.S) as ScalarAttributeType,
123
+ }));
124
+
125
+ // Recreate with all GSIs
126
+ await dynamodb.send(new CreateTableCommand({
127
+ TableName: tableName,
128
+ KeySchema: table.KeySchema,
129
+ AttributeDefinitions: attrDefs,
130
+ BillingMode: BillingMode.PAY_PER_REQUEST,
131
+ GlobalSecondaryIndexes: Object.entries(desired).map(([name, cfg]) => ({
132
+ IndexName: name,
133
+ KeySchema: [
134
+ { AttributeName: cfg.partitionKey, KeyType: KeyType.HASH },
135
+ ...(cfg.sortKey ? [{ AttributeName: cfg.sortKey, KeyType: KeyType.RANGE }] : []),
136
+ ],
137
+ Projection: { ProjectionType: 'ALL' as const },
138
+ })),
139
+ }));
140
+
141
+ // Wait for active
142
+ while (true) {
143
+ const t = await describeTable(tableName);
144
+ if (!isTableBusy(t)) break;
145
+ await new Promise(r => setTimeout(r, 3000));
146
+ }
147
+
148
+ // Restore data
149
+ for (let i = 0; i < items.length; i += 25) {
150
+ await dynamodb.send(new BatchWriteItemCommand({
151
+ RequestItems: { [tableName]: items.slice(i, i + 25).map(item => ({ PutRequest: { Item: item } })) },
152
+ }));
153
+ }
154
+ console.log(`✅ Sandbox: table recreated with ${Object.keys(desired).length} GSIs, ${items.length} items restored`);
155
+ }
156
+
157
+ // ── onEvent handler ─────────────────────────────────────────────────────────
158
+ // Called once per Create/Update/Delete. Kicks off the first GSI change (or
159
+ // the sandbox fast path). Returns immediately — isComplete polls for progress.
160
+
161
+ export async function handler(event: CfnEvent) {
162
+ console.log('onEvent:', JSON.stringify(event, null, 2));
163
+
164
+ const { TableName, Indexes, SandboxMode } = event.ResourceProperties;
165
+ const desired = event.RequestType === 'Delete' ? {} : (Indexes ?? {});
166
+ const isSandbox = SandboxMode === 'true';
167
+
168
+ // On Create, we set the physical resource ID. On Update/Delete, we must
169
+ // echo back the original ID — CloudFormation rejects changes to it.
170
+ const physicalId = event.PhysicalResourceId ?? TableName;
171
+
172
+ // Check if already done
173
+ const table = await describeTable(TableName);
174
+ if (gsiMatchesDesired(table, desired) && !isTableBusy(table)) {
175
+ console.log('Already in desired state');
176
+ return { PhysicalResourceId: physicalId, Data: { Status: 'COMPLETE' } };
177
+ }
178
+
179
+ // Sandbox fast path: drop and recreate with all GSIs at once
180
+ if (isSandbox && Object.keys(desired).length > 0) {
181
+ await recreateTableWithIndexes(TableName, desired);
182
+ return { PhysicalResourceId: physicalId, Data: { Status: 'COMPLETE' } };
183
+ }
184
+
185
+ // Production path: initiate the first GSI change if table is idle.
186
+ // If table is busy (prior GSI still updating), just return — isComplete will poll.
187
+ if (!isTableBusy(table)) {
188
+ await initiateNextChange(TableName, table, desired);
189
+ }
190
+
191
+ return { PhysicalResourceId: physicalId, Data: { Status: 'IN_PROGRESS' } };
192
+ }
193
+
194
+ // ── isComplete handler ──────────────────────────────────────────────────────
195
+ // Called periodically by the Provider framework. Checks if the table matches
196
+ // the desired state. If a GSI update is in progress, returns IsComplete=false.
197
+ // If the table is idle but doesn't match, initiates the next change.
198
+ // Creations are performed before deletions when possible.
199
+
200
+ export async function isCompleteHandler(event: any) {
201
+ console.log('isComplete:', JSON.stringify(event, null, 2));
202
+
203
+ const { TableName, Indexes, SandboxMode } = event.ResourceProperties;
204
+ const desired = event.RequestType === 'Delete' ? {} : (Indexes ?? {});
205
+
206
+ const table = await describeTable(TableName);
207
+
208
+ // If a GSI operation is in progress, wait for it
209
+ if (isTableBusy(table)) {
210
+ console.log('Table busy, waiting...');
211
+ return { IsComplete: false };
212
+ }
213
+
214
+ // If we match the desired state, we're done
215
+ if (gsiMatchesDesired(table, desired)) {
216
+ console.log('✅ All GSIs match desired state');
217
+ return { IsComplete: true };
218
+ }
219
+
220
+ // Table is idle but doesn't match — initiate the next change
221
+ await initiateNextChange(TableName, table, desired);
222
+ return { IsComplete: false };
223
+ }
224
+
225
+ // ── Initiate next GSI change ────────────────────────────────────────────────
226
+ // Performs creations before deletions when possible.
227
+ //
228
+ // Edge case where deletion must happen first: if a desired GSI has the same
229
+ // name as an existing GSI but different key schema. DynamoDB doesn't support
230
+ // in-place GSI modification — the old one must be deleted before the new one
231
+ // can be created. We detect this by checking if a current GSI name exists in
232
+ // desired but with a different key schema.
233
+
234
+ async function initiateNextChange(
235
+ tableName: string,
236
+ table: any,
237
+ desired: Record<string, IndexConfig>,
238
+ ) {
239
+ const current = getCurrentGSIs(table);
240
+ const existingAttrMap = new Map<string, string>();
241
+ for (const attr of table.AttributeDefinitions ?? []) {
242
+ existingAttrMap.set(attr.AttributeName!, attr.AttributeType!);
243
+ }
244
+
245
+ // 1. Check for schema-mismatched GSIs that must be deleted before recreation.
246
+ // These take priority because the creation of the replacement can't proceed
247
+ // until the old one is gone.
248
+ for (const [name, cur] of Object.entries(current)) {
249
+ const des = desired[name];
250
+ if (des && (cur.pk !== des.partitionKey || cur.sk !== des.sortKey)) {
251
+ console.log(`Deleting GSI '${name}' (schema mismatch — must delete before recreating)`);
252
+ await dynamodb.send(new UpdateTableCommand({
253
+ TableName: tableName,
254
+ GlobalSecondaryIndexUpdates: [{ Delete: { IndexName: name } }],
255
+ }));
256
+ return;
257
+ }
258
+ }
259
+
260
+ // 2. Create missing GSIs (creations before deletions)
261
+ for (const [name, cfg] of Object.entries(desired)) {
262
+ if (!current[name]) {
263
+ console.log(`Creating GSI '${name}'`);
264
+ const attrDefs: { AttributeName: string; AttributeType: ScalarAttributeType }[] = [];
265
+ attrDefs.push({
266
+ AttributeName: cfg.partitionKey,
267
+ AttributeType: (existingAttrMap.get(cfg.partitionKey) ?? cfg.partitionKeyType ?? ScalarAttributeType.S) as ScalarAttributeType,
268
+ });
269
+ if (cfg.sortKey) {
270
+ attrDefs.push({
271
+ AttributeName: cfg.sortKey,
272
+ AttributeType: (existingAttrMap.get(cfg.sortKey) ?? cfg.sortKeyType ?? ScalarAttributeType.S) as ScalarAttributeType,
273
+ });
274
+ }
275
+
276
+ await dynamodb.send(new UpdateTableCommand({
277
+ TableName: tableName,
278
+ AttributeDefinitions: attrDefs,
279
+ GlobalSecondaryIndexUpdates: [{
280
+ Create: {
281
+ IndexName: name,
282
+ KeySchema: [
283
+ { AttributeName: cfg.partitionKey, KeyType: KeyType.HASH },
284
+ ...(cfg.sortKey ? [{ AttributeName: cfg.sortKey, KeyType: KeyType.RANGE }] : []),
285
+ ],
286
+ Projection: { ProjectionType: 'ALL' },
287
+ },
288
+ }],
289
+ }));
290
+ return;
291
+ }
292
+ }
293
+
294
+ // 3. Delete extra GSIs (only after all creations are done)
295
+ for (const name of Object.keys(current)) {
296
+ if (!desired[name]) {
297
+ console.log(`Deleting GSI '${name}' (no longer desired)`);
298
+ await dynamodb.send(new UpdateTableCommand({
299
+ TableName: tableName,
300
+ GlobalSecondaryIndexUpdates: [{ Delete: { IndexName: name } }],
301
+ }));
302
+ return;
303
+ }
304
+ }
305
+ }