• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

autopulated / dynamodm / 16356840039

17 Jul 2025 09:55PM UTC coverage: 99.664% (-0.3%) from 99.915%
16356840039

push

github

autopulated
Do not create a type index if we only have one model type in the table.

494 of 497 branches covered (99.4%)

Branch coverage included in aggregate %.

31 of 31 new or added lines in 1 file covered. (100.0%)

5 existing lines in 1 file now uncovered.

1879 of 1884 relevant lines covered (99.73%)

146.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.5
/lib/table.js
1
'use strict';
7✔
2
const { DynamoDBClient, CreateTableCommand, DescribeTableCommand, UpdateTableCommand, DeleteTableCommand } = require('@aws-sdk/client-dynamodb');
7✔
3
const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb');
7✔
4

7✔
5
const { createModel } = require('./model');
7✔
6
const {
7✔
7
    kTableIsReady,
7✔
8
    kTableDDBClient,
7✔
9
    kTableIndices,
7✔
10
    kTableGetBackoffDelayMs,
7✔
11

7✔
12
    kSchemaIndices,
7✔
13

7✔
14
    delayMs,
7✔
15
} = require('./shared.js');
7✔
16

7✔
17
const validTableName = /^[a-zA-Z0-9_.-]{3,255}$/;
7✔
18

7✔
19
const keySchemaEqual = (a, b) => {
7✔
20
    return a.AttributeName === b.AttributeName &&
98✔
21
           a.KeyType === b.KeyType;
98✔
22
};
7✔
23

7✔
24
const indexDescriptionsEqual = (a, b) => {
7✔
25
    // TODO: not comparing NonKeyAttributes here, but should be
34✔
26
    return a && b &&
34✔
27
           a.IndexName === b.IndexName &&
34✔
28
           a.KeySchema.length === b.KeySchema.length &&
34✔
29
           a.Projection.ProjectionType === b.Projection.ProjectionType &&
34✔
30
           a.KeySchema.every((el, i) => keySchemaEqual(el, b.KeySchema[i]));
34✔
31
};
7✔
32

7✔
33
class Table {
7✔
34
    // public fields
7✔
35
    name = '';
7✔
36
    client = null;
54✔
37
    docClient = null;
54✔
38

54✔
39
    // private fields:
54✔
40
    #models = new Map();
54✔
41
    #idFieldName = '';
54✔
42
    #typeFieldName = '';
54✔
43
    #clientShouldBeDestroyed = false;
54✔
44
    #logger = null;
54✔
45
    #retryOptions = {
54✔
46
        exponent:2,
54✔
47
        delayRandomness:0.75, // 0 = no jitter, 1 = full jitter
54✔
48
        maxRetries:5
54✔
49
    };
54✔
50

54✔
51
    // protected fields that other classes need direct access to
54✔
52
    [kTableIsReady] = false;
54✔
53
    [kTableDDBClient] = null;
54✔
54
    [kTableIndices] = [];
54✔
55

54✔
56
    constructor(options) {
54✔
57
        // TODO: The marshall options should be fixed?
54✔
58
        let { name, client, clientOptions, retry } = options;
54✔
59

54✔
60
        if (typeof name !== 'string') {
54✔
61
            throw new Error('Invalid table name: Must be a string.');
2✔
62
        } else if(!validTableName.exec(name)) {
54✔
63
            throw new Error(`Invalid table name "${name}": Must be between 3 and 255 characters long, and may contain only the characters a-z, A-Z, 0-9, '_', '-', and '.'.`);
2✔
64
        }
2✔
65
        this.#logger = options.logger.child({table: name});
50✔
66
        if (!client) {
50✔
67
            client = new DynamoDBClient(clientOptions);
50✔
68
        }
50✔
69
        this.name = name;
50✔
70
        this.client = client;
50✔
71
        this.docClient = DynamoDBDocumentClient.from(client);
50✔
72
        this[kTableDDBClient] = this.docClient;
50✔
73
        this.#clientShouldBeDestroyed = !options.client;
50✔
74
        this.#retryOptions = Object.assign(this.#retryOptions, retry);
50✔
75
    }
54✔
76

54✔
77
    // public methods:
54✔
78
    // wait for this table to be ready
