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.
@@ -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.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
@@ -20,6 +20,7 @@ export type PrimaryKeyValue = string | number;
20
20
  export type KeyDefinition<T> =
21
21
  | keyof T
22
22
  | {
23
+ // eslint-disable-next-line no-unused-vars
23
24
  build: (rawKey: Partial<T>) => string;
24
25
  };
25
26
 
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import { type BetterDDB } from "../betterddb.js";
2
3
  import { BatchGetCommand } from "@aws-sdk/lib-dynamodb";
3
4
  import { type BatchGetItemInput } from "@aws-sdk/client-dynamodb";
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import {
2
3
  type AttributeValue,
3
4
  type Put,
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import { type BetterDDB } from "../betterddb.js";
2
3
  import {
3
4
  type TransactWriteItem,
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import { type BetterDDB } from "../betterddb.js";
2
3
  import { TransactGetCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
3
4
  import {
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import {
2
3
  type NativeAttributeValue,
3
4
  QueryCommand,
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-unused-vars */
1
2
  import {
2
3
  ScanCommand,
3
4
  type ScanCommandInput,
@@ -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 UpdateItemInput,
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" && value.trim() === "")
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
- let ExpressionAttributeValues:
135
- | Record<string, NativeAttributeValue>
136
- | undefined = {};
139
+ const ExpressionAttributeValues: Record<string, NativeAttributeValue> = {};
137
140
  const clauses: string[] = [];
138
141
 
139
- // Build SET clause.
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
- if (setParts.length > 0) {
150
- clauses.push(`SET ${setParts.join(", ")}`);
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
- // Build REMOVE clause.
155
- if (this.actions.remove && this.actions.remove.length > 0) {
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
- // Build ADD clause.
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 > 0) {
189
+ if (addParts.length) {
175
190
  clauses.push(`ADD ${addParts.join(", ")}`);
176
191
  }
177
192
  }
178
193
 
179
- // Build DELETE clause.
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 > 0) {
204
+ if (deleteParts.length) {
190
205
  clauses.push(`DELETE ${deleteParts.join(", ")}`);
191
206
  }
192
207
  }
193
208
 
194
- // Merge any provided condition attribute names and values
209
+ // 6) merge in conditionnames/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
- if (Object.keys(ExpressionAttributeValues).length === 0) {
201
- ExpressionAttributeValues = undefined;
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 - all values were empty or undefined",
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: ExpressionAttributeValues,
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(): TransactWriteItem {
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 rebuildIndexes() {
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 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;
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 existingItem;
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
- const rebuiltItem = await this.rebuildIndexes();
301
- return this.parent.getSchema().parse(rebuiltItem) as T;
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
- const params: UpdateItemInput = {
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 (
@@ -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
  }