betterddb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,600 @@
1
+ // src/dynamo-dal.ts
2
+ import { z, ZodSchema } from 'zod';
3
+ import { DynamoDB } from 'aws-sdk';
4
+
5
+ export type PrimaryKeyValue = string | number;
6
+
7
+ /**
8
+ * A key definition can be either a simple key (a property name)
9
+ * or an object containing a build function that computes the value.
10
+ * (In this design, the attribute name is provided separately.)
11
+ */
12
+ export type KeyDefinition<T> =
13
+ | keyof T
14
+ | {
15
+ build: (rawKey: Partial<T>) => string;
16
+ };
17
+
18
+ /**
19
+ * Configuration for a primary (partition) key.
20
+ */
21
+ export interface PrimaryKeyConfig<T> {
22
+ /** The attribute name for the primary key in DynamoDB */
23
+ name: string;
24
+ /** How to compute the key value; if a keyof T, then the raw value is used;
25
+ * if an object, the build function is used.
26
+ */
27
+ definition: KeyDefinition<T>;
28
+ }
29
+
30
+ /**
31
+ * Configuration for a sort key.
32
+ */
33
+ export interface SortKeyConfig<T> {
34
+ /** The attribute name for the sort key in DynamoDB */
35
+ name: string;
36
+ /** How to compute the sort key value */
37
+ definition: KeyDefinition<T>;
38
+ }
39
+
40
+ /**
41
+ * Configuration for a Global Secondary Index (GSI).
42
+ */
43
+ export interface GSIConfig<T> {
44
+ primary: PrimaryKeyConfig<T>;
45
+ sort?: SortKeyConfig<T>;
46
+ }
47
+
48
+ /**
49
+ * Keys configuration for the table.
50
+ */
51
+ export interface KeysConfig<T> {
52
+ primary: PrimaryKeyConfig<T>;
53
+ sort?: SortKeyConfig<T>;
54
+ gsis?: {
55
+ [gsiName: string]: GSIConfig<T>;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Options for initializing BetterDDB.
61
+ */
62
+ export interface BetterDDBOptions<T> {
63
+ schema: ZodSchema<T>;
64
+ tableName: string;
65
+ keys: KeysConfig<T>;
66
+ client: DynamoDB.DocumentClient;
67
+ /**
68
+ * If true, automatically inject timestamp fields:
69
+ * - On create, sets both `createdAt` and `updatedAt`
70
+ * - On update, sets `updatedAt`
71
+ *
72
+ * (T should include these fields if enabled.)
73
+ */
74
+ autoTimestamps?: boolean;
75
+ }
76
+
77
+ /**
78
+ * BetterDDB is a definition-based DynamoDB wrapper library.
79
+ */
80
+ export class BetterDDB<T> {
81
+ protected schema: ZodSchema<T>;
82
+ protected tableName: string;
83
+ protected client: DynamoDB.DocumentClient;
84
+ protected keys: KeysConfig<T>;
85
+ protected autoTimestamps: boolean;
86
+
87
+ constructor(options: BetterDDBOptions<T>) {
88
+ this.schema = options.schema;
89
+ this.tableName = options.tableName;
90
+ this.keys = options.keys;
91
+ this.client = options.client;
92
+ this.autoTimestamps = options.autoTimestamps ?? false;
93
+ }
94
+
95
+ // Helper: Retrieve the key value from a KeyDefinition.
96
+ protected getKeyValue(def: KeyDefinition<T>, rawKey: Partial<T>): string {
97
+ if (typeof def === 'string' || typeof def === 'number' || typeof def === 'symbol') {
98
+ return String(rawKey[def]);
99
+ } else {
100
+ return def.build(rawKey);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Build the primary key from a raw key object.
106
+ */
107
+ protected buildKey(rawKey: Partial<T>): Record<string, any> {
108
+ const keyObj: Record<string, any> = {};
109
+
110
+ // For primary (partition) key:
111
+ const pkConfig = this.keys.primary;
112
+ keyObj[pkConfig.name] =
113
+ (typeof pkConfig.definition === 'string' ||
114
+ typeof pkConfig.definition === 'number' ||
115
+ typeof pkConfig.definition === 'symbol')
116
+ ? String((rawKey as any)[pkConfig.definition])
117
+ : pkConfig.definition.build(rawKey);
118
+
119
+ // For sort key, if defined:
120
+ if (this.keys.sort) {
121
+ const skConfig = this.keys.sort;
122
+ keyObj[skConfig.name] =
123
+ (typeof skConfig.definition === 'string' ||
124
+ typeof skConfig.definition === 'number' ||
125
+ typeof skConfig.definition === 'symbol')
126
+ ? String((rawKey as any)[skConfig.definition])
127
+ : skConfig.definition.build(rawKey);
128
+ }
129
+ return keyObj;
130
+ }
131
+
132
+ /**
133
+ * Build index attributes for each defined GSI.
134
+ */
135
+ protected buildIndexes(rawItem: Partial<T>): Record<string, any> {
136
+ const indexAttributes: Record<string, any> = {};
137
+ if (this.keys.gsis) {
138
+ for (const gsiName in this.keys.gsis) {
139
+ const gsiConfig = this.keys.gsis[gsiName];
140
+
141
+ // Compute primary index attribute.
142
+ const primaryConfig = gsiConfig.primary;
143
+ indexAttributes[primaryConfig.name] =
144
+ (typeof primaryConfig.definition === 'string' ||
145
+ typeof primaryConfig.definition === 'number' ||
146
+ typeof primaryConfig.definition === 'symbol')
147
+ ? String((rawItem as any)[primaryConfig.definition])
148
+ : primaryConfig.definition.build(rawItem);
149
+
150
+ // Compute sort index attribute if provided.
151
+ if (gsiConfig.sort) {
152
+ const sortConfig = gsiConfig.sort;
153
+ indexAttributes[sortConfig.name] =
154
+ (typeof sortConfig.definition === 'string' ||
155
+ typeof sortConfig.definition === 'number' ||
156
+ typeof sortConfig.definition === 'symbol')
157
+ ? String((rawItem as any)[sortConfig.definition])
158
+ : sortConfig.definition.build(rawItem);
159
+ }
160
+ }
161
+ }
162
+ return indexAttributes;
163
+ }
164
+
165
+ /**
166
+ * Create an item:
167
+ * - Computes primary key and index attributes,
168
+ * - Optionally injects timestamps,
169
+ * - Validates the item and writes it to DynamoDB.
170
+ */
171
+ async create(item: T): Promise<T> {
172
+ if (this.autoTimestamps) {
173
+ const now = new Date().toISOString();
174
+ item = { ...item, createdAt: now, updatedAt: now } as T;
175
+ }
176
+
177
+ const validated = this.schema.parse(item);
178
+ let finalItem = { ...validated };
179
+
180
+ // Compute and merge primary key.
181
+ const computedKeys = this.buildKey(validated);
182
+ finalItem = { ...finalItem, ...computedKeys };
183
+
184
+ // Compute and merge index attributes.
185
+ const indexAttributes = this.buildIndexes(validated);
186
+ finalItem = { ...finalItem, ...indexAttributes };
187
+
188
+ try {
189
+ await this.client.put({ TableName: this.tableName, Item: finalItem as DynamoDB.DocumentClient.PutItemInputAttributeMap }).promise();
190
+ return validated;
191
+ } catch (error) {
192
+ console.error('Error during create operation:', error);
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ async get(rawKey: Partial<T>): Promise<T | null> {
198
+ const Key = this.buildKey(rawKey);
199
+ try {
200
+ const result = await this.client.get({ TableName: this.tableName, Key }).promise();
201
+ if (!result.Item) return null;
202
+ return this.schema.parse(result.Item);
203
+ } catch (error) {
204
+ console.error('Error during get operation:', error);
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ async update(
210
+ rawKey: Partial<T>,
211
+ update: Partial<T>,
212
+ options?: { expectedVersion?: number }
213
+ ): Promise<T> {
214
+ const ExpressionAttributeNames: Record<string, string> = {};
215
+ const ExpressionAttributeValues: Record<string, any> = {};
216
+ const UpdateExpressionParts: string[] = [];
217
+ const ConditionExpressionParts: string[] = [];
218
+
219
+ // Exclude key fields from update.
220
+ const keyFieldNames = [
221
+ this.keys.primary.name,
222
+ this.keys.sort ? this.keys.sort.name : undefined
223
+ ].filter(Boolean) as string[];
224
+
225
+ for (const [attr, value] of Object.entries(update)) {
226
+ if (keyFieldNames.includes(attr)) continue;
227
+ const attributeKey = `#${attr}`;
228
+ const valueKey = `:${attr}`;
229
+ ExpressionAttributeNames[attributeKey] = attr;
230
+ ExpressionAttributeValues[valueKey] = value;
231
+ UpdateExpressionParts.push(`${attributeKey} = ${valueKey}`);
232
+ }
233
+
234
+ if (this.autoTimestamps) {
235
+ const now = new Date().toISOString();
236
+ ExpressionAttributeNames['#updatedAt'] = 'updatedAt';
237
+ ExpressionAttributeValues[':updatedAt'] = now;
238
+ UpdateExpressionParts.push('#updatedAt = :updatedAt');
239
+ }
240
+
241
+ if (options?.expectedVersion !== undefined) {
242
+ ExpressionAttributeNames['#version'] = 'version';
243
+ ExpressionAttributeValues[':expectedVersion'] = options.expectedVersion;
244
+ ExpressionAttributeValues[':newVersion'] = options.expectedVersion + 1;
245
+ UpdateExpressionParts.push('#version = :newVersion');
246
+ ConditionExpressionParts.push('#version = :expectedVersion');
247
+ }
248
+
249
+ if (UpdateExpressionParts.length === 0) {
250
+ throw new Error('No attributes provided to update');
251
+ }
252
+
253
+ const UpdateExpression = 'SET ' + UpdateExpressionParts.join(', ');
254
+ const params: DynamoDB.DocumentClient.UpdateItemInput = {
255
+ TableName: this.tableName,
256
+ Key: this.buildKey(rawKey),
257
+ UpdateExpression,
258
+ ExpressionAttributeNames,
259
+ ExpressionAttributeValues,
260
+ ReturnValues: 'ALL_NEW'
261
+ };
262
+
263
+ if (ConditionExpressionParts.length > 0) {
264
+ params.ConditionExpression = ConditionExpressionParts.join(' AND ');
265
+ }
266
+
267
+ try {
268
+ const result = await this.client.update(params).promise();
269
+ if (!result.Attributes) {
270
+ throw new Error('No attributes returned after update');
271
+ }
272
+ return this.schema.parse(result.Attributes);
273
+ } catch (error) {
274
+ console.error('Error during update operation:', error);
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ async delete(rawKey: Partial<T>): Promise<void> {
280
+ const Key = this.buildKey(rawKey);
281
+ try {
282
+ await this.client.delete({ TableName: this.tableName, Key }).promise();
283
+ } catch (error) {
284
+ console.error('Error during delete operation:', error);
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ async queryByGsi(
290
+ gsiName: string,
291
+ key: Partial<T>,
292
+ sortKeyCondition?: { operator: 'eq' | 'begins_with' | 'between'; values: any | [any, any] }
293
+ ): Promise<T[]> {
294
+ if (!this.keys.gsis || !this.keys.gsis[gsiName]) {
295
+ throw new Error(`GSI "${gsiName}" is not configured`);
296
+ }
297
+ const indexConfig = this.keys.gsis[gsiName];
298
+ const ExpressionAttributeNames: Record<string, string> = {
299
+ [`#${indexConfig.primary.name}`]: indexConfig.primary.name
300
+ };
301
+ const ExpressionAttributeValues: Record<string, any> = {
302
+ [`:${indexConfig.primary.name}`]:
303
+ (typeof indexConfig.primary.definition === 'string' ||
304
+ typeof indexConfig.primary.definition === 'number' ||
305
+ typeof indexConfig.primary.definition === 'symbol')
306
+ ? String((key as any)[indexConfig.primary.definition])
307
+ : indexConfig.primary.definition.build(key)
308
+ };
309
+ let KeyConditionExpression = `#${indexConfig.primary.name} = :${indexConfig.primary.name}`;
310
+
311
+ if (indexConfig.sort && sortKeyCondition) {
312
+ const skFieldName = indexConfig.sort.name;
313
+ ExpressionAttributeNames['#gsiSk'] = skFieldName;
314
+ switch (sortKeyCondition.operator) {
315
+ case 'eq':
316
+ ExpressionAttributeValues[':gsiSk'] = sortKeyCondition.values;
317
+ KeyConditionExpression += ' AND #gsiSk = :gsiSk';
318
+ break;
319
+ case 'begins_with':
320
+ ExpressionAttributeValues[':gsiSk'] = sortKeyCondition.values;
321
+ KeyConditionExpression += ' AND begins_with(#gsiSk, :gsiSk)';
322
+ break;
323
+ case 'between':
324
+ if (!Array.isArray(sortKeyCondition.values) || sortKeyCondition.values.length !== 2) {
325
+ throw new Error("For 'between' operator, values must be a tuple of two items");
326
+ }
327
+ ExpressionAttributeValues[':gsiSkStart'] = sortKeyCondition.values[0];
328
+ ExpressionAttributeValues[':gsiSkEnd'] = sortKeyCondition.values[1];
329
+ KeyConditionExpression += ' AND #gsiSk BETWEEN :gsiSkStart AND :gsiSkEnd';
330
+ break;
331
+ default:
332
+ throw new Error(`Unsupported sort key operator: ${sortKeyCondition.operator}`);
333
+ }
334
+ }
335
+ try {
336
+ const result = await this.client
337
+ .query({
338
+ TableName: this.tableName,
339
+ IndexName: gsiName,
340
+ KeyConditionExpression,
341
+ ExpressionAttributeNames,
342
+ ExpressionAttributeValues
343
+ })
344
+ .promise();
345
+ return (result.Items || []).map(item => this.schema.parse(item));
346
+ } catch (error) {
347
+ console.error('Error during queryByGsi operation:', error);
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Query by primary key (using the computed primary key) and an optional sort key condition.
354
+ */
355
+ async queryByPrimaryKey(
356
+ rawKey: Partial<T>,
357
+ sortKeyCondition?: { operator: 'eq' | 'begins_with' | 'between'; values: any | [any, any] },
358
+ options?: { limit?: number; lastKey?: Record<string, any> }
359
+ ): Promise<{ items: T[]; lastKey?: Record<string, any> }> {
360
+ const pkAttrName = this.keys.primary.name;
361
+ const pkValue =
362
+ (typeof this.keys.primary.definition === 'string' ||
363
+ typeof this.keys.primary.definition === 'number' ||
364
+ typeof this.keys.primary.definition === 'symbol')
365
+ ? String((rawKey as any)[this.keys.primary.definition])
366
+ : this.keys.primary.definition.build(rawKey);
367
+
368
+ const ExpressionAttributeNames: Record<string, string> = {
369
+ [`#${pkAttrName}`]: pkAttrName
370
+ };
371
+ const ExpressionAttributeValues: Record<string, any> = {
372
+ [`:${pkAttrName}`]: pkValue
373
+ };
374
+
375
+ let KeyConditionExpression = `#${pkAttrName} = :${pkAttrName}`;
376
+
377
+ if (this.keys.sort && sortKeyCondition) {
378
+ const skAttrName = this.keys.sort.name;
379
+ ExpressionAttributeNames[`#${skAttrName}`] = skAttrName;
380
+ switch (sortKeyCondition.operator) {
381
+ case 'eq':
382
+ ExpressionAttributeValues[':skValue'] = sortKeyCondition.values;
383
+ KeyConditionExpression += ` AND #${skAttrName} = :skValue`;
384
+ break;
385
+ case 'begins_with':
386
+ ExpressionAttributeValues[':skValue'] = sortKeyCondition.values;
387
+ KeyConditionExpression += ` AND begins_with(#${skAttrName}, :skValue)`;
388
+ break;
389
+ case 'between':
390
+ if (!Array.isArray(sortKeyCondition.values) || sortKeyCondition.values.length !== 2) {
391
+ throw new Error("For 'between' operator, values must be a tuple of two items");
392
+ }
393
+ ExpressionAttributeValues[':skStart'] = sortKeyCondition.values[0];
394
+ ExpressionAttributeValues[':skEnd'] = sortKeyCondition.values[1];
395
+ KeyConditionExpression += ` AND #${skAttrName} BETWEEN :skStart AND :skEnd`;
396
+ break;
397
+ default:
398
+ throw new Error(`Unsupported sort key operator: ${sortKeyCondition.operator}`);
399
+ }
400
+ }
401
+
402
+ const queryParams: DynamoDB.DocumentClient.QueryInput = {
403
+ TableName: this.tableName,
404
+ KeyConditionExpression,
405
+ ExpressionAttributeNames,
406
+ ExpressionAttributeValues
407
+ };
408
+
409
+ if (options?.limit) {
410
+ queryParams.Limit = options.limit;
411
+ }
412
+ if (options?.lastKey) {
413
+ queryParams.ExclusiveStartKey = options.lastKey;
414
+ }
415
+
416
+ const result = await this.client.query(queryParams).promise();
417
+ const items = (result.Items || []).map(item => this.schema.parse(item));
418
+ return { items, lastKey: result.LastEvaluatedKey };
419
+ }
420
+
421
+ // ───── Transaction Helpers ─────────────────────────────
422
+
423
+ buildTransactPut(item: T): DynamoDB.DocumentClient.TransactWriteItem {
424
+ const computedKeys = this.buildKey(item);
425
+ const indexAttributes = this.buildIndexes(item);
426
+ const finalItem = { ...item, ...computedKeys, ...indexAttributes };
427
+ const validated = this.schema.parse(finalItem);
428
+ return {
429
+ Put: {
430
+ TableName: this.tableName,
431
+ Item: validated as DynamoDB.DocumentClient.PutItemInputAttributeMap
432
+ }
433
+ };
434
+ }
435
+
436
+ buildTransactUpdate(
437
+ rawKey: Partial<T>,
438
+ update: Partial<T>,
439
+ options?: { expectedVersion?: number }
440
+ ): DynamoDB.DocumentClient.TransactWriteItem {
441
+ const ExpressionAttributeNames: Record<string, string> = {};
442
+ const ExpressionAttributeValues: Record<string, any> = {};
443
+ const UpdateExpressionParts: string[] = [];
444
+ const ConditionExpressionParts: string[] = [];
445
+
446
+ const keyFieldNames = [
447
+ this.keys.primary.name,
448
+ this.keys.sort ? this.keys.sort.name : undefined
449
+ ].filter(Boolean) as string[];
450
+
451
+ for (const [attr, value] of Object.entries(update)) {
452
+ if (keyFieldNames.includes(attr)) continue;
453
+ const attributeKey = `#${attr}`;
454
+ const valueKey = `:${attr}`;
455
+ ExpressionAttributeNames[attributeKey] = attr;
456
+ ExpressionAttributeValues[valueKey] = value;
457
+ UpdateExpressionParts.push(`${attributeKey} = ${valueKey}`);
458
+ }
459
+
460
+ if (this.autoTimestamps) {
461
+ const now = new Date().toISOString();
462
+ ExpressionAttributeNames['#updatedAt'] = 'updatedAt';
463
+ ExpressionAttributeValues[':updatedAt'] = now;
464
+ UpdateExpressionParts.push('#updatedAt = :updatedAt');
465
+ }
466
+
467
+ if (options?.expectedVersion !== undefined) {
468
+ ExpressionAttributeNames['#version'] = 'version';
469
+ ExpressionAttributeValues[':expectedVersion'] = options.expectedVersion;
470
+ ExpressionAttributeValues[':newVersion'] = options.expectedVersion + 1;
471
+ UpdateExpressionParts.push('#version = :newVersion');
472
+ ConditionExpressionParts.push('#version = :expectedVersion');
473
+ }
474
+
475
+ if (UpdateExpressionParts.length === 0) {
476
+ throw new Error('No attributes provided to update in transactUpdate');
477
+ }
478
+
479
+ const UpdateExpression = 'SET ' + UpdateExpressionParts.join(', ');
480
+ const updateItem: DynamoDB.DocumentClient.Update = {
481
+ TableName: this.tableName,
482
+ Key: this.buildKey(rawKey),
483
+ UpdateExpression,
484
+ ExpressionAttributeNames,
485
+ ExpressionAttributeValues
486
+ };
487
+ if (ConditionExpressionParts.length > 0) {
488
+ updateItem.ConditionExpression = ConditionExpressionParts.join(' AND ');
489
+ }
490
+ return { Update: updateItem };
491
+ }
492
+
493
+ buildTransactDelete(rawKey: Partial<T>): DynamoDB.DocumentClient.TransactWriteItem {
494
+ return {
495
+ Delete: {
496
+ TableName: this.tableName,
497
+ Key: this.buildKey(rawKey)
498
+ }
499
+ };
500
+ }
501
+
502
+ async transactWrite(
503
+ operations: DynamoDB.DocumentClient.TransactWriteItemList
504
+ ): Promise<void> {
505
+ try {
506
+ await this.client.transactWrite({ TransactItems: operations }).promise();
507
+ } catch (error) {
508
+ console.error('Error during transactWrite operation:', error);
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ async transactGetByKeys(rawKeys: Partial<T>[]): Promise<T[]> {
514
+ const getItems = rawKeys.map(key => ({ TableName: this.tableName, Key: this.buildKey(key) }));
515
+ return this.transactGet(getItems);
516
+ }
517
+
518
+ async transactGet(
519
+ getItems: { TableName: string; Key: any }[]
520
+ ): Promise<T[]> {
521
+ try {
522
+ const response = await this.client
523
+ .transactGet({
524
+ TransactItems: getItems.map(item => ({ Get: item }))
525
+ })
526
+ .promise();
527
+ return (response.Responses || [])
528
+ .filter(r => r.Item)
529
+ .map(r => this.schema.parse(r.Item));
530
+ } catch (error) {
531
+ console.error('Error during transactGet operation:', error);
532
+ throw error;
533
+ }
534
+ }
535
+
536
+ // ───── Batch Write Support ─────────────────────────────
537
+
538
+ async batchWrite(ops: { puts?: T[]; deletes?: Partial<T>[] }): Promise<void> {
539
+ const putRequests = (ops.puts || []).map(item => {
540
+ const computedKeys = this.buildKey(item);
541
+ const indexAttributes = this.buildIndexes(item);
542
+ const finalItem = { ...item, ...computedKeys, ...indexAttributes };
543
+ const validated = this.schema.parse(finalItem);
544
+ return { PutRequest: { Item: validated } };
545
+ });
546
+
547
+ const deleteRequests = (ops.deletes || []).map(rawKey => {
548
+ const key = this.buildKey(rawKey);
549
+ return { DeleteRequest: { Key: key } };
550
+ });
551
+
552
+ const allRequests = [...putRequests, ...deleteRequests];
553
+
554
+ for (let i = 0; i < allRequests.length; i += 25) {
555
+ const chunk = allRequests.slice(i, i + 25);
556
+ let unprocessed = await this.batchWriteChunk(chunk as DynamoDB.DocumentClient.WriteRequest[]);
557
+ while (unprocessed && Object.keys(unprocessed).length > 0) {
558
+ await new Promise(resolve => setTimeout(resolve, 1000));
559
+ unprocessed = await this.retryBatchWrite(unprocessed);
560
+ }
561
+ }
562
+ }
563
+
564
+ private async batchWriteChunk(chunk: DynamoDB.DocumentClient.WriteRequest[]): Promise<DynamoDB.DocumentClient.BatchWriteItemOutput['UnprocessedItems']> {
565
+ const params = {
566
+ RequestItems: {
567
+ [this.tableName]: chunk
568
+ }
569
+ };
570
+ const result = await this.client.batchWrite(params as DynamoDB.DocumentClient.BatchWriteItemInput).promise();
571
+ return result.UnprocessedItems;
572
+ }
573
+
574
+ private async retryBatchWrite(unprocessed: DynamoDB.DocumentClient.BatchWriteItemOutput['UnprocessedItems']): Promise<DynamoDB.DocumentClient.BatchWriteItemOutput['UnprocessedItems']> {
575
+ const params = { RequestItems: unprocessed };
576
+ const result = await this.client.batchWrite(params as DynamoDB.DocumentClient.BatchWriteItemInput).promise();
577
+ return result.UnprocessedItems;
578
+ }
579
+
580
+ // ───── Batch Get Support ─────────────────────────────
581
+
582
+ async batchGet(rawKeys: Partial<T>[]): Promise<T[]> {
583
+ const keys = rawKeys.map(key => this.buildKey(key));
584
+ const results: T[] = [];
585
+ for (let i = 0; i < keys.length; i += 100) {
586
+ const chunk = keys.slice(i, i + 100);
587
+ const params = {
588
+ RequestItems: {
589
+ [this.tableName]: {
590
+ Keys: chunk
591
+ }
592
+ }
593
+ };
594
+ const result = await this.client.batchGet(params).promise();
595
+ const items = result.Responses ? result.Responses[this.tableName] : [];
596
+ results.push(...items.map(item => this.schema.parse(item)));
597
+ }
598
+ return results;
599
+ }
600
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './betterddb';
@@ -0,0 +1,98 @@
1
+ import { z } from 'zod';
2
+ import { BetterDDB } from '../src/betterddb';
3
+ import { DynamoDB } from 'aws-sdk';
4
+
5
+ const TEST_TABLE = 'TestTable';
6
+
7
+ // LocalStack Configuration
8
+ const client = new DynamoDB.DocumentClient({
9
+ region: 'us-east-1',
10
+ endpoint: 'http://localhost:4566'
11
+ });
12
+
13
+ // Table Schema
14
+ const UserSchema = z.object({
15
+ id: z.string(),
16
+ name: z.string(),
17
+ email: z.string().email(),
18
+ createdAt: z.string(),
19
+ updatedAt: z.string()
20
+ });
21
+
22
+ const userDal = new BetterDDB({
23
+ schema: UserSchema,
24
+ tableName: TEST_TABLE,
25
+ keys: {
26
+ primary: { name: 'pk', definition: 'id' }
27
+ },
28
+ client,
29
+ autoTimestamps: true
30
+ });
31
+
32
+ beforeAll(async () => {
33
+ const dynamoDB = new DynamoDB({
34
+ region: 'us-east-1',
35
+ endpoint: 'http://localhost:4566'
36
+ });
37
+
38
+ console.log('Creating DynamoDB table in LocalStack...');
39
+
40
+ await dynamoDB.createTable({
41
+ TableName: TEST_TABLE,
42
+ KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' }],
43
+ AttributeDefinitions: [{ AttributeName: 'pk', AttributeType: 'S' }],
44
+ BillingMode: 'PAY_PER_REQUEST'
45
+ }).promise();
46
+
47
+ // Wait for the table to become active
48
+ while (true) {
49
+ const { Table } = await dynamoDB.describeTable({ TableName: TEST_TABLE }).promise();
50
+ if (Table?.TableStatus === 'ACTIVE') {
51
+ console.log('DynamoDB table is ready.');
52
+ break;
53
+ }
54
+ console.log('Waiting for table to become ACTIVE...');
55
+ await new Promise(res => setTimeout(res, 1000)); // Wait 1 sec before retrying
56
+ }
57
+ });
58
+
59
+ afterAll(async () => {
60
+ // Cleanup: delete the table
61
+ const dynamoDB = new DynamoDB({
62
+ region: 'us-east-1',
63
+ endpoint: 'http://localhost:4566'
64
+ });
65
+
66
+ await dynamoDB.deleteTable({ TableName: TEST_TABLE }).promise();
67
+ });
68
+
69
+ describe('BetterDDB - Integration Tests', () => {
70
+ it('should insert an item into DynamoDB', async () => {
71
+ const user = {
72
+ id: 'user-123',
73
+ name: 'John Doe',
74
+ email: 'john@example.com'
75
+ };
76
+
77
+ const createdUser = await userDal.create(user as any);
78
+ expect(createdUser).toHaveProperty('createdAt');
79
+ expect(createdUser).toHaveProperty('updatedAt');
80
+ });
81
+
82
+ it('should retrieve an item by ID', async () => {
83
+ const user = await userDal.get({ id: 'user-123' });
84
+ expect(user).not.toBeNull();
85
+ expect(user?.id).toBe('user-123');
86
+ });
87
+
88
+ it('should update an existing item', async () => {
89
+ const updatedUser = await userDal.update({ id: 'user-123' }, { name: 'Jane Doe' });
90
+ expect(updatedUser.name).toBe('Jane Doe');
91
+ });
92
+
93
+ it('should delete an item', async () => {
94
+ await userDal.delete({ id: 'user-123' });
95
+ const deletedUser = await userDal.get({ id: 'user-123' });
96
+ expect(deletedUser).toBeNull();
97
+ });
98
+ });