@model-ts/dynamodb 4.0.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 (100) hide show
  1. package/CHANGELOG.md +12 -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/__test__/rollback.test.d.ts +1 -0
  12. package/dist/cjs/__test__/rollback.test.js +196 -0
  13. package/dist/cjs/__test__/rollback.test.js.map +1 -0
  14. package/dist/cjs/client.d.ts +2 -2
  15. package/dist/cjs/client.js +14 -6
  16. package/dist/cjs/client.js.map +1 -1
  17. package/dist/cjs/errors.d.ts +13 -1
  18. package/dist/cjs/errors.js +12 -1
  19. package/dist/cjs/errors.js.map +1 -1
  20. package/dist/cjs/in-memory/document-client.d.ts +45 -0
  21. package/dist/cjs/in-memory/document-client.js +795 -0
  22. package/dist/cjs/in-memory/document-client.js.map +1 -0
  23. package/dist/cjs/in-memory/expression.d.ts +42 -0
  24. package/dist/cjs/in-memory/expression.js +585 -0
  25. package/dist/cjs/in-memory/expression.js.map +1 -0
  26. package/dist/cjs/in-memory/index.d.ts +2 -0
  27. package/dist/cjs/in-memory/index.js +6 -0
  28. package/dist/cjs/in-memory/index.js.map +1 -0
  29. package/dist/cjs/in-memory/spec.d.ts +23 -0
  30. package/dist/cjs/in-memory/spec.js +141 -0
  31. package/dist/cjs/in-memory/spec.js.map +1 -0
  32. package/dist/cjs/in-memory/store.d.ts +73 -0
  33. package/dist/cjs/in-memory/store.js +267 -0
  34. package/dist/cjs/in-memory/store.js.map +1 -0
  35. package/dist/cjs/in-memory/treap.d.ts +31 -0
  36. package/dist/cjs/in-memory/treap.js +187 -0
  37. package/dist/cjs/in-memory/treap.js.map +1 -0
  38. package/dist/cjs/in-memory/utils.d.ts +10 -0
  39. package/dist/cjs/in-memory/utils.js +38 -0
  40. package/dist/cjs/in-memory/utils.js.map +1 -0
  41. package/dist/cjs/sandbox.d.ts +2 -0
  42. package/dist/cjs/sandbox.js +172 -1
  43. package/dist/cjs/sandbox.js.map +1 -1
  44. package/dist/esm/__test__/client-env-guard.test.d.ts +1 -0
  45. package/dist/esm/__test__/client-env-guard.test.js +26 -0
  46. package/dist/esm/__test__/client-env-guard.test.js.map +1 -0
  47. package/dist/esm/__test__/conformance.test.d.ts +1 -0
  48. package/dist/esm/__test__/conformance.test.js +1833 -0
  49. package/dist/esm/__test__/conformance.test.js.map +1 -0
  50. package/dist/esm/__test__/in-memory.spec.test.d.ts +1 -0
  51. package/dist/esm/__test__/in-memory.spec.test.js +183 -0
  52. package/dist/esm/__test__/in-memory.spec.test.js.map +1 -0
  53. package/dist/esm/__test__/rollback.test.d.ts +1 -0
  54. package/dist/esm/__test__/rollback.test.js +194 -0
  55. package/dist/esm/__test__/rollback.test.js.map +1 -0
  56. package/dist/esm/client.d.ts +2 -2
  57. package/dist/esm/client.js +14 -6
  58. package/dist/esm/client.js.map +1 -1
  59. package/dist/esm/errors.d.ts +13 -1
  60. package/dist/esm/errors.js +10 -0
  61. package/dist/esm/errors.js.map +1 -1
  62. package/dist/esm/in-memory/document-client.d.ts +45 -0
  63. package/dist/esm/in-memory/document-client.js +791 -0
  64. package/dist/esm/in-memory/document-client.js.map +1 -0
  65. package/dist/esm/in-memory/expression.d.ts +42 -0
  66. package/dist/esm/in-memory/expression.js +577 -0
  67. package/dist/esm/in-memory/expression.js.map +1 -0
  68. package/dist/esm/in-memory/index.d.ts +2 -0
  69. package/dist/esm/in-memory/index.js +3 -0
  70. package/dist/esm/in-memory/index.js.map +1 -0
  71. package/dist/esm/in-memory/spec.d.ts +23 -0
  72. package/dist/esm/in-memory/spec.js +138 -0
  73. package/dist/esm/in-memory/spec.js.map +1 -0
  74. package/dist/esm/in-memory/store.d.ts +73 -0
  75. package/dist/esm/in-memory/store.js +258 -0
  76. package/dist/esm/in-memory/store.js.map +1 -0
  77. package/dist/esm/in-memory/treap.d.ts +31 -0
  78. package/dist/esm/in-memory/treap.js +183 -0
  79. package/dist/esm/in-memory/treap.js.map +1 -0
  80. package/dist/esm/in-memory/utils.d.ts +10 -0
  81. package/dist/esm/in-memory/utils.js +28 -0
  82. package/dist/esm/in-memory/utils.js.map +1 -0
  83. package/dist/esm/sandbox.d.ts +2 -0
  84. package/dist/esm/sandbox.js +172 -1
  85. package/dist/esm/sandbox.js.map +1 -1
  86. package/package.json +2 -1
  87. package/src/__test__/client-env-guard.test.ts +31 -0
  88. package/src/__test__/conformance.test.ts +2042 -0
  89. package/src/__test__/in-memory.spec.test.ts +230 -0
  90. package/src/__test__/rollback.test.ts +279 -0
  91. package/src/client.ts +17 -4
  92. package/src/errors.ts +24 -0
  93. package/src/in-memory/document-client.ts +1140 -0
  94. package/src/in-memory/expression.ts +730 -0
  95. package/src/in-memory/index.ts +2 -0
  96. package/src/in-memory/spec.ts +159 -0
  97. package/src/in-memory/store.ts +360 -0
  98. package/src/in-memory/treap.ts +239 -0
  99. package/src/in-memory/utils.ts +45 -0
  100. package/src/sandbox.ts +227 -1
