@model-ts/dynamodb 3.1.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cjs/__test__/client-with-cursor-encryption.test.js +1241 -1343
  3. package/dist/cjs/__test__/client-with-cursor-encryption.test.js.map +1 -1
  4. package/dist/cjs/__test__/client.test.js +1395 -1497
  5. package/dist/cjs/__test__/client.test.js.map +1 -1
  6. package/dist/cjs/__test__/diff.test.d.ts +1 -0
  7. package/dist/cjs/__test__/diff.test.js +160 -0
  8. package/dist/cjs/__test__/diff.test.js.map +1 -0
  9. package/dist/cjs/__test__/rollback.test.d.ts +1 -0
  10. package/dist/cjs/__test__/rollback.test.js +196 -0
  11. package/dist/cjs/__test__/rollback.test.js.map +1 -0
  12. package/dist/cjs/diff.d.ts +3 -0
  13. package/dist/cjs/diff.js +339 -0
  14. package/dist/cjs/diff.js.map +1 -0
  15. package/dist/cjs/sandbox.d.ts +2 -0
  16. package/dist/cjs/sandbox.js +130 -3
  17. package/dist/cjs/sandbox.js.map +1 -1
  18. package/dist/cjs/test-utils/setup.d.ts +1 -1
  19. package/dist/cjs/test-utils/setup.js +11 -5
  20. package/dist/cjs/test-utils/setup.js.map +1 -1
  21. package/dist/esm/__test__/client-with-cursor-encryption.test.js +1241 -1343
  22. package/dist/esm/__test__/client-with-cursor-encryption.test.js.map +1 -1
  23. package/dist/esm/__test__/client.test.js +1396 -1498
  24. package/dist/esm/__test__/client.test.js.map +1 -1
  25. package/dist/esm/__test__/diff.test.d.ts +1 -0
  26. package/dist/esm/__test__/diff.test.js +158 -0
  27. package/dist/esm/__test__/diff.test.js.map +1 -0
  28. package/dist/esm/__test__/rollback.test.d.ts +1 -0
  29. package/dist/esm/__test__/rollback.test.js +194 -0
  30. package/dist/esm/__test__/rollback.test.js.map +1 -0
  31. package/dist/esm/diff.d.ts +3 -0
  32. package/dist/esm/diff.js +335 -0
  33. package/dist/esm/diff.js.map +1 -0
  34. package/dist/esm/sandbox.d.ts +2 -0
  35. package/dist/esm/sandbox.js +130 -3
  36. package/dist/esm/sandbox.js.map +1 -1
  37. package/dist/esm/test-utils/setup.d.ts +1 -1
  38. package/dist/esm/test-utils/setup.js +11 -5
  39. package/dist/esm/test-utils/setup.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/__test__/client-with-cursor-encryption.test.ts +1245 -1347
  42. package/src/__test__/client.test.ts +1400 -1502
  43. package/src/__test__/diff.test.ts +165 -0
  44. package/src/__test__/rollback.test.ts +279 -0
  45. package/src/diff.ts +443 -0
  46. package/src/sandbox.ts +173 -3
  47. package/src/test-utils/setup.ts +9 -5
