@model-ts/dynamodb 3.0.4 → 4.0.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 (45) 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 +1488 -1458
  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/diff.d.ts +3 -0
  10. package/dist/cjs/diff.js +339 -0
  11. package/dist/cjs/diff.js.map +1 -0
  12. package/dist/cjs/provider.d.ts +149 -19
  13. package/dist/cjs/provider.js +131 -1
  14. package/dist/cjs/provider.js.map +1 -1
  15. package/dist/cjs/sandbox.js +2 -2
  16. package/dist/cjs/sandbox.js.map +1 -1
  17. package/dist/cjs/test-utils/setup.d.ts +1 -1
  18. package/dist/cjs/test-utils/setup.js +11 -5
  19. package/dist/cjs/test-utils/setup.js.map +1 -1
  20. package/dist/esm/__test__/client-with-cursor-encryption.test.js +1241 -1343
  21. package/dist/esm/__test__/client-with-cursor-encryption.test.js.map +1 -1
  22. package/dist/esm/__test__/client.test.js +1488 -1458
  23. package/dist/esm/__test__/client.test.js.map +1 -1
  24. package/dist/esm/__test__/diff.test.d.ts +1 -0
  25. package/dist/esm/__test__/diff.test.js +158 -0
  26. package/dist/esm/__test__/diff.test.js.map +1 -0
  27. package/dist/esm/diff.d.ts +3 -0
  28. package/dist/esm/diff.js +335 -0
  29. package/dist/esm/diff.js.map +1 -0
  30. package/dist/esm/provider.d.ts +149 -19
  31. package/dist/esm/provider.js +131 -1
  32. package/dist/esm/provider.js.map +1 -1
  33. package/dist/esm/sandbox.js +2 -2
  34. package/dist/esm/sandbox.js.map +1 -1
  35. package/dist/esm/test-utils/setup.d.ts +1 -1
  36. package/dist/esm/test-utils/setup.js +11 -5
  37. package/dist/esm/test-utils/setup.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/__test__/client-with-cursor-encryption.test.ts +1245 -1347
  40. package/src/__test__/client.test.ts +1584 -1482
  41. package/src/__test__/diff.test.ts +165 -0
  42. package/src/diff.ts +443 -0
  43. package/src/provider.ts +147 -3
  44. package/src/sandbox.ts +2 -2
  45. package/src/test-utils/setup.ts +9 -5
@@ -0,0 +1,165 @@
1
+ import { formatSnapshotDiff } from "../diff"
2
+
3
+ describe("formatSnapshotDiff", () => {
4
+ test("renders added items with nested objects and arrays", () => {
5
+ const before = {}
6
+ const after = {
7
+ "PK#user#1__SK#profile": {
8
+ PK: "PK#user#1",
9
+ SK: "SK#profile",
10
+ name: "Ada",
11
+ stats: {
12
+ flags: ["a", "b"],
13
+ logins: 3
14
+ },
15
+ tags: ["alpha", "beta"],
16
+ connections: [
17
+ {
18
+ user: "1"
19
+ },
20
+ {
21
+ user: "2",
22
+ type: "friend"
23
+ }
24
+ ]
25
+ }
26
+ }
27
+
28
+ expect(formatSnapshotDiff(before, after)).toMatchInlineSnapshot(`
29
+ + [PK#user#1 / SK#profile]
30
+ + PK: "PK#user#1"
31
+ + SK: "SK#profile"
32
+ + connections:
33
+ + - user: "1"
34
+ + - type: "friend"
35
+ + user: "2"
36
+ + name: "Ada"
37
+ + stats:
38
+ + flags:
39
+ + - "a"
40
+ + - "b"
41
+ + logins: 3
42
+ + tags:
43
+ + - "alpha"
44
+ + - "beta"
45
+ `)
46
+ })
47
+
48
+ test("renders removed items", () => {
49
+ const before = {
50
+ "PK#post#9__SK#meta": {
51
+ PK: "PK#post#9",
52
+ SK: "SK#meta",
53
+ title: "Removed",
54
+ views: 10
55
+ }
56
+ }
57
+ const after = {}
58
+
59
+ expect(formatSnapshotDiff(before, after)).toMatchInlineSnapshot(`
60
+ - [PK#post#9 / SK#meta]
61
+ - PK: "PK#post#9"
62
+ - SK: "SK#meta"
63
+ - title: "Removed"
64
+ - views: 10
65
+ `)
66
+ })
67
+
68
+ test("renders updated fields with nested diffs", () => {
69
+ const before = {
70
+ "PK#order#1__SK#summary": {
71
+ PK: "PK#order#1",
72
+ SK: "SK#summary",
73
+ status: "pending",
74
+ count: 1,
75
+ meta: {
76
+ flags: ["a", "b", "c"],
77
+ stats: ["a", "b", "c"],
78
+ config: { enabled: true }
79
+ }
80
+ }
81
+ }
82
+ const after = {
83
+ "PK#order#1__SK#summary": {
84
+ PK: "PK#order#1",
85
+ SK: "SK#summary",
86
+ status: "paid",
87
+ count: 2,
88
+ meta: {
89
+ flags: ["a", "c", "d"],
90
+ stats: ["a", "c"],
91
+ config: { enabled: false, mode: "fast" }
92
+ }
93
+ }
94
+ }
95
+
96
+ expect(formatSnapshotDiff(before, after)).toMatchInlineSnapshot(`
97
+ [PK#order#1 / SK#summary]
98
+ PK: "PK#order#1"
99
+ SK: "SK#summary"
100
+ - count: 1
101
+ + count: 2
102
+ meta:
103
+ config:
104
+ - enabled: true
105
+ + enabled: false
106
+ + mode: "fast"
107
+ flags:
108
+ - "a"
109
+ - - "b"
110
+ - "c"
111
+ + - "d"
112
+ stats:
113
+ - "a"
114
+ - - "b"
115
+ - "c"
116
+ - status: "pending"
117
+ + status: "paid"
118
+ `)
119
+ })
120
+
121
+ test("skips unchanged items and keeps output compact", () => {
122
+ const before = {
123
+ "PK#steady__SK#1": {
124
+ PK: "PK#steady",
125
+ SK: "SK#1",
126
+ value: "same"
127
+ },
128
+ "PK#beta__SK#1": {
129
+ PK: "PK#beta",
130
+ SK: "SK#1",
131
+ count: 1
132
+ }
133
+ }
134
+ const after = {
135
+ "PK#steady__SK#1": {
136
+ PK: "PK#steady",
137
+ SK: "SK#1",
138
+ value: "same"
139
+ },
140
+ "PK#beta__SK#1": {
141
+ PK: "PK#beta",
142
+ SK: "SK#1",
143
+ count: 2
144
+ },
145
+ "PK#alpha__SK#1": {
146
+ PK: "PK#alpha",
147
+ SK: "SK#1",
148
+ flag: true
149
+ }
150
+ }
151
+
152
+ expect(formatSnapshotDiff(before, after)).toMatchInlineSnapshot(`
153
+ + [PK#alpha / SK#1]
154
+ + PK: "PK#alpha"
155
+ + SK: "SK#1"
156
+ + flag: true
157
+
158
+ [PK#beta / SK#1]
159
+ PK: "PK#beta"
160
+ SK: "SK#1"
161
+ - count: 1
162
+ + count: 2
163
+ `)
164
+ })
165
+ })
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
+ }