@@ -0,0 +1,730 @@
1
+ import { NotSupportedError } from "../errors"
2
+ import { InMemoryItem } from "./utils"
3
+
4
+ const MISSING = Symbol("missing")
5
+
6
+ type Missing = typeof MISSING
7
+
8
+ type ResolvedValue = Missing | any
9
+
10
+ export interface ExpressionContext {
11
+ method: string
12
+ expressionAttributeNames?: { [key: string]: string }
13
+ expressionAttributeValues?: { [key: string]: any }
14
+ item?: InMemoryItem
15
+ }
16
+
17
+ export type RangeCondition =
18
+ | { type: "begins_with"; value: string }
19
+ | { type: "=" | "<" | "<=" | ">" | ">="; value: any }
20
+ | { type: "between"; lower: any; upper: any }
21
+
22
+ export interface ParsedKeyCondition {
23
+ hashAttribute: string
24
+ hashValue: any
25
+ range?: {
26
+ attribute: string
27
+ condition: RangeCondition
28
+ }
29
+ }
30
+
31
+ export interface ParsedUpdateExpression {
32
+ set: Array<{ attribute: string; value: any }>
33
+ remove: string[]
34
+ }
35
+
36
+ const KEY_COND_BEGINS_WITH = /^(.+?)\s*=\s*(.+?)\s+and\s+begins_with\((.+?),\s*(.+?)\)$/i
37
+ const KEY_COND_BETWEEN = /^(.+?)\s*=\s*(.+?)\s+and\s+(.+?)\s+between\s+(.+?)\s+and\s+(.+)$/i
38
+ const KEY_COND_COMPARE = /^(.+?)\s*=\s*(.+?)\s+and\s+(.+?)\s*(<=|<|>=|>|=)\s*(.+)$/i
39
+ const KEY_COND_HASH_ONLY = /^(.+?)\s*=\s*(.+)$/i
40
+
41
+ export const parseKeyConditionExpression = (
42
+ expression: string,
43
+ context: ExpressionContext
44
+ ): ParsedKeyCondition => {
45
+ const source = expression.trim()
46
+
47
+ const beginsMatch = source.match(KEY_COND_BEGINS_WITH)
48
+ if (beginsMatch) {
49
+ const [, hashAttrToken, hashValueToken, rangeAttrToken, rangeValueToken] =
50
+ beginsMatch
51
+
52
+ const hashAttribute = resolveAttributeToken(hashAttrToken, context)
53
+ const rangeAttribute = resolveAttributeToken(rangeAttrToken, context)
54
+ const rangeValue = resolveValueToken(rangeValueToken, undefined, context)
55
+
56
+ if (typeof rangeValue !== "string") {
57
+ throw new NotSupportedError({
58
+ method: context.method,
59
+ featurePath: "KeyConditionExpression.begins_with.value",
60
+ reason: "begins_with currently supports string operands only.",
61
+ })
62
+ }
63
+
64
+ return {
65
+ hashAttribute,
66
+ hashValue: resolveValueToken(hashValueToken, undefined, context),
67
+ range: {
68
+ attribute: rangeAttribute,
69
+ condition: { type: "begins_with", value: rangeValue },
70
+ },
71
+ }
72
+ }
73
+
74
+ const betweenMatch = source.match(KEY_COND_BETWEEN)
75
+ if (betweenMatch) {
76
+ const [, hashAttrToken, hashValueToken, rangeAttrToken, lowerToken, upperToken] =
77
+ betweenMatch
78
+
79
+ return {
80
+ hashAttribute: resolveAttributeToken(hashAttrToken, context),
81
+ hashValue: resolveValueToken(hashValueToken, undefined, context),
82
+ range: {
83
+ attribute: resolveAttributeToken(rangeAttrToken, context),
84
+ condition: {
85
+ type: "between",
86
+ lower: resolveValueToken(lowerToken, undefined, context),
87
+ upper: resolveValueToken(upperToken, undefined, context),
88
+ },
89
+ },
90
+ }
91
+ }
92
+
93
+ const compareMatch = source.match(KEY_COND_COMPARE)
94
+ if (compareMatch) {
95
+ const [, hashAttrToken, hashValueToken, rangeAttrToken, operator, rangeValueToken] =
96
+ compareMatch
97
+
98
+ return {
99
+ hashAttribute: resolveAttributeToken(hashAttrToken, context),
100
+ hashValue: resolveValueToken(hashValueToken, undefined, context),
101
+ range: {
102
+ attribute: resolveAttributeToken(rangeAttrToken, context),
103
+ condition: {
104
+ type: operator as "=" | "<" | "<=" | ">" | ">=",
105
+ value: resolveValueToken(rangeValueToken, undefined, context),
106
+ },
107
+ },
108
+ }
109
+ }
110
+
111
+ const hashOnlyMatch = source.match(KEY_COND_HASH_ONLY)
112
+ if (hashOnlyMatch) {
113
+ const [, hashAttrToken, hashValueToken] = hashOnlyMatch
114
+
115
+ return {
116
+ hashAttribute: resolveAttributeToken(hashAttrToken, context),
117
+ hashValue: resolveValueToken(hashValueToken, undefined, context),
118
+ }
119
+ }
120
+
121
+ throw new NotSupportedError({
122
+ method: context.method,
123
+ featurePath: "KeyConditionExpression",
124
+ reason: "Unsupported key condition expression grammar.",
125
+ })
126
+ }
127
+
128
+ export const evaluateConditionExpression = (
129
+ expression: string,
130
+ item: InMemoryItem | undefined,
131
+ context: ExpressionContext
132
+ ): boolean => {
133
+ const orGroups = splitTopLevelByKeyword(expression, "or")
134
+
135
+ if (orGroups.length === 0) {
136
+ throw new NotSupportedError({
137
+ method: context.method,
138
+ featurePath: "ConditionExpression",
139
+ reason: "Empty condition expressions are not supported.",
140
+ })
141
+ }
142
+
143
+ return orGroups.some((group) => {
144
+ const andClauses = splitTopLevelByKeyword(group, "and")
145
+ return andClauses.every((clause) =>
146
+ evaluateSingleClause(clause, item, context)
147
+ )
148
+ })
149
+ }
150
+
151
+ export const parseUpdateExpression = (
152
+ expression: string,
153
+ context: ExpressionContext
154
+ ): ParsedUpdateExpression => {
155
+ const normalized = expression.trim()
156
+ if (!normalized) {
157
+ throw new NotSupportedError({
158
+ method: context.method,
159
+ featurePath: "UpdateExpression",
160
+ reason: "UpdateExpression must not be empty.",
161
+ })
162
+ }
163
+
164
+ const setMatch = normalized.match(/\bSET\b/i)
165
+ const removeMatch = normalized.match(/\bREMOVE\b/i)
166
+
167
+ if (!setMatch && !removeMatch) {
168
+ throw new NotSupportedError({
169
+ method: context.method,
170
+ featurePath: "UpdateExpression",
171
+ reason: "Only SET and REMOVE update operators are supported.",
172
+ })
173
+ }
174
+
175
+ const setStart = setMatch?.index ?? -1
176
+ const removeStart = removeMatch?.index ?? -1
177
+
178
+ let setClause = ""
179
+ let removeClause = ""
180
+
181
+ if (setStart >= 0) {
182
+ const setBodyStart = setStart + setMatch![0].length
183
+ const setBodyEnd = removeStart >= 0 ? removeStart : normalized.length
184
+ setClause = normalized.slice(setBodyStart, setBodyEnd).trim()
185
+ if (!setClause) {
186
+ throw new NotSupportedError({
187
+ method: context.method,
188
+ featurePath: "UpdateExpression.SET",
189
+ reason: "Malformed SET assignment.",
190
+ })
191
+ }
192
+ }
193
+
194
+ if (removeStart >= 0) {
195
+ const removeBodyStart = removeStart + removeMatch![0].length
196
+ removeClause = normalized.slice(removeBodyStart).trim()
197
+ if (!removeClause) {
198
+ throw new NotSupportedError({
199
+ method: context.method,
200
+ featurePath: "UpdateExpression.REMOVE",
201
+ reason: "Malformed REMOVE assignment.",
202
+ })
203
+ }
204
+ }
205
+
206
+ const set = setClause
207
+ ? splitTopLevelByDelimiter(setClause, ",")
208
+ .map((segment) => segment.trim())
209
+ .filter(Boolean)
210
+ .map((assignment) => {
211
+ const split = splitTopLevelAssignment(assignment)
212
+ if (!split) {
213
+ throw new NotSupportedError({
214
+ method: context.method,
215
+ featurePath: "UpdateExpression.SET",
216
+ reason: "Malformed SET assignment.",
217
+ })
218
+ }
219
+
220
+ const attribute = resolveAttributeToken(split.left, context)
221
+ const value = resolveUpdateSetValueToken(
222
+ split.right,
223
+ context.item,
224
+ context
225
+ )
226
+
227
+ return { attribute, value }
228
+ })
229
+ : []
230
+
231
+ const remove = removeClause
232
+ ? splitTopLevelByDelimiter(removeClause, ",")
233
+ .map((segment) => segment.trim())
234
+ .filter(Boolean)
235
+ .map((token) => resolveAttributeToken(token, context))
236
+ : []
237
+
238
+ return { set, remove }
239
+ }
240
+
241
+ export const matchesRangeCondition = (
242
+ value: any,
243
+ condition: RangeCondition
244
+ ): boolean => {
245
+ if (value === undefined || value === null) return false
246
+
247
+ switch (condition.type) {
248
+ case "begins_with":
249
+ return String(value).startsWith(condition.value)
250
+ case "between":
251
+ return compareValues(value, condition.lower) >= 0 && compareValues(value, condition.upper) <= 0
252
+ case "=":
253
+ return compareValues(value, condition.value) === 0
254
+ case "<":
255
+ return compareValues(value, condition.value) < 0
256
+ case "<=":
257
+ return compareValues(value, condition.value) <= 0
258
+ case ">":
259
+ return compareValues(value, condition.value) > 0
260
+ case ">=":
261
+ return compareValues(value, condition.value) >= 0
262
+ }
263
+ }
264
+
265
+ export const compareValues = (left: any, right: any): number => {
266
+ if (typeof left === "number" && typeof right === "number") {
267
+ return left - right
268
+ }
269
+
270
+ const leftString = String(left)
271
+ const rightString = String(right)
272
+
273
+ if (leftString < rightString) return -1
274
+ if (leftString > rightString) return 1
275
+ return 0
276
+ }
277
+
278
+ function evaluateSingleClause(
279
+ clause: string,
280
+ item: InMemoryItem | undefined,
281
+ context: ExpressionContext
282
+ ): boolean {
283
+ const source = clause.trim()
284
+
285
+ const existsMatch = source.match(/^attribute_exists\((.+)\)$/i)
286
+ if (existsMatch) {
287
+ const value = resolveAttributeValue(existsMatch[1].trim(), item, context)
288
+ return value !== MISSING
289
+ }
290
+
291
+ const notExistsMatch = source.match(/^attribute_not_exists\((.+)\)$/i)
292
+ if (notExistsMatch) {
293
+ const value = resolveAttributeValue(notExistsMatch[1].trim(), item, context)
294
+ return value === MISSING
295
+ }
296
+
297
+ const beginsWithMatch = source.match(/^begins_with\((.+?),\s*(.+)\)$/i)
298
+ if (beginsWithMatch) {
299
+ const [, attrToken, valueToken] = beginsWithMatch
300
+ const current = resolveAttributeValue(attrToken.trim(), item, context)
301
+ const expected = resolveValueToken(valueToken.trim(), item, context)
302
+
303
+ if (current === MISSING || expected === MISSING) return false
304
+ return String(current).startsWith(String(expected))
305
+ }
306
+
307
+ const containsMatch = source.match(/^contains\((.+?),\s*(.+)\)$/i)
308
+ if (containsMatch) {
309
+ const [, attrToken, valueToken] = containsMatch
310
+ const current = resolveAttributeValue(attrToken.trim(), item, context)
311
+ const expected = resolveValueToken(valueToken.trim(), item, context)
312
+
313
+ if (current === MISSING || expected === MISSING) return false
314
+ return containsValue(current, expected)
315
+ }
316
+
317
+ const attributeTypeMatch = source.match(/^attribute_type\((.+?),\s*(.+)\)$/i)
318
+ if (attributeTypeMatch) {
319
+ const [, attrToken, typeToken] = attributeTypeMatch
320
+ const current = resolveAttributeValue(attrToken.trim(), item, context)
321
+ const expectedType = resolveValueToken(typeToken.trim(), item, context)
322
+
323
+ if (current === MISSING) return false
324
+ if (typeof expectedType !== "string") {
325
+ throw new NotSupportedError({
326
+ method: context.method,
327
+ featurePath: "ConditionExpression.attribute_type",
328
+ reason: "attribute_type expects a DynamoDB type string.",
329
+ })
330
+ }
331
+
332
+ return attributeMatchesType(current, expectedType)
333
+ }
334
+
335
+ const betweenMatch = source.match(/^(.+?)\s+between\s+(.+?)\s+and\s+(.+)$/i)
336
+ if (betweenMatch) {
337
+ const [, attrToken, lowerToken, upperToken] = betweenMatch
338
+ const current = resolveAttributeValue(attrToken.trim(), item, context)
339
+ const lower = resolveValueToken(lowerToken.trim(), item, context)
340
+ const upper = resolveValueToken(upperToken.trim(), item, context)
341
+
342
+ if (current === MISSING || lower === MISSING || upper === MISSING) return false
343
+
344
+ return compareValues(current, lower) >= 0 && compareValues(current, upper) <= 0
345
+ }
346
+
347
+ const compareMatch = source.match(/^(.+?)\s*(=|<>|<=|<|>=|>)\s*(.+)$/)
348
+ if (compareMatch) {
349
+ const [, leftToken, operator, rightToken] = compareMatch
350
+
351
+ const left = resolveValueToken(leftToken.trim(), item, context)
352
+ const right = resolveValueToken(rightToken.trim(), item, context)
353
+
354
+ if (left === MISSING || right === MISSING) return false
355
+
356
+ const result = compareValues(left, right)
357
+
358
+ switch (operator) {
359
+ case "=":
360
+ return result === 0
361
+ case "<>":
362
+ return result !== 0
363
+ case "<":
364
+ return result < 0
365
+ case "<=":
366
+ return result <= 0
367
+ case ">":
368
+ return result > 0
369
+ case ">=":
370
+ return result >= 0
371
+ default:
372
+ return false
373
+ }
374
+ }
375
+
376
+ throw new NotSupportedError({
377
+ method: context.method,
378
+ featurePath: "ConditionExpression",
379
+ reason: `Unsupported clause: ${source}`,
380
+ })
381
+ }
382
+
383
+ const PLACEHOLDER_VALUE = /^:[A-Za-z_][A-Za-z0-9_]*$/
384
+ const PLACEHOLDER_NAME = /^#[A-Za-z_][A-Za-z0-9_]*$/
385
+ const NUMBER_LITERAL = /^-?\d+(?:\.\d+)?$/
386
+ const STRING_LITERAL = /^".*"$|^'.*'$/
387
+ const ATTRIBUTE_NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/
388
+
389
+ function resolveAttributeToken(token: string, context: ExpressionContext): string {
390
+ const trimmed = token.trim()
391
+
392
+ if (PLACEHOLDER_NAME.test(trimmed)) {
393
+ const resolved = context.expressionAttributeNames?.[trimmed]
394
+ if (!resolved) {
395
+ throw new NotSupportedError({
396
+ method: context.method,
397
+ featurePath: `ExpressionAttributeNames.${trimmed}`,
398
+ reason: "Missing expression attribute name placeholder.",
399
+ })
400
+ }
401
+
402
+ return resolved
403
+ }
404
+
405
+ if (!ATTRIBUTE_NAME.test(trimmed)) {
406
+ throw new NotSupportedError({
407
+ method: context.method,
408
+ featurePath: "ExpressionAttributeNames",
409
+ reason: `Unsupported attribute token: ${trimmed}`,
410
+ })
411
+ }
412
+
413
+ return trimmed
414
+ }
415
+
416
+ function resolveValueToken(
417
+ token: string,
418
+ item: InMemoryItem | undefined,
419
+ context: ExpressionContext
420
+ ): ResolvedValue {
421
+ const trimmed = token.trim()
422
+
423
+ if (PLACEHOLDER_VALUE.test(trimmed)) {
424
+ if (!context.expressionAttributeValues) {
425
+ throw new NotSupportedError({
426
+ method: context.method,
427
+ featurePath: "ExpressionAttributeValues",
428
+ reason: "ExpressionAttributeValues are required for value placeholders.",
429
+ })
430
+ }
431
+
432
+ if (!(trimmed in context.expressionAttributeValues)) {
433
+ throw new NotSupportedError({
434
+ method: context.method,
435
+ featurePath: `ExpressionAttributeValues.${trimmed}`,
436
+ reason: "Missing expression attribute value placeholder.",
437
+ })
438
+ }
439
+
440
+ return context.expressionAttributeValues[trimmed]
441
+ }
442
+
443
+ if (PLACEHOLDER_NAME.test(trimmed)) {
444
+ const attributeName = resolveAttributeToken(trimmed, context)
445
+ return item && attributeName in item ? item[attributeName] : MISSING
446
+ }
447
+
448
+ if (STRING_LITERAL.test(trimmed)) {
449
+ return trimmed.slice(1, -1)
450
+ }
451
+
452
+ const sizeMatch = trimmed.match(/^size\((.+)\)$/i)
453
+ if (sizeMatch) {
454
+ const value = resolveAttributeValue(sizeMatch[1].trim(), item, context)
455
+ if (value === MISSING) return MISSING
456
+
457
+ return sizeOfValue(value, context)
458
+ }
459
+
460
+ if (NUMBER_LITERAL.test(trimmed)) {
461
+ return Number(trimmed)
462
+ }
463
+
464
+ if (trimmed === "true") return true
465
+ if (trimmed === "false") return false
466
+ if (trimmed === "null") return null
467
+
468
+ if (ATTRIBUTE_NAME.test(trimmed)) {
469
+ return item && trimmed in item ? item[trimmed] : MISSING
470
+ }
471
+
472
+ throw new NotSupportedError({
473
+ method: context.method,
474
+ featurePath: "ExpressionValue",
475
+ reason: `Unsupported value token: ${trimmed}`,
476
+ })
477
+ }
478
+
479
+ function resolveAttributeValue(
480
+ token: string,
481
+ item: InMemoryItem | undefined,
482
+ context: ExpressionContext
483
+ ): ResolvedValue {
484
+ const attributeName = resolveAttributeToken(token, context)
485
+
486
+ if (!item) return MISSING
487
+ if (!(attributeName in item)) return MISSING
488
+
489
+ return item[attributeName]
490
+ }
491
+
492
+ function splitTopLevelByKeyword(
493
+ expression: string,
494
+ keyword: "and" | "or"
495
+ ): string[] {
496
+ const clauses: string[] = []
497
+ let current = ""
498
+ let depth = 0
499
+ const marker = ` ${keyword} `
500
+
501
+ for (let i = 0; i < expression.length; i += 1) {
502
+ const char = expression[i]
503
+
504
+ if (char === "(") depth += 1
505
+ if (char === ")") depth = Math.max(0, depth - 1)
506
+
507
+ if (
508
+ depth === 0 &&
509
+ expression.slice(i, i + marker.length).toLowerCase() === marker
510
+ ) {
511
+ clauses.push(current.trim())
512
+ current = ""
513
+ i += marker.length - 1
514
+ continue
515
+ }
516
+
517
+ current += char
518
+ }
519
+
520
+ if (current.trim()) clauses.push(current.trim())
521
+
522
+ return clauses
523
+ }
524
+
525
+ function splitTopLevelByDelimiter(
526
+ expression: string,
527
+ delimiter: "," | "+" | "-"
528
+ ): string[] {
529
+ const segments: string[] = []
530
+ let current = ""
531
+ let depth = 0
532
+
533
+ for (let i = 0; i < expression.length; i += 1) {
534
+ const char = expression[i]
535
+ if (char === "(") depth += 1
536
+ if (char === ")") depth = Math.max(0, depth - 1)
537
+
538
+ if (depth === 0 && char === delimiter) {
539
+ segments.push(current)
540
+ current = ""
541
+ continue
542
+ }
543
+
544
+ current += char
545
+ }
546
+
547
+ if (current.length > 0) segments.push(current)
548
+ return segments
549
+ }
550
+
551
+ function splitTopLevelAssignment(
552
+ assignment: string
553
+ ): { left: string; right: string } | null {
554
+ let depth = 0
555
+
556
+ for (let i = 0; i < assignment.length; i += 1) {
557
+ const char = assignment[i]
558
+ if (char === "(") depth += 1
559
+ if (char === ")") depth = Math.max(0, depth - 1)
560
+
561
+ if (depth === 0 && char === "=") {
562
+ const left = assignment.slice(0, i).trim()
563
+ const right = assignment.slice(i + 1).trim()
564
+ if (!left || !right) return null
565
+ return { left, right }
566
+ }
567
+ }
568
+
569
+ return null
570
+ }
571
+
572
+ function resolveUpdateSetValueToken(
573
+ token: string,
574
+ item: InMemoryItem | undefined,
575
+ context: ExpressionContext
576
+ ): any {
577
+ const trimmed = token.trim()
578
+
579
+ const arithmetic = splitTopLevelArithmetic(trimmed)
580
+ if (arithmetic) {
581
+ const left = resolveUpdateSetValueToken(arithmetic.left, item, context)
582
+ const right = resolveUpdateSetValueToken(arithmetic.right, item, context)
583
+
584
+ if (typeof left !== "number" || typeof right !== "number") {
585
+ throw new NotSupportedError({
586
+ method: context.method,
587
+ featurePath: "UpdateExpression.SET",
588
+ reason: "Arithmetic update operands must be numbers.",
589
+ })
590
+ }
591
+
592
+ return arithmetic.operator === "+" ? left + right : left - right
593
+ }
594
+
595
+ const ifNotExists = parseFunctionCall(trimmed, "if_not_exists")
596
+ if (ifNotExists) {
597
+ if (ifNotExists.length !== 2) {
598
+ throw new NotSupportedError({
599
+ method: context.method,
600
+ featurePath: "UpdateExpression.SET",
601
+ reason: "if_not_exists expects exactly two arguments.",
602
+ })
603
+ }
604
+
605
+ const existing = resolveAttributeValue(ifNotExists[0], item, context)
606
+ if (existing !== MISSING) return existing
607
+
608
+ return resolveUpdateSetValueToken(ifNotExists[1], item, context)
609
+ }
610
+
611
+ const listAppend = parseFunctionCall(trimmed, "list_append")
612
+ if (listAppend) {
613
+ if (listAppend.length !== 2) {
614
+ throw new NotSupportedError({
615
+ method: context.method,
616
+ featurePath: "UpdateExpression.SET",
617
+ reason: "list_append expects exactly two arguments.",
618
+ })
619
+ }
620
+
621
+ const left = resolveUpdateSetValueToken(listAppend[0], item, context)
622
+ const right = resolveUpdateSetValueToken(listAppend[1], item, context)
623
+
624
+ if (!Array.isArray(left) || !Array.isArray(right)) {
625
+ throw new NotSupportedError({
626
+ method: context.method,
627
+ featurePath: "UpdateExpression.SET",
628
+ reason: "list_append expects list operands.",
629
+ })
630
+ }
631
+
632
+ return [...left, ...right]
633
+ }
634
+
635
+ const value = resolveValueToken(trimmed, item, context)
636
+ if (value === MISSING) {
637
+ throw new NotSupportedError({
638
+ method: context.method,
639
+ featurePath: "UpdateExpression.SET",
640
+ reason: "The provided expression refers to an attribute that does not exist in the item.",
641
+ })
642
+ }
643
+
644
+ return value
645
+ }
646
+
647
+ function splitTopLevelArithmetic(
648
+ source: string
649
+ ): { left: string; right: string; operator: "+" | "-" } | null {
650
+ let depth = 0
651
+
652
+ for (let i = 0; i < source.length; i += 1) {
653
+ const char = source[i]
654
+ if (char === "(") depth += 1
655
+ if (char === ")") depth = Math.max(0, depth - 1)
656
+ if (depth !== 0) continue
657
+
658
+ if (char === "+" || char === "-") {
659
+ const left = source.slice(0, i).trim()
660
+ const right = source.slice(i + 1).trim()
661
+
662
+ if (!left || !right) continue
663
+ return {
664
+ left,
665
+ right,
666
+ operator: char,
667
+ }
668
+ }
669
+ }
670
+
671
+ return null
672
+ }
673
+
674
+ function parseFunctionCall(source: string, fnName: string): string[] | null {
675
+ const regex = new RegExp(`^${fnName}\\((.*)\\)$`, "i")
676
+ const match = source.match(regex)
677
+ if (!match) return null
678
+
679
+ return splitTopLevelByDelimiter(match[1], ",")
680
+ .map((segment) => segment.trim())
681
+ .filter(Boolean)
682
+ }
683
+
684
+ function sizeOfValue(value: any, context: ExpressionContext): number {
685
+ if (typeof value === "string" || Array.isArray(value)) return value.length
686
+ if (value instanceof Uint8Array || Buffer.isBuffer(value)) return value.length
687
+ if (value instanceof Set) return value.size
688
+ if (value && typeof value === "object") return Object.keys(value).length
689
+
690
+ throw new NotSupportedError({
691
+ method: context.method,
692
+ featurePath: "ConditionExpression.size",
693
+ reason: "size supports string, binary, list, set, and map values only.",
694
+ })
695
+ }
696
+
697
+ function containsValue(container: any, expected: any): boolean {
698
+ if (typeof container === "string") return container.includes(String(expected))
699
+ if (Array.isArray(container)) return container.some((entry) => entry === expected)
700
+ if (container instanceof Set) return container.has(expected)
701
+ if (container && typeof container === "object") {
702
+ return Object.prototype.hasOwnProperty.call(container, String(expected))
703
+ }
704
+
705
+ return false
706
+ }
707
+
708
+ function attributeMatchesType(value: any, expectedType: string): boolean {
709
+ const t = expectedType.toUpperCase()
710
+
711
+ if (t === "S") return typeof value === "string"
712
+ if (t === "N") return typeof value === "number"
713
+ if (t === "BOOL") return typeof value === "boolean"
714
+ if (t === "NULL") return value === null
715
+ if (t === "L") return Array.isArray(value)
716
+ if (t === "M") return !!value && typeof value === "object" && !Array.isArray(value) && !Buffer.isBuffer(value) && !(value instanceof Set)
717
+ if (t === "B") return Buffer.isBuffer(value) || value instanceof Uint8Array
718
+
719
+ if (value instanceof Set) {
720
+ const values = [...value.values()]
721
+ if (t === "SS") return values.every((entry) => typeof entry === "string")
722
+ if (t === "NS") return values.every((entry) => typeof entry === "number")
723
+ if (t === "BS")
724
+ return values.every(
725
+ (entry) => Buffer.isBuffer(entry) || entry instanceof Uint8Array
726
+ )
727
+ }
728
+
729
+ return false
730
+ }