@model-ts/dynamodb 4.1.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +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 +234 -0
  10. package/dist/cjs/__test__/in-memory.spec.test.js.map +1 -0
  11. package/dist/cjs/client.d.ts +2 -2
  12. package/dist/cjs/client.js +14 -6
  13. package/dist/cjs/client.js.map +1 -1
  14. package/dist/cjs/errors.d.ts +13 -1
  15. package/dist/cjs/errors.js +12 -1
  16. package/dist/cjs/errors.js.map +1 -1
  17. package/dist/cjs/in-memory/document-client.d.ts +45 -0
  18. package/dist/cjs/in-memory/document-client.js +795 -0
  19. package/dist/cjs/in-memory/document-client.js.map +1 -0
  20. package/dist/cjs/in-memory/expression.d.ts +42 -0
  21. package/dist/cjs/in-memory/expression.js +665 -0
  22. package/dist/cjs/in-memory/expression.js.map +1 -0
  23. package/dist/cjs/in-memory/index.d.ts +2 -0
  24. package/dist/cjs/in-memory/index.js +6 -0
  25. package/dist/cjs/in-memory/index.js.map +1 -0
  26. package/dist/cjs/in-memory/spec.d.ts +23 -0
  27. package/dist/cjs/in-memory/spec.js +141 -0
  28. package/dist/cjs/in-memory/spec.js.map +1 -0
  29. package/dist/cjs/in-memory/store.d.ts +73 -0
  30. package/dist/cjs/in-memory/store.js +267 -0
  31. package/dist/cjs/in-memory/store.js.map +1 -0
  32. package/dist/cjs/in-memory/treap.d.ts +31 -0
  33. package/dist/cjs/in-memory/treap.js +187 -0
  34. package/dist/cjs/in-memory/treap.js.map +1 -0
  35. package/dist/cjs/in-memory/utils.d.ts +10 -0
  36. package/dist/cjs/in-memory/utils.js +38 -0
  37. package/dist/cjs/in-memory/utils.js.map +1 -0
  38. package/dist/cjs/sandbox.js +44 -0
  39. package/dist/cjs/sandbox.js.map +1 -1
  40. package/dist/esm/__test__/client-env-guard.test.d.ts +1 -0
  41. package/dist/esm/__test__/client-env-guard.test.js +26 -0
  42. package/dist/esm/__test__/client-env-guard.test.js.map +1 -0
  43. package/dist/esm/__test__/conformance.test.d.ts +1 -0
  44. package/dist/esm/__test__/conformance.test.js +1833 -0
  45. package/dist/esm/__test__/conformance.test.js.map +1 -0
  46. package/dist/esm/__test__/in-memory.spec.test.d.ts +1 -0
  47. package/dist/esm/__test__/in-memory.spec.test.js +232 -0
  48. package/dist/esm/__test__/in-memory.spec.test.js.map +1 -0
  49. package/dist/esm/client.d.ts +2 -2
  50. package/dist/esm/client.js +14 -6
  51. package/dist/esm/client.js.map +1 -1
  52. package/dist/esm/errors.d.ts +13 -1
  53. package/dist/esm/errors.js +10 -0
  54. package/dist/esm/errors.js.map +1 -1
  55. package/dist/esm/in-memory/document-client.d.ts +45 -0
  56. package/dist/esm/in-memory/document-client.js +791 -0
  57. package/dist/esm/in-memory/document-client.js.map +1 -0
  58. package/dist/esm/in-memory/expression.d.ts +42 -0
  59. package/dist/esm/in-memory/expression.js +657 -0
  60. package/dist/esm/in-memory/expression.js.map +1 -0
  61. package/dist/esm/in-memory/index.d.ts +2 -0
  62. package/dist/esm/in-memory/index.js +3 -0
  63. package/dist/esm/in-memory/index.js.map +1 -0
  64. package/dist/esm/in-memory/spec.d.ts +23 -0
  65. package/dist/esm/in-memory/spec.js +138 -0
  66. package/dist/esm/in-memory/spec.js.map +1 -0
  67. package/dist/esm/in-memory/store.d.ts +73 -0
  68. package/dist/esm/in-memory/store.js +258 -0
  69. package/dist/esm/in-memory/store.js.map +1 -0
  70. package/dist/esm/in-memory/treap.d.ts +31 -0
  71. package/dist/esm/in-memory/treap.js +183 -0
  72. package/dist/esm/in-memory/treap.js.map +1 -0
  73. package/dist/esm/in-memory/utils.d.ts +10 -0
  74. package/dist/esm/in-memory/utils.js +28 -0
  75. package/dist/esm/in-memory/utils.js.map +1 -0
  76. package/dist/esm/sandbox.js +44 -0
  77. package/dist/esm/sandbox.js.map +1 -1
  78. package/package.json +2 -1
  79. package/src/__test__/client-env-guard.test.ts +31 -0
  80. package/src/__test__/conformance.test.ts +2042 -0
  81. package/src/__test__/in-memory.spec.test.ts +283 -0
  82. package/src/client.ts +17 -4
  83. package/src/errors.ts +24 -0
  84. package/src/in-memory/document-client.ts +1140 -0
  85. package/src/in-memory/expression.ts +830 -0
  86. package/src/in-memory/index.ts +2 -0
  87. package/src/in-memory/spec.ts +159 -0
  88. package/src/in-memory/store.ts +360 -0
  89. package/src/in-memory/treap.ts +239 -0
  90. package/src/in-memory/utils.ts +45 -0
  91. package/src/sandbox.ts +56 -0
