@model-ts/dynamodb 4.1.0 → 4.2.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cjs/__test__/client-env-guard.test.d.ts +1 -0
  3. package/dist/cjs/__test__/client-env-guard.test.js +28 -0
  4. package/dist/cjs/__test__/client-env-guard.test.js.map +1 -0
  5. package/dist/cjs/__test__/conformance.test.d.ts +1 -0
  6. package/dist/cjs/__test__/conformance.test.js +1835 -0
  7. package/dist/cjs/__test__/conformance.test.js.map +1 -0
  8. package/dist/cjs/__test__/in-memory.spec.test.d.ts +1 -0
  9. package/dist/cjs/__test__/in-memory.spec.test.js +185 -0
  10. package/dist/cjs/__test__/in-memory.spec.test.js.map +1 -0
  11. package/dist/cjs/client.d.ts +2 -2
  12. package/dist/cjs/client.js +14 -6
  13. package/dist/cjs/client.js.map +1 -1
  14. package/dist/cjs/errors.d.ts +13 -1
  15. package/dist/cjs/errors.js +12 -1
  16. package/dist/cjs/errors.js.map +1 -1
  17. package/dist/cjs/in-memory/document-client.d.ts +45 -0
  18. package/dist/cjs/in-memory/document-client.js +795 -0
  19. package/dist/cjs/in-memory/document-client.js.map +1 -0
  20. package/dist/cjs/in-memory/expression.d.ts +42 -0
  21. package/dist/cjs/in-memory/expression.js +585 -0
  22. package/dist/cjs/in-memory/expression.js.map +1 -0
  23. package/dist/cjs/in-memory/index.d.ts +2 -0
  24. package/dist/cjs/in-memory/index.js +6 -0
  25. package/dist/cjs/in-memory/index.js.map +1 -0
  26. package/dist/cjs/in-memory/spec.d.ts +23 -0
  27. package/dist/cjs/in-memory/spec.js +141 -0
  28. package/dist/cjs/in-memory/spec.js.map +1 -0
  29. package/dist/cjs/in-memory/store.d.ts +73 -0
  30. package/dist/cjs/in-memory/store.js +267 -0
  31. package/dist/cjs/in-memory/store.js.map +1 -0
  32. package/dist/cjs/in-memory/treap.d.ts +31 -0
  33. package/dist/cjs/in-memory/treap.js +187 -0
  34. package/dist/cjs/in-memory/treap.js.map +1 -0
  35. package/dist/cjs/in-memory/utils.d.ts +10 -0
  36. package/dist/cjs/in-memory/utils.js +38 -0
  37. package/dist/cjs/in-memory/utils.js.map +1 -0
  38. package/dist/cjs/sandbox.js +44 -0
  39. package/dist/cjs/sandbox.js.map +1 -1
  40. package/dist/esm/__test__/client-env-guard.test.d.ts +1 -0
  41. package/dist/esm/__test__/client-env-guard.test.js +26 -0
  42. package/dist/esm/__test__/client-env-guard.test.js.map +1 -0
  43. package/dist/esm/__test__/conformance.test.d.ts +1 -0
  44. package/dist/esm/__test__/conformance.test.js +1833 -0
  45. package/dist/esm/__test__/conformance.test.js.map +1 -0
  46. package/dist/esm/__test__/in-memory.spec.test.d.ts +1 -0
  47. package/dist/esm/__test__/in-memory.spec.test.js +183 -0
  48. package/dist/esm/__test__/in-memory.spec.test.js.map +1 -0
  49. package/dist/esm/client.d.ts +2 -2
  50. package/dist/esm/client.js +14 -6
  51. package/dist/esm/client.js.map +1 -1
  52. package/dist/esm/errors.d.ts +13 -1
  53. package/dist/esm/errors.js +10 -0
  54. package/dist/esm/errors.js.map +1 -1
  55. package/dist/esm/in-memory/document-client.d.ts +45 -0
  56. package/dist/esm/in-memory/document-client.js +791 -0
  57. package/dist/esm/in-memory/document-client.js.map +1 -0
  58. package/dist/esm/in-memory/expression.d.ts +42 -0
  59. package/dist/esm/in-memory/expression.js +577 -0
  60. package/dist/esm/in-memory/expression.js.map +1 -0
  61. package/dist/esm/in-memory/index.d.ts +2 -0
  62. package/dist/esm/in-memory/index.js +3 -0
  63. package/dist/esm/in-memory/index.js.map +1 -0
  64. package/dist/esm/in-memory/spec.d.ts +23 -0
  65. package/dist/esm/in-memory/spec.js +138 -0
  66. package/dist/esm/in-memory/spec.js.map +1 -0
  67. package/dist/esm/in-memory/store.d.ts +73 -0
  68. package/dist/esm/in-memory/store.js +258 -0
  69. package/dist/esm/in-memory/store.js.map +1 -0
  70. package/dist/esm/in-memory/treap.d.ts +31 -0
  71. package/dist/esm/in-memory/treap.js +183 -0
  72. package/dist/esm/in-memory/treap.js.map +1 -0
  73. package/dist/esm/in-memory/utils.d.ts +10 -0
  74. package/dist/esm/in-memory/utils.js +28 -0
  75. package/dist/esm/in-memory/utils.js.map +1 -0
  76. package/dist/esm/sandbox.js +44 -0
  77. package/dist/esm/sandbox.js.map +1 -1
  78. package/package.json +2 -1
  79. package/src/__test__/client-env-guard.test.ts +31 -0
  80. package/src/__test__/conformance.test.ts +2042 -0
  81. package/src/__test__/in-memory.spec.test.ts +230 -0
  82. package/src/client.ts +17 -4
  83. package/src/errors.ts +24 -0
  84. package/src/in-memory/document-client.ts +1140 -0
  85. package/src/in-memory/expression.ts +730 -0
  86. package/src/in-memory/index.ts +2 -0
  87. package/src/in-memory/spec.ts +159 -0
  88. package/src/in-memory/store.ts +360 -0
  89. package/src/in-memory/treap.ts +239 -0
  90. package/src/in-memory/utils.ts +45 -0
  91. package/src/sandbox.ts +56 -0
