fluxor-cloud-db 1.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.
@@ -0,0 +1,859 @@
1
+ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
2
+ import { Service } from "fluxor-cloud";
3
+ import { DynamoConfig } from "./dynamo.types";
4
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
5
+ import {
6
+ GetCommand,
7
+ QueryCommand,
8
+ ScanCommand,
9
+ PutCommand,
10
+ BatchWriteCommand,
11
+ UpdateCommand,
12
+ DeleteCommand
13
+ } from "@aws-sdk/lib-dynamodb";
14
+ import { DatabaseAdapter } from "../contracts/database-adapter";
15
+ import { DatabaseAdapterError } from "../types/error";
16
+ import { WhereClause, QueryOptions, PaginatedResult, BatchResult, UpdateClause, QueryOperator } from "../types/query";
17
+
18
+ // Source: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ReservedWords.html
19
+ const RESERVED_KEYWORDS = new Set([
20
+ 'ABORT', 'ABSOLUTE', 'ACTION', 'ADD', 'AFTER', 'AGENT', 'ALL', 'ALLOCATE', 'ALTER', 'ANALYZE',
21
+ 'AND', 'ANY', 'ARCHIVE', 'ARE', 'ARRAY', 'AS', 'ASC', 'ASCII', 'ASENSITIVE', 'ASSERTION',
22
+ 'ASYMMETRIC', 'AT', 'ATOMIC', 'ATTACH', 'ATTRIBUTE', 'AUTH', 'AUTHORIZATION', 'AUTHORIZE',
23
+ 'AUTO', 'AVG', 'BACK', 'BACKUP', 'BASE', 'BATCH', 'BEFORE', 'BEGIN', 'BETWEEN', 'BIGINT',
24
+ 'BINARY', 'BIT', 'BLOB', 'BLOCK', 'BOOLEAN', 'BOTH', 'BREADTH', 'BUCKET', 'BULK', 'BY',
25
+ 'CALL', 'CALLED', 'CALLING', 'CAPACITY', 'CASCADE', 'CASCADED', 'CASE', 'CAST', 'CATALOG',
26
+ 'CHAR', 'CHARACTER', 'CHECK', 'CLASS', 'CLOB', 'CLOSE', 'CLUSTER', 'CLUSTERED', 'CLUSTERING',
27
+ 'CLUSTERS', 'COALESCE', 'COLLATE', 'COLLATION', 'COLLECTION', 'COLUMN', 'COLUMNS', 'COMBINE',
28
+ 'COMMENT', 'COMMIT', 'COMPACT', 'COMPILE', 'COMPRESS', 'CONDITION', 'CONFLICT', 'CONNECT',
29
+ 'CONNECTION', 'CONSISTENCY', 'CONSISTENT', 'CONSTRAINT', 'CONSTRAINTS', 'CONSTRUCTOR',
30
+ 'CONSUMED', 'CONTINUE', 'CONVERT', 'COPY', 'CORRESPONDING', 'COUNT', 'COUNTER', 'CREATE',
31
+ 'CROSS', 'CUBE', 'CURRENT', 'CURSOR', 'CYCLE', 'DATA', 'DATABASE', 'DATE', 'DATETIME',
32
+ 'DAY', 'DEALLOCATE', 'DEC', 'DECIMAL', 'DECLARE', 'DEFAULT', 'DEFERRABLE', 'DEFERRED',
33
+ 'DEFINE', 'DEFINED', 'DEFINITION', 'DELETE', 'DELIMITED', 'DEPTH', 'DEREF', 'DESC',
34
+ 'DESCRIBE', 'DESCRIPTOR', 'DETACH', 'DETERMINISTIC', 'DIAGNOSTICS', 'DIRECTORIES', 'DISABLE',
35
+ 'DISCONNECT', 'DISTINCT', 'DISTRIBUTE', 'DO', 'DOMAIN', 'DOUBLE', 'DROP', 'DUMP', 'DURATION',
36
+ 'DYNAMIC', 'EACH', 'ELEMENT', 'ELSE', 'ELSEIF', 'EMPTY', 'ENABLE', 'END', 'EQUAL', 'EQUALS',
37
+ 'ERROR', 'ESCAPE', 'EVALUATED', 'EXCEPT', 'EXCEPTION', 'EXCEPTIONS', 'EXCLUSIVE', 'EXEC',
38
+ 'EXECUTE', 'EXISTS', 'EXIT', 'EXPLAIN', 'EXPLODE', 'EXPORT', 'EXPRESSION', 'EXTENDED',
39
+ 'EXTERNAL', 'EXTRACT', 'FAIL', 'FALSE', 'FAMILY', 'FETCH', 'FIELDS', 'FILE', 'FILTER',
40
+ 'FILTERING', 'FINAL', 'FINISH', 'FIRST', 'FIXED', 'FLATTERN', 'FLOAT', 'FOR', 'FORCE',
41
+ 'FOREIGN', 'FORMAT', 'FORWARD', 'FOUND', 'FREE', 'FROM', 'FULL', 'FUNCTION', 'FUNCTIONS',
42
+ 'GENERAL', 'GENERATE', 'GET', 'GLOB', 'GO', 'GOTO', 'GRANT', 'GREATER', 'GROUP', 'GROUPING',
43
+ 'HANDLER', 'HASH', 'HAVE', 'HAVING', 'HEAP', 'HIDDEN', 'HOLD', 'HOUR', 'IDENTIFIED', 'IF',
44
+ 'IGNORE', 'IMMEDIATE', 'IMPORT', 'IN', 'INCLUDING', 'INCLUSIVE', 'INCREMENT', 'INDETERMINATE',
45
+ 'INDEX', 'INDEXED', 'INDEXES', 'INDICATOR', 'INFINITE', 'INITIALLY', 'INLINE', 'INNER',
46
+ 'INNTER', 'INOUT', 'INPUT', 'INSENSITIVE', 'INSERT', 'INSTEAD', 'INT', 'INTEGER', 'INTERSECT',
47
+ 'INTERVAL', 'INTO', 'INVALIDATE', 'IS', 'ISOLATION', 'ITEM', 'ITEMS', 'ITERATE', 'JOIN',
48
+ 'KEY', 'KEYS', 'LAG', 'LANGUAGE', 'LARGE', 'LAST', 'LATERAL', 'LEAD', 'LEADING', 'LEAVE',
49
+ 'LEFT', 'LENGTH', 'LESS', 'LEVEL', 'LIKE', 'LIMIT', 'LIST', 'LOAD', 'LOCAL', 'LOCALTIME',
50
+ 'LOCALTIMESTAMP', 'LOCATION', 'LOCATOR', 'LOCK', 'LOCKS', 'LOG', 'LOGED', 'LONG', 'LOOP',
51
+ 'LOWER', 'MAP', 'MATCH', 'MATERIALIZED', 'MAX', 'MAXLEN', 'MEMBER', 'MERGE', 'METHOD', 'MIN',
52
+ 'MINUS', 'MINUTE', 'MISSING', 'MOD', 'MODE', 'MODIFIES', 'MODIFY', 'MODULE', 'MONTH',
53
+ 'MULTI', 'MULTISET', 'NAME', 'NAMES', 'NATIONAL', 'NATURAL', 'NCHAR', 'NCLOB', 'NEW',
54
+ 'NEXT', 'NO', 'NONE', 'NOT', 'NULL', 'NULLIF', 'NUMBER', 'NUMERIC', 'OBJECT', 'OF', 'OFFLINE',
55
+ 'OFFSET', 'OLD', 'ON', 'ONLINE', 'ONLY', 'OPEN', 'OPTION', 'OR', 'ORDER', 'ORDINALITY',
56
+ 'OUT', 'OUTER', 'OUTPUT', 'OVER', 'OVERLAPS', 'OVERRIDE', 'OWNER', 'PAD', 'PARALLEL',
57
+ 'PARAMETER', 'PARAMETERS', 'PARTIAL', 'PARTITION', 'PARTITIONED', 'PARTITIONS', 'PATH',
58
+ 'PERCENT', 'PERCENTILE', 'PERMISSION', 'PERMISSIONS', 'PIPE', 'PIPELINED', 'PLAN', 'POOL',
59
+ 'POSITION', 'PRECISION', 'PREPARE', 'PRESERVE', 'PRIMARY', 'PRIOR', 'PRIVATE', 'PRIVILEGES',
60
+ 'PROCEDURE', 'PROCESSED', 'PROJECT', 'PROJECTION', 'PROPERTY', 'PROVISIONING', 'PUBLIC',
61
+ 'PUT', 'QUERY', 'QUIT', 'QUORUM', 'RAISE', 'RANDOM', 'RANGE', 'RANK', 'RAW', 'READ',
62
+ 'READS', 'REAL', 'REBUILD', 'RECORD', 'RECURSIVE', 'REDUCE', 'REF', 'REFERENCE', 'REFERENCES',
63
+ 'REFERENCING', 'REGEXP', 'REINDEX', 'RELATIVE', 'RELEASE', 'REMAINDER', 'RENAME', 'REPEAT',
64
+ 'REPLACE', 'REQUEST', 'RESET', 'RESIGNAL', 'RESTRICT', 'RESULT', 'RETURN', 'RETURNING',
65
+ 'RETURNS', 'REVERSE', 'REVOKE', 'RIGHT', 'ROLE', 'ROLES', 'ROLLBACK', 'ROLLUP', 'ROUTINE',
66
+ 'ROW', 'ROWS', 'RULE', 'RULES', 'SAMPLE', 'SATISFIES', 'SAVE', 'SAVEPOINT', 'SCAN', 'SCHEMA',
67
+ 'SCOPE', 'SCROLL', 'SEARCH', 'SECOND', 'SECTION', 'SEGMENT', 'SELECT', 'SELF', 'SEMI',
68
+ 'SENSITIVE', 'SEPARATE', 'SEQUENCE', 'SERIALIZABLE', 'SESSION', 'SET', 'SETS', 'SHARD',
69
+ 'SHARE', 'SHARED', 'SHORT', 'SHOW', 'SIGNAL', 'SIMILAR', 'SIZE', 'SKEWED', 'SOME', 'SOURCE',
70
+ 'SPACE', 'SPACES', 'SPARSE', 'SPECIFIC', 'SPECIFICTYPE', 'SQL', 'SQLCODE', 'SQLERROR',
71
+ 'SQLEXCEPTION', 'SQLSTATE', 'SQLWARNING', 'START', 'STATE', 'STATIC', 'STATUS', 'STORAGE',
72
+ 'STORE', 'STORED', 'STREAM', 'STRING', 'STRUCT', 'STYLE', 'SUB', 'SUBMULTISET', 'SUBPARTITION',
73
+ 'SUBSTRING', 'SUBTYPE', 'SUM', 'SUPER', 'SYMMETRIC', 'SYNONYM', 'SYSTEM', 'TABLE', 'TABLESAMPLE',
74
+ 'TEMP', 'TEMPORARY', 'TERMINATED', 'TEXT', 'THAN', 'THEN', 'THROUGHPUT', 'TIME', 'TIMESTAMP',
75
+ 'TIMEZONE', 'TINYINT', 'TO', 'TOKEN', 'TOTAL', 'TOUCH', 'TRAILING', 'TRANSACTION', 'TRANSFORM',
76
+ 'TRANSLATE', 'TRANSLATION', 'TREAT', 'TRIGGER', 'TRIM', 'TRUE', 'TRUNCATE', 'TTL', 'TUPLE',
77
+ 'TYPE', 'UNDER', 'UNDO', 'UNION', 'UNIQUE', 'UNIT', 'UNKNOWN', 'UNLOGGED', 'UNNEST', 'UNPROCESSED',
78
+ 'UNSIGNED', 'UNTIL', 'UPDATE', 'UPPER', 'URL', 'USAGE', 'USE', 'USER', 'USERS', 'USING',
79
+ 'UUID', 'VACUUM', 'VALUE', 'VALUED', 'VALUES', 'VARCHAR', 'VARIABLE', 'VARIANCE', 'VARINT',
80
+ 'VARYING', 'VIEW', 'VIEWS', 'VIRTUAL', 'VOID', 'WAIT', 'WHEN', 'WHENEVER', 'WHERE', 'WHILE',
81
+ 'WINDOW', 'WITH', 'WITHIN', 'WITHOUT', 'WORK', 'WRAPPED', 'WRITE', 'YEAR', 'ZONE',
82
+ // Common attribute names that often conflict
83
+ 'NAME', 'STATUS', 'SCORE', 'PRICE', 'CATEGORY'
84
+ ]);
85
+
86
+ @Service()
87
+ export class DynamoService implements DatabaseAdapter {
88
+ private client: DynamoDBDocumentClient | undefined;
89
+ private dynamoDBClient: DynamoDBClient | undefined;
90
+ private isConnectedFlag = false;
91
+ private config: DynamoConfig | undefined;
92
+
93
+ public setConfig(config: DynamoConfig): void {
94
+ this.config = config;
95
+ }
96
+
97
+ connect(): Promise<void> {
98
+ if (!this.config) {
99
+ return Promise.reject(new DatabaseAdapterError(
100
+ "No configuration provided. Call setConfig() first.",
101
+ "INVALID_CONFIG"
102
+ ));
103
+ }
104
+
105
+ return Promise.resolve().then(() => {
106
+ try {
107
+ this.validateConfig(this.config!);
108
+
109
+ this.dynamoDBClient = new DynamoDBClient({
110
+ region: this.config!.region,
111
+ endpoint: this.config!.endpoint,
112
+ credentials: this.config!.accessKeyId
113
+ ? {
114
+ accessKeyId: this.config!.accessKeyId,
115
+ secretAccessKey: this.config!.secretAccessKey!
116
+ }
117
+ : undefined
118
+ });
119
+
120
+ this.client = DynamoDBDocumentClient.from(this.dynamoDBClient);
121
+ this.isConnectedFlag = true;
122
+ } catch (error) {
123
+ this.isConnectedFlag = false;
124
+
125
+ if (error instanceof DatabaseAdapterError) {
126
+ throw error;
127
+ }
128
+
129
+ if (error instanceof Error) {
130
+ throw new DatabaseAdapterError(
131
+ `Failed to connect to DynamoDB: ${error.message}`,
132
+ "CONNECTION_FAILED"
133
+ );
134
+ }
135
+
136
+ throw new DatabaseAdapterError(
137
+ "Failed to connect to DynamoDB: Unknown error",
138
+ "CONNECTION_FAILED"
139
+ );
140
+ }
141
+ });
142
+ }
143
+
144
+ async disconnect(): Promise<void> {
145
+ try {
146
+ if (this.dynamoDBClient) {
147
+ this.dynamoDBClient.destroy();
148
+ }
149
+ this.isConnectedFlag = false;
150
+ this.client = undefined;
151
+ this.dynamoDBClient = undefined;
152
+ } catch (error) {
153
+ console.warn("Error disconnecting from DynamoDB:", error);
154
+ this.isConnectedFlag = false;
155
+ this.client = undefined;
156
+ this.dynamoDBClient = undefined;
157
+ }
158
+ }
159
+
160
+ isConnected(): boolean {
161
+ return this.isConnectedFlag && this.client !== undefined;
162
+ }
163
+
164
+ async healthCheck(): Promise<boolean> {
165
+ try {
166
+ const client = this.getClient();
167
+ await client.send(new ScanCommand({ TableName: "fake_table", Limit: 1 }));
168
+ return true;
169
+ } catch (error) {
170
+ if (error instanceof Error && error.message.includes("ResourceNotFoundException")) {
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+ }
176
+
177
+ async selectOne<T = Record<string, any>>(
178
+ tableName: string,
179
+ where: WhereClause,
180
+ options?: QueryOptions
181
+ ): Promise<T | null> {
182
+ try {
183
+ const client = this.getClient();
184
+ const key = this.buildKey(where);
185
+
186
+ const params: any = {
187
+ TableName: tableName,
188
+ Key: key
189
+ };
190
+
191
+ if (options?.projection) {
192
+ const { projection, names } = this.buildProjection(options.projection);
193
+ params.ProjectionExpression = projection;
194
+ if (Object.keys(names).length > 0) {
195
+ params.ExpressionAttributeNames = names;
196
+ }
197
+ }
198
+
199
+ const command = new GetCommand(params);
200
+ const result = await client.send(command);
201
+ return (result.Item as T) || null;
202
+ } catch (error) {
203
+ throw this.handleError(error, "selectOne");
204
+ }
205
+ }
206
+
207
+ async selectMany<T = Record<string, any>>(
208
+ tableName: string,
209
+ where?: WhereClause,
210
+ options?: QueryOptions
211
+ ): Promise<PaginatedResult<T>> {
212
+ try {
213
+ const client = this.getClient();
214
+ let command;
215
+ let items: T[] = [];
216
+ let scannedCount = 0;
217
+
218
+ // Check if where clause contains only key attributes
219
+ const isKeyQuery = where && this.isKeyQuery(where);
220
+
221
+ if (isKeyQuery) {
222
+ const { keyConditionExpression, expressionAttributeValues, names } = this.buildKeyConditionExpression(where!);
223
+
224
+ const params: any = {
225
+ TableName: tableName,
226
+ KeyConditionExpression: keyConditionExpression,
227
+ ExpressionAttributeValues: expressionAttributeValues,
228
+ Limit: options?.limit,
229
+ ScanIndexForward: options?.orderBy?.[0]?.direction === "DESC" ? false : true,
230
+ ConsistentRead: options?.consistent
231
+ };
232
+
233
+ if (Object.keys(names).length > 0) {
234
+ params.ExpressionAttributeNames = names;
235
+ }
236
+
237
+ if (options?.projection) {
238
+ const { projection, names: projNames } = this.buildProjection(options.projection);
239
+ params.ProjectionExpression = projection;
240
+ if (Object.keys(projNames).length > 0) {
241
+ params.ExpressionAttributeNames = { ...params.ExpressionAttributeNames, ...projNames };
242
+ }
243
+ }
244
+
245
+ command = new QueryCommand(params);
246
+ } else {
247
+ // Use Scan for non-key queries or no where clause
248
+ const params: any = {
249
+ TableName: tableName,
250
+ Limit: options?.limit
251
+ };
252
+
253
+ if (where && Object.keys(where).length > 0) {
254
+ const { filterExpression, expressionAttributeValues, names } = this.buildFilterExpression(where);
255
+ params.FilterExpression = filterExpression;
256
+ params.ExpressionAttributeValues = expressionAttributeValues;
257
+ if (Object.keys(names).length > 0) {
258
+ params.ExpressionAttributeNames = names;
259
+ }
260
+ }
261
+
262
+ if (options?.projection) {
263
+ const { projection, names: projNames } = this.buildProjection(options.projection);
264
+ params.ProjectionExpression = projection;
265
+ if (Object.keys(projNames).length > 0) {
266
+ params.ExpressionAttributeNames = { ...params.ExpressionAttributeNames, ...projNames };
267
+ }
268
+ }
269
+
270
+ command = new ScanCommand(params);
271
+ }
272
+
273
+ const result = await client.send(command);
274
+ items = (result.Items as T[]) || [];
275
+ scannedCount = result.ScannedCount || 0;
276
+
277
+ // Apply client-side ordering (only meaningful for Scan; Query uses ScanIndexForward)
278
+ if (!isKeyQuery && options?.orderBy?.length) {
279
+ const { field, direction } = options.orderBy[0];
280
+ items = [...items].sort((a: any, b: any) => {
281
+ if (a[field] < b[field]) return direction === "DESC" ? 1 : -1;
282
+ if (a[field] > b[field]) return direction === "DESC" ? -1 : 1;
283
+ return 0;
284
+ });
285
+ }
286
+
287
+ return {
288
+ items,
289
+ total: items.length,
290
+ hasMore: (result.LastEvaluatedKey !== undefined),
291
+ nextOffset: options?.offset ? options.offset + items.length : undefined
292
+ };
293
+ } catch (error) {
294
+ throw this.handleError(error, "selectMany");
295
+ }
296
+ }
297
+
298
+ async createOne<T = Record<string, any>>(
299
+ tableName: string,
300
+ data: Record<string, any>
301
+ ): Promise<T> {
302
+ try {
303
+ const client = this.getClient();
304
+
305
+ const command = new PutCommand({
306
+ TableName: tableName,
307
+ Item: data
308
+ });
309
+
310
+ await client.send(command);
311
+ return data as T;
312
+ } catch (error) {
313
+ throw this.handleError(error, "createOne");
314
+ }
315
+ }
316
+
317
+ async createMany<T = Record<string, any>>(
318
+ tableName: string,
319
+ data: Record<string, any>[],
320
+ options?: { stopOnError?: boolean }
321
+ ): Promise<{ items: T[]; result: BatchResult }> {
322
+ try {
323
+ if (!Array.isArray(data)) {
324
+ throw new DatabaseAdapterError(
325
+ "createMany requires an array of records",
326
+ "INVALID_INPUT"
327
+ );
328
+ }
329
+
330
+ const client = this.getClient();
331
+ const batchSize = 25;
332
+ let successful = 0;
333
+ let failed = 0;
334
+ const errors: { index: number; error: string }[] = [];
335
+ const items: T[] = [];
336
+
337
+ for (let i = 0; i < data.length; i += batchSize) {
338
+ const batch = data.slice(i, i + batchSize);
339
+ const requestItems = batch.map(item => ({
340
+ PutRequest: { Item: item }
341
+ }));
342
+
343
+ try {
344
+ const command = new BatchWriteCommand({
345
+ RequestItems: {
346
+ [tableName]: requestItems
347
+ }
348
+ });
349
+
350
+ await client.send(command);
351
+ successful += batch.length;
352
+ items.push(...(batch as T[]));
353
+ } catch (error) {
354
+ if (options?.stopOnError) {
355
+ throw error;
356
+ }
357
+ failed += batch.length;
358
+ batch.forEach((_, index) => {
359
+ errors.push({
360
+ index: i + index,
361
+ error: error instanceof Error ? error.message : "Unknown error"
362
+ });
363
+ });
364
+ }
365
+ }
366
+
367
+ return {
368
+ items,
369
+ result: { successful, failed, errors: errors.length > 0 ? errors : undefined }
370
+ };
371
+ } catch (error) {
372
+ throw this.handleError(error, "createMany");
373
+ }
374
+ }
375
+
376
+ async updateOne<T = Record<string, any>>(
377
+ tableName: string,
378
+ update: UpdateClause,
379
+ where: WhereClause
380
+ ): Promise<T> {
381
+ try {
382
+ const client = this.getClient();
383
+ const key = this.buildKey(where);
384
+
385
+ // Verify the item exists before updating
386
+ const existing = await client.send(new GetCommand({ TableName: tableName, Key: key }));
387
+ if (!existing.Item) {
388
+ throw new DatabaseAdapterError(
389
+ `Record not found`,
390
+ "NOT_FOUND"
391
+ );
392
+ }
393
+
394
+ const { updateExpression, expressionAttributeValues, names } = this.buildUpdateExpression(update);
395
+
396
+ const params: any = {
397
+ TableName: tableName,
398
+ Key: key,
399
+ UpdateExpression: updateExpression,
400
+ ReturnValues: "ALL_NEW"
401
+ };
402
+
403
+ if (Object.keys(expressionAttributeValues).length > 0) {
404
+ params.ExpressionAttributeValues = expressionAttributeValues;
405
+ }
406
+
407
+ if (Object.keys(names).length > 0) {
408
+ params.ExpressionAttributeNames = names;
409
+ }
410
+
411
+ const command = new UpdateCommand(params);
412
+ const result = await client.send(command);
413
+ return result.Attributes as T;
414
+ } catch (error) {
415
+ throw this.handleError(error, "updateOne");
416
+ }
417
+ }
418
+
419
+ async updateMany<T = Record<string, any>>(
420
+ tableName: string,
421
+ update: UpdateClause,
422
+ where?: WhereClause,
423
+ options?: { limit?: number }
424
+ ): Promise<{ items: T[]; result: BatchResult }> {
425
+ try {
426
+ const selectResult = await this.selectMany(
427
+ tableName,
428
+ where,
429
+ { limit: options?.limit }
430
+ );
431
+
432
+ let successful = 0;
433
+ let failed = 0;
434
+ const errors: { index: number; error: string }[] = [];
435
+ const items: T[] = [];
436
+
437
+ for (let i = 0; i < selectResult.items.length; i++) {
438
+ try {
439
+ const item = selectResult.items[i];
440
+ const key = this.extractKey(item as Record<string, any>);
441
+ const updated = await this.updateOne(tableName, update, key);
442
+ successful++;
443
+ items.push(updated as T);
444
+ } catch (error) {
445
+ failed++;
446
+ errors.push({
447
+ index: i,
448
+ error: error instanceof Error ? error.message : "Unknown error"
449
+ });
450
+ }
451
+ }
452
+
453
+ return {
454
+ items,
455
+ result: { successful, failed, errors: errors.length > 0 ? errors : undefined }
456
+ };
457
+ } catch (error) {
458
+ throw this.handleError(error, "updateMany");
459
+ }
460
+ }
461
+
462
+ async deleteOne(
463
+ tableName: string,
464
+ where: WhereClause
465
+ ): Promise<{ success: boolean; deletedCount: number }> {
466
+ try {
467
+ const client = this.getClient();
468
+ const key = this.buildKey(where);
469
+
470
+ // Check if key exists first
471
+ const getResult = await client.send(new GetCommand({
472
+ TableName: tableName,
473
+ Key: key
474
+ }));
475
+
476
+ if (!getResult.Item) {
477
+ return { success: false, deletedCount: 0 };
478
+ }
479
+
480
+ const command = new DeleteCommand({
481
+ TableName: tableName,
482
+ Key: key
483
+ });
484
+
485
+ await client.send(command);
486
+ return { success: true, deletedCount: 1 };
487
+ } catch (error) {
488
+ throw this.handleError(error, "deleteOne");
489
+ }
490
+ }
491
+
492
+ async deleteMany(
493
+ tableName: string,
494
+ where?: WhereClause,
495
+ options?: { limit?: number }
496
+ ): Promise<{ success: boolean; deletedCount: number; result: BatchResult }> {
497
+ try {
498
+ const selectResult = await this.selectMany(
499
+ tableName,
500
+ where,
501
+ { limit: options?.limit }
502
+ );
503
+
504
+ let successful = 0;
505
+ let failed = 0;
506
+ const errors: { index: number; error: string }[] = [];
507
+
508
+ for (let i = 0; i < selectResult.items.length; i++) {
509
+ try {
510
+ const item = selectResult.items[i];
511
+ const key = this.extractKey(item as Record<string, any>);
512
+ await this.deleteOne(tableName, key);
513
+ successful++;
514
+ } catch (error) {
515
+ failed++;
516
+ errors.push({
517
+ index: i,
518
+ error: error instanceof Error ? error.message : "Unknown error"
519
+ });
520
+ }
521
+ }
522
+
523
+ return {
524
+ success: failed === 0,
525
+ deletedCount: successful,
526
+ result: { successful, failed, errors: errors.length > 0 ? errors : undefined }
527
+ };
528
+ } catch (error) {
529
+ throw this.handleError(error, "deleteMany");
530
+ }
531
+ }
532
+
533
+ async executeRaw<T = any>(query: string, params?: Record<string, any>): Promise<T> {
534
+ throw new DatabaseAdapterError(
535
+ "executeRaw is not implemented for DynamoDB. Use native DynamoDB syntax instead.",
536
+ "NOT_IMPLEMENTED"
537
+ );
538
+ }
539
+
540
+ // ============ Private Helper Methods ============
541
+
542
+ private validateConfig(config: DynamoConfig): void {
543
+ if (!config) {
544
+ throw new DatabaseAdapterError(
545
+ "DynamoDB configuration is required",
546
+ "INVALID_CONFIG"
547
+ );
548
+ }
549
+
550
+ if (!config.region || config.region.trim() === "") {
551
+ throw new DatabaseAdapterError(
552
+ "DynamoDB region is required",
553
+ "MISSING_REGION"
554
+ );
555
+ }
556
+
557
+ if (config.accessKeyId && !config.secretAccessKey) {
558
+ throw new DatabaseAdapterError(
559
+ "secretAccessKey is required when accessKeyId is provided",
560
+ "INVALID_CREDENTIALS"
561
+ );
562
+ }
563
+
564
+ if (config.secretAccessKey && !config.accessKeyId) {
565
+ throw new DatabaseAdapterError(
566
+ "accessKeyId is required when secretAccessKey is provided",
567
+ "INVALID_CREDENTIALS"
568
+ );
569
+ }
570
+ }
571
+
572
+ private getClient(): DynamoDBDocumentClient {
573
+ if (!this.isConnectedFlag || !this.client) {
574
+ throw new DatabaseAdapterError(
575
+ "DynamoDB client is not connected. Call connect() first.",
576
+ "NOT_CONNECTED"
577
+ );
578
+ }
579
+
580
+ return this.client;
581
+ }
582
+
583
+ private buildKey(where: WhereClause): Record<string, any> {
584
+ const key: Record<string, any> = {};
585
+
586
+ for (const [field, condition] of Object.entries(where)) {
587
+ if (condition == null) {
588
+ continue;
589
+ }
590
+ if (typeof condition === "object" && "value" in condition) {
591
+ key[field] = condition.value;
592
+ } else {
593
+ key[field] = condition;
594
+ }
595
+ }
596
+
597
+ return key;
598
+ }
599
+
600
+ private extractKey(item: Record<string, any>): WhereClause {
601
+ return {
602
+ id: {
603
+ operator: "=",
604
+ value: item.id
605
+ }
606
+ };
607
+ }
608
+
609
+ private isKeyQuery(where: WhereClause): boolean {
610
+ // Only route to Query when filtering by 'id' with an operator supported by KeyConditionExpression.
611
+ // IN and NOT_IN are not valid in KeyConditionExpression — fall back to Scan for those.
612
+ return Object.keys(where).some(key => {
613
+ if (key !== 'id') return false;
614
+ const cond = (where as any)[key];
615
+ if (typeof cond === 'object' && 'operator' in cond) {
616
+ const unsupportedInKey = ['IN', 'NOT_IN'];
617
+ return !unsupportedInKey.includes(cond.operator);
618
+ }
619
+ return true;
620
+ });
621
+ }
622
+
623
+ private buildProjection(fields: string[]): { projection: string; names: Record<string, string> } {
624
+ const names: Record<string, string> = {};
625
+ const projectionParts: string[] = [];
626
+
627
+ for (const field of fields) {
628
+ if (RESERVED_KEYWORDS.has(field.toUpperCase())) {
629
+ const placeholder = `#${field}`;
630
+ names[placeholder] = field;
631
+ projectionParts.push(placeholder);
632
+ } else {
633
+ projectionParts.push(field);
634
+ }
635
+ }
636
+
637
+ return {
638
+ projection: projectionParts.join(", "),
639
+ names
640
+ };
641
+ }
642
+
643
+ private buildKeyConditionExpression(where: WhereClause): {
644
+ keyConditionExpression: string;
645
+ expressionAttributeValues: Record<string, any>;
646
+ names: Record<string, string>;
647
+ } {
648
+ const parts: string[] = [];
649
+ const values: Record<string, any> = {};
650
+ const names: Record<string, string> = {};
651
+ let valueIndex = 0;
652
+
653
+ for (const [field, condition] of Object.entries(where)) {
654
+ const valueKey = `:val${valueIndex}`;
655
+ const cond = condition as any;
656
+ const fieldPlaceholder = RESERVED_KEYWORDS.has(field.toUpperCase()) ? `#${field}` : field;
657
+
658
+ if (RESERVED_KEYWORDS.has(field.toUpperCase())) {
659
+ names[fieldPlaceholder] = field;
660
+ }
661
+
662
+ if (typeof cond === "object" && "operator" in cond) {
663
+ const operator = cond.operator as QueryOperator;
664
+
665
+ switch (operator) {
666
+ case "=":
667
+ parts.push(`${fieldPlaceholder} = ${valueKey}`);
668
+ values[valueKey] = cond.value;
669
+ break;
670
+ case "!=":
671
+ parts.push(`${fieldPlaceholder} <> ${valueKey}`);
672
+ values[valueKey] = cond.value;
673
+ break;
674
+ case ">":
675
+ parts.push(`${fieldPlaceholder} > ${valueKey}`);
676
+ values[valueKey] = cond.value;
677
+ break;
678
+ case ">=":
679
+ parts.push(`${fieldPlaceholder} >= ${valueKey}`);
680
+ values[valueKey] = cond.value;
681
+ break;
682
+ case "<":
683
+ parts.push(`${fieldPlaceholder} < ${valueKey}`);
684
+ values[valueKey] = cond.value;
685
+ break;
686
+ case "<=":
687
+ parts.push(`${fieldPlaceholder} <= ${valueKey}`);
688
+ values[valueKey] = cond.value;
689
+ break;
690
+ case "BEGINS_WITH":
691
+ parts.push(`begins_with(${fieldPlaceholder}, ${valueKey})`);
692
+ values[valueKey] = cond.value;
693
+ break;
694
+ case "IN":
695
+ const inValues = (cond.value as any[]).map((v, i) => `:inval${valueIndex}_${i}`);
696
+ (cond.value as any[]).forEach((v, i) => {
697
+ values[`:inval${valueIndex}_${i}`] = v;
698
+ });
699
+ parts.push(`${fieldPlaceholder} IN (${inValues.join(", ")})`);
700
+ valueIndex += cond.value.length - 1;
701
+ break;
702
+ default:
703
+ throw new DatabaseAdapterError(
704
+ `Unsupported operator: ${operator}`,
705
+ "UNSUPPORTED_OPERATOR"
706
+ );
707
+ }
708
+ } else {
709
+ parts.push(`${fieldPlaceholder} = ${valueKey}`);
710
+ values[valueKey] = cond;
711
+ }
712
+
713
+ valueIndex++;
714
+ }
715
+
716
+ return {
717
+ keyConditionExpression: parts.join(" AND "),
718
+ expressionAttributeValues: Object.keys(values).length > 0 ? values : {},
719
+ names
720
+ };
721
+ }
722
+
723
+ private buildFilterExpression(where: WhereClause): {
724
+ filterExpression: string;
725
+ expressionAttributeValues: Record<string, any>;
726
+ names: Record<string, string>;
727
+ } {
728
+ const parts: string[] = [];
729
+ const values: Record<string, any> = {};
730
+ const names: Record<string, string> = {};
731
+ let valueIndex = 0;
732
+
733
+ for (const [field, condition] of Object.entries(where)) {
734
+ const valueKey = `:val${valueIndex}`;
735
+ const cond = condition as any;
736
+ const fieldPlaceholder = RESERVED_KEYWORDS.has(field.toUpperCase()) ? `#${field}` : field;
737
+
738
+ if (RESERVED_KEYWORDS.has(field.toUpperCase())) {
739
+ names[fieldPlaceholder] = field;
740
+ }
741
+
742
+ if (typeof cond === "object" && "operator" in cond) {
743
+ const operator = cond.operator as QueryOperator;
744
+
745
+ switch (operator) {
746
+ case "=":
747
+ parts.push(`${fieldPlaceholder} = ${valueKey}`);
748
+ values[valueKey] = cond.value;
749
+ break;
750
+ case "!=":
751
+ parts.push(`${fieldPlaceholder} <> ${valueKey}`);
752
+ values[valueKey] = cond.value;
753
+ break;
754
+ case ">":
755
+ parts.push(`${fieldPlaceholder} > ${valueKey}`);
756
+ values[valueKey] = cond.value;
757
+ break;
758
+ case ">=":
759
+ parts.push(`${fieldPlaceholder} >= ${valueKey}`);
760
+ values[valueKey] = cond.value;
761
+ break;
762
+ case "<":
763
+ parts.push(`${fieldPlaceholder} < ${valueKey}`);
764
+ values[valueKey] = cond.value;
765
+ break;
766
+ case "<=":
767
+ parts.push(`${fieldPlaceholder} <= ${valueKey}`);
768
+ values[valueKey] = cond.value;
769
+ break;
770
+ case "BEGINS_WITH":
771
+ parts.push(`begins_with(${fieldPlaceholder}, ${valueKey})`);
772
+ values[valueKey] = cond.value;
773
+ break;
774
+ case "IN":
775
+ const inValues = (cond.value as any[]).map((v, i) => `:inval${valueIndex}_${i}`);
776
+ (cond.value as any[]).forEach((v, i) => {
777
+ values[`:inval${valueIndex}_${i}`] = v;
778
+ });
779
+ parts.push(`${fieldPlaceholder} IN (${inValues.join(", ")})`);
780
+ valueIndex += cond.value.length - 1;
781
+ break;
782
+ case "NOT_IN":
783
+ const notInValues = (cond.value as any[]).map((v, i) => `:notinval${valueIndex}_${i}`);
784
+ (cond.value as any[]).forEach((v, i) => {
785
+ values[`:notinval${valueIndex}_${i}`] = v;
786
+ });
787
+ parts.push(`NOT ${fieldPlaceholder} IN (${notInValues.join(", ")})`);
788
+ valueIndex += cond.value.length - 1;
789
+ break;
790
+ default:
791
+ throw new DatabaseAdapterError(
792
+ `Unsupported operator: ${operator}`,
793
+ "UNSUPPORTED_OPERATOR"
794
+ );
795
+ }
796
+ } else {
797
+ parts.push(`${fieldPlaceholder} = ${valueKey}`);
798
+ values[valueKey] = cond;
799
+ }
800
+
801
+ valueIndex++;
802
+ }
803
+
804
+ return {
805
+ filterExpression: parts.join(" AND "),
806
+ expressionAttributeValues: Object.keys(values).length > 0 ? values : {},
807
+ names
808
+ };
809
+ }
810
+
811
+ private buildUpdateExpression(update: UpdateClause): {
812
+ updateExpression: string;
813
+ expressionAttributeValues: Record<string, any>;
814
+ names: Record<string, string>;
815
+ } {
816
+ const setParts: string[] = [];
817
+ const values: Record<string, any> = {};
818
+ const names: Record<string, string> = {};
819
+ let valueIndex = 0;
820
+
821
+ for (const [field, value] of Object.entries(update)) {
822
+ const valueKey = `:val${valueIndex}`;
823
+ const fieldPlaceholder = RESERVED_KEYWORDS.has(field.toUpperCase()) ? `#${field}` : field;
824
+
825
+ if (RESERVED_KEYWORDS.has(field.toUpperCase())) {
826
+ names[fieldPlaceholder] = field;
827
+ }
828
+
829
+ setParts.push(`${fieldPlaceholder} = ${valueKey}`);
830
+ values[valueKey] = value;
831
+ valueIndex++;
832
+ }
833
+
834
+ return {
835
+ updateExpression: `SET ${setParts.join(", ")}`,
836
+ expressionAttributeValues: Object.keys(values).length > 0 ? values : {},
837
+ names
838
+ };
839
+ }
840
+
841
+ private handleError(error: unknown, operation: string): DatabaseAdapterError {
842
+ if (error instanceof DatabaseAdapterError) {
843
+ return error;
844
+ }
845
+
846
+ if (error instanceof Error) {
847
+ return new DatabaseAdapterError(
848
+ `${operation} failed: ${error.message}`,
849
+ "OPERATION_FAILED",
850
+ { originalError: error.message }
851
+ );
852
+ }
853
+
854
+ return new DatabaseAdapterError(
855
+ `${operation} failed: Unknown error`,
856
+ "OPERATION_FAILED"
857
+ );
858
+ }
859
+ }