@@ -120,9 +120,57 @@ export interface ConditionalOptions {
120
120
attributeValues ?: AttributeValues ;
121
121
}
122
122
123
- export type PutOptions = ConditionalOptions ;
124
- export type UpdateOptions = ConditionalOptions ;
125
- export type DeleteOptions = ConditionalOptions ;
123
+ export interface SaveBehavior {
124
+ optimisticLockVersionAttribute ?: string ;
125
+ optimisticLockVersionIncrement ?: number ;
126
+ }
127
+
128
+ export interface MutateBehavior {
129
+ ignoreOptimisticLocking ?: boolean ;
130
+ }
131
+
132
+ export type PutOptions = ConditionalOptions & MutateBehavior ;
133
+ export type UpdateOptions = ConditionalOptions & MutateBehavior ;
134
+ export type DeleteOptions = ConditionalOptions & MutateBehavior ;
135
+
136
+ export interface BuildOptimisticLockOptionsInput extends ConditionalOptions {
137
+ versionAttribute : string ;
138
+ versionAttributeValue : any ;
139
+ }
140
+
141
+ export function buildOptimisticLockOptions (
142
+ options : BuildOptimisticLockOptionsInput
143
+ ) : ConditionalOptions {
144
+ const { versionAttribute, versionAttributeValue } = options ;
145
+ let { conditionExpression, attributeNames, attributeValues } = options ;
146
+
147
+ const lockExpression = versionAttributeValue
148
+ ? `#${ versionAttribute } = :${ versionAttribute } `
149
+ : `attribute_not_exists(${ versionAttribute } )` ;
150
+
151
+ conditionExpression = conditionExpression
152
+ ? `(${ conditionExpression } ) AND ${ lockExpression } `
153
+ : lockExpression ;
154
+
155
+ if ( versionAttributeValue ) {
156
+ attributeNames = {
157
+ ...attributeNames ,
158
+ [ `#${ versionAttribute } ` ] : versionAttribute ,
159
+ } ;
160
+ attributeValues = {
161
+ ...attributeValues ,
162
+ [ `:${ versionAttribute } ` ] : versionAttributeValue ,
163
+ } ;
164
+ }
165
+
166
+ return {
167
+ conditionExpression,
168
+ attributeNames,
169
+ attributeValues,
170
+ } ;
171
+ }
172
+
173
+ type DataModelAsMap = { [ key : string ] : any } ;
126
174
127
175
export interface GenerateUpdateParamsInput extends UpdateOptions {
128
176
tableName : string ;
@@ -131,9 +179,10 @@ export interface GenerateUpdateParamsInput extends UpdateOptions {
131
179
}
132
180
133
181
export function generateUpdateParams (
134
- options : GenerateUpdateParamsInput
182
+ options : GenerateUpdateParamsInput & SaveBehavior
135
183
) : DocumentClient . UpdateItemInput {
136
184
const setExpressions : string [ ] = [ ] ;
185
+ const addExpressions : string [ ] = [ ] ;
137
186
const removeExpressions : string [ ] = [ ] ;
138
187
const expressionAttributeNameMap : AttributeNames = { } ;
139
188
const expressionAttributeValueMap : AttributeValues = { } ;
@@ -142,15 +191,41 @@ export function generateUpdateParams(
142
191
tableName,
143
192
key,
144
193
data,
145
- conditionExpression,
146
194
attributeNames,
147
195
attributeValues,
196
+ optimisticLockVersionAttribute : versionAttribute ,
197
+ optimisticLockVersionIncrement : versionInc ,
198
+ ignoreOptimisticLocking : ignoreLocking = false ,
148
199
} = options ;
149
200
201
+ let conditionExpression = options . conditionExpression ;
202
+
203
+ if ( versionAttribute ) {
204
+ addExpressions . push ( `#${ versionAttribute } :${ versionAttribute } Inc` ) ;
205
+ expressionAttributeNameMap [ `#${ versionAttribute } ` ] = versionAttribute ;
206
+ expressionAttributeValueMap [ `:${ versionAttribute } Inc` ] = versionInc ?? 1 ;
207
+
208
+ if ( ! ignoreLocking ) {
209
+ ( { conditionExpression } = buildOptimisticLockOptions ( {
210
+ versionAttribute,
211
+ versionAttributeValue : ( data as DataModelAsMap ) [ versionAttribute ] ,
212
+ conditionExpression,
213
+ } ) ) ;
214
+ expressionAttributeValueMap [ `:${ versionAttribute } ` ] = (
215
+ data as DataModelAsMap
216
+ ) [ versionAttribute ] ;
217
+ }
218
+ }
219
+
150
220
const keys = Object . keys ( options . data ) . sort ( ) ;
151
221
152
222
for ( let i = 0 ; i < keys . length ; i ++ ) {
153
223
const name = keys [ i ] ;
224
+ if ( name === versionAttribute ) {
225
+ // versionAttribute is a special case and should always be handled
226
+ // explicitly as above with the supplied value ignored
227
+ continue ;
228
+ }
154
229
155
230
const valueName = `:a${ i } ` ;
156
231
const attributeName = `#a${ i } ` ;
@@ -178,11 +253,13 @@ export function generateUpdateParams(
178
253
? 'remove ' + removeExpressions . join ( ', ' )
179
254
: undefined ;
180
255
256
+ const addString =
257
+ addExpressions . length > 0 ? 'add ' + addExpressions . join ( ', ' ) : undefined ;
181
258
return {
182
259
TableName : tableName ,
183
260
Key : key ,
184
261
ConditionExpression : conditionExpression ,
185
- UpdateExpression : [ setString , removeString ]
262
+ UpdateExpression : [ addString , setString , removeString ]
186
263
. filter ( ( val ) => val !== undefined )
187
264
. join ( ' ' ) ,
188
265
ExpressionAttributeNames : {
@@ -197,9 +274,10 @@ export function generateUpdateParams(
197
274
} ;
198
275
}
199
276
200
- interface DynamoDbDaoInput {
277
+ export interface DynamoDbDaoInput < T > {
201
278
tableName : string ;
202
279
documentClient : DocumentClient ;
280
+ optimisticLockingAttribute ?: keyof NumberPropertiesInType < T > ;
203
281
}
204
282
205
283
function invalidCursorError ( cursor : string ) : Error {
@@ -261,10 +339,12 @@ export type NumberPropertiesInType<T> = Pick<
261
339
export default class DynamoDbDao < DataModel , KeySchema > {
262
340
public readonly tableName : string ;
263
341
public readonly documentClient : DocumentClient ;
342
+ public readonly optimisticLockingAttribute ?: keyof NumberPropertiesInType < DataModel > ;
264
343
265
- constructor ( options : DynamoDbDaoInput ) {
344
+ constructor ( options : DynamoDbDaoInput < DataModel > ) {
266
345
this . tableName = options . tableName ;
267
346
this . documentClient = options . documentClient ;
347
+ this . optimisticLockingAttribute = options . optimisticLockingAttribute ;
268
348
}
269
349
270
350
/**
@@ -292,16 +372,30 @@ export default class DynamoDbDao<DataModel, KeySchema> {
292
372
*/
293
373
async delete (
294
374
key : KeySchema ,
295
- options : DeleteOptions = { }
375
+ options : DeleteOptions = { } ,
376
+ data : Partial < DataModel > = { }
296
377
) : Promise < DataModel | undefined > {
378
+ let { attributeNames, attributeValues, conditionExpression } = options ;
379
+
380
+ if ( this . optimisticLockingAttribute && ! options . ignoreOptimisticLocking ) {
381
+ const versionAttribute = this . optimisticLockingAttribute . toString ( ) ;
382
+ ( { attributeNames, attributeValues, conditionExpression } =
383
+ buildOptimisticLockOptions ( {
384
+ versionAttribute,
385
+ versionAttributeValue : ( data as DataModelAsMap ) [ versionAttribute ] ,
386
+ conditionExpression : conditionExpression ,
387
+ attributeNames,
388
+ attributeValues,
389
+ } ) ) ;
390
+ }
297
391
const { Attributes : attributes } = await this . documentClient
298
392
. delete ( {
299
393
TableName : this . tableName ,
300
394
Key : key ,
301
395
ReturnValues : 'ALL_OLD' ,
302
- ConditionExpression : options . conditionExpression ,
303
- ExpressionAttributeNames : options . attributeNames ,
304
- ExpressionAttributeValues : options . attributeValues ,
396
+ ConditionExpression : conditionExpression ,
397
+ ExpressionAttributeNames : attributeNames ,
398
+ ExpressionAttributeValues : attributeValues ,
305
399
} )
306
400
. promise ( ) ;
307
401
@@ -312,13 +406,36 @@ export default class DynamoDbDao<DataModel, KeySchema> {
312
406
* Creates/Updates an item in the table
313
407
*/
314
408
async put ( data : DataModel , options : PutOptions = { } ) : Promise < DataModel > {
409
+ let { conditionExpression, attributeNames, attributeValues } = options ;
410
+ if ( this . optimisticLockingAttribute ) {
411
+ // Must cast data to avoid tripping the linter, otherwise, it'll complain
412
+ // about expression of type 'string' can't be used to index type 'unknown'
413
+ const dataAsMap = data as DataModelAsMap ;
414
+ const versionAttribute = this . optimisticLockingAttribute . toString ( ) ;
415
+
416
+ if ( ! options . ignoreOptimisticLocking ) {
417
+ ( { conditionExpression, attributeNames, attributeValues } =
418
+ buildOptimisticLockOptions ( {
419
+ versionAttribute,
420
+ versionAttributeValue : dataAsMap [ versionAttribute ] ,
421
+ conditionExpression,
422
+ attributeNames,
423
+ attributeValues,
424
+ } ) ) ;
425
+ }
426
+
427
+ dataAsMap [ versionAttribute ] = dataAsMap [ versionAttribute ]
428
+ ? dataAsMap [ versionAttribute ] + 1
429
+ : 1 ;
430
+ }
431
+
315
432
await this . documentClient
316
433
. put ( {
317
434
TableName : this . tableName ,
318
435
Item : data ,
319
- ConditionExpression : options . conditionExpression ,
320
- ExpressionAttributeNames : options . attributeNames ,
321
- ExpressionAttributeValues : options . attributeValues ,
436
+ ConditionExpression : conditionExpression ,
437
+ ExpressionAttributeNames : attributeNames ,
438
+ ExpressionAttributeValues : attributeValues ,
322
439
} )
323
440
. promise ( ) ;
324
441
return data ;
@@ -337,6 +454,8 @@ export default class DynamoDbDao<DataModel, KeySchema> {
337
454
key,
338
455
data,
339
456
...updateOptions ,
457
+ optimisticLockVersionAttribute :
458
+ this . optimisticLockingAttribute ?. toString ( ) ,
340
459
} ) ;
341
460
const { Attributes : attributes } = await this . documentClient
342
461
. update ( params )
0 commit comments