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.
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ presets: [
3
+ ['@babel/preset-env', { targets: { node: 'current' } }],
4
+ '@babel/preset-typescript',
5
+ ],
6
+ };
@@ -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)$": "ts-jest",
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
- extensionsToTreatAsEsm: [".ts", ".tsx"],
13
- moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
14
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"]
14
15
  };
15
16
 
16
- export default config;
17
+ module.exports = config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "betterddb",
3
- "version": "0.7.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.js";
3
- import { ScanBuilder } from "./builders/scan-builder.js";
4
- import { UpdateBuilder } from "./builders/update-builder.js";
5
- import { CreateBuilder } from "./builders/create-builder.js";
6
- import { GetBuilder } from "./builders/get-builder.js";
7
- import { DeleteBuilder } from "./builders/delete-builder.js";
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.js";
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 UpdateItemInput,
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" && value.trim() === "")
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
- let ExpressionAttributeValues:
135
- | Record<string, NativeAttributeValue>
136
- | undefined = {};
138
+ const ExpressionAttributeValues: Record<string, NativeAttributeValue> = {};
137
139
  const clauses: string[] = [];
138
140
 
139
- // Build SET clause.
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
- if (setParts.length > 0) {
150
- clauses.push(`SET ${setParts.join(", ")}`);
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
- // Build REMOVE clause.
155
- if (this.actions.remove && this.actions.remove.length > 0) {
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
- // Build ADD clause.
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 > 0) {
188
+ if (addParts.length) {
175
189
  clauses.push(`ADD ${addParts.join(", ")}`);
176
190
  }
177
191
  }
178
192
 
179
- // Build DELETE clause.
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 > 0) {
203
+ if (deleteParts.length) {
190
204
  clauses.push(`DELETE ${deleteParts.join(", ")}`);
191
205
  }
192
206
  }
193
207
 
194
- // Merge any provided condition attribute names and values
208
+ // 6) merge in conditionnames/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
- if (Object.keys(ExpressionAttributeValues).length === 0) {
201
- ExpressionAttributeValues = undefined;
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 - all values were empty or undefined",
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: ExpressionAttributeValues,
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(): TransactWriteItem {
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 rebuildIndexes() {
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 indexAttributes = this.parent.buildIndexes(existingItem);
243
- if (Object.keys(indexAttributes).length > 0) {
244
- const updateExpression = `SET ${Object.entries(indexAttributes)
245
- .map(([attr]) => `#n_${attr} = :v_${attr}`)
246
- .join(", ")}`;
247
- const attributeNames = Object.fromEntries(
248
- Object.entries(indexAttributes).map(([attr]) => [`#n_${attr}`, attr]),
249
- );
250
- const attributeValues = Object.fromEntries(
251
- Object.entries(indexAttributes).map(([attr, value]) => [
252
- `:v_${attr}`,
253
- value,
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
- return existingItem;
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
- const rebuiltItem = await this.rebuildIndexes();
301
- return this.parent.getSchema().parse(rebuiltItem) as T;
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
- const params: UpdateItemInput = {
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 (
@@ -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).toBeUndefined(); // Removed due to empty string
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
@@ -16,8 +16,8 @@
16
16
  "sourceMap": true,
17
17
  "composite": true,
18
18
  "declarationMap": true,
19
- "rootDir": "./src"
19
+ "rootDir": "."
20
20
  },
21
- "include": ["src/**/*"],
21
+ "include": ["src/**/*", "test/**/*"],
22
22
  "exclude": ["node_modules", "dist"]
23
23
  }