package/src/diff.ts ADDED
@@ -0,0 +1,443 @@
1
+ const INDENT_SIZE = 2
2
+ const ITEM_FIELD_INDENT = 4
3
+ const PRIORITY_FIELDS = ["PK", "SK"]
4
+ const PRIORITY_FIELD_SET = new Set(PRIORITY_FIELDS)
5
+ const ARRAY_ITEM_KEY = "[]"
6
+
7
+ type Snapshot = Record<string, any>
8
+ type RenderResult = { lines: string[]; changed: boolean }
9
+
10
+ const indent = (level: number) => " ".repeat(level)
11
+
12
+ const isPlainObject = (value: any): value is Record<string, any> =>
13
+ value !== null && typeof value === "object" && !Array.isArray(value)
14
+
15
+ const orderKeys = (keys: string[]) => {
16
+ const uniqueKeys = Array.from(new Set(keys))
17
+ const prioritized = PRIORITY_FIELDS.filter((key) => uniqueKeys.includes(key))
18
+ const rest = uniqueKeys
19
+ .filter((key) => !PRIORITY_FIELD_SET.has(key))
20
+ .sort()
21
+ return [...prioritized, ...rest]
22
+ }
23
+
24
+ const isEqual = (left: any, right: any): boolean => {
25
+ if (left === right) return true
26
+ if (left === null || right === null) return left === right
27
+ if (left === undefined || right === undefined) return left === right
28
+ if (Array.isArray(left) && Array.isArray(right)) {
29
+ if (left.length !== right.length) return false
30
+ for (let i = 0; i < left.length; i += 1) {
31
+ if (!isEqual(left[i], right[i])) return false
32
+ }
33
+ return true
34
+ }
35
+ if (isPlainObject(left) && isPlainObject(right)) {
36
+ const leftKeys = Object.keys(left).sort()
37
+ const rightKeys = Object.keys(right).sort()
38
+ if (leftKeys.length !== rightKeys.length) return false
39
+ for (let i = 0; i < leftKeys.length; i += 1) {
40
+ const key = leftKeys[i]
41
+ if (key !== rightKeys[i]) return false
42
+ if (!isEqual(left[key], right[key])) return false
43
+ }
44
+ return true
45
+ }
46
+ if (left instanceof Date && right instanceof Date) {
47
+ return left.toISOString() === right.toISOString()
48
+ }
49
+ return false
50
+ }
51
+
52
+ const formatScalar = (value: any): string => {
53
+ if (value === null) return "null"
54
+ if (value === undefined) return "undefined"
55
+ if (typeof value === "string") return JSON.stringify(value)
56
+ if (typeof value === "number" || typeof value === "boolean")
57
+ return String(value)
58
+ if (typeof value === "bigint") return `${value}n`
59
+ if (value instanceof Date) return JSON.stringify(value.toISOString())
60
+
61
+ return JSON.stringify(value)
62
+ }
63
+
64
+ const parseItemKey = (key: string, item?: Record<string, any>) => {
65
+ const pk = typeof item?.PK === "string" ? item.PK : undefined
66
+ const sk = typeof item?.SK === "string" ? item.SK : undefined
67
+
68
+ if (pk && sk) return { pk, sk }
69
+
70
+ const [rawPk, rawSk] = key.split("__")
71
+ return {
72
+ pk: pk ?? rawPk ?? "?",
73
+ sk: sk ?? rawSk ?? "?",
74
+ }
75
+ }
76
+
77
+ const formatItemHeader = (key: string, item?: Record<string, any>) => {
78
+ const { pk, sk } = parseItemKey(key, item)
79
+ return `[${pk} / ${sk}]`
80
+ }
81
+
82
+ const renderValueLines = (
83
+ key: string | null,
84
+ value: any,
85
+ indentLevel: number
86
+ ): string[] => {
87
+ const prefix = indent(indentLevel)
88
+
89
+ if (key === ARRAY_ITEM_KEY) {
90
+ return renderArrayItemLines(value, indentLevel)
91
+ }
92
+
93
+ if (isPlainObject(value)) {
94
+ const keys = orderKeys(Object.keys(value))
95
+ if (keys.length === 0) {
96
+ const label = key ? `${key}: {}` : "{}"
97
+ return [`${prefix}${label}`]
98
+ }
99
+
100
+ const lines: string[] = []
101
+ if (key) lines.push(`${prefix}${key}:`)
102
+ keys.forEach((childKey) => {
103
+ lines.push(
104
+ ...renderValueLines(childKey, value[childKey], indentLevel + INDENT_SIZE)
105
+ )
106
+ })
107
+ return lines
108
+ }
109
+
110
+ if (Array.isArray(value)) {
111
+ if (value.length === 0) {
112
+ const label = key ? `${key}: []` : "[]"
113
+ return [`${prefix}${label}`]
114
+ }
115
+
116
+ const lines: string[] = []
117
+ if (key) lines.push(`${prefix}${key}:`)
118
+ value.forEach((item) => {
119
+ lines.push(
120
+ ...renderValueLines(ARRAY_ITEM_KEY, item, indentLevel + INDENT_SIZE)
121
+ )
122
+ })
123
+ return lines
124
+ }
125
+
126
+ const label = key ? `${key}: ${formatScalar(value)}` : formatScalar(value)
127
+ return [`${prefix}${label}`]
128
+ }
129
+
130
+ const applyPrefix = (line: string, prefix: "+" | "-") => {
131
+ if (line.startsWith(" ")) return `${prefix}${line.slice(1)}`
132
+ return `${prefix} ${line}`
133
+ }
134
+
135
+ const renderValueLinesWithPrefix = (
136
+ key: string | null,
137
+ value: any,
138
+ indentLevel: number,
139
+ prefix: "+" | "-"
140
+ ) =>
141
+ renderValueLines(key, value, indentLevel).map((line) =>
142
+ applyPrefix(line, prefix)
143
+ )
144
+
145
+ const renderArrayItemLines = (value: any, indentLevel: number): string[] => {
146
+ const prefix = indent(indentLevel)
147
+
148
+ if (isPlainObject(value)) {
149
+ const keys = orderKeys(Object.keys(value))
150
+ if (keys.length === 0) return [`${prefix}- {}`]
151
+
152
+ const lines: string[] = []
153
+ const [firstKey, ...restKeys] = keys
154
+ const firstLines = renderValueLines(
155
+ firstKey,
156
+ value[firstKey],
157
+ indentLevel + INDENT_SIZE
158
+ )
159
+ const trimmedFirstLine = firstLines[0].slice(indentLevel + INDENT_SIZE)
160
+ lines.push(`${prefix}- ${trimmedFirstLine}`)
161
+ lines.push(...firstLines.slice(1))
162
+
163
+ restKeys.forEach((key) => {
164
+ lines.push(...renderValueLines(key, value[key], indentLevel + INDENT_SIZE))
165
+ })
166
+ return lines
167
+ }
168
+
169
+ if (Array.isArray(value)) {
170
+ if (value.length === 0) return [`${prefix}- []`]
171
+
172
+ const lines: string[] = [`${prefix}-`]
173
+ value.forEach((item) => {
174
+ lines.push(
175
+ ...renderValueLines(ARRAY_ITEM_KEY, item, indentLevel + INDENT_SIZE)
176
+ )
177
+ })
178
+ return lines
179
+ }
180
+
181
+ return [`${prefix}- ${formatScalar(value)}`]
182
+ }
183
+
184
+ const findArrayMatches = (before: any[], after: any[]) => {
185
+ const rows = before.length + 1
186
+ const cols = after.length + 1
187
+ const dp: number[][] = Array.from({ length: rows }, () =>
188
+ Array(cols).fill(0)
189
+ )
190
+
191
+ for (let i = before.length - 1; i >= 0; i -= 1) {
192
+ for (let j = after.length - 1; j >= 0; j -= 1) {
193
+ if (isEqual(before[i], after[j])) {
194
+ dp[i][j] = dp[i + 1][j + 1] + 1
195
+ } else {
196
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
197
+ }
198
+ }
199
+ }
200
+
201
+ const matches: Array<{ beforeMatch: number; afterMatch: number }> = []
202
+ let i = 0
203
+ let j = 0
204
+ while (i < before.length && j < after.length) {
205
+ if (isEqual(before[i], after[j])) {
206
+ matches.push({ beforeMatch: i, afterMatch: j })
207
+ i += 1
208
+ j += 1
209
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
210
+ i += 1
211
+ } else {
212
+ j += 1
213
+ }
214
+ }
215
+
216
+ return matches
217
+ }
218
+
219
+ const renderArraySegment = (
220
+ beforeSegment: any[],
221
+ afterSegment: any[],
222
+ indentLevel: number
223
+ ): RenderResult => {
224
+ const lines: string[] = []
225
+ let changed = false
226
+
227
+ if (beforeSegment.length === 0 && afterSegment.length === 0) {
228
+ return { lines, changed: false }
229
+ }
230
+
231
+ if (beforeSegment.length === afterSegment.length) {
232
+ for (let i = 0; i < beforeSegment.length; i += 1) {
233
+ const result = renderFieldDiff(
234
+ ARRAY_ITEM_KEY,
235
+ beforeSegment[i],
236
+ afterSegment[i],
237
+ indentLevel
238
+ )
239
+ if (result.lines.length > 0) lines.push(...result.lines)
240
+ if (result.changed) changed = true
241
+ }
242
+ return { lines, changed }
243
+ }
244
+
245
+ beforeSegment.forEach((value) => {
246
+ lines.push(
247
+ ...renderValueLinesWithPrefix(ARRAY_ITEM_KEY, value, indentLevel, "-")
248
+ )
249
+ changed = true
250
+ })
251
+ afterSegment.forEach((value) => {
252
+ lines.push(
253
+ ...renderValueLinesWithPrefix(ARRAY_ITEM_KEY, value, indentLevel, "+")
254
+ )
255
+ changed = true
256
+ })
257
+ return { lines, changed }
258
+ }
259
+
260
+ const renderArrayDiff = (
261
+ key: string,
262
+ before: any[],
263
+ after: any[],
264
+ indentLevel: number
265
+ ): RenderResult => {
266
+ const lines: string[] = [`${indent(indentLevel)}${key}:`]
267
+ let changed = false
268
+
269
+ const matches = findArrayMatches(before, after)
270
+ let beforeIndex = 0
271
+ let afterIndex = 0
272
+
273
+ matches.forEach(({ beforeMatch, afterMatch }) => {
274
+ const segment = renderArraySegment(
275
+ before.slice(beforeIndex, beforeMatch),
276
+ after.slice(afterIndex, afterMatch),
277
+ indentLevel + INDENT_SIZE
278
+ )
279
+ if (segment.lines.length > 0) lines.push(...segment.lines)
280
+ if (segment.changed) changed = true
281
+
282
+ lines.push(
283
+ ...renderValueLines(
284
+ ARRAY_ITEM_KEY,
285
+ before[beforeMatch],
286
+ indentLevel + INDENT_SIZE
287
+ )
288
+ )
289
+
290
+ beforeIndex = beforeMatch + 1
291
+ afterIndex = afterMatch + 1
292
+ })
293
+
294
+ const tailSegment = renderArraySegment(
295
+ before.slice(beforeIndex),
296
+ after.slice(afterIndex),
297
+ indentLevel + INDENT_SIZE
298
+ )
299
+ if (tailSegment.lines.length > 0) lines.push(...tailSegment.lines)
300
+ if (tailSegment.changed) changed = true
301
+
302
+ return { lines, changed }
303
+ }
304
+
305
+ const renderObjectDiff = (
306
+ key: string,
307
+ before: Record<string, any>,
308
+ after: Record<string, any>,
309
+ indentLevel: number
310
+ ): RenderResult => {
311
+ const lines: string[] = [`${indent(indentLevel)}${key}:`]
312
+ let changed = false
313
+ const keys = orderKeys([...Object.keys(before), ...Object.keys(after)])
314
+
315
+ keys.forEach((childKey) => {
316
+ const result = renderFieldDiff(
317
+ childKey,
318
+ before[childKey],
319
+ after[childKey],
320
+ indentLevel + INDENT_SIZE
321
+ )
322
+ if (result.lines.length > 0) lines.push(...result.lines)
323
+ if (result.changed) changed = true
324
+ })
325
+
326
+ return { lines, changed }
327
+ }
328
+
329
+ const renderFieldDiff = (
330
+ key: string,
331
+ beforeValue: any,
332
+ afterValue: any,
333
+ indentLevel: number
334
+ ): RenderResult => {
335
+ if (beforeValue === undefined && afterValue === undefined) {
336
+ return { lines: [], changed: false }
337
+ }
338
+
339
+ if (isEqual(beforeValue, afterValue)) {
340
+ return {
341
+ lines: renderValueLines(key, beforeValue, indentLevel),
342
+ changed: false,
343
+ }
344
+ }
345
+
346
+ if (beforeValue === undefined) {
347
+ return {
348
+ lines: renderValueLinesWithPrefix(key, afterValue, indentLevel, "+"),
349
+ changed: true,
350
+ }
351
+ }
352
+
353
+ if (afterValue === undefined) {
354
+ return {
355
+ lines: renderValueLinesWithPrefix(key, beforeValue, indentLevel, "-"),
356
+ changed: true,
357
+ }
358
+ }
359
+
360
+ if (Array.isArray(beforeValue) && Array.isArray(afterValue)) {
361
+ return renderArrayDiff(key, beforeValue, afterValue, indentLevel)
362
+ }
363
+
364
+ if (isPlainObject(beforeValue) && isPlainObject(afterValue)) {
365
+ return renderObjectDiff(key, beforeValue, afterValue, indentLevel)
366
+ }
367
+
368
+ return {
369
+ lines: [
370
+ ...renderValueLinesWithPrefix(key, beforeValue, indentLevel, "-"),
371
+ ...renderValueLinesWithPrefix(key, afterValue, indentLevel, "+"),
372
+ ],
373
+ changed: true,
374
+ }
375
+ }
376
+
377
+ const renderItemFields = (item: Record<string, any>, prefix: "+" | "-") => {
378
+ const keys = orderKeys(Object.keys(item))
379
+
380
+ const lines: string[] = []
381
+ keys.forEach((key) => {
382
+ lines.push(
383
+ ...renderValueLinesWithPrefix(key, item[key], ITEM_FIELD_INDENT, prefix)
384
+ )
385
+ })
386
+ return lines
387
+ }
388
+
389
+ const renderItemFieldsDiff = (
390
+ before: Record<string, any>,
391
+ after: Record<string, any>
392
+ ): RenderResult => {
393
+ const keys = orderKeys([...Object.keys(before), ...Object.keys(after)])
394
+ const lines: string[] = []
395
+ let changed = false
396
+
397
+ keys.forEach((key) => {
398
+ const result = renderFieldDiff(
399
+ key,
400
+ before[key],
401
+ after[key],
402
+ ITEM_FIELD_INDENT
403
+ )
404
+ if (result.lines.length > 0) lines.push(...result.lines)
405
+ if (result.changed) changed = true
406
+ })
407
+
408
+ return { lines, changed }
409
+ }
410
+
411
+ export const formatSnapshotDiff = (before: Snapshot, after: Snapshot) => {
412
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
413
+ const sortedKeys = Array.from(keys).sort((a, b) => {
414
+ const itemA = after[a] ?? before[a]
415
+ const itemB = after[b] ?? before[b]
416
+ return formatItemHeader(a, itemA).localeCompare(formatItemHeader(b, itemB))
417
+ })
418
+
419
+ const lines: string[] = []
420
+
421
+ sortedKeys.forEach((key) => {
422
+ const beforeItem = before[key]
423
+ const afterItem = after[key]
424
+ const header = formatItemHeader(key, afterItem ?? beforeItem)
425
+
426
+ let itemLines: string[] = []
427
+ if (!beforeItem && afterItem) {
428
+ itemLines = [`+ ${header}`, ...renderItemFields(afterItem, "+")]
429
+ } else if (beforeItem && !afterItem) {
430
+ itemLines = [`- ${header}`, ...renderItemFields(beforeItem, "-")]
431
+ } else if (beforeItem && afterItem) {
432
+ const diffResult = renderItemFieldsDiff(beforeItem, afterItem)
433
+ if (diffResult.changed) itemLines = [header, ...diffResult.lines]
434
+ }
435
+
436
+ if (itemLines.length > 0) {
437
+ if (lines.length > 0) lines.push("")
438
+ lines.push(...itemLines)
439
+ }
440
+ })
441
+
442
+ return lines.join("\n")
443
+ }
package/src/sandbox.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import crypto from "crypto"
2
2
  import { chunksOf } from "fp-ts/lib/Array"