@@ -0,0 +1,830 @@
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
+ const DOCUMENT_PATH_TOKEN =
389
+ /^(?:#[A-Za-z_][A-Za-z0-9_]*|[A-Za-z_][A-Za-z0-9_-]*)(?:\[\d+\])*(?:\.(?:#[A-Za-z_][A-Za-z0-9_]*|[A-Za-z_][A-Za-z0-9_-]*)(?:\[\d+\])*)*$/
390
+ const ATTRIBUTE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_-]*/
391
+ const PLACEHOLDER_SEGMENT = /^#[A-Za-z_][A-Za-z0-9_]*/
392
+
393
+ type DocumentPathPart =
394
+ | { type: "attribute"; value: string }
395
+ | { type: "index"; value: number }
396
+
397
+ function resolveAttributeToken(token: string, context: ExpressionContext): string {
398
+ const trimmed = token.trim()
399
+
400
+ if (PLACEHOLDER_NAME.test(trimmed)) {
401
+ const resolved = context.expressionAttributeNames?.[trimmed]
402
+ if (!resolved) {
403
+ throw new NotSupportedError({
404
+ method: context.method,
405
+ featurePath: `ExpressionAttributeNames.${trimmed}`,
406
+ reason: "Missing expression attribute name placeholder.",
407
+ })
408
+ }
409
+
410
+ return resolved
411
+ }
412
+
413
+ if (!ATTRIBUTE_NAME.test(trimmed)) {
414
+ throw new NotSupportedError({
415
+ method: context.method,
416
+ featurePath: "ExpressionAttributeNames",
417
+ reason: `Unsupported attribute token: ${trimmed}`,
418
+ })
419
+ }
420
+
421
+ return trimmed
422
+ }
423
+
424
+ function resolveValueToken(
425
+ token: string,
426
+ item: InMemoryItem | undefined,
427
+ context: ExpressionContext
428
+ ): ResolvedValue {
429
+ const trimmed = token.trim()
430
+
431
+ if (PLACEHOLDER_VALUE.test(trimmed)) {
432
+ if (!context.expressionAttributeValues) {
433
+ throw new NotSupportedError({
434
+ method: context.method,
435
+ featurePath: "ExpressionAttributeValues",
436
+ reason: "ExpressionAttributeValues are required for value placeholders.",
437
+ })
438
+ }
439
+
440
+ if (!(trimmed in context.expressionAttributeValues)) {
441
+ throw new NotSupportedError({
442
+ method: context.method,
443
+ featurePath: `ExpressionAttributeValues.${trimmed}`,
444
+ reason: "Missing expression attribute value placeholder.",
445
+ })
446
+ }
447
+
448
+ return context.expressionAttributeValues[trimmed]
449
+ }
450
+
451
+ if (PLACEHOLDER_NAME.test(trimmed)) {
452
+ return resolveAttributeValue(trimmed, item, context)
453
+ }
454
+
455
+ if (STRING_LITERAL.test(trimmed)) {
456
+ return trimmed.slice(1, -1)
457
+ }
458
+
459
+ const sizeMatch = trimmed.match(/^size\((.+)\)$/i)
460
+ if (sizeMatch) {
461
+ const value = resolveAttributeValue(sizeMatch[1].trim(), item, context)
462
+ if (value === MISSING) return MISSING
463
+
464
+ return sizeOfValue(value, context)
465
+ }
466
+
467
+ if (NUMBER_LITERAL.test(trimmed)) {
468
+ return Number(trimmed)
469
+ }
470
+
471
+ if (trimmed === "true") return true
472
+ if (trimmed === "false") return false
473
+ if (trimmed === "null") return null
474
+
475
+ if (DOCUMENT_PATH_TOKEN.test(trimmed) || ATTRIBUTE_NAME.test(trimmed)) {
476
+ return resolveAttributeValue(trimmed, item, context)
477
+ }
478
+
479
+ throw new NotSupportedError({
480
+ method: context.method,
481
+ featurePath: "ExpressionValue",
482
+ reason: `Unsupported value token: ${trimmed}`,
483
+ })
484
+ }
485
+
486
+ function resolveAttributeValue(
487
+ token: string,
488
+ item: InMemoryItem | undefined,
489
+ context: ExpressionContext
490
+ ): ResolvedValue {
491
+ const path = parseDocumentPath(token, context)
492
+ if (!item) return MISSING
493
+
494
+ let current: any = item
495
+
496
+ for (const part of path) {
497
+ if (part.type === "attribute") {
498
+ if (!current || typeof current !== "object") return MISSING
499
+ if (!(part.value in current)) return MISSING
500
+ current = current[part.value]
501
+ continue
502
+ }
503
+
504
+ if (!Array.isArray(current)) return MISSING
505
+ if (!(part.value in current)) return MISSING
506
+ current = current[part.value]
507
+ }
508
+
509
+ return current
510
+ }
511
+
512
+ function parseDocumentPath(
513
+ token: string,
514
+ context: ExpressionContext
515
+ ): DocumentPathPart[] {
516
+ const source = token.trim()
517
+ if (!source) {
518
+ throwUnsupportedAttributeToken(token, context)
519
+ }
520
+
521
+ const parts: DocumentPathPart[] = []
522
+ let cursor = 0
523
+
524
+ while (cursor < source.length) {
525
+ const remaining = source.slice(cursor)
526
+ const placeholderMatch = remaining.match(PLACEHOLDER_SEGMENT)
527
+ const segmentMatch = remaining.match(ATTRIBUTE_SEGMENT)
528
+ const segmentToken = placeholderMatch?.[0] ?? segmentMatch?.[0]
529
+
530
+ if (!segmentToken) {
531
+ throwUnsupportedAttributeToken(token, context)
532
+ }
533
+
534
+ if (segmentToken.startsWith("#")) {
535
+ const resolved = context.expressionAttributeNames?.[segmentToken]
536
+ if (!resolved) {
537
+ throw new NotSupportedError({
538
+ method: context.method,
539
+ featurePath: `ExpressionAttributeNames.${segmentToken}`,
540
+ reason: "Missing expression attribute name placeholder.",
541
+ })
542
+ }
543
+ parts.push({ type: "attribute", value: resolved })
544
+ } else {
545
+ parts.push({ type: "attribute", value: segmentToken })
546
+ }
547
+
548
+ cursor += segmentToken.length
549
+
550
+ while (source[cursor] === "[") {
551
+ const start = cursor + 1
552
+ let end = start
553
+ while (end < source.length && /\d/.test(source[end])) end += 1
554
+
555
+ if (start === end || source[end] !== "]") {
556
+ throwUnsupportedAttributeToken(token, context)
557
+ }
558
+
559
+ parts.push({
560
+ type: "index",
561
+ value: Number(source.slice(start, end)),
562
+ })
563
+ cursor = end + 1
564
+ }
565
+
566
+ if (cursor >= source.length) break
567
+
568
+ if (source[cursor] !== ".") {
569
+ throwUnsupportedAttributeToken(token, context)
570
+ }
571
+
572
+ cursor += 1
573
+ if (cursor >= source.length) {
574
+ throwUnsupportedAttributeToken(token, context)
575
+ }
576
+ }
577
+
578
+ return parts
579
+ }
580
+
581
+ function throwUnsupportedAttributeToken(
582
+ token: string,
583
+ context: ExpressionContext
584
+ ): never {
585
+ throw new NotSupportedError({
586
+ method: context.method,
587
+ featurePath: "ExpressionAttributeNames",
588
+ reason: `Unsupported attribute token: ${token.trim()}`,
589
+ })
590
+ }
591
+
592
+ function splitTopLevelByKeyword(
593
+ expression: string,
594
+ keyword: "and" | "or"
595
+ ): string[] {
596
+ const clauses: string[] = []
597
+ let current = ""
598
+ let depth = 0
599
+ const marker = ` ${keyword} `
600
+
601
+ for (let i = 0; i < expression.length; i += 1) {
602
+ const char = expression[i]
603
+
604
+ if (char === "(") depth += 1
605
+ if (char === ")") depth = Math.max(0, depth - 1)
606
+
607
+ if (
608
+ depth === 0 &&
609
+ expression.slice(i, i + marker.length).toLowerCase() === marker
610
+ ) {
611
+ clauses.push(current.trim())
612
+ current = ""
613
+ i += marker.length - 1
614
+ continue
615
+ }
616
+
617
+ current += char
618
+ }
619
+
620
+ if (current.trim()) clauses.push(current.trim())
621
+
622
+ return clauses
623
+ }
624
+
625
+ function splitTopLevelByDelimiter(
626
+ expression: string,
627
+ delimiter: "," | "+" | "-"
628
+ ): string[] {
629
+ const segments: string[] = []
630
+ let current = ""
631
+ let depth = 0
632
+
633
+ for (let i = 0; i < expression.length; i += 1) {
634
+ const char = expression[i]
635
+ if (char === "(") depth += 1
636
+ if (char === ")") depth = Math.max(0, depth - 1)
637
+
638
+ if (depth === 0 && char === delimiter) {
639
+ segments.push(current)
640
+ current = ""
641
+ continue
642
+ }
643
+
644
+ current += char
645
+ }
646
+
647
+ if (current.length > 0) segments.push(current)
648
+ return segments
649
+ }
650
+
651
+ function splitTopLevelAssignment(
652
+ assignment: string
653
+ ): { left: string; right: string } | null {
654
+ let depth = 0
655
+
656
+ for (let i = 0; i < assignment.length; i += 1) {
657
+ const char = assignment[i]
658
+ if (char === "(") depth += 1
659
+ if (char === ")") depth = Math.max(0, depth - 1)
660
+
661
+ if (depth === 0 && char === "=") {
662
+ const left = assignment.slice(0, i).trim()
663
+ const right = assignment.slice(i + 1).trim()
664
+ if (!left || !right) return null
665
+ return { left, right }
666
+ }
667
+ }
668
+
669
+ return null
670
+ }
671
+
672
+ function resolveUpdateSetValueToken(
673
+ token: string,
674
+ item: InMemoryItem | undefined,
675
+ context: ExpressionContext
676
+ ): any {
677
+ const trimmed = token.trim()
678
+
679
+ const arithmetic = splitTopLevelArithmetic(trimmed)
680
+ if (arithmetic) {
681
+ const left = resolveUpdateSetValueToken(arithmetic.left, item, context)
682
+ const right = resolveUpdateSetValueToken(arithmetic.right, item, context)
683
+
684
+ if (typeof left !== "number" || typeof right !== "number") {
685
+ throw new NotSupportedError({
686
+ method: context.method,
687
+ featurePath: "UpdateExpression.SET",
688
+ reason: "Arithmetic update operands must be numbers.",
689
+ })
690
+ }
691
+
692
+ return arithmetic.operator === "+" ? left + right : left - right
693
+ }
694
+
695
+ const ifNotExists = parseFunctionCall(trimmed, "if_not_exists")
696
+ if (ifNotExists) {
697
+ if (ifNotExists.length !== 2) {
698
+ throw new NotSupportedError({
699
+ method: context.method,
700
+ featurePath: "UpdateExpression.SET",
701
+ reason: "if_not_exists expects exactly two arguments.",
702
+ })
703
+ }
704
+
705
+ const existing = resolveAttributeValue(ifNotExists[0], item, context)
706
+ if (existing !== MISSING) return existing
707
+
708
+ return resolveUpdateSetValueToken(ifNotExists[1], item, context)
709
+ }
710
+
711
+ const listAppend = parseFunctionCall(trimmed, "list_append")
712
+ if (listAppend) {
713
+ if (listAppend.length !== 2) {
714
+ throw new NotSupportedError({
715
+ method: context.method,
716
+ featurePath: "UpdateExpression.SET",
717
+ reason: "list_append expects exactly two arguments.",
718
+ })
719
+ }
720
+
721
+ const left = resolveUpdateSetValueToken(listAppend[0], item, context)
722
+ const right = resolveUpdateSetValueToken(listAppend[1], item, context)
723
+
724
+ if (!Array.isArray(left) || !Array.isArray(right)) {
725
+ throw new NotSupportedError({
726
+ method: context.method,
727
+ featurePath: "UpdateExpression.SET",
728
+ reason: "list_append expects list operands.",
729
+ })
730
+ }
731
+
732
+ return [...left, ...right]
733
+ }
734
+
735
+ const value = resolveValueToken(trimmed, item, context)
736
+ if (value === MISSING) {
737
+ throw new NotSupportedError({
738
+ method: context.method,
739
+ featurePath: "UpdateExpression.SET",
740
+ reason: "The provided expression refers to an attribute that does not exist in the item.",
741
+ })
742
+ }
743
+
744
+ return value
745
+ }
746
+
747
+ function splitTopLevelArithmetic(
748
+ source: string
749
+ ): { left: string; right: string; operator: "+" | "-" } | null {
750
+ let depth = 0
751
+
752
+ for (let i = 0; i < source.length; i += 1) {
753
+ const char = source[i]
754
+ if (char === "(") depth += 1
755
+ if (char === ")") depth = Math.max(0, depth - 1)
756
+ if (depth !== 0) continue
757
+
758
+ if (char === "+" || char === "-") {
759
+ const left = source.slice(0, i).trim()
760
+ const right = source.slice(i + 1).trim()
761
+
762
+ if (!left || !right) continue
763
+ return {
764
+ left,
765
+ right,
766
+ operator: char,
767
+ }
768
+ }
769
+ }
770
+
771
+ return null
772
+ }
773
+
774
+ function parseFunctionCall(source: string, fnName: string): string[] | null {
775
+ const regex = new RegExp(`^${fnName}\\((.*)\\)$`, "i")
776
+ const match = source.match(regex)
777
+ if (!match) return null
778
+
779
+ return splitTopLevelByDelimiter(match[1], ",")
780
+ .map((segment) => segment.trim())
781
+ .filter(Boolean)
782
+ }
783
+
784
+ function sizeOfValue(value: any, context: ExpressionContext): number {
785
+ if (typeof value === "string" || Array.isArray(value)) return value.length
786
+ if (value instanceof Uint8Array || Buffer.isBuffer(value)) return value.length
787
+ if (value instanceof Set) return value.size
788
+ if (value && typeof value === "object") return Object.keys(value).length
789
+
790
+ throw new NotSupportedError({
791
+ method: context.method,
792
+ featurePath: "ConditionExpression.size",
793
+ reason: "size supports string, binary, list, set, and map values only.",
794
+ })
795
+ }
796
+
797
+ function containsValue(container: any, expected: any): boolean {
798
+ if (typeof container === "string") return container.includes(String(expected))
799
+ if (Array.isArray(container)) return container.some((entry) => entry === expected)
800
+ if (container instanceof Set) return container.has(expected)
801
+ if (container && typeof container === "object") {
802
+ return Object.prototype.hasOwnProperty.call(container, String(expected))
803
+ }
804
+
805
+ return false
806
+ }
807
+
808
+ function attributeMatchesType(value: any, expectedType: string): boolean {
809
+ const t = expectedType.toUpperCase()
810
+
811
+ if (t === "S") return typeof value === "string"
812
+ if (t === "N") return typeof value === "number"
813
+ if (t === "BOOL") return typeof value === "boolean"
814
+ if (t === "NULL") return value === null
815
+ if (t === "L") return Array.isArray(value)
816
+ if (t === "M") return !!value && typeof value === "object" && !Array.isArray(value) && !Buffer.isBuffer(value) && !(value instanceof Set)
817
+ if (t === "B") return Buffer.isBuffer(value) || value instanceof Uint8Array
818
+
819
+ if (value instanceof Set) {
820
+ const values = [...value.values()]
821
+ if (t === "SS") return values.every((entry) => typeof entry === "string")
822
+ if (t === "NS") return values.every((entry) => typeof entry === "number")
823
+ if (t === "BS")
824
+ return values.every(
825
+ (entry) => Buffer.isBuffer(entry) || entry instanceof Uint8Array
826
+ )
827
+ }
828
+
829
+ return false
830
+ }