@@ -0,0 +1,1140 @@
1
+ import { NotSupportedError } from "../errors"
2
+ import {
3
+ evaluateConditionExpression,
4
+ parseKeyConditionExpression,
5
+ parseUpdateExpression,
6
+ } from "./expression"
7
+ import {
8
+ InMemoryTableState,
9
+ PRIMARY_INDEX_NAME,
10
+ isSupportedIndexName,
11
+ matchesKeyConditionDescriptor,
12
+ parseIndexName,
13
+ isGSI,
14
+ } from "./store"
15
+ import { IN_MEMORY_SPEC, InMemoryIndexName } from "./spec"
16
+ import { cloneItem, encodeItemKey } from "./utils"
17
+
18
+ interface PromiseRequest<T> {
19
+ promise: () => Promise<T>
20
+ }
21
+
22
+ type AnyParams = { [key: string]: any }
23
+
24
+ export interface InMemoryDocumentClient {
25
+ get(params: AnyParams): PromiseRequest<{ Item?: any }>
26
+ put(params: AnyParams): PromiseRequest<{}>
27
+ update(params: AnyParams): PromiseRequest<{ Attributes?: any }>
28
+ delete(params: AnyParams): PromiseRequest<{}>
29
+ query(params: AnyParams): PromiseRequest<{
30
+ Items?: any[]
31
+ Count: number
32
+ ScannedCount: number
33
+ LastEvaluatedKey?: any
34
+ }>
35
+ scan(params: AnyParams): PromiseRequest<{
36
+ Items?: any[]
37
+ Count: number
38
+ ScannedCount: number
39
+ LastEvaluatedKey?: any
40
+ }>
41
+ batchGet(params: AnyParams): PromiseRequest<{ Responses?: { [tableName: string]: any[] } }>
42
+ batchWrite(params: AnyParams): PromiseRequest<{ UnprocessedItems: { [tableName: string]: any[] } }>
43
+ transactWrite(params: AnyParams): PromiseRequest<{}>
44
+ __inMemorySnapshot(tableName: string): { [key: string]: any }
45
+ __inMemoryResetTable(tableName: string): void
46
+ }
47
+
48
+ class InMemoryDocumentClientImpl implements InMemoryDocumentClient {
49
+ private readonly tables = new Map<string, InMemoryTableState>()
50
+
51
+ get(params: AnyParams): PromiseRequest<{ Item?: any }> {
52
+ return this.request("get", params, async () => {
53
+ this.assertSupportedParams("get", params)
54
+ const tableName = this.getRequiredTableName(params)
55
+ const key = this.getRequiredKey(params, "Key")
56
+
57
+ const table = this.getTable(tableName)
58
+ const item = table.cloneItemByKey(key)
59
+
60
+ return item ? { Item: item } : {}
61
+ })
62
+ }
63
+
64
+ put(params: AnyParams): PromiseRequest<{}> {
65
+ return this.request("put", params, async () => {
66
+ this.assertSupportedParams("put", params)
67
+ const tableName = this.getRequiredTableName(params)
68
+ const table = this.getTable(tableName)
69
+
70
+ const item = params.Item
71
+ if (!item || typeof item !== "object") {
72
+ throw new NotSupportedError({
73
+ method: "put",
74
+ featurePath: "put.Item",
75
+ reason: "Item must be an object.",
76
+ })
77
+ }
78
+
79
+ this.assertPrimaryKey(item, "put")
80
+ this.assertExpressionAttributeInputs(params, [params.ConditionExpression])
81
+
82
+ const existing = table.cloneItemByKey({ PK: item.PK, SK: item.SK })
83
+ this.assertCondition("put", params, existing)
84
+
85
+ table.put(item)
86
+
87
+ return {}
88
+ })
89
+ }
90
+
91
+ update(params: AnyParams): PromiseRequest<{ Attributes?: any }> {
92
+ return this.request("update", params, async () => {
93
+ this.assertSupportedParams("update", params)
94
+ const tableName = this.getRequiredTableName(params)
95
+ const key = this.getRequiredKey(params, "Key")
96
+
97
+ if (typeof params.UpdateExpression !== "string") {
98
+ throw new NotSupportedError({
99
+ method: "update",
100
+ featurePath: "update.UpdateExpression",
101
+ reason: "UpdateExpression is required.",
102
+ })
103
+ }
104
+
105
+ const returnValues = params.ReturnValues ?? "NONE"
106
+ if (!["NONE", "ALL_NEW"].includes(returnValues)) {
107
+ throw this.validationError(
108
+ `Unsupported ReturnValues value: ${returnValues}`
109
+ )
110
+ }
111
+ this.assertExpressionAttributeInputs(params, [
112
+ params.ConditionExpression,
113
+ params.UpdateExpression,
114
+ ])
115
+
116
+ const table = this.getTable(tableName)
117
+ const existing = table.cloneItemByKey(key)
118
+
119
+ this.assertCondition("update", params, existing)
120
+
121
+ const base = existing ?? { PK: key.PK, SK: key.SK }
122
+ const parsed = this.parseUpdateExpressionOrValidationError(
123
+ params.UpdateExpression,
124
+ {
125
+ method: "update",
126
+ expressionAttributeNames: params.ExpressionAttributeNames,
127
+ expressionAttributeValues: params.ExpressionAttributeValues,
128
+ item: base,
129
+ }
130
+ )
131
+
132
+ const next = cloneItem(base)
133
+
134
+ for (const assignment of parsed.set) {
135
+ if ((assignment.attribute === "PK" || assignment.attribute === "SK") && assignment.value !== next[assignment.attribute]) {
136
+ throw this.validationError(
137
+ `One or more parameter values were invalid: Cannot update attribute ${assignment.attribute}. This attribute is part of the key`
138
+ )
139
+ }
140
+
141
+ next[assignment.attribute] = assignment.value
142
+ }
143
+
144
+ for (const attribute of parsed.remove) {
145
+ if (attribute === "PK" || attribute === "SK") {
146
+ throw this.validationError(
147
+ `One or more parameter values were invalid: Cannot update attribute ${attribute}. This attribute is part of the key`
148
+ )
149
+ }
150
+
151
+ delete next[attribute]
152
+ }
153
+
154
+ this.assertPrimaryKey(next, "update")
155
+ table.put(next)
156
+
157
+ if (returnValues === "ALL_NEW") {
158
+ return { Attributes: cloneItem(next) }
159
+ }
160
+
161
+ return {}
162
+ })
163
+ }
164
+
165
+ delete(params: AnyParams): PromiseRequest<{}> {
166
+ return this.request("delete", params, async () => {
167
+ this.assertSupportedParams("delete", params)
168
+ const tableName = this.getRequiredTableName(params)
169
+ const key = this.getRequiredKey(params, "Key")
170
+
171
+ const table = this.getTable(tableName)
172
+ const existing = table.cloneItemByKey(key)
173
+ this.assertExpressionAttributeInputs(params, [params.ConditionExpression])
174
+
175
+ this.assertCondition("delete", params, existing)
176
+
177
+ table.deleteByKey(key)
178
+ return {}
179
+ })
180
+ }
181
+
182
+ query(params: AnyParams): PromiseRequest<{
183
+ Items?: any[]
184
+ Count: number
185
+ ScannedCount: number
186
+ LastEvaluatedKey?: any
187
+ }> {
188
+ return this.request("query", params, async () => {
189
+ this.assertSupportedParams("query", params)
190
+ const tableName = this.getRequiredTableName(params)
191
+ const table = this.getTable(tableName)
192
+
193
+ if (typeof params.KeyConditionExpression !== "string") {
194
+ throw new NotSupportedError({
195
+ method: "query",
196
+ featurePath: "query.KeyConditionExpression",
197
+ reason: "KeyConditionExpression is required.",
198
+ })
199
+ }
200
+
201
+ const indexName = this.resolveIndexName(params.IndexName)
202
+
203
+ if (params.ConsistentRead && isGSI(indexName)) {
204
+ throw this.validationError(
205
+ "Consistent read cannot be true when querying a GSI"
206
+ )
207
+ }
208
+ this.assertExpressionAttributeInputs(params, [
209
+ params.KeyConditionExpression,
210
+ params.FilterExpression,
211
+ ])
212
+
213
+ const condition = this.parseKeyConditionOrValidationError(
214
+ params.KeyConditionExpression,
215
+ {
216
+ method: "query",
217
+ expressionAttributeNames: params.ExpressionAttributeNames,
218
+ expressionAttributeValues: params.ExpressionAttributeValues,
219
+ }
220
+ )
221
+
222
+ if (!matchesKeyConditionDescriptor(indexName, condition)) {
223
+ throw new NotSupportedError({
224
+ method: "query",
225
+ featurePath: "query.KeyConditionExpression",
226
+ reason: "KeyConditionExpression attributes do not match the selected index.",
227
+ })
228
+ }
229
+
230
+ if (typeof condition.hashValue !== "string") {
231
+ throw new NotSupportedError({
232
+ method: "query",
233
+ featurePath: "query.KeyConditionExpression.partitionValue",
234
+ reason: "Partition key values must be strings.",
235
+ })
236
+ }
237
+
238
+ const scanIndexForward = params.ScanIndexForward !== false
239
+ const limit = this.normalizeLimit(params.Limit, "query")
240
+ const exclusiveStartKey = params.ExclusiveStartKey
241
+ ? this.resolveExclusiveStartKey(params.ExclusiveStartKey, indexName, table)
242
+ : undefined
243
+
244
+ const iterator = table.iterateQueryCandidates({
245
+ indexName,
246
+ hashKey: condition.hashValue,
247
+ rangeCondition: condition.range?.condition,
248
+ scanIndexForward,
249
+ exclusiveStartKey,
250
+ })[Symbol.iterator]()
251
+
252
+ const items: any[] = []
253
+ let scannedCount = 0
254
+ let lastEvaluatedKey: any | undefined
255
+
256
+ let next = iterator.next()
257
+ while (!next.done) {
258
+ const candidate = next.value
259
+ scannedCount += 1
260
+
261
+ const include = params.FilterExpression
262
+ ? this.evaluateConditionOrValidationError(
263
+ params.FilterExpression,
264
+ candidate.item,
265
+ {
266
+ method: "query",
267
+ expressionAttributeNames: params.ExpressionAttributeNames,
268
+ expressionAttributeValues: params.ExpressionAttributeValues,
269
+ },
270
+ "FilterExpression"
271
+ )
272
+ : true
273
+
274
+ if (include) {
275
+ items.push(candidate.item)
276
+ }
277
+
278
+ if (limit !== undefined && scannedCount >= limit) {
279
+ lastEvaluatedKey = this.buildLastEvaluatedKey(indexName, candidate.item)
280
+ break
281
+ }
282
+
283
+ next = iterator.next()
284
+ }
285
+
286
+ return {
287
+ Items: items,
288
+ Count: items.length,
289
+ ScannedCount: scannedCount,
290
+ LastEvaluatedKey: lastEvaluatedKey,
291
+ }
292
+ })
293
+ }
294
+
295
+ scan(params: AnyParams): PromiseRequest<{
296
+ Items?: any[]
297
+ Count: number
298
+ ScannedCount: number
299
+ LastEvaluatedKey?: any
300
+ }> {
301
+ return this.request("scan", params, async () => {
302
+ this.assertSupportedParams("scan", params)
303
+
304
+ const tableName = this.getRequiredTableName(params)
305
+ const table = this.getTable(tableName)
306
+ const limit = this.normalizeLimit(params.Limit, "scan")
307
+ this.assertExpressionAttributeInputs(params, [params.FilterExpression])
308
+
309
+ const exclusiveStartKey = params.ExclusiveStartKey
310
+ ? this.getRequiredKey({ Key: params.ExclusiveStartKey }, "ExclusiveStartKey")
311
+ : undefined
312
+
313
+ const all = table.scanItems(exclusiveStartKey)
314
+
315
+ const items: any[] = []
316
+ let scannedCount = 0
317
+ let lastEvaluatedKey: any | undefined
318
+
319
+ for (let index = 0; index < all.length; index += 1) {
320
+ const candidate = all[index]
321
+ scannedCount += 1
322
+
323
+ const include = params.FilterExpression
324
+ ? this.evaluateConditionOrValidationError(
325
+ params.FilterExpression,
326
+ candidate,
327
+ {
328
+ method: "scan",
329
+ expressionAttributeNames: params.ExpressionAttributeNames,
330
+ expressionAttributeValues: params.ExpressionAttributeValues,
331
+ },
332
+ "FilterExpression"
333
+ )
334
+ : true
335
+
336
+ if (include) {
337
+ items.push(candidate)
338
+ }
339
+
340
+ if (limit !== undefined && scannedCount >= limit) {
341
+ lastEvaluatedKey = { PK: candidate.PK, SK: candidate.SK }
342
+ break
343
+ }
344
+ }
345
+
346
+ return {
347
+ Items: items,
348
+ Count: items.length,
349
+ ScannedCount: scannedCount,
350
+ LastEvaluatedKey: lastEvaluatedKey,
351
+ }
352
+ })
353
+ }
354
+
355
+ batchGet(params: AnyParams): PromiseRequest<{ Responses?: { [tableName: string]: any[] } }> {
356
+ return this.request("batchGet", params, async () => {
357
+ this.assertSupportedParams("batchGet", params)
358
+
359
+ if (!params.RequestItems || typeof params.RequestItems !== "object") {
360
+ throw new NotSupportedError({
361
+ method: "batchGet",
362
+ featurePath: "batchGet.RequestItems",
363
+ reason: "RequestItems is required.",
364
+ })
365
+ }
366
+
367
+ const responses: { [tableName: string]: any[] } = {}
368
+
369
+ for (const [tableName, request] of Object.entries<any>(params.RequestItems)) {
370
+ if (!request || typeof request !== "object") {
371
+ throw new NotSupportedError({
372
+ method: "batchGet",
373
+ featurePath: "batchGet.RequestItems",
374
+ reason: "Each RequestItems entry must be an object.",
375
+ })
376
+ }
377
+
378
+ const keys = Array.isArray(request.Keys) ? request.Keys : []
379
+
380
+ if (keys.length > 100) {
381
+ throw this.validationError(
382
+ "Too many items requested for the BatchGetItem call"
383
+ )
384
+ }
385
+
386
+ const table = this.getTable(tableName)
387
+ const tableResponses: any[] = []
388
+ const unique = new Set<string>()
389
+
390
+ for (const key of keys) {
391
+ const parsedKey = this.getRequiredKey({ Key: key }, "Key")
392
+ const dedupKey = `${parsedKey.PK}::${parsedKey.SK}`
393
+ if (unique.has(dedupKey)) {
394
+ throw this.validationError(
395
+ "Provided list of item keys contains duplicates"
396
+ )
397
+ }
398
+ unique.add(dedupKey)
399
+
400
+ const item = table.cloneItemByKey(parsedKey)
401
+ if (item) tableResponses.push(item)
402
+ }
403
+
404
+ responses[tableName] = tableResponses
405
+ }
406
+
407
+ return { Responses: responses }
408
+ })
409
+ }
410
+
411
+ batchWrite(params: AnyParams): PromiseRequest<{ UnprocessedItems: { [tableName: string]: any[] } }> {
412
+ return this.request("batchWrite", params, async () => {
413
+ this.assertSupportedParams("batchWrite", params)
414
+
415
+ if (!params.RequestItems || typeof params.RequestItems !== "object") {
416
+ throw new NotSupportedError({
417
+ method: "batchWrite",
418
+ featurePath: "batchWrite.RequestItems",
419
+ reason: "RequestItems is required.",
420
+ })
421
+ }
422
+
423
+ for (const [tableName, requests] of Object.entries<any[]>(params.RequestItems)) {
424
+ if (!Array.isArray(requests)) {
425
+ throw new NotSupportedError({
426
+ method: "batchWrite",
427
+ featurePath: "batchWrite.RequestItems",
428
+ reason: "Each RequestItems entry must be an array.",
429
+ })
430
+ }
431
+
432
+ if (requests.length > 25) {
433
+ throw this.validationError(
434
+ "Too many items requested for the BatchWriteItem call"
435
+ )
436
+ }
437
+
438
+ const table = this.getTable(tableName)
439
+
440
+ for (const entry of requests) {
441
+ if (entry.PutRequest) {
442
+ const item = entry.PutRequest.Item
443
+ if (!item || typeof item !== "object") {
444
+ throw new NotSupportedError({
445
+ method: "batchWrite",
446
+ featurePath: "batchWrite.RequestItems.PutRequest.Item",
447
+ reason: "PutRequest.Item must be an object.",
448
+ })
449
+ }
450
+
451
+ this.assertPrimaryKey(item, "batchWrite")
452
+ table.put(item)
453
+ continue
454
+ }
455
+
456
+ if (entry.DeleteRequest) {
457
+ const key = this.getRequiredKey(
458
+ { Key: entry.DeleteRequest.Key },
459
+ "DeleteRequest.Key"
460
+ )
461
+ table.deleteByKey(key)
462
+ continue
463
+ }
464
+
465
+ throw this.validationError(
466
+ "Supplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes"
467
+ )
468
+ }
469
+ }
470
+
471
+ return { UnprocessedItems: {} }
472
+ })
473
+ }
474
+
475
+ transactWrite(params: AnyParams): PromiseRequest<{}> {
476
+ return this.request("transactWrite", params, async () => {
477
+ this.assertSupportedParams("transactWrite", params)
478
+
479
+ const transactItems = params.TransactItems
480
+ if (!Array.isArray(transactItems) || transactItems.length === 0) {
481
+ throw new NotSupportedError({
482
+ method: "transactWrite",
483
+ featurePath: "transactWrite.TransactItems",
484
+ reason: "TransactItems must be a non-empty array.",
485
+ })
486
+ }
487
+
488
+ if (transactItems.length > 100) {
489
+ throw this.validationError(
490
+ "Member must have length less than or equal to 100"
491
+ )
492
+ }
493
+
494
+ const touched = new Set<string>()
495
+ const journal = new Map<string, { tableName: string; key: { PK: string; SK: string }; before: any | null }>()
496
+
497
+ const remember = (tableName: string, key: { PK: string; SK: string }, before: any | undefined) => {
498
+ const marker = `${tableName}::${key.PK}::${key.SK}`
499
+ if (!journal.has(marker)) {
500
+ journal.set(marker, {
501
+ tableName,
502
+ key,
503
+ before: before ? cloneItem(before) : null,
504
+ })
505
+ }
506
+ }
507
+
508
+ const ensureNoDuplicate = (tableName: string, key: { PK: string; SK: string }) => {
509
+ const marker = `${tableName}::${key.PK}::${key.SK}`
510
+ if (touched.has(marker)) {
511
+ throw this.validationError(
512
+ "Transaction request cannot include multiple operations on one item"
513
+ )
514
+ }
515
+
516
+ touched.add(marker)
517
+ }
518
+
519
+ const rollback = () => {
520
+ for (const entry of [...journal.values()].reverse()) {
521
+ const table = this.getTable(entry.tableName)
522
+
523
+ if (entry.before === null) {
524
+ table.deleteByKey(entry.key)
525
+ } else {
526
+ table.put(entry.before)
527
+ }
528
+ }
529
+ }
530
+
531
+ try {
532
+ for (const transactionEntry of transactItems) {
533
+ if (transactionEntry.Put) {
534
+ const put = transactionEntry.Put
535
+ const tableName = this.getRequiredTableName(put)
536
+ const table = this.getTable(tableName)
537
+ const item = put.Item
538
+
539
+ if (!item || typeof item !== "object") {
540
+ throw new NotSupportedError({
541
+ method: "transactWrite",
542
+ featurePath: "transactWrite.TransactItems.Put.Item",
543
+ reason: "Put.Item must be an object.",
544
+ })
545
+ }
546
+
547
+ this.assertPrimaryKey(item, "transactWrite")
548
+ const key = { PK: item.PK, SK: item.SK }
549
+ ensureNoDuplicate(tableName, key)
550
+
551
+ const existing = table.cloneItemByKey(key)
552
+ this.assertExpressionAttributeInputs(put, [put.ConditionExpression])
553
+ this.assertCondition("transactWrite", put, existing)
554
+
555
+ remember(tableName, key, existing)
556
+ table.put(item)
557
+
558
+ continue
559
+ }
560
+
561
+ if (transactionEntry.Update) {
562
+ const update = transactionEntry.Update
563
+ const tableName = this.getRequiredTableName(update)
564
+ const key = this.getRequiredKey(update, "Key")
565
+ ensureNoDuplicate(tableName, key)
566
+
567
+ const table = this.getTable(tableName)
568
+ const existing = table.cloneItemByKey(key)
569
+ this.assertExpressionAttributeInputs(update, [
570
+ update.ConditionExpression,
571
+ update.UpdateExpression,
572
+ ])
573
+ this.assertCondition("transactWrite", update, existing)
574
+
575
+ if (typeof update.UpdateExpression !== "string") {
576
+ throw new NotSupportedError({
577
+ method: "transactWrite",
578
+ featurePath: "transactWrite.TransactItems.Update.UpdateExpression",
579
+ reason: "UpdateExpression is required.",
580
+ })
581
+ }
582
+
583
+ const base = existing ?? { PK: key.PK, SK: key.SK }
584
+ const parsed = this.parseUpdateExpressionOrValidationError(
585
+ update.UpdateExpression,
586
+ {
587
+ method: "transactWrite",
588
+ expressionAttributeNames: update.ExpressionAttributeNames,
589
+ expressionAttributeValues: update.ExpressionAttributeValues,
590
+ item: base,
591
+ }
592
+ )
593
+
594
+ const next = cloneItem(base)
595
+
596
+ for (const assignment of parsed.set) {
597
+ if ((assignment.attribute === "PK" || assignment.attribute === "SK") && assignment.value !== next[assignment.attribute]) {
598
+ throw this.validationError(
599
+ `One or more parameter values were invalid: Cannot update attribute ${assignment.attribute}. This attribute is part of the key`
600
+ )
601
+ }
602
+
603
+ next[assignment.attribute] = assignment.value
604
+ }
605
+
606
+ for (const attribute of parsed.remove) {
607
+ if (attribute === "PK" || attribute === "SK") {
608
+ throw this.validationError(
609
+ `One or more parameter values were invalid: Cannot update attribute ${attribute}. This attribute is part of the key`
610
+ )
611
+ }
612
+
613
+ delete next[attribute]
614
+ }
615
+
616
+ this.assertPrimaryKey(next, "transactWrite")
617
+
618
+ remember(tableName, key, existing)
619
+ table.put(next)
620
+
621
+ continue
622
+ }
623
+
624
+ if (transactionEntry.Delete) {
625
+ const del = transactionEntry.Delete
626
+ const tableName = this.getRequiredTableName(del)
627
+ const key = this.getRequiredKey(del, "Key")
628
+ ensureNoDuplicate(tableName, key)
629
+
630
+ const table = this.getTable(tableName)
631
+ const existing = table.cloneItemByKey(key)
632
+ this.assertExpressionAttributeInputs(del, [del.ConditionExpression])
633
+ this.assertCondition("transactWrite", del, existing)
634
+ remember(tableName, key, existing)
635
+
636
+ table.deleteByKey(key)
637
+ continue
638
+ }
639
+
640
+ if (transactionEntry.ConditionCheck) {
641
+ const check = transactionEntry.ConditionCheck
642
+ const tableName = this.getRequiredTableName(check)
643
+ const key = this.getRequiredKey(check, "Key")
644
+ ensureNoDuplicate(tableName, key)
645
+
646
+ const table = this.getTable(tableName)
647
+ const existing = table.cloneItemByKey(key)
648
+ this.assertExpressionAttributeInputs(check, [check.ConditionExpression])
649
+ this.assertCondition("transactWrite", check, existing)
650
+ continue
651
+ }
652
+
653
+ throw new NotSupportedError({
654
+ method: "transactWrite",
655
+ featurePath: "transactWrite.TransactItems",
656
+ reason: "Each transaction entry must include Put, Update, Delete, or ConditionCheck.",
657
+ })
658
+ }
659
+ } catch (error: any) {
660
+ rollback()
661
+
662
+ if (error instanceof NotSupportedError) throw error
663
+
664
+ if (
665
+ error?.code === "ValidationException" &&
666
+ /Cannot update attribute (PK|SK)\. This attribute is part of the key/.test(
667
+ String(error?.message ?? "")
668
+ )
669
+ ) {
670
+ throw this.transactionCanceledError(
671
+ "Transaction cancelled, please refer cancellation reasons for specific reasons [ValidationError]"
672
+ )
673
+ }
674
+
675
+ if (error?.code === "ValidationException") {
676
+ throw error
677
+ }
678
+
679
+ if (error?.code === "ConditionalCheckFailedException") {
680
+ throw this.transactionCanceledError(
681
+ "Transaction cancelled, please refer cancellation reasons for specific reasons [None, ConditionalCheckFailed]"
682
+ )
683
+ }
684
+
685
+ if (error?.code === "TransactionCanceledException") {
686
+ throw error
687
+ }
688
+
689
+ throw this.transactionCanceledError(error?.message ?? "Transaction failed.")
690
+ }
691
+
692
+ return {}
693
+ })
694
+ }
695
+
696
+ __inMemorySnapshot(tableName: string): { [key: string]: any } {
697
+ return this.getTable(tableName).snapshot()
698
+ }
699
+
700
+ __inMemoryResetTable(tableName: string): void {
701
+ this.getTable(tableName).clear()
702
+ }
703
+
704
+ private request<T>(method: string, params: AnyParams, fn: () => Promise<T>): PromiseRequest<T> {
705
+ return {
706
+ promise: async () => {
707
+ try {
708
+ return await fn()
709
+ } catch (error) {
710
+ throw error
711
+ }
712
+ },
713
+ }
714
+ }
715
+
716
+ private getTable(tableName: string): InMemoryTableState {
717
+ const existing = this.tables.get(tableName)
718
+ if (existing) return existing
719
+
720
+ const next = new InMemoryTableState()
721
+ this.tables.set(tableName, next)
722
+ return next
723
+ }
724
+
725
+ private assertSupportedParams(method: string, params: AnyParams) {
726
+ const spec = IN_MEMORY_SPEC.methods[method]
727
+
728
+ if (!spec) {
729
+ throw new NotSupportedError({
730
+ method,
731
+ featurePath: method,
732
+ reason: `Unsupported method: ${method}`,
733
+ })
734
+ }
735
+
736
+ for (const [key, value] of Object.entries(params)) {
737
+ if (typeof value === "undefined") continue
738
+
739
+ if (spec.unsupportedParams?.includes(key)) {
740
+ throw new NotSupportedError({
741
+ method,
742
+ featurePath: `${method}.${key}`,
743
+ reason: `${key} is not supported in in-memory mode.`,
744
+ })
745
+ }
746
+
747
+ if (!spec.supportedParams.includes(key)) {
748
+ throw new NotSupportedError({
749
+ method,
750
+ featurePath: `${method}.${key}`,
751
+ reason: `Unsupported parameter: ${key}`,
752
+ })
753
+ }
754
+ }
755
+ }
756
+
757
+ private getRequiredTableName(params: AnyParams): string {
758
+ if (typeof params.TableName !== "string" || !params.TableName) {
759
+ throw new NotSupportedError({
760
+ method: "documentClient",
761
+ featurePath: "TableName",
762
+ reason: "TableName must be a non-empty string.",
763
+ })
764
+ }
765
+
766
+ return params.TableName
767
+ }
768
+
769
+ private getRequiredKey(
770
+ params: AnyParams,
771
+ featurePath: string
772
+ ): { PK: string; SK: string } {
773
+ const key = params.Key
774
+
775
+ if (!key || typeof key !== "object") {
776
+ throw new NotSupportedError({
777
+ method: "documentClient",
778
+ featurePath,
779
+ reason: "Key must be an object.",
780
+ })
781
+ }
782
+
783
+ if (typeof key.PK !== "string" || typeof key.SK !== "string") {
784
+ throw this.validationError(
785
+ "One or more parameter values were invalid: Type mismatch for key"
786
+ )
787
+ }
788
+
789
+ return { PK: key.PK, SK: key.SK }
790
+ }
791
+
792
+ private assertPrimaryKey(item: any, method: string) {
793
+ if (typeof item.PK !== "string" || typeof item.SK !== "string") {
794
+ throw this.validationError(
795
+ "One or more parameter values were invalid: Type mismatch for key"
796
+ )
797
+ }
798
+ }
799
+
800
+ private assertCondition(method: string, params: AnyParams, item?: any) {
801
+ if (!params.ConditionExpression) return
802
+
803
+ const ok = this.evaluateConditionOrValidationError(
804
+ params.ConditionExpression,
805
+ item,
806
+ {
807
+ method,
808
+ expressionAttributeNames: params.ExpressionAttributeNames,
809
+ expressionAttributeValues: params.ExpressionAttributeValues,
810
+ },
811
+ "ConditionExpression"
812
+ )
813
+
814
+ if (!ok) {
815
+ throw this.awsError(
816
+ "ConditionalCheckFailedException",
817
+ "The conditional request failed."
818
+ )
819
+ }
820
+ }
821
+
822
+ private resolveIndexName(indexName?: string): InMemoryIndexName {
823
+ if (!indexName) return PRIMARY_INDEX_NAME
824
+ if (indexName === "GSI1") {
825
+ throw new NotSupportedError({
826
+ method: "query",
827
+ featurePath: "query.IndexName",
828
+ reason: "GSI1 is intentionally excluded from in-memory mode.",
829
+ })
830
+ }
831
+
832
+ if (!isSupportedIndexName(indexName)) {
833
+ throw new NotSupportedError({
834
+ method: "query",
835
+ featurePath: "query.IndexName",
836
+ reason: `Unsupported index: ${indexName}`,
837
+ })
838
+ }
839
+
840
+ return parseIndexName(indexName)
841
+ }
842
+
843
+ private resolveExclusiveStartKey(
844
+ startKey: any,
845
+ indexName: InMemoryIndexName,
846
+ table: InMemoryTableState
847
+ ) {
848
+ const key = this.getRequiredKey({ Key: startKey }, "query.ExclusiveStartKey")
849
+
850
+ const descriptor = table.getDescriptor(indexName)
851
+ const rangeKey = startKey?.[descriptor.rangeAttribute]
852
+ const hashKey = startKey?.[descriptor.hashAttribute]
853
+
854
+ if (typeof rangeKey !== "string" || typeof hashKey !== "string") {
855
+ throw this.validationError(
856
+ "Exclusive Start Key must have same size as table's key schema"
857
+ )
858
+ }
859
+
860
+ return {
861
+ itemKey: encodeItemKey(key.PK, key.SK),
862
+ rangeKey,
863
+ }
864
+ }
865
+
866
+ private buildLastEvaluatedKey(indexName: InMemoryIndexName, item: any): any {
867
+ const key: any = {
868
+ PK: item.PK,
869
+ SK: item.SK,
870
+ }
871
+
872
+ if (indexName !== PRIMARY_INDEX_NAME) {
873
+ key[`${indexName}PK`] = item[`${indexName}PK`]
874
+ key[`${indexName}SK`] = item[`${indexName}SK`]
875
+ }
876
+
877
+ return key
878
+ }
879
+
880
+ private normalizeLimit(value: any, method: "query" | "scan"): number | undefined {
881
+ if (typeof value === "undefined") return undefined
882
+
883
+ if (
884
+ typeof value !== "number" ||
885
+ !Number.isFinite(value) ||
886
+ value < 1
887
+ ) {
888
+ throw this.validationError(
889
+ "Limit must be greater than or equal to 1"
890
+ )
891
+ }
892
+
893
+ return Math.floor(value)
894
+ }
895
+
896
+ private awsError(code: string, message: string): Error & { code: string } {
897
+ const error = new Error(message) as Error & { code: string }
898
+ error.code = code
899
+ error.name = code
900
+ return error
901
+ }
902
+
903
+ private transactionCanceledError(message: string): Error & { code: string } {
904
+ return this.awsError("TransactionCanceledException", message)
905
+ }
906
+
907
+ private validationError(message: string): Error & { code: string } {
908
+ return this.awsError("ValidationException", message)
909
+ }
910
+
911
+ private parseKeyConditionOrValidationError(
912
+ expression: string,
913
+ context: {
914
+ method: string
915
+ expressionAttributeNames?: { [key: string]: string }
916
+ expressionAttributeValues?: { [key: string]: any }
917
+ }
918
+ ) {
919
+ try {
920
+ return parseKeyConditionExpression(expression, context)
921
+ } catch (error) {
922
+ if (error instanceof NotSupportedError) {
923
+ throw this.validationError(
924
+ this.toDynamoExpressionValidationMessage(
925
+ "KeyConditionExpression",
926
+ error
927
+ )
928
+ )
929
+ }
930
+
931
+ throw error
932
+ }
933
+ }
934
+
935
+ private parseUpdateExpressionOrValidationError(
936
+ expression: string,
937
+ context: {
938
+ method: string
939
+ expressionAttributeNames?: { [key: string]: string }
940
+ expressionAttributeValues?: { [key: string]: any }
941
+ item?: any
942
+ }
943
+ ) {
944
+ try {
945
+ return parseUpdateExpression(expression, context)
946
+ } catch (error) {
947
+ if (error instanceof NotSupportedError) {
948
+ throw this.validationError(
949
+ this.toDynamoExpressionValidationMessage("UpdateExpression", error)
950
+ )
951
+ }
952
+
953
+ throw error
954
+ }
955
+ }
956
+
957
+ private evaluateConditionOrValidationError(
958
+ expression: string,
959
+ item: any,
960
+ context: {
961
+ method: string
962
+ expressionAttributeNames?: { [key: string]: string }
963
+ expressionAttributeValues?: { [key: string]: any }
964
+ },
965
+ expressionType: "ConditionExpression" | "FilterExpression"
966
+ ): boolean {
967
+ try {
968
+ return evaluateConditionExpression(expression, item, context)
969
+ } catch (error) {
970
+ if (error instanceof NotSupportedError) {
971
+ throw this.validationError(
972
+ this.toDynamoExpressionValidationMessage(expressionType, error)
973
+ )
974
+ }
975
+
976
+ throw error
977
+ }
978
+ }
979
+
980
+ private toDynamoExpressionValidationMessage(
981
+ expressionType:
982
+ | "ConditionExpression"
983
+ | "FilterExpression"
984
+ | "KeyConditionExpression"
985
+ | "UpdateExpression",
986
+ error: NotSupportedError
987
+ ): string {
988
+ const reason = String(error.reason ?? "").replace(/\.$/, "")
989
+
990
+ const namePlaceholder = error.featurePath.match(
991
+ /ExpressionAttributeNames\.(#[A-Za-z_][A-Za-z0-9_]*)$/
992
+ )?.[1]
993
+ if (namePlaceholder) {
994
+ return `Invalid ${expressionType}: An expression attribute name used in the document path is not defined; attribute name: ${namePlaceholder}`
995
+ }
996
+
997
+ const valuePlaceholder = error.featurePath.match(
998
+ /ExpressionAttributeValues\.(:[A-Za-z_][A-Za-z0-9_]*)$/
999
+ )?.[1]
1000
+ if (
1001
+ reason === "ExpressionAttributeValues are required for value placeholders" ||
1002
+ reason === "Missing expression attribute value placeholder"
1003
+ ) {
1004
+ const token = valuePlaceholder ?? ":missing"
1005
+ return `Invalid ${expressionType}: An expression attribute value used in expression is not defined; attribute value: ${token}`
1006
+ }
1007
+
1008
+ if (
1009
+ reason === "Malformed SET assignment" ||
1010
+ reason === "Malformed REMOVE assignment"
1011
+ ) {
1012
+ const token = reason.includes("REMOVE") ? "REMOVE" : "SET"
1013
+ return `Invalid UpdateExpression: Syntax error; token: "<EOF>", near: "${token}"`
1014
+ }
1015
+
1016
+ const invalidFunctionFromClause = reason.match(
1017
+ /^Unsupported clause:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/
1018
+ )?.[1]
1019
+ if (invalidFunctionFromClause) {
1020
+ return `Invalid ${expressionType}: Invalid function name; function: ${invalidFunctionFromClause}`
1021
+ }
1022
+
1023
+ const unsupportedValue = reason.match(/^Unsupported value token:\s*(.+)$/)?.[1]
1024
+ if (unsupportedValue) {
1025
+ const fn =
1026
+ unsupportedValue.match(/\band\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/i)?.[1] ??
1027
+ unsupportedValue.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\(/)?.[1]
1028
+ if (fn) {
1029
+ return `Invalid ${expressionType}: Invalid function name; function: ${fn}`
1030
+ }
1031
+ }
1032
+
1033
+ return reason
1034
+ }
1035
+
1036
+ private assertExpressionAttributeInputs(
1037
+ params: AnyParams,
1038
+ expressions: Array<string | undefined>
1039
+ ) {
1040
+ const activeExpressions = expressions.filter(
1041
+ (value): value is string => typeof value === "string" && value.trim().length > 0
1042
+ )
1043
+ if (activeExpressions.length === 0) return
1044
+
1045
+ if (typeof params.ExpressionAttributeValues !== "undefined") {
1046
+ const values = params.ExpressionAttributeValues
1047
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
1048
+ throw this.validationError("ExpressionAttributeValues must not be empty")
1049
+ }
1050
+
1051
+ const valueKeys = Object.keys(values)
1052
+ if (valueKeys.length === 0) {
1053
+ throw this.validationError("ExpressionAttributeValues must not be empty")
1054
+ }
1055
+
1056
+ const usedValueTokens = new Set<string>()
1057
+ for (const expression of activeExpressions) {
1058
+ for (const token of expression.match(/:[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
1059
+ usedValueTokens.add(token)
1060
+ }
1061
+ }
1062
+
1063
+ const unusedValues = valueKeys
1064
+ .filter((token) => !usedValueTokens.has(token))
1065
+ .sort()
1066
+ if (unusedValues.length > 0) {
1067
+ throw this.validationError(
1068
+ `Value provided in ExpressionAttributeValues unused in expressions: keys: {${unusedValues.join(", ")}}`
1069
+ )
1070
+ }
1071
+ }
1072
+
1073
+ if (typeof params.ExpressionAttributeNames !== "undefined") {
1074
+ const names = params.ExpressionAttributeNames
1075
+ if (!names || typeof names !== "object" || Array.isArray(names)) {
1076
+ throw this.validationError("ExpressionAttributeNames must not be empty")
1077
+ }
1078
+
1079
+ const nameKeys = Object.keys(names)
1080
+ if (nameKeys.length === 0) {
1081
+ throw this.validationError("ExpressionAttributeNames must not be empty")
1082
+ }
1083
+
1084
+ const usedNameTokens = new Set<string>()
1085
+ for (const expression of activeExpressions) {
1086
+ for (const token of expression.match(/#[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
1087
+ usedNameTokens.add(token)
1088
+ }
1089
+ }
1090
+
1091
+ const unusedNames = nameKeys
1092
+ .filter((token) => !usedNameTokens.has(token))
1093
+ .sort()
1094
+ if (unusedNames.length > 0) {
1095
+ throw this.validationError(
1096
+ `Value provided in ExpressionAttributeNames unused in expressions: keys: {${unusedNames.join(", ")}}`
1097
+ )
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ const createUnsupportedRequest = (
1104
+ method: string
1105
+ ): ((params: AnyParams) => PromiseRequest<never>) => {
1106
+ return (_params: AnyParams) => ({
1107
+ promise: async () => {
1108
+ throw new NotSupportedError({
1109
+ method,
1110
+ featurePath: method,
1111
+ reason: `${method} is not supported in in-memory mode.`,
1112
+ })
1113
+ },
1114
+ })
1115
+ }
1116
+
1117
+ export const createInMemoryDocumentClient = (): InMemoryDocumentClient => {
1118
+ const instance = new InMemoryDocumentClientImpl()
1119
+
1120
+ const proxied = new Proxy(instance as any, {
1121
+ get(target, prop, receiver) {
1122
+ if (typeof prop === "string") {
1123
+ if (prop in target) {
1124
+ const value = Reflect.get(target, prop, receiver)
1125
+ return typeof value === "function" ? value.bind(target) : value
1126
+ }
1127
+
1128
+ if (IN_MEMORY_SPEC.unsupportedMethods.includes(prop)) {
1129
+ return createUnsupportedRequest(prop)
1130
+ }
1131
+
1132
+ return createUnsupportedRequest(prop)
1133
+ }
1134
+
1135
+ return Reflect.get(target, prop, receiver)
1136
+ },
1137
+ })
1138
+
1139
+ return proxied as InMemoryDocumentClient
1140
+ }