54✔
79
    async ready({allowAliasedSchemas, waitForIndexes}={}) {
54✔
80
        if (this[kTableIsReady] && !waitForIndexes) {
48✔
81
            return;
3✔
82
        } else if (!this.client) {
48✔
83
            throw new Error('Connection has been destroyed.');
1✔
84
        }
1✔
85
        if (!this.#models.size) {
48✔
86
            throw new Error('At least one schema is required in a table.');
1✔
87
        }
1✔
88
        // check for and create indexes:
43✔
89
        const {idField, typeField} = this.#basicReadyChecks({allowAliasedSchemas});
43✔
90
        this.#idFieldName = idField;
43✔
91
        this.#typeFieldName = typeField;
43✔
92

43✔
93
        const requiredIndexes = this.#requiredIndexes();
43✔
94
        this[kTableIndices] = requiredIndexes;
43✔
95
        const {uniqueRequiredAttributes} = this.#checkIndexCompatibility(requiredIndexes);
43✔
96
        const tableKeySchema = [
43✔
97
            { AttributeName: this.#idFieldName, KeyType: 'HASH' }
43✔
98
        ];
43✔
99

43✔
100
        try {
43✔
101
            await this[kTableDDBClient].send(new CreateTableCommand({
43✔
102
                TableName: this.name,
43✔
103
                // the id field (table key), as well as any attributes referred to
43✔
104
                // by indexes need to be defined at table creation
43✔
105
                AttributeDefinitions: uniqueRequiredAttributes,
43✔
106
                KeySchema: tableKeySchema,
43✔
107
                BillingMode: 'PAY_PER_REQUEST',
43✔
108
                ...(requiredIndexes.length && {GlobalSecondaryIndexes: requiredIndexes.map(x => x.index)})
48✔
109
            }));
48✔
110
        } catch (err) {
40✔
111
            // ResourceInUseException is only thrown if the table already exists
17✔
112
            /* c8 ignore next 3 */
1✔
113
            if (err.name !== 'ResourceInUseException') {
1✔
114
                throw err;
1✔
115
            }
1✔
116
        }
17✔
117

38✔
118
        let tableHasRequiredIndexes;
38✔
119
        let missingIndexes = [];
38✔
120
        let differentIndexes = [];
38✔
121
        let response;
38✔
122
        let created = false;
38✔
123
        do {
45✔
124
            tableHasRequiredIndexes = true;
39✔
125
            response = await this[kTableDDBClient].send(new DescribeTableCommand({TableName: this.name}));
39✔
126
            if (response.Table.TableStatus === 'CREATING') {
39✔
127
                await delayMs(500);
1✔
128
            } else if (response.Table.TableStatus === 'ACTIVE' || response.Table.TableStatus === 'UPDATING') {
39✔
129
                this.#logger.info('Table %s now %s', this.name, response.Table.TableStatus);
38✔
130
                created = true;
38✔
131
                /* c8 ignore next 3 */
1✔
132
            } else {
1✔
133
                throw new Error(`Table ${this.name} status is ${response.Table.TableStatus}.`);
1✔
134
            }
1✔
135

39✔
136
            // check if the table has the correct key schema (if it already
39✔
137
            // existed, then it might not):
39✔
138
            if (created) {
39✔
139
                if (!response.Table.KeySchema.some((el, i) => keySchemaEqual(el, tableKeySchema[i]))) {
38✔
140
                    throw new Error(`Table ${this.name} exists with incompatible key schema ${JSON.stringify(response.Table.KeySchema)}, the schemas require "${this.#idFieldName}" to be the hash key.`);
1✔
141
                }
1✔
142
            }
38✔
143

38✔
144
            // check if we have all the required indexes
38✔
145
            for (const {index, requiredAttributes} of requiredIndexes) {
39✔
146
                const match = response.Table.GlobalSecondaryIndexes?.find(i => i.IndexName === index.IndexName);
39✔
147
                if (!match) {
39✔
148
                    missingIndexes.push({index, requiredAttributes});
6✔
149
                    tableHasRequiredIndexes = false;
6✔
150
                } else if (!indexDescriptionsEqual(match, index)){
39!
UNCOV
151
                    differentIndexes.push({index, requiredAttributes});
×
UNCOV
152
                    tableHasRequiredIndexes = false;
×
UNCOV
153
                }
×
154
                // TODO: while we don't need to check for missing
39✔
155
                // requiredAttributes, we should check for incompatible
39✔
156
                // requiredAttributes returned from DescribeTableCommand I think,
39✔
157
                // as those could prevent index creation?
39✔
158
            }
39✔
159
        } while (!created);
48✔
160

37✔
161
        if (!tableHasRequiredIndexes) {
48✔
162
            await this.#updateIndexes({differentIndexes, missingIndexes, existingIndexes: response.Table.GlobalSecondaryIndexes, createAll: waitForIndexes});
3✔
163
        }
2✔
164

36✔
165
        if (waitForIndexes) {
48✔
166
            await this.#waitForIndexesActive();
8✔
167
        }
8✔
168

36✔
169
        this[kTableIsReady] = true;
36✔
170
    }
48✔
171

54✔
172
    // assume that the table is ready (e.g. if connecting to the DB from a
54✔
173
    // short-lived lambda function, and you know the table has been created
54✔
174
    // correctly already).
54✔
175
    assumeReady() {
54✔
176
        // still do a quick check that the registered models are compatible:
2✔
177
        this.#basicReadyChecks();
2✔
178
        // the list of indexes needs initialising for the query api
2✔
179
        this[kTableIndices] = this.#requiredIndexes();
2✔
180
        // TODO: not sure if index compatibility should be checked, this could be relatively expensive:
2✔
181
        this.#checkIndexCompatibility(this[kTableIndices]);
2✔
182
        this[kTableIsReady] = true;
2✔
183
    }
2✔
184

54✔
185
    async destroyConnection() {
54✔
186
        this[kTableIsReady] = false;
12✔
187
        if (this.#clientShouldBeDestroyed) {
12✔
188
            this.client.destroy();
12✔
189
            this.#clientShouldBeDestroyed = false;
12✔
190
        }
12✔
191
        this.client = this.docClient = null;
12✔
192
        this.#models.clear();
12✔
193
    }
12✔
194

54✔
195
    model(schema){
54✔
196
        // Not checking using 'instanceof' here, because the Schema might come
98✔
197
        // from a different realm, and we want to allow that.
98✔
198
        if (!(schema && schema.name && schema.idFieldName && schema.source && schema.methods)) {
98✔
199
            throw new Error('The model schema must be a valid DynamoDM.Schema().');
1✔
200
        }
1✔
201
        // add the schema to the table's schema list and return a corresponding
97✔
202
        // model that can be used to create and retrieve documents,or return the
97✔
203
        // existing model if this schema has already been added.
97✔
204
        // Validating most compatibility is done in Table.ready()
97✔
205
        if (this.#models.has(schema)) {
98✔
206
            return this.#models.get(schema);
2✔
207
        }
2✔
208
        if (this[kTableIsReady]) {
98✔
209
            throw new Error(`Table ${this.name} ready() has been called, so more schemas cannot be added now.`);
2✔
210
        }
2✔
211

93✔
212
        const model = createModel({table:this, schema, logger:this.#logger});
93✔
213
        this.#models.set(schema, model);
93✔
214

93✔
215
        return model;
93✔
216
    }
98✔
217

54✔
218
    async getById(id) {
54✔
219
        // load a model by id only (without knowing its type in advance). The
3✔
220
        // type is inferred from the id, and requires the id to be of the form
3✔
221
        // {schemaName}.{anything}, via the Schema->Models map this.#models:
3✔
222
        const matchingModels = [...this.#models.entries()].filter(([s,ignored_m]) => id.startsWith(`${s.name}.`)).map(([ignored_s,m]) => m);
3✔
223
        if (matchingModels.length > 1) {
3✔
224
            throw new Error(`Table has multiple ambiguous model types for id "${id}", so it cannot be loaded generically.`);
1✔
225
        } else if (matchingModels.length === 0) {
3✔
226
            throw new Error(`Table has no matching model type for id "${id}", so it cannot be loaded.`);
1✔
227
        }
1✔
228
        return matchingModels[0].getById(id);
1✔
229
    }
3✔
230

54✔
231
    async deleteTable() {
54✔
232
        this.#logger.info({}, 'Deleting tqble %s', this.name);
12✔
233
        await this[kTableDDBClient].send(new DeleteTableCommand({ TableName: this.name }));
12✔
234
    }
12✔
235

54✔
236
    // protected methods
54✔
237
    [kTableGetBackoffDelayMs] = (retryNumber) => {
54✔
238
        if (retryNumber >= this.#retryOptions.maxRetries) {
2✔
239
            throw new Error('Request failed: maximum retries exceeded.');
1✔
240
        }
1✔
241
        return (this.#retryOptions.exponent ** retryNumber) * ((1-this.#retryOptions.delayRandomness) + this.#retryOptions.delayRandomness * Math.random());
1✔
242
    };
7✔
243

7✔
244
    // private methods:
7✔
245
    #basicReadyChecks({allowAliasedSchemas} = {}) {
7✔
246
        const idProps = new Set();
45✔
247
        const typeProps = new Set();
45✔
248
        const typeNames = new Map();
45✔
249
        for (const schema of this.#models.keys()) {
45✔
250
            idProps.add(schema.idFieldName);
93✔
251
            typeProps.add(schema.typeFieldName);
93✔
252
            if (!typeNames.has(schema.name)) typeNames.set(schema.name, []);
93✔
253
            typeNames.get(schema.name).push(schema);
93✔
254
        }
93✔
255
        if (!allowAliasedSchemas) {
45✔
256
            for (const [name, schemas] of typeNames) {
44✔
257
                if (schemas.length > 1) {
81✔
258
                    throw new Error(`Schemas in the same table must have unique names (${name} referrs to multiple unique schemas).`);
1✔
259
                }
1✔
260
            }
81✔
261
        }
43✔
262
        if (idProps.size > 1) {
45✔
263
            throw new Error(`Schemas in the same table must have the same idFieldName (encountered:${[...idProps].join(',')}).`);
2✔
264
        }
2✔
265
        if (typeProps.size > 1) {
45✔
266
            throw new Error(`Schemas in the same table must have the same typeFieldName (encountered:${[...typeProps].join(',')}).`);
1✔
267
        }
1✔
268
        return {
41✔
269
            idField: idProps.values().next().value,
41✔
270
            typeField: typeProps.values().next().value
41✔
271
        };
41✔
272
    }
45✔
273

7✔
274
    #requiredIndexes() {
7✔
275
        const requiredIndexes = [];
41✔
276
        // Only require the type index if we have multiple schemas in this
41✔
277
        // table, to support single-model tables mode efficiently:
41✔
278
        if (this.#models.size > 1) {
41✔
279
            requiredIndexes.push({
12✔
280
                index: {
12✔
281
                    // The built-in type index,
12✔
282
                    IndexName: 'type',
12✔
283
                    KeySchema: [
12✔
284
                      { AttributeName: this.#typeFieldName, KeyType: 'HASH' },
12✔
285
                      { AttributeName: this.#idFieldName,   KeyType: 'RANGE'}
12✔
286
                    ],
12✔
287
                    Projection: { ProjectionType: 'KEYS_ONLY' },
12✔
288
                },
12✔
289
                requiredAttributes: [
12✔
290
                    { AttributeName: this.#idFieldName,   AttributeType: 'S' },
12✔
291
                    { AttributeName: this.#typeFieldName, AttributeType: 'S' }
12✔
292
                ],
12✔
293
                hashKey: this.#typeFieldName,
12✔
294
                sortKey: this.#idFieldName
12✔
295
            });
12✔
296
        }
12✔
297
        for (const schema of this.#models.keys()) {
41✔
298
            requiredIndexes.push(...schema[kSchemaIndices]);
82✔
299
        }
82✔
300
        return requiredIndexes;
41✔
301
    }
41✔
302

7✔
303
    #checkIndexCompatibility(allRequiredIndexes) {
7✔
304
        const uniqueRequiredAttributes = [];
41✔
305
        const attributeTypes = new Map();
41✔
306
        const indexNames = new Map();
41✔
307

41✔
308
        // The table hash key is always the ID field, and while it is not an
41✔
309
        // index field, it is a required attribute type that we need to check
41✔
310
        // compatibility with:
41✔
311
        allRequiredIndexes = [{
41✔
312
            requiredAttributes: [
41✔
313
                { AttributeName: this.#idFieldName, AttributeType: 'S' },
41✔
314
            ]
41✔
315
        }].concat(allRequiredIndexes);
41✔
316
        for (const {index, requiredAttributes} of allRequiredIndexes) {
41✔
317
            for (const {AttributeName, AttributeType} of requiredAttributes) {
98✔
318
                // store all the types we encounter for each attribute name for comparison:
137✔
319
                if(!attributeTypes.has(AttributeName)) {
137✔
320
                    attributeTypes.set(AttributeName, AttributeType);
95✔
321
                    uniqueRequiredAttributes.push({AttributeName, AttributeType});
95✔
322
                } else if (attributeTypes.get(AttributeName) !== AttributeType) {
137✔
323
                    let offendingSchemas = [];
1✔
324
                    let offendingIndexes = [];
1✔
325
                    let offendingDefinitions = [];
1✔
326
                    for (const schema of this.#models.keys()) {
1✔
327
                        for (const schemaIndex of schema[kSchemaIndices]) {
6✔
328
                            for (const attr of schemaIndex.requiredAttributes) {
9✔
329
                                if (attr.AttributeName === AttributeName) {
14✔
330
                                    offendingSchemas.push(schema.name);
2✔
331
                                    offendingIndexes.push(schemaIndex.index.IndexName);
2✔
332
                                    offendingDefinitions.push(attr.AttributeType);
2✔
333
                                    break;
2✔
334
                                }
2✔
335
                            }
14✔
336
                        }
9✔
337
                    }
6✔
338
                    throw new Error(`Schema(s) "${offendingSchemas.join(', ')}" define incompatible types (${offendingDefinitions.join(',')}) for ".${AttributeName}" in index(es) "${offendingIndexes.join(', ')}".`);
1✔
339
                }
1✔
340
            }
137✔
341
            if (index) {
98✔
342
                if(!indexNames.has(index.IndexName)) {
56✔
343
                    indexNames.set(index.IndexName, index);
55✔
344
                } else if(!indexDescriptionsEqual(index, indexNames.get(index.IndexName))) {
56✔
345
                    let offendingSchemas = [];
1✔
346
                    let offendingDefinitions = [];
1✔
347
                    for (const schema of this.#models.keys()) {
1✔
348
                        for (const schemaIndex of schema[kSchemaIndices]) {
6✔
349
                            if (schemaIndex.index.IndexName === index.IndexName) {
8✔
350
                                offendingSchemas.push(schema.name);
2✔
351
                                offendingDefinitions.push(schemaIndex.index);
2✔
352
                                break;
2✔
353
                            }
2✔
354
                        }
8✔
355
                    }
6✔
356
                    throw new Error(`Schema(s) "${offendingSchemas.join(', ')}" define incompatible versions of index "${index.IndexName}".`);
1✔
357
                }
1✔
358
            }
56✔
359
        }
98✔
360
        return {uniqueRequiredAttributes};
39✔
361
    }
41✔
362

7✔
363
    async #updateIndexes({differentIndexes, missingIndexes, existingIndexes, createAll}) {
7✔
364
        if (differentIndexes.length) {
3!
UNCOV
365
            this.#logger.warn({existingIndexes, differentIndexes}, `WARNING: indexes "${differentIndexes.map(i => i.index.IndexName).join(',')}" differ from the current specifications, but these will not be automatically updated.`);
×
UNCOV
366
        }
×
367
        if (missingIndexes.length) {
3✔
368
            // Only one index can be added at a time:
3✔
369
            for (const missingIndex of missingIndexes) {
3✔
370
                const updates = {
4✔
371
                    TableName: this.name,
4✔
372
                    GlobalSecondaryIndexUpdates: [],
4✔
373
                    AttributeDefinitions: []
4✔
374
                };
4✔
375
                // we only need to include the attribute definitions required by
4✔
376
                // the indexes being created, existing attribute definitions used
4✔
377
                // by other indexes do not need to be repeated:
4✔
378
                updates.GlobalSecondaryIndexUpdates.push({Create: missingIndex.index});
4✔
379
                updates.AttributeDefinitions = updates.AttributeDefinitions.concat(missingIndex.requiredAttributes);
4✔
380

4✔
381
                this.#logger.info({updates}, 'Updating table %s.', this.name);
4✔
382
                await this[kTableDDBClient].send(new UpdateTableCommand(updates));
4✔
383

4✔
384
                if (createAll) {
4✔
385
                    await this.#waitForIndexesActive();
3✔
386
                } else {
4✔
387
                    break;
1✔
388
                }
1✔
389
            }
4✔
390
        }
2✔
391
    }
3✔
392

7✔
393
    async #waitForIndexesActive() {
7✔
394
        while (true) {
11✔
395
            const response = await this[kTableDDBClient].send(new DescribeTableCommand({TableName: this.name}));
15✔
396
            if (response.Table.TableStatus === 'ACTIVE' &&
15✔
397
                (response.Table.GlobalSecondaryIndexes || []).every(gsi => gsi.IndexStatus === 'ACTIVE')) {
15✔
398
                break;
10✔
399
            } else if (['UPDATING', 'ACTIVE'].includes(response.Table.TableStatus) &&
10✔
400
                (response.Table.GlobalSecondaryIndexes || []).every(gsi => ['CREATING', 'UPDATING', 'ACTIVE'].includes(gsi.IndexStatus))) {
5!
401
                await delayMs(500);
4✔
402
            } else {
5✔
403
                throw new Error(`Table ${this.name} status is ${response.Table.TableStatus}, index statuses are [${(response.Table.GlobalSecondaryIndexes || []).map(gsi => gsi.IndexStatus).join(', ')}].`);
1✔
404
            }
1✔
405
        }
15✔
406
    }
11✔
407
}
7✔
408

7✔
409
module.exports = {
7✔
410
    Table
7✔
411
};
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc