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

sensedeep / dynamodb-onetable / #68

18 Aug 2023 03:12AM UTC coverage: 72.741% (+0.1%) from 72.614%
#68

push

Michael O'Brien
Merge branch 'main' of github.com:sensedeep/dynamodb-onetable

1121 of 1620 branches covered (69.2%)

Branch coverage included in aggregate %.

1801 of 2397 relevant lines covered (75.14%)

625.06 hits per line

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

66.74
/src/Table.js
1
/*
2
    Table.js - DynamoDB table class
3

4
    A OneTable Table represents a single (connected) DynamoDB table
5
 */
6

7
import {Buffer} from 'buffer'
50✔
8
import Crypto from 'crypto'
50✔
9
import UUID from './UUID.js'
50✔
10
import ULID from './ULID.js'
50✔
11
import UID from './UID.js'
50✔
12
import Dynamo from './Dynamo.js'
50✔
13
import {Expression} from './Expression.js'
50✔
14
import {Schema} from './Schema.js'
50✔
15
import {Metrics} from './Metrics.js'
50✔
16
import {OneTableArgError, OneTableError} from './Error.js'
50✔
17

18
/*
19
    AWS V2 DocumentClient methods
20
 */
21
const DocumentClientMethods = {
50✔
22
    delete: 'delete',
23
    get: 'get',
24
    find: 'query',
25
    put: 'put',
26
    scan: 'scan',
27
    update: 'update',
28
    batchGet: 'batchGet',
29
    batchWrite: 'batchWrite',
30
    transactGet: 'transactGet',
31
    transactWrite: 'transactWrite',
32
}
33

34
/*
35
    Safety string required on API to delete a table
36
*/
37
const ConfirmRemoveTable = 'DeleteTableForever'
50✔
38

39
/*
40
    Crypto IV length
41
*/
42
const IV_LENGTH = 16
50✔
43

44
const DynamoOps = {
50✔
45
    delete: 'deleteItem',
46
    get: 'getItem',
47
    find: 'query',
48
    put: 'putItem',
49
    scan: 'scan',
50
    update: 'updateItem',
51
    batchGet: 'batchGet',
52
    batchWrite: 'batchWrite',
53
    transactGet: 'transactGet',
54
    transactWrite: 'transactWrite',
55
}
56

57
const GenericModel = '_Generic'
50✔
58

59
const maxBatchSize = 25
50✔
60

61
/*
62
    Represent a single DynamoDB table
63
 */
64
export class Table {
50✔
65
    constructor(params = {}) {
×
66
        if (!params.name) {
56✔
67
            throw new OneTableArgError('Missing "name" property')
1✔
68
        }
69
        this.context = {}
55✔
70

71
        this.log = params.senselogs ? params.senselogs : new Log(params.logger)
55!
72
        this.log.trace(`Loading OneTable`)
55✔
73

74
        if (params.client) {
55✔
75
            this.setClient(params.client)
52✔
76
        }
77
        if (params.crypto) {
55✔
78
            this.initCrypto(params.crypto)
1✔
79
            this.crypto = Object.assign(params.crypto)
1✔
80
            for (let [name, crypto] of Object.entries(this.crypto)) {
1✔
81
                crypto.secret = Crypto.createHash('sha256').update(crypto.password, 'utf8').digest()
1✔
82
                this.crypto[name] = crypto
1✔
83
                this.crypto[name].name = name
1✔
84
            }
85
        }
86
        this.setParams(params)
55✔
87

88
        //  Set schema param defaults
89
        this.typeField = '_type'
55✔
90
        this.createdField = 'created'
55✔
91
        this.isoDates = false
55✔
92
        this.nulls = false
55✔
93
        this.timestamps = false
55✔
94
        this.updatedField = 'updated'
55✔
95

96
        this.schema = new Schema(this, params.schema)
55✔
97
        if (params.dataloader) {
55✔
98
            this.dataloader = new params.dataloader((cmds) => this.batchLoaderFunction(cmds), {maxBatchSize})
2✔
99
        }
100
    }
101

102
    setClient(client) {
103
        if (client.send && !client.V3) {
55✔
104
            //  V3 SDK and not yet wrapped by Dynamo
105
            client = new Dynamo({client})
52✔
106
        }
107
        this.client = client
55✔
108
        this.V3 = client.V3
55✔
109
        this.service = this.V3 ? this.client : this.client.service
55✔
110
    }
111

112
    setParams(params) {
113
        if (
55!
114
            params.createdField != null ||
330✔
115
            this.isoDates != null ||
116
            this.nulls != null ||
117
            this.timestamps != null ||
118
            this.typeField != null ||
119
            this.updatedField != null
120
        ) {
121
            throw new OneTableArgError(
×
122
                'Using deprecated Table constructor parameters. Define in the Schema.params instead.'
123
            )
124
        }
125

126
        if (params.uuid) {
55!
127
            console.warn(
×
128
                'OneTable: Using deprecated Table constructor "uuid" parameter. Use a "generate" function instead or ' +
129
                    'Set schema models to use "generate: uuid|ulid" explicitly.'
130
            )
131
            params.generate = params.generate | params.uuid
×
132
        }
133

134
        if (params.partial == null) {
55!
135
            console.warn(
×
136
                'OneTable: Must set Table constructor "partial" param to true or false. ' +
137
                    'This param permits updating partial nested schemas. Currently defaults to false, ' +
138
                    'but in a future version will default to true. ' +
139
                    'Set to false to future proof or set to true for the new behavior.'
140
            )
141
            params.partial = true
×
142
        }
143
        //  Return hidden fields by default. Default is false.
144
        this.hidden = params.hidden != null ? params.hidden : false
55!
145
        this.partial = params.partial
55✔
146
        this.warn = params.warn || true
55✔
147

148
        if (typeof params.generate == 'function') {
55!
149
            this.generate = params.generate || this.uuid
×
150
        } else if (params.generate) {
55!
151
            throw new OneTableArgError('OneTable: Generate can only be a function')
×
152
        }
153

154
        this.name = params.name
55✔
155

156
        if (params.metrics) {
55!
157
            this.metrics = new Metrics(this, params.metrics, this.metrics)
×
158
        }
159
        if (params.monitor) {
55!
160
            this.monitor = params.monitor
×
161
        }
162
        this.params = params
55✔
163
    }
164

165
    setSchemaParams(params = {}) {
8✔
166
        this.createdField = params.createdField || 'created'
54✔
167
        this.isoDates = params.isoDates || false
54✔
168
        this.nulls = params.nulls || false
54✔
169
        this.timestamps = params.timestamps != null ? params.timestamps : false
54✔
170
        this.typeField = params.typeField || '_type'
54✔
171
        this.updatedField = params.updatedField || 'updated'
54✔
172

173
        if (params.hidden != null) {
54!
174
            console.warn(`Schema hidden params should be specified via the Table constructor params`)
×
175
        }
176
    }
177

178
    getSchemaParams() {
179
        return {
55✔
180
            createdField: this.createdField,
181
            isoDates: this.isoDates,
182
            nulls: this.nulls,
183
            timestamps: this.timestamps,
184
            typeField: this.typeField,
185
            updatedField: this.updatedField,
186
        }
187
    }
188

189
    async setSchema(schema) {
190
        return await this.schema.setSchema(schema)
×
191
    }
192

193
    getCurrentSchema() {
194
        return this.schema.getCurrentSchema()
5✔
195
    }
196

197
    async getKeys() {
198
        return await this.schema.getKeys()
×
199
    }
200

201
    async getPrimaryKeys() {
202
        let keys = await this.schema.getKeys()
×
203
        return keys.primary
×
204
    }
205

206
    async readSchema() {
207
        return this.schema.readSchema()
×
208
    }
209

210
    async readSchemas() {
211
        return this.schema.readSchemas()
×
212
    }
213

214
    async removeSchema(schema) {
215
        return this.schema.removeSchema(schema)
×
216
    }
217

218
    async saveSchema(schema) {
219
        return this.schema.saveSchema(schema)
×
220
    }
221

222
    /*
223
        Output the AWS table definition as a JSON structure to use in external tools such as CloudFormation
224
        or the AWS CLI to create your DynamoDB table. Uses the current schema index definition.
225
        Alternatively, params may contain standard DynamoDB createTable parameters.
226
    */
227
    getTableDefinition(params = {}) {
×
228
        let def = {
52✔
229
            AttributeDefinitions: [],
230
            KeySchema: [],
231
            LocalSecondaryIndexes: [],
232
            GlobalSecondaryIndexes: [],
233
            TableName: this.name,
234
        }
235
        let provisioned = params.provisioned || params.ProvisionedThroughput
52✔
236
        if (provisioned) {
52✔
237
            if (!provisioned.ReadCapacityUnits && !provisioned.WriteCapacityUnits) {
1!
238
                def.BillingMode = 'PAY_PER_REQUEST'
×
239
            } else {
240
                def.ProvisionedThroughput = provisioned
1✔
241
                def.BillingMode = 'PROVISIONED'
1✔
242
            }
243
        } else {
244
            def.BillingMode = 'PAY_PER_REQUEST'
51✔
245
        }
246
        if (params.StreamSpecification) {
52!
247
            def.StreamSpecification = params.StreamSpecification
×
248
        }
249
        let attributes = {}
52✔
250
        let {indexes} = this.schema
52✔
251

252
        if (!indexes) {
52!
253
            throw new OneTableArgError('Cannot create table without schema indexes')
×
254
        }
255
        for (let [name, index] of Object.entries(indexes)) {
52✔
256
            let keys
257
            if (name == 'primary') {
125✔
258
                keys = def.KeySchema
52✔
259
            } else {
260
                let collection = index.type == 'local' ? 'LocalSecondaryIndexes' : 'GlobalSecondaryIndexes'
73✔
261
                keys = []
73✔
262
                let project, projection
263
                if (Array.isArray(index.project)) {
73✔
264
                    projection = 'INCLUDE'
3✔
265
                    project = index.project.filter((a) => a != indexes.primary.hash && a != indexes.primary.sort)
9✔
266
                } else if (index.project == 'keys') {
70✔
267
                    projection = 'KEYS_ONLY'
1✔
268
                } else {
269
                    projection = 'ALL'
69✔
270
                }
271
                let projDef = {
73✔
272
                    IndexName: name,
273
                    KeySchema: keys,
274
                    Projection: {
275
                        ProjectionType: projection,
276
                    },
277
                }
278
                if (project) {
73✔
279
                    projDef.Projection.NonKeyAttributes = project
3✔
280
                }
281
                def[collection].push(projDef)
73✔
282
            }
283
            keys.push({AttributeName: index.hash || indexes.primary.hash, KeyType: 'HASH'})
125!
284

285
            if (index.hash && !attributes[index.hash]) {
125✔
286
                let type = this.getAttributeType(index.hash) == 'number' ? 'N' : 'S'
121!
287
                def.AttributeDefinitions.push({AttributeName: index.hash, AttributeType: type})
121✔
288
                attributes[index.hash] = true
121✔
289
            }
290
            if (index.sort) {
125✔
291
                if (!attributes[index.sort]) {
120✔
292
                    let type = this.getAttributeType(index.sort) == 'number' ? 'N' : 'S'
118✔
293
                    def.AttributeDefinitions.push({AttributeName: index.sort, AttributeType: type})
118✔
294
                    attributes[index.sort] = true
118✔
295
                }
296
                keys.push({AttributeName: index.sort, KeyType: 'RANGE'})
120✔
297
            }
298
        }
299
        if (def.GlobalSecondaryIndexes.length == 0) {
52✔
300
            delete def.GlobalSecondaryIndexes
17✔
301
        } else if (provisioned) {
35✔
302
            for (let index of def.GlobalSecondaryIndexes) {
1✔
303
                index.ProvisionedThroughput = provisioned
3✔
304
            }
305
        }
306
        if (def.LocalSecondaryIndexes.length == 0) {
52✔
307
            delete def.LocalSecondaryIndexes
49✔
308
        }
309
        return def
52✔
310
    }
311

312
    /*
313
        Create a DynamoDB table. Uses the current schema index definition.
314
        Alternatively, params may contain standard DynamoDB createTable parameters.
315
    */
316
    async createTable(params = {}) {
51✔
317
        const def = this.getTableDefinition(params)
52✔
318
        let result
319

320
        this.log.trace(`OneTable createTable for "${this.name}"`, {def})
52✔
321
        if (this.V3) {
52✔
322
            result = await this.service.createTable(def)
50✔
323
        } else {
324
            result = await this.service.createTable(def).promise()
2✔
325
        }
326

327
        /*
328
            Wait for table to become active. Must do if setting a TTL attribute
329
        */
330
        if (params.TimeToLiveSpecification) {
52!
331
            params.wait = 5 * 60
×
332
        }
333
        if (params.wait) {
52!
334
            let deadline = new Date(Date.now() + params.wait * 1000)
×
335
            let info
336
            do {
×
337
                info = await this.describeTable()
×
338
                if (info.Table.TableStatus == 'ACTIVE') {
×
339
                    break
×
340
                }
341
                if (deadline < Date.now()) {
×
342
                    throw new Error('Table has not become active')
×
343
                }
344
                await this.delay(1000)
×
345
            } while (Date.now() < deadline)
346
        }
347

348
        /*
349
            Define a TTL attribute
350
        */
351
        if (params.TimeToLiveSpecification) {
52!
352
            let def = {
×
353
                TableName: this.name,
354
                TimeToLiveSpecification: params.TimeToLiveSpecification,
355
            }
356
            if (this.V3) {
×
357
                await this.service.updateTimeToLive(def)
×
358
            } else {
359
                await this.service.updateTimeToLive(def).promise()
×
360
            }
361
        }
362
        return result
52✔
363
    }
364

365
    getAttributeType(name) {
366
        for (let model of Object.values(this.schema.models)) {
239✔
367
            let fields = model.block.fields
261✔
368
            if (fields[name]) {
261✔
369
                return fields[name].type
231✔
370
            }
371
        }
372
        return null
8✔
373
    }
374

375
    /*
376
        Delete the DynamoDB table forever. Be careful.
377
    */
378
    async deleteTable(confirmation) {
379
        if (confirmation == ConfirmRemoveTable) {
52✔
380
            this.log.trace(`OneTable deleteTable for "${this.name}"`)
51✔
381
            if (this.V3) {
51✔
382
                await this.service.deleteTable({TableName: this.name})
49✔
383
            } else {
384
                await this.service.deleteTable({TableName: this.name}).promise()
2✔
385
            }
386
        } else {
387
            throw new OneTableArgError(`Missing required confirmation "${ConfirmRemoveTable}"`)
1✔
388
        }
389
    }
390

391
    async updateTable(params = {}) {
×
392
        let def = {
×
393
            AttributeDefinitions: [],
394
            GlobalSecondaryIndexUpdates: [],
395
            TableName: this.name,
396
        }
397
        let {create, provisioned} = params
×
398

399
        if (provisioned) {
×
400
            if (!provisioned.ReadCapacityUnits && !provisioned.WriteCapacityUnits) {
×
401
                def.BillingMode = 'PAY_PER_REQUEST'
×
402
            } else {
403
                if (!create) {
×
404
                    def.ProvisionedThroughput = provisioned
×
405
                }
406
                def.BillingMode = 'PROVISIONED'
×
407
            }
408
        }
409
        let indexes = this.schema.indexes
×
410
        if (!indexes) {
×
411
            throw new OneTableArgError('Cannot update table without schema indexes')
×
412
        }
413
        if (create) {
×
414
            if (create.hash == null || create.hash == indexes.primary.hash || create.type == 'local') {
×
415
                throw new OneTableArgError('Cannot update table to create an LSI')
×
416
            }
417
            let keys = []
×
418
            let projection, project
419

420
            if (Array.isArray(create.project)) {
×
421
                projection = 'INCLUDE'
×
422
                project = create.project.filter((a) => a != create.hash && a != create.sort)
×
423
            } else if (create.project == 'keys') {
×
424
                projection = 'KEYS_ONLY'
×
425
            } else {
426
                projection = 'ALL'
×
427
            }
428
            let projDef = {
×
429
                IndexName: create.name,
430
                KeySchema: keys,
431
                Projection: {
432
                    ProjectionType: projection,
433
                },
434
            }
435
            if (project) {
×
436
                projDef.Projection.NonKeyAttributes = project
×
437
            }
438
            keys.push({AttributeName: create.hash, KeyType: 'HASH'})
×
439
            def.AttributeDefinitions.push({AttributeName: create.hash, AttributeType: 'S'})
×
440

441
            if (create.sort) {
×
442
                def.AttributeDefinitions.push({AttributeName: create.sort, AttributeType: 'S'})
×
443
                keys.push({AttributeName: create.sort, KeyType: 'RANGE'})
×
444
            }
445
            if (provisioned) {
×
446
                projDef.ProvisionedThroughput = provisioned
×
447
            }
448
            def.GlobalSecondaryIndexUpdates.push({Create: projDef})
×
449
        } else if (params.remove) {
×
450
            def.GlobalSecondaryIndexUpdates.push({Delete: {IndexName: params.remove.name}})
×
451
        } else if (params.update) {
×
452
            let update = {Update: {IndexName: params.update.name}}
×
453
            if (provisioned) {
×
454
                update.Update.ProvisionedThroughput = provisioned
×
455
            }
456
            def.GlobalSecondaryIndexUpdates.push(update)
×
457
        }
458
        if (def.GlobalSecondaryIndexUpdates.length == 0) {
×
459
            delete def.GlobalSecondaryIndexUpdates
×
460
        }
461
        if (params.TimeToLiveSpecification) {
×
462
            let def = {
×
463
                TableName: params.TableName,
464
                TimeToLiveSpecification: params.TimeToLiveSpecification,
465
            }
466
            if (this.V3) {
×
467
                await this.service.updateTimeToLive(def)
×
468
            } else {
469
                await this.service.updateTimeToLive(def).promise()
×
470
            }
471
        }
472
        this.log.trace(`OneTable updateTable for "${this.name}"`, {def})
×
473
        if (this.V3) {
×
474
            return await this.service.updateTable(def)
×
475
        } else {
476
            return await this.service.updateTable(def).promise()
×
477
        }
478
    }
479

480
    /*
481
        Return the raw AWS table description
482
    */
483
    async describeTable() {
484
        if (this.V3) {
4✔
485
            return await this.service.describeTable({TableName: this.name})
3✔
486
        } else {
487
            return await this.service.describeTable({TableName: this.name}).promise()
1✔
488
        }
489
    }
490

491
    /*
492
        Return true if the underlying DynamoDB table represented by this OneTable instance is present.
493
    */
494
    async exists() {
495
        let results = await this.listTables()
132✔
496
        return results && results.find((t) => t == this.name) != null ? true : false
370✔
497
    }
498

499
    /*
500
        Return a list of tables in the AWS region described by the Table instance
501
    */
502
    async listTables() {
503
        let results
504
        if (this.V3) {
135✔
505
            results = await this.service.listTables({})
129✔
506
        } else {
507
            results = await this.service.listTables({}).promise()
6✔
508
        }
509
        return results.TableNames
135✔
510
    }
511

512
    listModels() {
513
        return this.schema.listModels()
8✔
514
    }
515

516
    addModel(name, fields) {
517
        this.schema.addModel(name, fields)
1✔
518
    }
519

520
    getLog() {
521
        return this.log
×
522
    }
523

524
    setLog(log) {
525
        this.log = log
×
526
    }
527

528
    /*
529
        Thows exception if model cannot be found
530
     */
531
    getModel(name, options = {nothrow: false}) {
128✔
532
        return this.schema.getModel(name, options)
128✔
533
    }
534

535
    removeModel(name) {
536
        return this.schema.removeModel(name)
2✔
537
    }
538

539
    getContext() {
540
        return this.context
11✔
541
    }
542

543
    addContext(context = {}) {
×
544
        this.context = Object.assign(this.context, context)
1✔
545
        return this
1✔
546
    }
547

548
    setContext(context = {}, merge = false) {
6!
549
        this.context = merge ? Object.assign(this.context, context) : context
7✔
550
        return this
7✔
551
    }
552

553
    clearContext() {
554
        this.context = {}
1✔
555
        return this
1✔
556
    }
557

558
    /*  PROTOTYPE
559
        Create a clone of the table with the same settings and replace the context
560
    */
561
    child(context) {
562
        let table = JSON.parse(JSON.stringify(this))
×
563
        table.context = context
×
564
        return table
×
565
    }
566

567
    /*
568
        High level model factory API
569
        The high level API is similar to the Model API except the model name is provided as the first parameter.
570
        This API is useful for factories
571
    */
572
    async create(modelName, properties, params) {
573
        let model = this.getModel(modelName)
19✔
574
        return await model.create(properties, params)
19✔
575
    }
576

577
    async find(modelName, properties, params) {
578
        let model = this.getModel(modelName)
3✔
579
        return await model.find(properties, params)
3✔
580
    }
581

582
    async get(modelName, properties, params) {
583
        let model = this.getModel(modelName)
21✔
584
        return await model.get(properties, params)
21✔
585
    }
586

587
    async load(modelName, properties, params) {
588
        let model = this.getModel(modelName)
6✔
589
        return await model.load(properties, params)
6✔
590
    }
591

592
    init(modelName, properties, params) {
593
        let model = this.getModel(modelName)
×
594
        return model.init(properties, params)
×
595
    }
596

597
    async remove(modelName, properties, params) {
598
        let model = this.getModel(modelName)
5✔
599
        return await model.remove(properties, params)
5✔
600
    }
601

602
    async scan(modelName, properties, params) {
603
        let model = this.getModel(modelName)
20✔
604
        return await model.scan(properties, params)
20✔
605
    }
606

607
    async update(modelName, properties, params) {
608
        let model = this.getModel(modelName)
6✔
609
        return await model.update(properties, params)
6✔
610
    }
611

612
    async upsert(modelName, properties, params = {}) {
×
613
        params.exists = null
×
614
        return this.update(modelName, properties, params)
×
615
    }
616

617
    async execute(model, op, cmd, properties = {}, params = {}) {
×
618
        let mark = new Date()
898✔
619
        let trace = {model, cmd, op, properties}
898✔
620
        let result
621
        try {
898✔
622
            let client = params.client || this.client
898✔
623
            if (params.stats || this.metrics || this.monitor) {
898✔
624
                cmd.ReturnConsumedCapacity = params.capacity || 'INDEXES'
1✔
625
                cmd.ReturnItemCollectionMetrics = 'SIZE'
1✔
626
            }
627
            this.log[params.log ? 'info' : 'trace'](`OneTable "${op}" "${model}"`, {trace})
898✔
628
            if (this.V3) {
898✔
629
                result = await client[op](cmd)
868✔
630
            } else {
631
                result = await client[DocumentClientMethods[op]](cmd).promise()
30✔
632
            }
633
        } catch (err) {
634
            //  V3 stores the error in 'name' (Ugh!)
635
            let code = err.code || err.name
6✔
636
            if (params.throw === false) {
6✔
637
                result = {}
1✔
638
            } else if (code == 'ConditionalCheckFailedException' && op == 'put') {
5!
639
                this.log.info(`Conditional check failed "${op}" on "${model}"`, {err, trace})
×
640
                throw new OneTableError(`Conditional create failed for "${model}"`, {
×
641
                    code,
642
                    err,
643
                    trace,
644
                })
645
            } else if (code == 'ProvisionedThroughputExceededException') {
5!
646
                throw new OneTableError('Provisioning Throughput Exception', {
×
647
                    code,
648
                    err,
649
                    trace,
650
                })
651
            } else if (code == 'TransactionCanceledException') {
5✔
652
                throw new OneTableError('Transaction Cancelled', {
3✔
653
                    code,
654
                    err,
655
                    trace,
656
                })
657
            } else {
658
                result = result || {}
2✔
659
                result.Error = 1
2✔
660
                if (params.log != false) {
2✔
661
                    this.log.error(`OneTable exception in "${op}" on "${model} ${err.message}"`, {err, trace})
2✔
662
                }
663
                throw new OneTableError(`OneTable execute failed "${op}" for "${model}", ${err.message}`, {
2✔
664
                    code,
665
                    err,
666
                    trace,
667
                })
668
            }
669
        } finally {
670
            if (result) {
898✔
671
                if (this.metrics) {
895!
672
                    this.metrics.add(model, op, result, params, mark)
×
673
                }
674
                if (this.monitor) {
895!
675
                    await this.monitor(model, op, result, params, mark)
×
676
                }
677
            }
678
        }
679
        if (typeof params.info == 'object') {
893!
680
            params.info.operation = DynamoOps[op]
×
681
            params.info.args = cmd
×
682
            params.info.properties = properties
×
683
        }
684
        return result
893✔
685
    }
686

687
    /*
688
        The low level API does not use models. It permits the reading / writing of any attribute.
689
    */
690
    async batchGet(batch, params = {}) {
2✔
691
        if (Object.getOwnPropertyNames(batch).length == 0) {
7!
692
            return []
×
693
        }
694
        let def = batch.RequestItems[this.name]
7✔
695

696
        if (params.fields) {
7✔
697
            if (params.fields.indexOf(this.typeField) < 0) {
1✔
698
                params.fields.push(this.typeField)
1✔
699
            }
700
            let expression = new Expression(this.schema.genericModel, 'batchGet', {}, params)
1✔
701
            let cmd = expression.command()
1✔
702
            def.ProjectionExpression = cmd.ProjectionExpression
1✔
703
            def.ExpressionAttributeNames = cmd.ExpressionAttributeNames
1✔
704
        }
705
        def.ConsistentRead = params.consistent ? true : false
7✔
706

707
        // let result = await this.execute(GenericModel, 'batchGet', batch, {}, params)
708

709
        let result,
710
            retries = 0,
6✔
711
            more
712
        result = params.parse ? [] : {Responses: {}}
6✔
713
        do {
6✔
714
            more = false
6✔
715
            let data = await this.execute(GenericModel, 'batchGet', batch, {}, params)
6✔
716
            if (data) {
6✔
717
                let responses = data.Responses
6✔
718
                if (responses) {
6✔
719
                    for (let [key, items] of Object.entries(responses)) {
6✔
720
                        for (let item of items) {
6✔
721
                            if (params.parse) {
12✔
722
                                item = this.unmarshall(item, params)
7✔
723
                                let type = item[this.typeField] || '_unknown'
7!
724
                                let model = this.schema.models[type]
7✔
725
                                if (model && model != this.schema.uniqueModel) {
7✔
726
                                    result.push(model.transformReadItem('get', item, {}, params))
7✔
727
                                }
728
                            } else {
729
                                let set = (result.Responses[key] = result.Responses[key] || [])
5✔
730
                                set.push(item)
5✔
731
                            }
732
                        }
733
                    }
734
                }
735
                let unprocessed = data.UnprocessedItems
6✔
736
                if (unprocessed && Object.keys(unprocessed).length) {
6!
737
                    batch.RequestItems = unprocessed
×
738
                    if (params.reprocess === false) {
×
739
                        return false
×
740
                    }
741
                    if (retries > 11) {
×
742
                        throw new Error(unprocessed)
×
743
                    }
744
                    await this.delay(10 * 2 ** retries++)
×
745
                    more = true
×
746
                }
747
            }
748
        } while (more)
749
        return result
6✔
750
    }
751

752
    /*
753
        AWS BatchWrite may throw an exception if no items can be processed.
754
        Otherwise it will retry (up to 11 times) and return partial results in UnprocessedItems.
755
        Those will be handled here if possible.
756
    */
757
    async batchWrite(batch, params = {}) {
5✔
758
        if (Object.getOwnPropertyNames(batch).length === 0) {
6!
759
            return {}
×
760
        }
761
        let retries = 0,
6✔
762
            more
763
        do {
6✔
764
            more = false
6✔
765
            let response = await this.execute(GenericModel, 'batchWrite', batch, {}, params)
6✔
766
            if (response && response.UnprocessedItems && Object.keys(response.UnprocessedItems).length) {
5!
767
                batch.RequestItems = response.UnprocessedItems
×
768
                if (params.reprocess === false) {
×
769
                    return false
×
770
                }
771
                if (retries > 11) {
×
772
                    throw new Error(response.UnprocessedItems)
×
773
                }
774
                await this.delay(10 * 2 ** retries++)
×
775
                more = true
×
776
            }
777
        } while (more)
778
        return true
5✔
779
    }
780

781
    async batchLoad(expression) {
782
        if (this.dataloader) {
6✔
783
            return await this.dataloader.load(expression)
6✔
784
        }
785
        throw new Error('params.dataloader DataLoader constructor is required to use load feature')
×
786
    }
787

788
    async deleteItem(properties, params) {
789
        return await this.schema.genericModel.deleteItem(properties, params)
1✔
790
    }
791

792
    async getItem(properties, params) {
793
        return await this.schema.genericModel.getItem(properties, params)
1✔
794
    }
795

796
    async putItem(properties, params) {
797
        return await this.schema.genericModel.putItem(properties, params)
2✔
798
    }
799

800
    async queryItems(properties, params) {
801
        return await this.schema.genericModel.queryItems(properties, params)
8✔
802
    }
803

804
    async scanItems(properties, params) {
805
        return await this.schema.genericModel.scanItems(properties, params)
20✔
806
    }
807

808
    async updateItem(properties, params) {
809
        return await this.schema.genericModel.updateItem(properties, params)
3✔
810
    }
811

812
    async fetch(models, properties, params) {
813
        return await this.schema.genericModel.fetch(models, properties, params)
2✔
814
    }
815

816
    /*
817
        Invoke a prepared transaction. Note: transactGet does not work on non-primary indexes.
818
     */
819
    async transact(op, transaction, params = {}) {
3✔
820
        if (params.execute === false) {
23!
821
            if (params.log !== false) {
×
822
                this.log[params.log ? 'info' : 'data'](`OneTable transaction for "${op}" (not executed)`, {
×
823
                    transaction,
824
                    op,
825
                    params,
826
                })
827
            }
828
            return transaction
×
829
        }
830
        let result = await this.execute(
23✔
831
            GenericModel,
832
            op == 'write' ? 'transactWrite' : 'transactGet',
23✔
833
            transaction,
834
            {},
835
            params
836
        )
837
        if (op == 'get') {
20✔
838
            if (params.parse) {
3✔
839
                let items = []
2✔
840
                for (let r of result.Responses) {
2✔
841
                    if (r.Item) {
6✔
842
                        let item = this.unmarshall(r.Item, params)
6✔
843
                        let type = item[this.typeField] || '_unknown'
6!
844
                        let model = this.schema.models[type]
6✔
845
                        if (model && model != this.schema.uniqueModel) {
6✔
846
                            items.push(model.transformReadItem('get', item, {}, params))
6✔
847
                        }
848
                    }
849
                }
850
                result = items
2✔
851
            }
852
        }
853
        return result
20✔
854
    }
855

856
    /*
857
        Convert items into a map of items by model type
858
    */
859
    groupByType(items, params = {}) {
5✔
860
        let result = {}
6✔
861
        for (let item of items) {
6✔
862
            let type = item[this.typeField] || '_unknown'
23!
863
            let list = (result[type] = result[type] || [])
23✔
864
            let model = this.schema.models[type]
23✔
865
            let preparedItem
866
            if (typeof params.hidden === 'boolean' && !params.hidden) {
23✔
867
                let fields = model.block.fields
4✔
868
                preparedItem = {}
4✔
869
                for (let [name, field] of Object.entries(fields)) {
4✔
870
                    if (!(field.hidden && params.hidden !== true)) {
37✔
871
                        preparedItem[name] = item[name]
17✔
872
                    }
873
                }
874
            } else {
875
                preparedItem = item
19✔
876
            }
877
            list.push(preparedItem)
23✔
878
        }
879
        return result
6✔
880
    }
881

882
    /**
883
        This is a function passed to the DataLoader that given an array of Expression
884
        will group all commands by TableName and make all Keys unique and then will perform
885
        a BatchGet request and process the response of the BatchRequest to return an array
886
        with the corresponding response in the same order as it was requested.
887
        @param expressions
888
        @returns {Promise<*>}
889
     */
890
    async batchLoaderFunction(expressions) {
891
        const commands = expressions.map((each) => each.command())
6✔
892

893
        const groupedByTableName = commands.reduce((groupedBy, item) => {
2✔
894
            const tableName = item.TableName
6✔
895
            if (!groupedBy[tableName]) groupedBy[tableName] = []
6✔
896
            groupedBy[tableName].push(item)
6✔
897
            return groupedBy
6✔
898
        }, {})
899

900
        // convert each of the get requests into a single RequestItem with unique Keys
901
        const requestItems = Object.keys(groupedByTableName).reduce((requestItems, tableName) => {
2✔
902
            // batch get does not support duplicate Keys, so we need to make them unique
903
            // it's complex because we have the unmarshalled values on the Keys when it's V3
904
            const allKeys = groupedByTableName[tableName].map((each) => each.Key)
6✔
905
            const uniqueKeys = allKeys.filter((key1, index1, self) => {
2✔
906
                const index2 = self.findIndex((key2) => {
6✔
907
                    return Object.keys(key2).every((prop) => {
9✔
908
                        if (this.V3) {
15✔
909
                            const type = Object.keys(key1[prop])[0] // { S: "XX" } => type is S
15✔
910
                            return key2[prop][type] === key1[prop][type]
15✔
911
                        }
912
                        return key2[prop] === key1[prop]
×
913
                    })
914
                })
915
                return index2 === index1
6✔
916
            })
917
            requestItems[tableName] = {Keys: uniqueKeys}
2✔
918
            return requestItems
2✔
919
        }, {})
920

921
        const results = await this.batchGet({RequestItems: requestItems})
2✔
922

923
        // return the exact mapping (on same order as input) of each get command request to the result from database
924
        // to do that we need to find in the Responses object the item that was request and return it in the same position
925
        return commands.map((command, index) => {
2✔
926
            const {model, params} = expressions[index]
6✔
927

928
            // each key is { pk: { S: "XX" } } when V3 or { pk: "XX" } when V2
929
            // on map function, key will be pk and unmarshalled will be { S: "XX" }, OR "XXX"
930
            const criteria = Object.entries(command.Key).map(([key, unmarshalled]) => {
6✔
931
                if (this.V3) {
12✔
932
                    const type = Object.keys(unmarshalled)[0] // the type will be S
12✔
933
                    return [[key, type], unmarshalled[type]] // return [[pk, S], "XX"]
12✔
934
                }
935
                return [[key], unmarshalled]
×
936
            })
937

938
            // finds the matching object in the unmarshalled Responses array with criteria Key above
939
            const findByKeyUnmarshalled = (items = []) =>
6!
940
                items.find((item) => {
6✔
941
                    return criteria.every(([[prop, type], value]) => {
9✔
942
                        if (type) return item[prop][type] === value // if it has a type it means it is V3
15✔
943
                        return item[prop] === value
×
944
                    })
945
                })
946

947
            const items = results.Responses[command.TableName]
6✔
948
            const item = findByKeyUnmarshalled(items)
6✔
949
            if (item) {
6✔
950
                const unmarshalled = this.unmarshall(item, params)
6✔
951
                return model.transformReadItem('get', unmarshalled, {}, params)
6✔
952
            }
953
        })
954
    }
955

956
    /*
957
        Simple non-crypto UUID. See node-uuid if you require crypto UUIDs.
958
        Consider ULIDs which are crypto sortable.
959
    */
960
    uuid() {
961
        return UUID()
9✔
962
    }
963

964
    // Simple time-based, sortable unique ID.
965
    ulid() {
966
        return new ULID().toString()
601✔
967
    }
968

969
    /*
970
        Crypto-grade ID of given length. If >= 10 in length, suitably unique for most use-cases.
971
     */
972
    uid(size = 10) {
×
973
        return UID(size)
×
974
    }
975

976
    setGenerate(fn) {
977
        this.generate = fn
×
978
    }
979

980
    /*
981
        Return the value template variable references in a list
982
     */
983
    getVars(v) {
984
        let list = []
468✔
985
        if (Array.isArray(v)) {
468!
986
            list = v
×
987
        } else if (typeof v == 'string') {
468✔
988
            v.replace(/\${(.*?)}/g, (match, varName) => {
467✔
989
                list.push(varName)
493✔
990
            })
991
        }
992
        return list
468✔
993
    }
994

995
    initCrypto(crypto) {
996
        this.crypto = Object.assign(crypto)
1✔
997
        for (let [name, crypto] of Object.entries(this.crypto)) {
1✔
998
            crypto.secret = Crypto.createHash('sha256').update(crypto.password, 'utf8').digest()
1✔
999
            this.crypto[name] = crypto
1✔
1000
            this.crypto[name].name = name
1✔
1001
        }
1002
    }
1003

1004
    encrypt(text, name = 'primary', inCode = 'utf8', outCode = 'base64') {
×
1005
        if (text) {
1✔
1006
            if (!this.crypto) {
1!
1007
                throw new OneTableArgError('No database secret or cipher defined')
×
1008
            }
1009
            let crypto = this.crypto[name]
1✔
1010
            if (!crypto) {
1!
1011
                throw new OneTableArgError(`Database crypto not defined for ${name}`)
×
1012
            }
1013
            let iv = Crypto.randomBytes(IV_LENGTH)
1✔
1014
            let crypt = Crypto.createCipheriv(crypto.cipher, crypto.secret, iv)
1✔
1015
            let crypted = crypt.update(text, inCode, outCode) + crypt.final(outCode)
1✔
1016
            let tag = crypto.cipher.indexOf('-gcm') > 0 ? crypt.getAuthTag().toString(outCode) : ''
1!
1017
            text = `${crypto.name}:${tag}:${iv.toString('hex')}:${crypted}`
1✔
1018
        }
1019
        return text
1✔
1020
    }
1021

1022
    decrypt(text, inCode = 'base64', outCode = 'utf8') {
×
1023
        if (text) {
2✔
1024
            let [name, tag, iv, data] = text.split(':')
2✔
1025
            if (!data || !iv || !tag || !name) {
2!
1026
                return text
×
1027
            }
1028
            if (!this.crypto) {
2!
1029
                throw new OneTableArgError('No database secret or cipher defined')
×
1030
            }
1031
            let crypto = this.crypto[name]
2✔
1032
            if (!crypto) {
2!
1033
                throw new OneTableArgError(`Database crypto not defined for ${name}`)
×
1034
            }
1035
            iv = Buffer.from(iv, 'hex')
2✔
1036
            let crypt = Crypto.createDecipheriv(crypto.cipher, crypto.secret, iv)
2✔
1037
            crypt.setAuthTag(Buffer.from(tag, inCode))
2✔
1038
            text = crypt.update(data, inCode, outCode) + crypt.final(outCode)
2✔
1039
        }
1040
        return text
2✔
1041
    }
1042

1043
    /*
1044
        Marshall data into and out of DynamoDB format
1045
    */
1046
    marshall(item, params) {
1047
        let client = params.client || this.client
2,561✔
1048
        if (client.V3) {
2,561✔
1049
            let options = client.params.marshall
2,476✔
1050
            if (Array.isArray(item)) {
2,476!
1051
                for (let i = 0; i < item.length; i++) {
×
1052
                    item[i] = client.marshall(item[i], options)
×
1053
                }
1054
            } else {
1055
                item = client.marshall(item, options)
2,476✔
1056
            }
1057
        } else {
1058
            if (Array.isArray(item)) {
85!
1059
                for (let i = 0; i < item.length; i++) {
×
1060
                    item = this.marshallv2(item, params)
×
1061
                }
1062
            } else {
1063
                item = this.marshallv2(item, params)
85✔
1064
            }
1065
        }
1066
        return item
2,561✔
1067
    }
1068

1069
    /*
1070
        Marshall data out of DynamoDB format
1071
    */
1072
    unmarshall(item, params) {
1073
        if (this.V3) {
310✔
1074
            let client = params.client ? params.client : this.client
282!
1075
            let options = client.params.unmarshall
282✔
1076
            if (Array.isArray(item)) {
282✔
1077
                for (let i = 0; i < item.length; i++) {
231✔
1078
                    item[i] = client.unmarshall(item[i], options)
1,824✔
1079
                }
1080
            } else {
1081
                item = client.unmarshall(item, options)
51✔
1082
            }
1083
        } else {
1084
            if (Array.isArray(item)) {
28✔
1085
                for (let i = 0; i < item.length; i++) {
22✔
1086
                    item[i] = this.unmarshallv2(item[i])
26✔
1087
                }
1088
            } else {
1089
                item = this.unmarshallv2(item)
6✔
1090
            }
1091
        }
1092
        return item
310✔
1093
    }
1094

1095
    marshallv2(item, params) {
1096
        let client = params.client ? params.client : this.client
85!
1097
        for (let [key, value] of Object.entries(item)) {
85✔
1098
            if (value instanceof Set) {
203✔
1099
                item[key] = client.createSet(Array.from(value))
7✔
1100
            }
1101
        }
1102
        return item
85✔
1103
    }
1104

1105
    unmarshallv2(item) {
1106
        for (let [key, value] of Object.entries(item)) {
32✔
1107
            if (
440✔
1108
                value != null &&
898✔
1109
                typeof value == 'object' &&
1110
                value.wrapperName == 'Set' &&
1111
                Array.isArray(value.values)
1112
            ) {
1113
                let list = value.values
9✔
1114
                if (value.type == 'Binary') {
9✔
1115
                    //  Match AWS SDK V3 behavior
1116
                    list = list.map((v) => new Uint8Array(v))
9✔
1117
                }
1118
                item[key] = new Set(list)
9✔
1119
            }
1120
        }
1121
        return item
32✔
1122
    }
1123

1124
    unmarshallStreamImage(image, params) {
1125
        let client = params.client ? params.client : this.client
×
1126
        let options = client.params.unmarshall
×
1127
        return client.unmarshall(image, options)
×
1128
    }
1129

1130
    /*
1131
        Handle DynamoDb Stream Records
1132
     */
1133
    stream(records, params = {}) {
×
1134
        const tableModels = this.listModels()
×
1135

1136
        const result = {}
×
1137
        for (const record of records) {
×
1138
            if (!record.dynamodb.NewImage && !record.dynamodb.OldImage) {
×
1139
                continue
×
1140
            }
1141
            const model = {type: record.eventName}
×
1142
            let typeNew
1143
            let typeOld
1144

1145
            // Unmarshall and transform the New Image if it exists
1146
            if (record.dynamodb.NewImage) {
×
1147
                const jsonNew = this.unmarshallStreamImage(record.dynamodb.NewImage, params)
×
1148
                typeNew = jsonNew[this.typeField]
×
1149

1150
                // If type not found then don't do anything
1151
                if (typeNew && tableModels.includes(typeNew)) {
×
1152
                    model.new = this.schema.models[typeNew].transformReadItem('get', jsonNew, {}, params)
×
1153
                }
1154
            }
1155
            // Unmarshall and transform the Old Image if it exists
1156
            if (record.dynamodb.OldImage) {
×
1157
                const jsonOld = this.unmarshallStreamImage(record.dynamodb.OldImage, params)
×
1158
                typeOld = jsonOld[this.typeField]
×
1159

1160
                // If type not found then don't do anything
1161
                if (typeOld && tableModels.includes(typeOld)) {
×
1162
                    // If there was a new image of a different type then skip
1163
                    if (typeNew && typeNew !== typeOld) {
×
1164
                        continue
×
1165
                    }
1166
                    model.old = this.schema.models[typeOld].transformReadItem('get', jsonOld, {}, params)
×
1167
                }
1168
            }
1169
            const type = typeNew || typeOld
×
1170
            let list = (result[type] = result[type] || [])
×
1171
            list.push(model)
×
1172
        }
1173
        return result
×
1174
    }
1175

1176
    /*
1177
        Recursive Object.assign. Will clone dates, regexp, simple objects and arrays.
1178
        Other class instances and primitives are copied not cloned.
1179
        Max recursive depth of 20
1180
    */
1181
    assign(dest, ...sources) {
1182
        for (let src of sources) {
1,252✔
1183
            if (src) {
1,257✔
1184
                dest = this.assignInner(dest, src)
1,257✔
1185
            }
1186
        }
1187
        return dest
1,252✔
1188
    }
1189

1190
    assignInner(dest, src, recurse = 0) {
1,257✔
1191
        if (recurse++ > 1000) {
3,204!
1192
            throw new OneTableError('Recursive merge', {code: 'RuntimeError'})
×
1193
        }
1194
        if (!src || !dest || typeof src != 'object') {
3,204!
1195
            return
×
1196
        }
1197
        for (let [key, v] of Object.entries(src)) {
3,204✔
1198
            if (v === undefined) {
9,287!
1199
                continue
×
1200
            } else if (v instanceof Date) {
9,287✔
1201
                dest[key] = new Date(v)
21✔
1202
            } else if (v instanceof RegExp) {
9,266✔
1203
                dest[key] = new RegExp(v.source, v.flags)
12✔
1204
            } else if (Array.isArray(v)) {
9,254✔
1205
                if (!Array.isArray(dest[key])) {
17✔
1206
                    dest[key] = []
17✔
1207
                }
1208
                if (v.length) {
17✔
1209
                    dest[key] = this.assignInner([key], v, recurse)
17✔
1210
                }
1211
            } else if (typeof v == 'object' && v != null && v.constructor.name == 'Object') {
9,237✔
1212
                if (typeof dest[key] != 'object') {
1,930✔
1213
                    dest[key] = {}
1,925✔
1214
                }
1215
                dest[key] = this.assignInner(dest[key], v, recurse)
1,930✔
1216
            } else {
1217
                dest[key] = v
7,307✔
1218
            }
1219
        }
1220
        return dest
3,204✔
1221
    }
1222

1223
    async delay(time) {
1224
        return new Promise(function (resolve) {
×
1225
            setTimeout(() => resolve(true), time)
×
1226
        })
1227
    }
1228
}
1229

1230
/*
1231
    Emulate SenseLogs API
1232
*/
1233
class Log {
1234
    constructor(logger) {
1235
        if (logger === true) {
55✔
1236
            this.logger = this.defaultLogger
19✔
1237
        } else if (logger) {
36✔
1238
            this.logger = logger
2✔
1239
        }
1240
    }
1241

1242
    enabled() {
1243
        return true
×
1244
    }
1245

1246
    data(message, context) {
1247
        this.process('data', message, context)
853✔
1248
    }
1249

1250
    emit(chan, message, context) {
1251
        this.process(chan, message, context)
×
1252
    }
1253

1254
    error(message, context) {
1255
        this.process('error', message, context)
2✔
1256
    }
1257

1258
    info(message, context) {
1259
        this.process('info', message, context)
8✔
1260
    }
1261

1262
    trace(message, context) {
1263
        this.process('trace', message, context)
1,052✔
1264
    }
1265

1266
    process(level, message, context) {
1267
        if (this.logger) {
1,915✔
1268
            this.logger(level, message, context)
412✔
1269
        }
1270
    }
1271

1272
    defaultLogger(level, message, context) {
1273
        if (level == 'trace' || level == 'data') {
358✔
1274
            //  params.log: true will cause the level to be changed to 'info'
1275
            return
358✔
1276
        }
1277
        if (context) {
×
1278
            try {
×
1279
                console.log(level, message, JSON.stringify(context, null, 4))
×
1280
            } catch (err) {
1281
                let buf = ['{']
×
1282
                for (let [key, value] of Object.entries(context)) {
×
1283
                    try {
×
1284
                        buf.push(`    ${key}: ${JSON.stringify(value, null, 4)}`)
×
1285
                    } catch (err) {
1286
                        /* continue */
1287
                    }
1288
                }
1289
                buf.push('}')
×
1290
                console.log(level, message, buf.join('\n'))
×
1291
            }
1292
        } else {
1293
            console.log(level, message)
×
1294
        }
1295
    }
1296
}
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