@mshick/dyno 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.
@@ -0,0 +1,29 @@
1
+ import { reduceCapacity } from "../util.js";
2
+ import { ParallelWritable } from "./parallel-writable.js";
3
+ export class DynoWritableStream extends ParallelWritable {
4
+ Count = 0;
5
+ UnprocessedItems;
6
+ ConsumedCapacity;
7
+ TableName;
8
+ _consumedCapacity = [];
9
+ constructor(opts) {
10
+ super(opts);
11
+ this.TableName = opts.TableName;
12
+ }
13
+ update(response, items) {
14
+ const unprocessedItems = response?.UnprocessedItems?.[this.TableName];
15
+ const unprocessedItemsLength = unprocessedItems?.length ?? 0;
16
+ this.Count += items.length - unprocessedItemsLength;
17
+ this.UnprocessedItems = unprocessedItems
18
+ ? [...(this.UnprocessedItems ?? []), ...unprocessedItems]
19
+ : this.UnprocessedItems;
20
+ if (response?.ConsumedCapacity) {
21
+ this._consumedCapacity.push(...response.ConsumedCapacity);
22
+ }
23
+ }
24
+ done() {
25
+ if (this._consumedCapacity.length) {
26
+ this.ConsumedCapacity = reduceCapacity(this.ConsumedCapacity ?? {}, this._consumedCapacity);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,8 @@
1
+ import { Writable, type WritableOptions } from 'node:stream';
2
+ export type ParallelWritableOptions = WritableOptions & {
3
+ concurrency?: number;
4
+ };
5
+ export declare class ParallelWritable extends Writable {
6
+ pending: number;
7
+ constructor({ concurrency, write, final, ...writableOptions }?: ParallelWritableOptions);
8
+ }
@@ -0,0 +1,52 @@
1
+ import { Writable } from 'node:stream';
2
+ export class ParallelWritable extends Writable {
3
+ pending;
4
+ constructor({ concurrency = 5, write, final, ...writableOptions } = {}) {
5
+ super(writableOptions);
6
+ this.setMaxListeners(Number.POSITIVE_INFINITY);
7
+ this.pending = 0;
8
+ if (write) {
9
+ this._write = (chunk, enc, callback) => {
10
+ if (this.pending >= concurrency) {
11
+ this.once('free', () => {
12
+ this._write(chunk, enc, callback);
13
+ });
14
+ return;
15
+ }
16
+ this.pending += 1;
17
+ write.call(this, chunk, enc, (err) => {
18
+ this.pending -= 1;
19
+ if (err) {
20
+ this.destroy(err);
21
+ }
22
+ else {
23
+ this.emit('free');
24
+ }
25
+ });
26
+ callback();
27
+ };
28
+ }
29
+ if (final) {
30
+ this._final = (callback) => {
31
+ if (this.writableLength > 0) {
32
+ // TODO Is this necessary, or does _final already wait for drain?
33
+ // Wait for buffer to drain for calling final callback
34
+ this.once('drain', () => {
35
+ final.call(this, callback);
36
+ });
37
+ return;
38
+ }
39
+ if (this.pending) {
40
+ // Wait for pending requests to drop below allowed concurrency
41
+ this.on('free', () => {
42
+ if (!this.pending) {
43
+ final.call(this, callback);
44
+ }
45
+ });
46
+ return;
47
+ }
48
+ final.call(this, callback);
49
+ };
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,28 @@
1
+ import { type CreateTableCommandInput, type DeleteTableCommandInput, type DynamoDB, IndexStatus, type TableDescription, TableStatus, type UpdateTimeToLiveCommandOutput } from '@aws-sdk/client-dynamodb';
2
+ import type { EnsureTableInput, RequiredTableName } from './types.ts';
3
+ export type WaitForConnectionOptions = {
4
+ stabilizeDelay?: number;
5
+ maxDelay?: number;
6
+ };
7
+ export declare function waitForConnection(client: DynamoDB, options?: WaitForConnectionOptions): Promise<void>;
8
+ export declare function killConnection(client: DynamoDB): void;
9
+ export type WaitForIndexParams = {
10
+ TableName: string;
11
+ IndexName: string;
12
+ };
13
+ export type WaitForIndexOperation = typeof IndexStatus.DELETING | typeof IndexStatus.CREATING;
14
+ export declare function waitForIndex(client: DynamoDB, { TableName, IndexName }: WaitForIndexParams, operation: WaitForIndexOperation): Promise<void>;
15
+ export type WaitForTableParams = {
16
+ TableName: string;
17
+ };
18
+ export type WaitForTableOperation = typeof TableStatus.DELETING | typeof TableStatus.CREATING;
19
+ export declare function waitForTable(client: DynamoDB, { TableName }: WaitForTableParams, operation: WaitForTableOperation): Promise<void>;
20
+ export declare function createTable(client: DynamoDB, input: RequiredTableName<CreateTableCommandInput>): Promise<{
21
+ TableDescription: TableDescription;
22
+ }>;
23
+ export declare function deleteTable(client: DynamoDB, input: RequiredTableName<DeleteTableCommandInput>): Promise<void>;
24
+ export type EnsureTableOutput = {
25
+ TableDescription: TableDescription;
26
+ TimeToLiveSpecification?: UpdateTimeToLiveCommandOutput['TimeToLiveSpecification'];
27
+ };
28
+ export declare function ensureTable(client: DynamoDB, params: EnsureTableInput): Promise<EnsureTableOutput>;
package/dist/table.js ADDED
@@ -0,0 +1,161 @@
1
+ import { IndexStatus, ResourceInUseException, ResourceNotFoundException, TableStatus, TimeToLiveStatus, } from '@aws-sdk/client-dynamodb';
2
+ import isEqual from 'lodash/isEqual.js';
3
+ import { delay, getIndexUpdates, isEqualGSI } from "./util.js";
4
+ export async function waitForConnection(client, options = {}) {
5
+ const attemptDelay = 100;
6
+ const stabilizeDelay = options.stabilizeDelay ?? 500;
7
+ const maxDelay = options.maxDelay ?? 5000;
8
+ const maxAttempts = Math.floor(maxDelay / attemptDelay);
9
+ let attempts = 0;
10
+ while (true) {
11
+ if (maxAttempts > 0 && attempts >= maxAttempts) {
12
+ throw new Error('Max connection attempts reached');
13
+ }
14
+ const tables = await client.listTables().catch(() => undefined);
15
+ if (tables) {
16
+ await delay(stabilizeDelay);
17
+ break;
18
+ }
19
+ attempts += 1;
20
+ await delay(attemptDelay);
21
+ }
22
+ }
23
+ export function killConnection(client) {
24
+ client.destroy();
25
+ }
26
+ export async function waitForIndex(client, { TableName, IndexName }, operation) {
27
+ let creatingOrDeleting = true;
28
+ while (creatingOrDeleting) {
29
+ await delay(1000);
30
+ const tableDescription = await client.describeTable({ TableName });
31
+ const index = tableDescription.Table?.GlobalSecondaryIndexes?.find((index) => index.IndexName === IndexName);
32
+ if (operation === IndexStatus.DELETING) {
33
+ creatingOrDeleting = index?.IndexStatus === IndexStatus.DELETING;
34
+ }
35
+ else {
36
+ creatingOrDeleting = !index || index.IndexStatus === IndexStatus.CREATING;
37
+ }
38
+ }
39
+ }
40
+ export async function waitForTable(client, { TableName }, operation) {
41
+ let creatingOrDeleting = true;
42
+ while (creatingOrDeleting) {
43
+ await delay(1000);
44
+ const tableDescription = await client.describeTable({ TableName });
45
+ if (operation === TableStatus.DELETING) {
46
+ creatingOrDeleting = tableDescription?.Table?.TableStatus === TableStatus.DELETING;
47
+ }
48
+ else {
49
+ creatingOrDeleting =
50
+ !tableDescription?.Table || tableDescription?.Table?.TableStatus === TableStatus.CREATING;
51
+ }
52
+ }
53
+ }
54
+ async function updateTableIndexes(client, tableName, tableConfig, indexUpdates) {
55
+ let result;
56
+ // Updating the indexes must happen sequentially
57
+ for (const update of indexUpdates) {
58
+ // Cannot run simultaneous ops on the same GSI
59
+ const indexName = update.Delete?.IndexName ?? update.Create?.IndexName;
60
+ const operation = update.Delete ? IndexStatus.DELETING : IndexStatus.CREATING;
61
+ if (!indexName) {
62
+ continue;
63
+ }
64
+ result = await client.updateTable({
65
+ TableName: tableName,
66
+ AttributeDefinitions: tableConfig.AttributeDefinitions,
67
+ GlobalSecondaryIndexUpdates: [update],
68
+ });
69
+ await waitForIndex(client, { TableName: tableName, IndexName: indexName }, operation);
70
+ }
71
+ return result;
72
+ }
73
+ export async function createTable(client, input) {
74
+ const { TableName } = input;
75
+ try {
76
+ const table = await client.describeTable({ TableName });
77
+ if (!table.Table) {
78
+ throw new Error(`describeTable returned no Table for ${TableName}`);
79
+ }
80
+ return { TableDescription: table.Table };
81
+ }
82
+ catch (err) {
83
+ if (err instanceof ResourceNotFoundException) {
84
+ try {
85
+ const table = await client.createTable(input);
86
+ if (!table.TableDescription) {
87
+ throw new Error(`createTable returned no TableDescription for ${TableName}`);
88
+ }
89
+ return { TableDescription: table.TableDescription };
90
+ }
91
+ catch (err) {
92
+ if (err instanceof ResourceInUseException) {
93
+ await waitForTable(client, { TableName }, TableStatus.CREATING);
94
+ const table = await client.describeTable({ TableName });
95
+ if (!table.Table) {
96
+ throw new Error(`describeTable returned no Table for ${TableName}`);
97
+ }
98
+ return { TableDescription: table.Table };
99
+ }
100
+ throw new Error(`Failed to create ${TableName}`, { cause: err });
101
+ }
102
+ }
103
+ throw new Error(`Failed to create ${TableName}`, { cause: err });
104
+ }
105
+ }
106
+ export async function deleteTable(client, input) {
107
+ const { TableName } = input;
108
+ try {
109
+ const table = await client.describeTable({ TableName });
110
+ if (table?.Table?.TableStatus === TableStatus.ACTIVE) {
111
+ await client.deleteTable(input);
112
+ await waitForTable(client, { TableName }, TableStatus.DELETING);
113
+ }
114
+ }
115
+ catch (err) {
116
+ if (err instanceof ResourceNotFoundException) {
117
+ return;
118
+ }
119
+ throw new Error(`Failed to delete ${TableName}`, { cause: err });
120
+ }
121
+ }
122
+ export async function ensureTable(client, params) {
123
+ const { TimeToLiveSpecification, ...tableConfig } = params;
124
+ const { TableName } = tableConfig;
125
+ let { TableDescription } = await createTable(client, tableConfig);
126
+ if (tableConfig.StreamSpecification?.StreamEnabled &&
127
+ !isEqual(TableDescription.StreamSpecification, tableConfig.StreamSpecification)) {
128
+ TableDescription =
129
+ (await client.updateTable({
130
+ TableName,
131
+ StreamSpecification: tableConfig.StreamSpecification,
132
+ })).TableDescription ?? TableDescription;
133
+ }
134
+ if (tableConfig.GlobalSecondaryIndexes &&
135
+ !isEqualGSI(TableDescription.GlobalSecondaryIndexes, tableConfig.GlobalSecondaryIndexes)) {
136
+ const indexUpdates = getIndexUpdates(TableDescription, tableConfig);
137
+ if (indexUpdates) {
138
+ TableDescription =
139
+ (await updateTableIndexes(client, TableName, tableConfig, indexUpdates))
140
+ ?.TableDescription ?? TableDescription;
141
+ }
142
+ }
143
+ let updateTimeToLive;
144
+ if (TimeToLiveSpecification) {
145
+ const { TimeToLiveDescription } = await client.describeTimeToLive({
146
+ TableName,
147
+ });
148
+ if (TimeToLiveDescription &&
149
+ (TimeToLiveDescription.TimeToLiveStatus === TimeToLiveStatus.DISABLED ||
150
+ TimeToLiveDescription.TimeToLiveStatus === TimeToLiveStatus.DISABLING)) {
151
+ updateTimeToLive = await client.updateTimeToLive({
152
+ TableName,
153
+ TimeToLiveSpecification,
154
+ });
155
+ }
156
+ }
157
+ return {
158
+ TableDescription,
159
+ TimeToLiveSpecification: updateTimeToLive?.TimeToLiveSpecification,
160
+ };
161
+ }
@@ -0,0 +1,42 @@
1
+ import type { CreateTableCommandInput, GlobalSecondaryIndex, KeySchemaElement, KeyType, LocalSecondaryIndex, UpdateTimeToLiveCommandInput } from '@aws-sdk/client-dynamodb';
2
+ import type { NativeAttributeValue } from '@aws-sdk/util-dynamodb';
3
+ /**
4
+ * Can represent either a marshalled or unmarshalled document.
5
+ */
6
+ export type NativeAttributeMap = Record<string, NativeAttributeValue>;
7
+ export type MaybeTableName = {
8
+ TableName?: string | undefined;
9
+ };
10
+ /**
11
+ * AWS types make most tables `string | undefined` which is problematic
12
+ */
13
+ export type RequiredTableName<T extends MaybeTableName> = Omit<T, 'TableName'> & {
14
+ TableName: string;
15
+ };
16
+ /**
17
+ * Enforces that a TableName not be present, to avoid confusion in some inputs
18
+ */
19
+ export type OptionalTableName<T extends MaybeTableName, U extends string | undefined> = U extends string ? Omit<T, 'TableName'> : Omit<T, 'TableName'> & {
20
+ TableName: string;
21
+ };
22
+ type MaybeCompleteIndex = {
23
+ IndexName: string | undefined;
24
+ KeySchema: KeySchemaElement[] | undefined;
25
+ };
26
+ type RequiredCompleteIndex<T extends MaybeCompleteIndex> = Omit<T, 'IndexName' | 'KeySchmaElement'> & {
27
+ IndexName: string;
28
+ KeySchema: DynoKeySchemaElement[];
29
+ };
30
+ export type DynoKeySchemaElement = Omit<KeySchemaElement, 'AttributeName' | 'KeyType'> & {
31
+ AttributeName: string;
32
+ KeyType: KeyType;
33
+ };
34
+ export type DynoLocalSecondaryIndex = RequiredCompleteIndex<LocalSecondaryIndex>;
35
+ export type DynoGlobalSecondaryIndex = RequiredCompleteIndex<GlobalSecondaryIndex>;
36
+ export type EnsureTableInput = Omit<CreateTableCommandInput, 'TableName' | 'KeySchema' | 'LocalSecondaryIndexes' | 'GlobalSecondaryIndexes'> & {
37
+ TableName: string;
38
+ KeySchema: DynoKeySchemaElement[];
39
+ LocalSecondaryIndexes?: DynoLocalSecondaryIndex[];
40
+ GlobalSecondaryIndexes?: DynoGlobalSecondaryIndex[];
41
+ } & Partial<UpdateTimeToLiveCommandInput>;
42
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/util.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type AttributeValue, type ConsumedCapacity, type CreateTableCommandInput, type GlobalSecondaryIndexDescription, type GlobalSecondaryIndexUpdate, type TableDescription } from '@aws-sdk/client-dynamodb';
2
+ import type { NativeAttributeMap } from './types.ts';
3
+ export declare function calculateDelay(retryCount: number, retryBase?: number, jitterBase?: number, maxDelay?: number): number;
4
+ /**
5
+ * Calculate the size in bytes of a DynamoDB record.
6
+ */
7
+ export declare function itemSize(item: Record<string, AttributeValue>): number;
8
+ export declare function getItemSize(item: NativeAttributeMap): number;
9
+ export declare function readCost(item: NativeAttributeMap): number;
10
+ export declare function writeCost(item: NativeAttributeMap): number;
11
+ export declare function storageCost(item: NativeAttributeMap): number;
12
+ export declare const delay: <T>(timeout: number, value?: T) => Promise<T>;
13
+ /**
14
+ * Reduce two sets of consumed capacity metrics into a single object
15
+ * This should be in sync with Callback Parameters section of
16
+ * https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#query-property
17
+ */
18
+ export declare function reduceCapacity(existing: ConsumedCapacity, incoming: ConsumedCapacity[] | ConsumedCapacity): ConsumedCapacity;
19
+ /**
20
+ * Get the partition key from a table description
21
+ */
22
+ export declare function getPartitionKey(tableDescription: TableDescription): {
23
+ hashKey: string | undefined;
24
+ rangeKey: string | undefined;
25
+ keyName: string;
26
+ } | undefined;
27
+ /**
28
+ * Turn unknown objects into Errors
29
+ */
30
+ export declare function ensureError(err: unknown, msg?: string): Error;
31
+ export declare function isEqualGSI(a?: GlobalSecondaryIndexDescription[], b?: GlobalSecondaryIndexDescription[]): boolean;
32
+ export declare function getIndexUpdates(oldTable: TableDescription, newTable: CreateTableCommandInput): GlobalSecondaryIndexUpdate[] | undefined;
package/dist/util.js ADDED
@@ -0,0 +1,272 @@
1
+ import { TextEncoder } from 'node:util';
2
+ import { KeyType, } from '@aws-sdk/client-dynamodb';
3
+ import { marshall } from '@aws-sdk/util-dynamodb';
4
+ import Big from 'big.js';
5
+ import cloneDeep from 'lodash/cloneDeep.js';
6
+ import difference from 'lodash/difference.js';
7
+ import intersection from 'lodash/intersection.js';
8
+ import isEqual from 'lodash/isEqual.js';
9
+ import isEqualWith from 'lodash/isEqualWith.js';
10
+ import isPlainObject from 'lodash/isPlainObject.js';
11
+ import pick from 'lodash/pick.js';
12
+ export function calculateDelay(retryCount, retryBase = 250, jitterBase = 1000, maxDelay = 20000) {
13
+ const backoff = retryBase * 2 ** Math.max(0, retryCount);
14
+ const jitter = Math.random() * jitterBase;
15
+ return Math.round(Math.min(maxDelay, backoff + jitter));
16
+ }
17
+ const stringSize = (val) => {
18
+ // Account for size of utf-8 encoded chars in strings, emoji, etc
19
+ return new TextEncoder().encode(val).length;
20
+ };
21
+ const bufferSize = (val) => {
22
+ return Buffer.from(val).toString('base64').length;
23
+ };
24
+ const bigNumberSize = (val) => {
25
+ const v = new Big(val);
26
+ return Math.ceil(v.c.length / 2) + (v.e % 2 ? 1 : 2);
27
+ };
28
+ /**
29
+ * Calculate the size in bytes of a DynamoDB record.
30
+ */
31
+ export function itemSize(item) {
32
+ let size = 0;
33
+ const valueSize = (attrVal) => {
34
+ const type = Object.keys(attrVal)[0];
35
+ const val = attrVal[type];
36
+ let vSize = 0;
37
+ switch (type) {
38
+ case 'S':
39
+ vSize += stringSize(val);
40
+ break;
41
+ case 'B':
42
+ vSize += bufferSize(val);
43
+ break;
44
+ case 'N':
45
+ vSize += bigNumberSize(val);
46
+ break;
47
+ case 'SS':
48
+ vSize += val.reduce((sum, v) => sum + stringSize(v), 0);
49
+ break;
50
+ case 'BS':
51
+ vSize += val.reduce((sum, v) => sum + bufferSize(v), 0);
52
+ break;
53
+ case 'NS':
54
+ vSize += val.reduce((sum, v) => sum + bigNumberSize(v), 0);
55
+ break;
56
+ case 'M':
57
+ vSize += itemSize(val);
58
+ break;
59
+ case 'L':
60
+ vSize += val.reduce((sum, v) => sum + valueSize(v), 0);
61
+ break;
62
+ case 'BOOL':
63
+ // Best guess, this is the string length of the boolean
64
+ vSize += val ? 4 : 5;
65
+ break;
66
+ case 'NULL':
67
+ // String length of null?
68
+ vSize += 4;
69
+ break;
70
+ default:
71
+ break;
72
+ }
73
+ return vSize;
74
+ };
75
+ for (const [attributeName, attributeValue] of Object.entries(item)) {
76
+ size += attributeName.length;
77
+ size += valueSize(attributeValue);
78
+ }
79
+ return size;
80
+ }
81
+ export function getItemSize(item) {
82
+ return itemSize(marshall(item, { removeUndefinedValues: true }));
83
+ }
84
+ export function readCost(item) {
85
+ const size = getItemSize(item);
86
+ return Math.ceil(size / 1024 / 4);
87
+ }
88
+ export function writeCost(item) {
89
+ const size = getItemSize(item);
90
+ return Math.ceil(size / 1024);
91
+ }
92
+ export function storageCost(item) {
93
+ const size = getItemSize(item);
94
+ return size + 100;
95
+ }
96
+ export const delay = async (timeout, value) => new Promise((resolve) => {
97
+ setTimeout(resolve, timeout, value);
98
+ });
99
+ /**
100
+ * Reduce two sets of consumed capacity metrics into a single object
101
+ * This should be in sync with Callback Parameters section of
102
+ * https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#query-property
103
+ */
104
+ export function reduceCapacity(existing, incoming) {
105
+ let target = cloneDeep(existing);
106
+ if (Array.isArray(incoming)) {
107
+ for (const item of incoming) {
108
+ target = reduceCapacity(target, item);
109
+ }
110
+ return target;
111
+ }
112
+ const mergeCapacityUnits = (dst, src) => {
113
+ if (src.CapacityUnits) {
114
+ dst.CapacityUnits = (dst.CapacityUnits ?? 0) + src.CapacityUnits;
115
+ }
116
+ if (src.ReadCapacityUnits) {
117
+ dst.ReadCapacityUnits = (dst.ReadCapacityUnits ?? 0) + src.ReadCapacityUnits;
118
+ }
119
+ if (src.WriteCapacityUnits) {
120
+ dst.WriteCapacityUnits = (dst.WriteCapacityUnits ?? 0) + src.WriteCapacityUnits;
121
+ }
122
+ };
123
+ const mergeCapacityParents = (dst, src, k) => {
124
+ const s = src[k];
125
+ const d = dst[k] ?? {};
126
+ if (!s) {
127
+ return;
128
+ }
129
+ dst[k] = d;
130
+ mergeCapacityUnits(d, s);
131
+ };
132
+ target.Table ??= target.Table ?? {};
133
+ target.TableName ??= incoming.TableName;
134
+ mergeCapacityUnits(target, incoming);
135
+ mergeCapacityUnits(target.Table, incoming.Table ?? {});
136
+ for (const indexGroup of ['LocalSecondaryIndexes', 'GlobalSecondaryIndexes']) {
137
+ const dst = target[indexGroup] ?? {};
138
+ const src = incoming[indexGroup] ?? {};
139
+ for (const index of Object.keys(src)) {
140
+ mergeCapacityParents(dst, src, index);
141
+ }
142
+ target[indexGroup] = dst;
143
+ }
144
+ return target;
145
+ }
146
+ /**
147
+ * Get the partition key from a table description
148
+ */
149
+ export function getPartitionKey(tableDescription) {
150
+ const hashKey = tableDescription.KeySchema?.find((el) => el.KeyType === KeyType.HASH)?.AttributeName;
151
+ const rangeKey = tableDescription.KeySchema?.find((el) => el.KeyType === KeyType.RANGE)?.AttributeName;
152
+ if (!hashKey && !rangeKey) {
153
+ return;
154
+ }
155
+ return {
156
+ hashKey,
157
+ rangeKey,
158
+ keyName: [hashKey, rangeKey].filter((x) => x).join('_'),
159
+ };
160
+ }
161
+ /**
162
+ * Turn unknown objects into Errors
163
+ */
164
+ export function ensureError(err, msg = 'Unknown error') {
165
+ if (err instanceof Error) {
166
+ return err;
167
+ }
168
+ let message = msg;
169
+ if (err && typeof err === 'object') {
170
+ message = typeof err.message === 'string' ? err.message : message;
171
+ }
172
+ return new Error(message, { cause: err });
173
+ }
174
+ function isRecord(value) {
175
+ return isPlainObject(value);
176
+ }
177
+ const pickGSIKeys = (gsi) => pick(gsi, ['IndexName', 'KeySchema', 'Projection']);
178
+ const pickGSIList = (gsiList) => gsiList?.map(pickGSIKeys) ?? null;
179
+ const arrayObjectSortKeys = ['AttributeName', 'IndexName'];
180
+ const arraySorter = (a, b) => {
181
+ if (typeof a === 'string' && typeof b === 'string') {
182
+ return a.localeCompare(b);
183
+ }
184
+ if (isRecord(a) && isRecord(b)) {
185
+ for (const key of arrayObjectSortKeys) {
186
+ const aa = a[key];
187
+ const bb = b[key];
188
+ if (aa && bb) {
189
+ return aa.localeCompare(bb);
190
+ }
191
+ }
192
+ }
193
+ throw new Error('Unexpected config, could not determine if index migration is needed');
194
+ };
195
+ const equalWith = (a, b) => {
196
+ if (Array.isArray(a) && Array.isArray(b)) {
197
+ if (a.length !== b.length) {
198
+ return false;
199
+ }
200
+ const aSorted = a.sort(arraySorter);
201
+ const bSorted = b.sort(arraySorter);
202
+ for (let i = 0; i < aSorted.length; i++) {
203
+ if (!isEqualWith(aSorted[i], bSorted[i], equalWith)) {
204
+ return false;
205
+ }
206
+ }
207
+ return true;
208
+ }
209
+ return undefined;
210
+ };
211
+ export function isEqualGSI(a, b) {
212
+ return isEqualWith(pickGSIList(a), pickGSIList(b), equalWith);
213
+ }
214
+ function isDefined(x) {
215
+ return x !== null && x !== undefined;
216
+ }
217
+ function getChangedIndexes(oldIndexList, newIndexList) {
218
+ const oldIndexes = oldIndexList.map(({ IndexName }) => IndexName) ?? [];
219
+ const newIndexes = newIndexList.map(({ IndexName }) => IndexName);
220
+ const added = difference(newIndexes, oldIndexes).filter(isDefined);
221
+ const removed = difference(oldIndexes, newIndexes).filter(isDefined);
222
+ let updated = intersection(oldIndexes, newIndexes).filter(isDefined);
223
+ if (updated.length) {
224
+ updated = updated.filter((indexName) => {
225
+ const oldIndex = oldIndexList.find(({ IndexName }) => IndexName === indexName);
226
+ const newIndex = newIndexList.find(({ IndexName }) => IndexName === indexName);
227
+ if (!oldIndex && !newIndex) {
228
+ return false;
229
+ }
230
+ return !isEqual(oldIndex ? pickGSIKeys(oldIndex) : undefined, newIndex ? pickGSIKeys(newIndex) : undefined);
231
+ });
232
+ }
233
+ return {
234
+ added,
235
+ removed,
236
+ updated,
237
+ };
238
+ }
239
+ export function getIndexUpdates(oldTable, newTable) {
240
+ const oldIndexList = oldTable.GlobalSecondaryIndexes ?? [];
241
+ const newIndexList = newTable.GlobalSecondaryIndexes ?? [];
242
+ const { added, removed, updated } = getChangedIndexes(oldIndexList, newIndexList);
243
+ let indexUpdates = [];
244
+ if (updated.length) {
245
+ const updates = updated.flatMap((indexName) => [
246
+ {
247
+ Delete: {
248
+ IndexName: indexName,
249
+ },
250
+ },
251
+ {
252
+ Create: newTable.GlobalSecondaryIndexes?.find(({ IndexName }) => IndexName === indexName),
253
+ },
254
+ ]);
255
+ indexUpdates = [...indexUpdates, ...updates];
256
+ }
257
+ if (removed.length) {
258
+ const updates = removed.map((indexName) => ({
259
+ Delete: {
260
+ IndexName: indexName,
261
+ },
262
+ }));
263
+ indexUpdates = [...indexUpdates, ...updates];
264
+ }
265
+ if (added.length) {
266
+ const updates = added.map((indexName) => ({
267
+ Create: newTable.GlobalSecondaryIndexes?.find(({ IndexName }) => IndexName === indexName),
268
+ }));
269
+ indexUpdates = [...indexUpdates, ...updates];
270
+ }
271
+ return indexUpdates.length ? indexUpdates : undefined;
272
+ }