3
3
  import DynamoDB from "aws-sdk/clients/dynamodb"
4
- import diff from "snapshot-diff"
4
+ import { formatSnapshotDiff } from "./diff"
5
5
  import { Client } from "./client"
6
6
  import { GSI_NAMES } from "./gsi"
7
7
 
@@ -105,18 +105,186 @@ export const getTableContents = async (
105
105
  return acc
106
106
  }
107
107
 
108
+ // -------------------------------------------------------------------------------------
109
+ // Tracking
110
+ // -------------------------------------------------------------------------------------
111
+
112
+ interface TrackedEntry {
113
+ pk: string
114
+ sk: string
115
+ original: any | null
116
+ }
117
+
118
+ const WRITE_METHODS = new Set([
119
+ "put",
120
+ "update",
121
+ "delete",
122
+ "batchWrite",
123
+ "transactWrite",
124
+ ])
125
+
126
+ function createTrackedDocClient(
127
+ original: DynamoDB.DocumentClient,
128
+ tableName: string
129
+ ) {
130
+ let isTracking = false
131
+ const trackedKeys = new Map<string, TrackedEntry>()
132
+
133
+ const captureKey = async (pk: string, sk: string) => {
134
+ const compositeKey = `${pk}__${sk}`
135
+ if (trackedKeys.has(compositeKey)) return
136
+
137
+ const { Item } = await original
138
+ .get({ TableName: tableName, Key: { PK: pk, SK: sk } })
139
+ .promise()
140
+
141
+ trackedKeys.set(compositeKey, { pk, sk, original: Item ?? null })
142
+ }
143
+
144
+ const captureKeysForOperation = async (method: string, params: any) => {
145
+ switch (method) {
146
+ case "put":
147
+ if (params.TableName === tableName && params.Item) {
148
+ await captureKey(params.Item.PK, params.Item.SK)
149
+ }
150
+ break
151
+ case "update":
152
+ case "delete":
153
+ if (params.TableName === tableName && params.Key) {
154
+ await captureKey(params.Key.PK, params.Key.SK)
155
+ }
156
+ break
157
+ case "batchWrite": {
158
+ const tableItems = params.RequestItems?.[tableName] || []
159
+ await Promise.all(
160
+ tableItems.map((item: any) => {
161
+ if (item.PutRequest) {
162
+ return captureKey(
163
+ item.PutRequest.Item.PK,
164
+ item.PutRequest.Item.SK
165
+ )
166
+ }
167
+ if (item.DeleteRequest) {
168
+ return captureKey(
169
+ item.DeleteRequest.Key.PK,
170
+ item.DeleteRequest.Key.SK
171
+ )
172
+ }
173
+ })
174
+ )
175
+ break
176
+ }
177
+ case "transactWrite": {
178
+ const transactItems = params.TransactItems || []
179
+ await Promise.all(
180
+ transactItems
181
+ .map((item: any) => {
182
+ if (item.Put?.TableName === tableName) {
183
+ return captureKey(item.Put.Item.PK, item.Put.Item.SK)
184
+ }
185
+ if (item.Update?.TableName === tableName) {
186
+ return captureKey(item.Update.Key.PK, item.Update.Key.SK)
187
+ }
188
+ if (item.Delete?.TableName === tableName) {
189
+ return captureKey(item.Delete.Key.PK, item.Delete.Key.SK)
190
+ }
191
+ })
192
+ .filter(Boolean)
193
+ )
194
+ break
195
+ }
196
+ }
197
+ }
198
+
199
+ const proxy = new Proxy(original, {
200
+ get(target, prop) {
201
+ const value = (target as any)[prop]
202
+ if (value === undefined) return undefined
203
+
204
+ if (typeof value === "function") {
205
+ if (isTracking && WRITE_METHODS.has(prop as string)) {
206
+ return (params: any) => {
207
+ const request = value.call(target, params)
208
+ const origPromise = request.promise.bind(request)
209
+ request.promise = async () => {
210
+ await captureKeysForOperation(prop as string, params)
211
+ return origPromise()
212
+ }
213
+ return request
214
+ }
215
+ }
216
+ return value.bind(target)
217
+ }
218
+
219
+ return value
220
+ },
221
+ })
222
+
223
+ return {
224
+ proxy: proxy as DynamoDB.DocumentClient,
225
+ startTracking: () => {
226
+ isTracking = true
227
+ trackedKeys.clear()
228
+ },
229
+ rollback: async () => {
230
+ isTracking = false
231
+
232
+ const entries = Array.from(trackedKeys.values())
233
+ const toDelete = entries.filter((e) => e.original === null)
234
+ const toRestore = entries.filter((e) => e.original !== null)
235
+
236
+ const deleteChunks = chunksOf(25)(toDelete)
237
+ const restoreChunks = chunksOf(25)(toRestore)
238
+
239
+ await Promise.all([
240
+ ...deleteChunks.map((chunk) =>
241
+ original
242
+ .batchWrite({
243
+ RequestItems: {
244
+ [tableName]: chunk.map(({ pk, sk }) => ({
245
+ DeleteRequest: { Key: { PK: pk, SK: sk } },
246
+ })),
247
+ },
248
+ })
249
+ .promise()
250
+ ),
251
+ ...restoreChunks.map((chunk) =>
252
+ original
253
+ .batchWrite({
254
+ RequestItems: {
255
+ [tableName]: chunk.map(({ original: item }) => ({
256
+ PutRequest: { Item: item },
257
+ })),
258
+ },
259
+ })
260
+ .promise()
261
+ ),
262
+ ])
263
+
264
+ trackedKeys.clear()
265
+ },
266
+ }
267
+ }
268
+
269
+ // -------------------------------------------------------------------------------------
270
+ // Sandbox
271
+ // -------------------------------------------------------------------------------------
272
+
108
273
  export interface Sandbox {
109
274
  destroy: () => Promise<void>
110
275
  snapshot: () => Promise<{ [key: string]: any }>
111
276
  seed: (...args: Array<{ [key: string]: any }>) => Promise<void>
112
277
  get: (pk: string, sk: string) => Promise<null | any>
113
278
  diff: (before: { [key: string]: any }) => Promise<string>
279
+ startTracking: () => void
280
+ rollback: () => Promise<void>
114
281
  }
