@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.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/__test__/client-with-cursor-encryption.test.js +1241 -1343
- package/dist/cjs/__test__/client-with-cursor-encryption.test.js.map +1 -1
- package/dist/cjs/__test__/client.test.js +1395 -1497
- package/dist/cjs/__test__/client.test.js.map +1 -1
- package/dist/cjs/__test__/diff.test.d.ts +1 -0
- package/dist/cjs/__test__/diff.test.js +160 -0
- package/dist/cjs/__test__/diff.test.js.map +1 -0
- package/dist/cjs/__test__/rollback.test.d.ts +1 -0
- package/dist/cjs/__test__/rollback.test.js +196 -0
- package/dist/cjs/__test__/rollback.test.js.map +1 -0
- package/dist/cjs/diff.d.ts +3 -0
- package/dist/cjs/diff.js +339 -0
- package/dist/cjs/diff.js.map +1 -0
- package/dist/cjs/sandbox.d.ts +2 -0
- package/dist/cjs/sandbox.js +130 -3
- package/dist/cjs/sandbox.js.map +1 -1
- package/dist/cjs/test-utils/setup.d.ts +1 -1
- package/dist/cjs/test-utils/setup.js +11 -5
- package/dist/cjs/test-utils/setup.js.map +1 -1
- package/dist/esm/__test__/client-with-cursor-encryption.test.js +1241 -1343
- package/dist/esm/__test__/client-with-cursor-encryption.test.js.map +1 -1
- package/dist/esm/__test__/client.test.js +1396 -1498
- package/dist/esm/__test__/client.test.js.map +1 -1
- package/dist/esm/__test__/diff.test.d.ts +1 -0
- package/dist/esm/__test__/diff.test.js +158 -0
- package/dist/esm/__test__/diff.test.js.map +1 -0
- package/dist/esm/__test__/rollback.test.d.ts +1 -0
- package/dist/esm/__test__/rollback.test.js +194 -0
- package/dist/esm/__test__/rollback.test.js.map +1 -0
- package/dist/esm/diff.d.ts +3 -0
- package/dist/esm/diff.js +335 -0
- package/dist/esm/diff.js.map +1 -0
- package/dist/esm/sandbox.d.ts +2 -0
- package/dist/esm/sandbox.js +130 -3
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/test-utils/setup.d.ts +1 -1
- package/dist/esm/test-utils/setup.js +11 -5
- package/dist/esm/test-utils/setup.js.map +1 -1
- package/package.json +1 -1
- package/src/__test__/client-with-cursor-encryption.test.ts +1245 -1347
- package/src/__test__/client.test.ts +1400 -1502
- package/src/__test__/diff.test.ts +165 -0
- package/src/__test__/rollback.test.ts +279 -0
- package/src/diff.ts +443 -0
- package/src/sandbox.ts +173 -3
- 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
|
|
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
|
-
|
|
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
|
|
324
|
+
return formatSnapshotDiff(before, snapshot)
|
|
157
325
|
},
|
|
326
|
+
startTracking: tracked.startTracking,
|
|
327
|
+
rollback: tracked.rollback,
|
|
158
328
|
}
|
|
159
329
|
}
|
package/src/test-utils/setup.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import "snapshot-diff"
|
|
2
|
-
import { getSnapshotDiffSerializer } from "snapshot-diff"
|
|
3
1
|
import mockdate from "mockdate"
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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"))
|