@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.
- package/LICENSE +174 -0
- package/README.md +292 -0
- package/dist/errors.d.ts +111 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/gsi-manager-lambda/index.js +221 -0
- package/dist/gsi-manager-lambda.d.ts +26 -0
- package/dist/gsi-manager-lambda.d.ts.map +1 -0
- package/dist/gsi-manager-lambda.js +244 -0
- package/dist/index.aws.d.ts +59 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +310 -0
- package/dist/index.browser.d.ts +5 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +7 -0
- package/dist/index.cdk.d.ts +27 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +180 -0
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +93 -0
- package/dist/index.mock.d.ts +101 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +301 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +555 -0
- package/dist/parity.test.d.ts +2 -0
- package/dist/parity.test.d.ts.map +1 -0
- package/dist/parity.test.js +557 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/package.json +49 -0
- package/src/errors.ts +145 -0
- package/src/gsi-manager-lambda.ts +305 -0
- package/src/index.aws.ts +400 -0
- package/src/index.browser.ts +8 -0
- package/src/index.cdk.test.ts +107 -0
- package/src/index.cdk.ts +220 -0
- package/src/index.mock.ts +363 -0
- package/src/index.test.ts +657 -0
- package/src/parity.test.ts +763 -0
- package/src/types.ts +163 -0
- package/src/version.ts +3 -0
package/dist/types.d.ts
ADDED
|
@@ -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":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,+EAA+E;IAC/E,YAAY,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;IAC/B,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB,CACvC,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;IAErF,mFAAmF;IACnF,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC5B,iCAAiC;IACjC,GAAG,EAAE,CAAC,CAAC;IACP,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;;;;OAcG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;IACvB,sDAAsD;IACtD,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAID;;;GAGG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,IACtE,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,EAAE,SAAS,MAAM,CAAC,GAAG,MAAM,CAAA;CAAE,GACrD,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,EAAE,CAAC,CAAC,GACzC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC;AAIzC,gFAAgF;AAChF,MAAM,MAAM,qBAAqB,CAAC,CAAC,IAAI;IAAE,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC;AAErD,8EAA8E;AAC9E,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI;IACjC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,CAAC;IACb,eAAe,CAAC,EAAE,CAAC,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,KAAK,CAAC;CAC/C,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,IACtD,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,EAAE,SAAS,MAAM,CAAC,GAAG,MAAM,CAAA;CAAE,GACrD;KAAG,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GACzD;KAAG,CAAC,IAAI,EAAE,CAAC,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GACtC;KAAG,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,CAAC;AAI9D;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,YAAY,CACvB,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,EAC3B,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,IAC9C;KACF,IAAI,IAAI,MAAM,GAAG,MAAM,OAAO,GAAG;QACjC,mDAAmD;QACnD,KAAK,EAAE,IAAI,CAAC;QACZ,oCAAoC;QACpC,KAAK,EAAE,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,yCAAyC;QACzC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,qCAAqC;QACrC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;KACvB;CACD,CAAC,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG;IAC3B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,gDAAgD;IAChD,KAAK,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1B,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,WAAW,WAAW;IAC3B,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,IACrB;IAAE,WAAW,EAAE,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5C;IAAE,WAAW,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAAE,GAClD,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAEzB,MAAM,MAAM,aAAa,CAAC,CAAC,IACxB;IAAE,QAAQ,EAAE,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GACzC;IAAE,QAAQ,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAAE,GAC/C,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -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"}
|
package/dist/version.js
ADDED
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
|
+
}
|