115
282
 
116
283
  export const createSandbox = async (client: Client): Promise<Sandbox> => {
117
284
  const tableName = await createTable()
118
285
 
119
- client.setDocumentClient(docClient)
286
+ const tracked = createTrackedDocClient(docClient, tableName)
287
+ client.setDocumentClient(tracked.proxy)
120
288
  client.setTableName(tableName)
121
289
 
122
290
  return {
@@ -153,7 +321,9 @@ export const createSandbox = async (client: Client): Promise<Sandbox> => {
153
321
  diff: async (before) => {
154
322
  const snapshot = await getTableContents(tableName)
155
323
 
156
- return diff(before, snapshot)
324
+ return formatSnapshotDiff(before, snapshot)
157
325
  },
326
+ startTracking: tracked.startTracking,
327
+ rollback: tracked.rollback,
158
328
  }
159
329
  }
@@ -1,10 +1,14 @@
1
- import "snapshot-diff"
2
- import { getSnapshotDiffSerializer } from "snapshot-diff"
3
1
  import mockdate from "mockdate"
4
2
 
5
- // Register `toMatchDiffSnapshot`
6
- require("snapshot-diff/extend-expect")
3
+ const isSnapshotDiff = (value: unknown): value is string => {
4
+ if (typeof value !== "string") return false
5
+ if (!value.includes(" / ") || !value.includes("[")) return false
6
+ return /^(?:\+ |- |\[)/m.test(value)
7
+ }
7
8
 
8
- expect.addSnapshotSerializer(getSnapshotDiffSerializer())
9
+ expect.addSnapshotSerializer({
10
+ test: isSnapshotDiff,
11
+ print: (value) => String(value),
12
+ })
9
13
 
10
14
  mockdate.set(new Date("2021-05-01T08:00:00.000Z"))