betterddb 0.7.0 → 0.8.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.
- package/babel.config.cjs +6 -0
- package/{jest.config.js → jest.config.cjs} +5 -4
- package/package.json +5 -1
- package/src/betterddb.ts +1 -0
- package/src/builders/batch-get-builder.ts +1 -0
- package/src/builders/create-builder.ts +1 -0
- package/src/builders/delete-builder.ts +1 -0
- package/src/builders/get-builder.ts +1 -0
- package/src/builders/query-builder.ts +1 -0
- package/src/builders/scan-builder.ts +1 -0
- package/src/builders/update-builder.ts +94 -67
- package/test/update.test.ts +6 -3
- package/tsconfig.json +2 -2
package/babel.config.cjs
ADDED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
1
2
|
const config = {
|
|
2
3
|
testEnvironment: "node",
|
|
3
4
|
roots: ["<rootDir>/test/"],
|
|
4
5
|
testMatch: ["**/*.test.ts"],
|
|
5
6
|
transform: {
|
|
6
|
-
"^.+\\.(ts|tsx|js|jsx)$": "
|
|
7
|
+
"^.+\\.(ts|tsx|js|jsx)$": "babel-jest"
|
|
7
8
|
},
|
|
8
9
|
moduleNameMapper: {
|
|
9
10
|
"^@/(.*)$": "<rootDir>/src/$1",
|
|
10
11
|
"^~/(.*)$": "<rootDir>/src/$1",
|
|
12
|
+
"^(\\.{1,2}/.*)\\.js$": "$1"
|
|
11
13
|
},
|
|
12
|
-
|
|
13
|
-
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
|
14
|
+
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"]
|
|
14
15
|
};
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
module.exports = config;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "betterddb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "A definition-based DynamoDB wrapper library that provides a schema-driven and fully typesafe DAL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -43,11 +43,15 @@
|
|
|
43
43
|
"zod": "^3.24.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
+
"@babel/core": "^7.28.0",
|
|
47
|
+
"@babel/preset-env": "^7.28.0",
|
|
48
|
+
"@babel/preset-typescript": "^7.27.1",
|
|
46
49
|
"@eslint/js": "^9.11.1",
|
|
47
50
|
"@types/jest": "^29.5.14",
|
|
48
51
|
"@types/node": "^22.13.5",
|
|
49
52
|
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
|
50
53
|
"@typescript-eslint/parser": "^8.24.1",
|
|
54
|
+
"babel-jest": "^30.0.4",
|
|
51
55
|
"del-cli": "^6.0.0",
|
|
52
56
|
"eslint": "^8.57.1",
|
|
53
57
|
"eslint-config-prettier": "^9.1.0",
|
package/src/betterddb.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
/* eslint-disable no-unused-vars */
|
|
1
2
|
import {
|
|
2
3
|
type NativeAttributeValue,
|
|
3
4
|
TransactWriteCommand,
|
|
4
5
|
UpdateCommand,
|
|
6
|
+
type UpdateCommandInput,
|
|
5
7
|
} from "@aws-sdk/lib-dynamodb";
|
|
6
8
|
import { type BetterDDB } from "../betterddb.js";
|
|
7
9
|
import {
|
|
8
10
|
type TransactWriteItem,
|
|
9
11
|
type Update,
|
|
10
|
-
type
|
|
12
|
+
type ReturnValue,
|
|
11
13
|
} from "@aws-sdk/client-dynamodb";
|
|
14
|
+
|
|
12
15
|
interface UpdateActions<T> {
|
|
13
16
|
set?: Partial<T>;
|
|
14
17
|
remove?: (keyof T)[];
|
|
@@ -39,7 +42,9 @@ export class UpdateBuilder<T> {
|
|
|
39
42
|
(acc, [key, value]) => {
|
|
40
43
|
if (
|
|
41
44
|
value === undefined ||
|
|
42
|
-
(typeof value === "string" &&
|
|
45
|
+
(typeof value === "string" &&
|
|
46
|
+
value.trim() === "" &&
|
|
47
|
+
this.parent.getSchema().shape[key]?.isOptional?.())
|
|
43
48
|
) {
|
|
44
49
|
acc.toRemove.push(key as keyof T);
|
|
45
50
|
} else {
|
|
@@ -125,34 +130,44 @@ export class UpdateBuilder<T> {
|
|
|
125
130
|
/**
|
|
126
131
|
* Builds the update expression and associated maps.
|
|
127
132
|
*/
|
|
128
|
-
private buildExpression(): {
|
|
133
|
+
private buildExpression(newItemForIndexes?: T): {
|
|
129
134
|
updateExpression: string;
|
|
130
135
|
attributeNames: Record<string, string>;
|
|
131
136
|
attributeValues?: Record<string, NativeAttributeValue>;
|
|
132
137
|
} {
|
|
133
138
|
const ExpressionAttributeNames: Record<string, string> = {};
|
|
134
|
-
|
|
135
|
-
| Record<string, NativeAttributeValue>
|
|
136
|
-
| undefined = {};
|
|
139
|
+
const ExpressionAttributeValues: Record<string, NativeAttributeValue> = {};
|
|
137
140
|
const clauses: string[] = [];
|
|
138
141
|
|
|
139
|
-
//
|
|
142
|
+
// 1) SET – from actions.set
|
|
143
|
+
const setParts: string[] = [];
|
|
140
144
|
if (this.actions.set) {
|
|
141
|
-
const setParts: string[] = [];
|
|
142
145
|
for (const [attr, value] of Object.entries(this.actions.set)) {
|
|
143
146
|
const nameKey = `#n_${attr}`;
|
|
144
147
|
const valueKey = `:v_${attr}`;
|
|
145
148
|
ExpressionAttributeNames[nameKey] = attr;
|
|
146
|
-
ExpressionAttributeValues[valueKey] = value
|
|
149
|
+
ExpressionAttributeValues[valueKey] = value!;
|
|
147
150
|
setParts.push(`${nameKey} = ${valueKey}`);
|
|
148
151
|
}
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 2) also SET – from index‐rebuild if requested
|
|
155
|
+
if (newItemForIndexes) {
|
|
156
|
+
const indexAttrs = this.parent.buildIndexes(newItemForIndexes);
|
|
157
|
+
for (const [attr, value] of Object.entries(indexAttrs)) {
|
|
158
|
+
const nameKey = `#n_idx_${attr}`;
|
|
159
|
+
const valueKey = `:v_idx_${attr}`;
|
|
160
|
+
ExpressionAttributeNames[nameKey] = attr;
|
|
161
|
+
ExpressionAttributeValues[valueKey] = value;
|
|
162
|
+
setParts.push(`${nameKey} = ${valueKey}`);
|
|
151
163
|
}
|
|
152
164
|
}
|
|
165
|
+
if (setParts.length > 0) {
|
|
166
|
+
clauses.push(`SET ${setParts.join(", ")}`);
|
|
167
|
+
}
|
|
153
168
|
|
|
154
|
-
//
|
|
155
|
-
if (this.actions.remove
|
|
169
|
+
// 3) REMOVE
|
|
170
|
+
if (this.actions.remove?.length) {
|
|
156
171
|
const removeParts = this.actions.remove.map((attr) => {
|
|
157
172
|
const nameKey = `#n_${String(attr)}`;
|
|
158
173
|
ExpressionAttributeNames[nameKey] = String(attr);
|
|
@@ -161,66 +176,70 @@ export class UpdateBuilder<T> {
|
|
|
161
176
|
clauses.push(`REMOVE ${removeParts.join(", ")}`);
|
|
162
177
|
}
|
|
163
178
|
|
|
164
|
-
//
|
|
179
|
+
// 4) ADD
|
|
165
180
|
if (this.actions.add) {
|
|
166
181
|
const addParts: string[] = [];
|
|
167
182
|
for (const [attr, value] of Object.entries(this.actions.add)) {
|
|
168
183
|
const nameKey = `#n_${attr}`;
|
|
169
184
|
const valueKey = `:v_${attr}`;
|
|
170
185
|
ExpressionAttributeNames[nameKey] = attr;
|
|
171
|
-
ExpressionAttributeValues[valueKey] = value;
|
|
186
|
+
ExpressionAttributeValues[valueKey] = value as NativeAttributeValue;
|
|
172
187
|
addParts.push(`${nameKey} ${valueKey}`);
|
|
173
188
|
}
|
|
174
|
-
if (addParts.length
|
|
189
|
+
if (addParts.length) {
|
|
175
190
|
clauses.push(`ADD ${addParts.join(", ")}`);
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
|
|
179
|
-
//
|
|
194
|
+
// 5) DELETE
|
|
180
195
|
if (this.actions.delete) {
|
|
181
196
|
const deleteParts: string[] = [];
|
|
182
197
|
for (const [attr, value] of Object.entries(this.actions.delete)) {
|
|
183
198
|
const nameKey = `#n_${attr}`;
|
|
184
199
|
const valueKey = `:v_${attr}`;
|
|
185
200
|
ExpressionAttributeNames[nameKey] = attr;
|
|
186
|
-
ExpressionAttributeValues[valueKey] = value;
|
|
201
|
+
ExpressionAttributeValues[valueKey] = value as NativeAttributeValue;
|
|
187
202
|
deleteParts.push(`${nameKey} ${valueKey}`);
|
|
188
203
|
}
|
|
189
|
-
if (deleteParts.length
|
|
204
|
+
if (deleteParts.length) {
|
|
190
205
|
clauses.push(`DELETE ${deleteParts.join(", ")}`);
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
//
|
|
209
|
+
// 6) merge in condition‐names/values
|
|
195
210
|
if (this.condition) {
|
|
196
211
|
Object.assign(ExpressionAttributeNames, this.condition.attributeNames);
|
|
197
212
|
Object.assign(ExpressionAttributeValues, this.condition.attributeValues);
|
|
198
213
|
}
|
|
199
214
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
// 7) normalize empty values
|
|
216
|
+
const hasValues = Object.keys(ExpressionAttributeValues).length > 0;
|
|
217
|
+
const finalValues = hasValues ? ExpressionAttributeValues : undefined;
|
|
203
218
|
|
|
204
|
-
// If no clauses were generated, throw an error
|
|
205
219
|
if (clauses.length === 0) {
|
|
206
220
|
throw new Error(
|
|
207
|
-
"No attributes to update
|
|
221
|
+
"No attributes to update – all values were empty or undefined",
|
|
208
222
|
);
|
|
209
223
|
}
|
|
210
224
|
|
|
211
225
|
return {
|
|
212
226
|
updateExpression: clauses.join(" "),
|
|
213
227
|
attributeNames: ExpressionAttributeNames,
|
|
214
|
-
attributeValues:
|
|
228
|
+
attributeValues: finalValues,
|
|
215
229
|
};
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
/**
|
|
219
233
|
* Returns a transaction update item that can be included in a transactWrite call.
|
|
220
234
|
*/
|
|
221
|
-
public toTransactUpdate(
|
|
235
|
+
public async toTransactUpdate(
|
|
236
|
+
newItemForIndexes?: T,
|
|
237
|
+
): Promise<TransactWriteItem> {
|
|
238
|
+
if (!newItemForIndexes) {
|
|
239
|
+
newItemForIndexes = await this.createExpectedNewItem();
|
|
240
|
+
}
|
|
222
241
|
const { updateExpression, attributeNames, attributeValues } =
|
|
223
|
-
this.buildExpression();
|
|
242
|
+
this.buildExpression(newItemForIndexes);
|
|
224
243
|
const updateItem: Update = {
|
|
225
244
|
TableName: this.parent.getTableName(),
|
|
226
245
|
Key: this.parent.buildKey(this.key),
|
|
@@ -234,47 +253,55 @@ export class UpdateBuilder<T> {
|
|
|
234
253
|
return { Update: updateItem };
|
|
235
254
|
}
|
|
236
255
|
|
|
237
|
-
private async
|
|
256
|
+
private async createExpectedNewItem() {
|
|
238
257
|
const existingItem = await this.parent.get(this.key).execute();
|
|
239
258
|
if (existingItem === null) {
|
|
240
259
|
throw new Error("Item not found");
|
|
241
260
|
}
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
261
|
+
const expectedNewItem: Record<string, any> = {
|
|
262
|
+
...existingItem,
|
|
263
|
+
...this.actions.set,
|
|
264
|
+
};
|
|
265
|
+
if (this.actions.remove) {
|
|
266
|
+
this.actions.remove.forEach((attr) => {
|
|
267
|
+
delete expectedNewItem[String(attr)];
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (this.actions.add) {
|
|
271
|
+
Object.entries(this.actions.add).forEach(([attr, value]) => {
|
|
272
|
+
const currentValue = expectedNewItem[attr] ?? 0;
|
|
273
|
+
if (typeof value === "number") {
|
|
274
|
+
expectedNewItem[attr] = currentValue + value;
|
|
275
|
+
} else if (value instanceof Set) {
|
|
276
|
+
const currentSet =
|
|
277
|
+
expectedNewItem[attr] instanceof Set
|
|
278
|
+
? expectedNewItem[attr]
|
|
279
|
+
: new Set();
|
|
280
|
+
expectedNewItem[attr] = new Set([...currentSet, ...value]);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (this.actions.delete) {
|
|
285
|
+
Object.entries(this.actions.delete).forEach(([attr, value]) => {
|
|
286
|
+
if (value instanceof Set) {
|
|
287
|
+
const currentSet = expectedNewItem[attr];
|
|
288
|
+
if (currentSet instanceof Set) {
|
|
289
|
+
value.forEach((v) => {
|
|
290
|
+
currentSet.delete(v);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
270
295
|
}
|
|
271
|
-
return
|
|
296
|
+
return this.parent.getSchema().parse(expectedNewItem) as T;
|
|
272
297
|
}
|
|
273
298
|
|
|
274
299
|
/**
|
|
275
300
|
* Commits the update immediately by calling the parent's update method.
|
|
276
301
|
*/
|
|
277
302
|
public async execute(): Promise<T> {
|
|
303
|
+
const expectedNewItem = await this.createExpectedNewItem();
|
|
304
|
+
|
|
278
305
|
if (this.parent.getTimestamps()) {
|
|
279
306
|
const now = new Date().toISOString();
|
|
280
307
|
if (!this.actions.set) {
|
|
@@ -285,7 +312,7 @@ export class UpdateBuilder<T> {
|
|
|
285
312
|
if (this.extraTransactItems.length > 0) {
|
|
286
313
|
// For transactions, we must throw if there's nothing to update
|
|
287
314
|
// since we can't safely skip updates in a transaction
|
|
288
|
-
const myTransactItem = this.toTransactUpdate();
|
|
315
|
+
const myTransactItem = await this.toTransactUpdate(expectedNewItem);
|
|
289
316
|
const allItems = [...this.extraTransactItems, myTransactItem];
|
|
290
317
|
await this.parent.getClient().send(
|
|
291
318
|
new TransactWriteCommand({
|
|
@@ -297,22 +324,24 @@ export class UpdateBuilder<T> {
|
|
|
297
324
|
if (result === null) {
|
|
298
325
|
throw new Error("Item not found after transaction update");
|
|
299
326
|
}
|
|
300
|
-
|
|
301
|
-
return
|
|
327
|
+
|
|
328
|
+
return result;
|
|
302
329
|
}
|
|
303
330
|
|
|
304
331
|
// For normal updates, handle empty updates gracefully
|
|
305
332
|
try {
|
|
306
333
|
const { updateExpression, attributeNames, attributeValues } =
|
|
307
|
-
this.buildExpression();
|
|
308
|
-
|
|
334
|
+
this.buildExpression(expectedNewItem);
|
|
335
|
+
|
|
336
|
+
let params: UpdateCommandInput = {
|
|
309
337
|
TableName: this.parent.getTableName(),
|
|
310
338
|
Key: this.parent.buildKey(this.key),
|
|
311
339
|
UpdateExpression: updateExpression,
|
|
312
340
|
ExpressionAttributeNames: attributeNames,
|
|
313
341
|
ExpressionAttributeValues: attributeValues,
|
|
314
|
-
ReturnValues: "ALL_NEW",
|
|
342
|
+
ReturnValues: "ALL_NEW" as ReturnValue,
|
|
315
343
|
};
|
|
344
|
+
|
|
316
345
|
if (this.condition?.expression) {
|
|
317
346
|
params.ConditionExpression = this.condition.expression;
|
|
318
347
|
}
|
|
@@ -322,9 +351,7 @@ export class UpdateBuilder<T> {
|
|
|
322
351
|
if (!result.Attributes) {
|
|
323
352
|
throw new Error("No attributes returned after update");
|
|
324
353
|
}
|
|
325
|
-
|
|
326
|
-
const rebuiltItem = await this.rebuildIndexes();
|
|
327
|
-
return this.parent.getSchema().parse(rebuiltItem) as T;
|
|
354
|
+
return this.parent.getSchema().parse(result.Attributes) as T;
|
|
328
355
|
} catch (error) {
|
|
329
356
|
// If there's nothing to update, just return the existing item
|
|
330
357
|
if (
|
package/test/update.test.ts
CHANGED
|
@@ -55,6 +55,7 @@ const UserSchema = z.object({
|
|
|
55
55
|
id: z.string(),
|
|
56
56
|
name: z.string(),
|
|
57
57
|
email: z.string().email(),
|
|
58
|
+
nickname: z.string().optional(),
|
|
58
59
|
points: z.number().optional(),
|
|
59
60
|
tags: z.set(z.string()).optional(),
|
|
60
61
|
});
|
|
@@ -229,7 +230,7 @@ describe("BetterDDB - Update Operation", () => {
|
|
|
229
230
|
await userDdb.create(newUser).execute();
|
|
230
231
|
|
|
231
232
|
// Create a second update builder for the transaction
|
|
232
|
-
const secondUpdate = userDdb
|
|
233
|
+
const secondUpdate = await userDdb
|
|
233
234
|
.update({ id: "user-456", email: "alice@example.com" })
|
|
234
235
|
.set({ name: "Alice Updated" })
|
|
235
236
|
.toTransactUpdate();
|
|
@@ -318,7 +319,7 @@ describe("BetterDDB - Update Operation", () => {
|
|
|
318
319
|
// First set initial values
|
|
319
320
|
await userDdb
|
|
320
321
|
.update({ id: "user-123", email: "john@example.com" })
|
|
321
|
-
.set({ name: "John Doe", points: 10 })
|
|
322
|
+
.set({ name: "John Doe", points: 10, nickname: "John" })
|
|
322
323
|
.execute();
|
|
323
324
|
|
|
324
325
|
// Update with a mix of values - some to set, some to remove
|
|
@@ -327,13 +328,15 @@ describe("BetterDDB - Update Operation", () => {
|
|
|
327
328
|
.set({
|
|
328
329
|
name: "", // Should trigger remove
|
|
329
330
|
points: undefined, // Should trigger remove
|
|
331
|
+
nickname: "", // Should trigger remove
|
|
330
332
|
email: "john@example.com", // Should remain (required field)
|
|
331
333
|
})
|
|
332
334
|
.execute();
|
|
333
335
|
|
|
334
336
|
// Fields should be removed, not null or undefined
|
|
335
|
-
expect(updatedUser.name).
|
|
337
|
+
expect(updatedUser.name).toBe(""); // Removed due to empty string
|
|
336
338
|
expect(updatedUser.points).toBeUndefined(); // Removed due to undefined
|
|
339
|
+
expect(updatedUser.nickname).toBeUndefined(); // Removed due to empty string
|
|
337
340
|
expect(updatedUser.email).toBe("john@example.com"); // Required field remains
|
|
338
341
|
|
|
339
342
|
// Now let's verify we can still set values normally
|
package/tsconfig.json
CHANGED