betterddb 0.7.0 → 0.8.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.
- package/babel.config.cjs +6 -0
- package/{jest.config.js → jest.config.cjs} +5 -4
- package/package.json +5 -1
- package/src/betterddb.ts +7 -7
- package/src/builders/update-builder.ts +86 -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.0",
|
|
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,15 +1,15 @@
|
|
|
1
1
|
import { type z } from "zod";
|
|
2
|
-
import { QueryBuilder } from "./builders/query-builder
|
|
3
|
-
import { ScanBuilder } from "./builders/scan-builder
|
|
4
|
-
import { UpdateBuilder } from "./builders/update-builder
|
|
5
|
-
import { CreateBuilder } from "./builders/create-builder
|
|
6
|
-
import { GetBuilder } from "./builders/get-builder
|
|
7
|
-
import { DeleteBuilder } from "./builders/delete-builder
|
|
2
|
+
import { QueryBuilder } from "./builders/query-builder";
|
|
3
|
+
import { ScanBuilder } from "./builders/scan-builder";
|
|
4
|
+
import { UpdateBuilder } from "./builders/update-builder";
|
|
5
|
+
import { CreateBuilder } from "./builders/create-builder";
|
|
6
|
+
import { GetBuilder } from "./builders/get-builder";
|
|
7
|
+
import { DeleteBuilder } from "./builders/delete-builder";
|
|
8
8
|
import {
|
|
9
9
|
type NativeAttributeValue,
|
|
10
10
|
type DynamoDBDocumentClient,
|
|
11
11
|
} from "@aws-sdk/lib-dynamodb";
|
|
12
|
-
import { BatchGetBuilder } from "./builders/batch-get-builder
|
|
12
|
+
import { BatchGetBuilder } from "./builders/batch-get-builder";
|
|
13
13
|
export type PrimaryKeyValue = string | number;
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -2,13 +2,15 @@ import {
|
|
|
2
2
|
type NativeAttributeValue,
|
|
3
3
|
TransactWriteCommand,
|
|
4
4
|
UpdateCommand,
|
|
5
|
+
type UpdateCommandInput,
|
|
5
6
|
} from "@aws-sdk/lib-dynamodb";
|
|
6
7
|
import { type BetterDDB } from "../betterddb.js";
|
|
7
8
|
import {
|
|
8
9
|
type TransactWriteItem,
|
|
9
10
|
type Update,
|
|
10
|
-
type
|
|
11
|
+
type ReturnValue,
|
|
11
12
|
} from "@aws-sdk/client-dynamodb";
|
|
13
|
+
|
|
12
14
|
interface UpdateActions<T> {
|
|
13
15
|
set?: Partial<T>;
|
|
14
16
|
remove?: (keyof T)[];
|
|
@@ -39,7 +41,9 @@ export class UpdateBuilder<T> {
|
|
|
39
41
|
(acc, [key, value]) => {
|
|
40
42
|
if (
|
|
41
43
|
value === undefined ||
|
|
42
|
-
(typeof value === "string" &&
|
|
44
|
+
(typeof value === "string" &&
|
|
45
|
+
value.trim() === "" &&
|
|
46
|
+
this.parent.getSchema().shape[key]?.isOptional?.())
|
|
43
47
|
) {
|
|
44
48
|
acc.toRemove.push(key as keyof T);
|
|
45
49
|
} else {
|
|
@@ -125,34 +129,44 @@ export class UpdateBuilder<T> {
|
|
|
125
129
|
/**
|
|
126
130
|
* Builds the update expression and associated maps.
|
|
127
131
|
*/
|
|
128
|
-
private buildExpression(): {
|
|
132
|
+
private buildExpression(newItemForIndexes?: T): {
|
|
129
133
|
updateExpression: string;
|
|
130
134
|
attributeNames: Record<string, string>;
|
|
131
135
|
attributeValues?: Record<string, NativeAttributeValue>;
|
|
132
136
|
} {
|
|
133
137
|
const ExpressionAttributeNames: Record<string, string> = {};
|
|
134
|
-
|
|
135
|
-
| Record<string, NativeAttributeValue>
|
|
136
|
-
| undefined = {};
|
|
138
|
+
const ExpressionAttributeValues: Record<string, NativeAttributeValue> = {};
|
|
137
139
|
const clauses: string[] = [];
|
|
138
140
|
|
|
139
|
-
//
|
|
141
|
+
// 1) SET – from actions.set
|
|
142
|
+
const setParts: string[] = [];
|
|
140
143
|
if (this.actions.set) {
|
|
141
|
-
const setParts: string[] = [];
|
|
142
144
|
for (const [attr, value] of Object.entries(this.actions.set)) {
|
|
143
145
|
const nameKey = `#n_${attr}`;
|
|
144
146
|
const valueKey = `:v_${attr}`;
|
|
145
147
|
ExpressionAttributeNames[nameKey] = attr;
|
|
146
|
-
ExpressionAttributeValues[valueKey] = value
|
|
148
|
+
ExpressionAttributeValues[valueKey] = value!;
|
|
147
149
|
setParts.push(`${nameKey} = ${valueKey}`);
|
|
148
150
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2) also SET – from index‐rebuild if requested
|
|
154
|
+
if (newItemForIndexes) {
|
|
155
|
+
const indexAttrs = this.parent.buildIndexes(newItemForIndexes);
|
|
156
|
+
for (const [attr, value] of Object.entries(indexAttrs)) {
|
|
157
|
+
const nameKey = `#n_idx_${attr}`;
|
|
158
|
+
const valueKey = `:v_idx_${attr}`;
|
|
159
|
+
ExpressionAttributeNames[nameKey] = attr;
|
|
160
|
+
ExpressionAttributeValues[valueKey] = value;
|
|
161
|
+
setParts.push(`${nameKey} = ${valueKey}`);
|
|
151
162
|
}
|
|
152
163
|
}
|
|
164
|
+
if (setParts.length > 0) {
|
|
165
|
+
clauses.push(`SET ${setParts.join(", ")}`);
|
|
166
|
+
}
|
|
153
167
|
|
|
154
|
-
//
|
|
155
|
-
if (this.actions.remove
|
|
168
|
+
// 3) REMOVE
|
|
169
|
+
if (this.actions.remove?.length) {
|
|
156
170
|
const removeParts = this.actions.remove.map((attr) => {
|
|
157
171
|
const nameKey = `#n_${String(attr)}`;
|
|
158
172
|
ExpressionAttributeNames[nameKey] = String(attr);
|
|
@@ -161,66 +175,70 @@ export class UpdateBuilder<T> {
|
|
|
161
175
|
clauses.push(`REMOVE ${removeParts.join(", ")}`);
|
|
162
176
|
}
|
|
163
177
|
|
|
164
|
-
//
|
|
178
|
+
// 4) ADD
|
|
165
179
|
if (this.actions.add) {
|
|
166
180
|
const addParts: string[] = [];
|
|
167
181
|
for (const [attr, value] of Object.entries(this.actions.add)) {
|
|
168
182
|
const nameKey = `#n_${attr}`;
|
|
169
183
|
const valueKey = `:v_${attr}`;
|
|
170
184
|
ExpressionAttributeNames[nameKey] = attr;
|
|
171
|
-
ExpressionAttributeValues[valueKey] = value;
|
|
185
|
+
ExpressionAttributeValues[valueKey] = value as NativeAttributeValue;
|
|
172
186
|
addParts.push(`${nameKey} ${valueKey}`);
|
|
173
187
|
}
|
|
174
|
-
if (addParts.length
|
|
188
|
+
if (addParts.length) {
|
|
175
189
|
clauses.push(`ADD ${addParts.join(", ")}`);
|
|
176
190
|
}
|
|
177
191
|
}
|
|
178
192
|
|
|
179
|
-
//
|
|
193
|
+
// 5) DELETE
|
|
180
194
|
if (this.actions.delete) {
|
|
181
195
|
const deleteParts: string[] = [];
|
|
182
196
|
for (const [attr, value] of Object.entries(this.actions.delete)) {
|
|
183
197
|
const nameKey = `#n_${attr}`;
|
|
184
198
|
const valueKey = `:v_${attr}`;
|
|
185
199
|
ExpressionAttributeNames[nameKey] = attr;
|
|
186
|
-
ExpressionAttributeValues[valueKey] = value;
|
|
200
|
+
ExpressionAttributeValues[valueKey] = value as NativeAttributeValue;
|
|
187
201
|
deleteParts.push(`${nameKey} ${valueKey}`);
|
|
188
202
|
}
|
|
189
|
-
if (deleteParts.length
|
|
203
|
+
if (deleteParts.length) {
|
|
190
204
|
clauses.push(`DELETE ${deleteParts.join(", ")}`);
|
|
191
205
|
}
|
|
192
206
|
}
|
|
193
207
|
|
|
194
|
-
//
|
|
208
|
+
// 6) merge in condition‐names/values
|
|
195
209
|
if (this.condition) {
|
|
196
210
|
Object.assign(ExpressionAttributeNames, this.condition.attributeNames);
|
|
197
211
|
Object.assign(ExpressionAttributeValues, this.condition.attributeValues);
|
|
198
212
|
}
|
|
199
213
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
// 7) normalize empty values
|
|
215
|
+
const hasValues = Object.keys(ExpressionAttributeValues).length > 0;
|
|
216
|
+
const finalValues = hasValues ? ExpressionAttributeValues : undefined;
|
|
203
217
|
|
|
204
|
-
// If no clauses were generated, throw an error
|
|
205
218
|
if (clauses.length === 0) {
|
|
206
219
|
throw new Error(
|
|
207
|
-
"No attributes to update
|
|
220
|
+
"No attributes to update – all values were empty or undefined",
|
|
208
221
|
);
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
return {
|
|
212
225
|
updateExpression: clauses.join(" "),
|
|
213
226
|
attributeNames: ExpressionAttributeNames,
|
|
214
|
-
attributeValues:
|
|
227
|
+
attributeValues: finalValues,
|
|
215
228
|
};
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
/**
|
|
219
232
|
* Returns a transaction update item that can be included in a transactWrite call.
|
|
220
233
|
*/
|
|
221
|
-
public toTransactUpdate(
|
|
234
|
+
public async toTransactUpdate(
|
|
235
|
+
newItemForIndexes?: T,
|
|
236
|
+
): Promise<TransactWriteItem> {
|
|
237
|
+
if (newItemForIndexes) {
|
|
238
|
+
newItemForIndexes = await this.createExpectedNewItem();
|
|
239
|
+
}
|
|
222
240
|
const { updateExpression, attributeNames, attributeValues } =
|
|
223
|
-
this.buildExpression();
|
|
241
|
+
this.buildExpression(newItemForIndexes);
|
|
224
242
|
const updateItem: Update = {
|
|
225
243
|
TableName: this.parent.getTableName(),
|
|
226
244
|
Key: this.parent.buildKey(this.key),
|
|
@@ -234,47 +252,48 @@ export class UpdateBuilder<T> {
|
|
|
234
252
|
return { Update: updateItem };
|
|
235
253
|
}
|
|
236
254
|
|
|
237
|
-
private async
|
|
255
|
+
private async createExpectedNewItem() {
|
|
238
256
|
const existingItem = await this.parent.get(this.key).execute();
|
|
239
257
|
if (existingItem === null) {
|
|
240
258
|
throw new Error("Item not found");
|
|
241
259
|
}
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
]
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const params: UpdateItemInput = {
|
|
258
|
-
TableName: this.parent.getTableName(),
|
|
259
|
-
Key: this.parent.buildKey(this.key),
|
|
260
|
-
UpdateExpression: updateExpression,
|
|
261
|
-
ExpressionAttributeNames: attributeNames,
|
|
262
|
-
ExpressionAttributeValues: attributeValues,
|
|
263
|
-
ReturnValues: "ALL_NEW",
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const result = await this.parent
|
|
267
|
-
.getClient()
|
|
268
|
-
.send(new UpdateCommand(params));
|
|
269
|
-
return result.Attributes;
|
|
260
|
+
const expectedNewItem: Record<string, any> = {
|
|
261
|
+
...existingItem,
|
|
262
|
+
...this.actions.set,
|
|
263
|
+
};
|
|
264
|
+
if (this.actions.remove) {
|
|
265
|
+
this.actions.remove.forEach((attr) => {
|
|
266
|
+
delete expectedNewItem[String(attr)];
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (this.actions.add) {
|
|
270
|
+
Object.entries(this.actions.add).forEach(([attr, value]) => {
|
|
271
|
+
const currentValue = expectedNewItem[attr] ?? 0;
|
|
272
|
+
expectedNewItem[attr] =
|
|
273
|
+
typeof value === "number" ? currentValue + value : value;
|
|
274
|
+
});
|
|
270
275
|
}
|
|
271
|
-
|
|
276
|
+
if (this.actions.delete) {
|
|
277
|
+
Object.entries(this.actions.delete).forEach(([attr, value]) => {
|
|
278
|
+
if (value instanceof Set) {
|
|
279
|
+
const currentSet = expectedNewItem[attr];
|
|
280
|
+
if (currentSet instanceof Set) {
|
|
281
|
+
value.forEach((v) => {
|
|
282
|
+
currentSet.delete(v);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return this.parent.getSchema().parse(expectedNewItem) as T;
|
|
272
289
|
}
|
|
273
290
|
|
|
274
291
|
/**
|
|
275
292
|
* Commits the update immediately by calling the parent's update method.
|
|
276
293
|
*/
|
|
277
294
|
public async execute(): Promise<T> {
|
|
295
|
+
const expectedNewItem = await this.createExpectedNewItem();
|
|
296
|
+
|
|
278
297
|
if (this.parent.getTimestamps()) {
|
|
279
298
|
const now = new Date().toISOString();
|
|
280
299
|
if (!this.actions.set) {
|
|
@@ -285,7 +304,7 @@ export class UpdateBuilder<T> {
|
|
|
285
304
|
if (this.extraTransactItems.length > 0) {
|
|
286
305
|
// For transactions, we must throw if there's nothing to update
|
|
287
306
|
// since we can't safely skip updates in a transaction
|
|
288
|
-
const myTransactItem = this.toTransactUpdate();
|
|
307
|
+
const myTransactItem = await this.toTransactUpdate(expectedNewItem);
|
|
289
308
|
const allItems = [...this.extraTransactItems, myTransactItem];
|
|
290
309
|
await this.parent.getClient().send(
|
|
291
310
|
new TransactWriteCommand({
|
|
@@ -297,22 +316,24 @@ export class UpdateBuilder<T> {
|
|
|
297
316
|
if (result === null) {
|
|
298
317
|
throw new Error("Item not found after transaction update");
|
|
299
318
|
}
|
|
300
|
-
|
|
301
|
-
return
|
|
319
|
+
|
|
320
|
+
return result;
|
|
302
321
|
}
|
|
303
322
|
|
|
304
323
|
// For normal updates, handle empty updates gracefully
|
|
305
324
|
try {
|
|
306
325
|
const { updateExpression, attributeNames, attributeValues } =
|
|
307
|
-
this.buildExpression();
|
|
308
|
-
|
|
326
|
+
this.buildExpression(expectedNewItem);
|
|
327
|
+
|
|
328
|
+
let params: UpdateCommandInput = {
|
|
309
329
|
TableName: this.parent.getTableName(),
|
|
310
330
|
Key: this.parent.buildKey(this.key),
|
|
311
331
|
UpdateExpression: updateExpression,
|
|
312
332
|
ExpressionAttributeNames: attributeNames,
|
|
313
333
|
ExpressionAttributeValues: attributeValues,
|
|
314
|
-
ReturnValues: "ALL_NEW",
|
|
334
|
+
ReturnValues: "ALL_NEW" as ReturnValue,
|
|
315
335
|
};
|
|
336
|
+
|
|
316
337
|
if (this.condition?.expression) {
|
|
317
338
|
params.ConditionExpression = this.condition.expression;
|
|
318
339
|
}
|
|
@@ -322,9 +343,7 @@ export class UpdateBuilder<T> {
|
|
|
322
343
|
if (!result.Attributes) {
|
|
323
344
|
throw new Error("No attributes returned after update");
|
|
324
345
|
}
|
|
325
|
-
|
|
326
|
-
const rebuiltItem = await this.rebuildIndexes();
|
|
327
|
-
return this.parent.getSchema().parse(rebuiltItem) as T;
|
|
346
|
+
return this.parent.getSchema().parse(result.Attributes) as T;
|
|
328
347
|
} catch (error) {
|
|
329
348
|
// If there's nothing to update, just return the existing item
|
|
330
349
|
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