dinah 0.11.0 → 0.12.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/README.md CHANGED
@@ -99,16 +99,34 @@ const UserTable = new Table(
99
99
 
100
100
  ### Using the Repository Class
101
101
 
102
- `Repository` is the recommended way to interact with DynamoDB. Created from a `Table`, it provides full type inference for keys, items, queries, and projections:
102
+ `Repo` is the recommended way to interact with DynamoDB. Use `db.makeRepo` to create a repo inline, with or without configuration:
103
103
 
104
104
  ```typescript
105
- const userRepo = db.createRepo(UserTable);
105
+ const userRepo = db.makeRepo(UserTable);
106
+
107
+ const userRepo = db.makeRepo(UserTable, {
108
+ defaultPutData: () => ({ createdAt: Date.now() }),
109
+ defaultUpdateData: () => ({ updatedAt: Date.now() }),
110
+ });
111
+ ```
112
+
113
+ To create a named, reusable repo class, use the standalone `makeRepo` (see [Repository Configuration](#repository-configuration)):
114
+
115
+ ```typescript
116
+ import { makeRepo } from "dinah";
117
+
118
+ class UserRepo extends makeRepo(UserTable, {
119
+ defaultPutData: () => ({ createdAt: Date.now() }),
120
+ defaultUpdateData: () => ({ updatedAt: Date.now() }),
121
+ }) {}
122
+
123
+ const userRepo = new UserRepo(db);
106
124
  ```
107
125
 
108
126
  #### CRUD
109
127
 
110
128
  ```typescript
111
- // Create (conditional put - fails if item already exists)
129
+ // Create (conditional put fails if item already exists)
112
130
  const user = await userRepo.create({
113
131
  userId: "u1",
114
132
  email: "alice@example.com",
@@ -126,14 +144,47 @@ const partial = await userRepo.get({ userId: "u1" }, { projection: ["name", "ema
126
144
  // getOrThrow (throws if item not found)
127
145
  const aliceOrThrow = await userRepo.getOrThrow({ userId: "u1" });
128
146
 
129
- // Update
147
+ // Put (upsert)
148
+ await userRepo.put({
149
+ userId: "u1",
150
+ email: "alice@example.com",
151
+ name: "Alice",
152
+ role: "admin",
153
+ createdAt: Date.now(),
154
+ });
155
+
156
+ // Update (throws if item does not exist)
130
157
  const updated = await userRepo.update(
131
158
  { userId: "u1" },
132
159
  { name: "Alice Smith", updatedAt: Date.now() },
133
160
  );
134
161
 
135
- // Delete
136
- await userRepo.delete({ userId: "u1" });
162
+ // Delete (returns old item or undefined)
163
+ const deleted = await userRepo.delete({ userId: "u1" });
164
+
165
+ // deleteOrThrow (throws if item not found)
166
+ const item = await userRepo.deleteOrThrow({ userId: "u1" });
167
+ ```
168
+
169
+ #### Update Expressions
170
+
171
+ The update argument supports MongoDB-style operators:
172
+
173
+ ```typescript
174
+ await userRepo.update(
175
+ { userId: "u1" },
176
+ {
177
+ name: "Alice", // set
178
+ age: undefined, // remove
179
+ score: { $plus: 10 }, // increment
180
+ score: { $minus: 5 }, // decrement
181
+ score: { $ifNotExists: 0 }, // set only if missing
182
+ tags: { $append: "vip" }, // list_append to end
183
+ tags: { $prepend: "featured" }, // list_append to front
184
+ followers: { $setAdd: "user-99" }, // ADD to DynamoDB set
185
+ followers: { $setDel: "user-99" }, // DELETE from DynamoDB set
186
+ },
187
+ );
137
188
  ```
138
189
 
139
190
  #### Querying
@@ -142,7 +193,10 @@ Queries use a MongoDB-like syntax with operators like `$gt`, `$between`, `$prefi
142
193
 
143
194
  ```typescript
144
195
  // Query by partition key
145
- const admins = await userRepo.query({ userId: "u1" });
196
+ const posts = await postRepo.query({ authorId: "u1" });
197
+
198
+ // Query with sort key condition
199
+ const recent = await postRepo.query({ authorId: "u1" }, { postId: { $gte: "2024-" } });
146
200
 
147
201
  // Query a GSI
148
202
  const adminsByDate = await userRepo.queryGsi("byRole", {
@@ -150,82 +204,153 @@ const adminsByDate = await userRepo.queryGsi("byRole", {
150
204
  createdAt: { $gt: 1700000000000 },
151
205
  });
152
206
 
207
+ // Paginated query (async generator, one page at a time)
208
+ for await (const page of postRepo.queryPaged({ authorId: "u1" }, { limit: 20 })) {
209
+ console.log(page);
210
+ }
211
+
212
+ // Paginated GSI query
213
+ for await (const page of userRepo.queryGsiPaged("byRole", { role: "admin" })) {
214
+ console.log(page);
215
+ }
216
+
153
217
  // Scan with filters
154
218
  const recentUsers = await userRepo.scan({
155
219
  filter: { createdAt: { $gte: 1700000000000 } },
156
220
  });
157
221
 
158
- // Paginated query with async iteration
159
- for await (const page of userRepo.queryGsiPaged("byRole", { role: "admin" })) {
160
- console.log(page); // each page is an array of items
161
- }
222
+ // Scan a GSI
223
+ const allByStatus = await postRepo.scanGsi("byStatus");
224
+
225
+ // Check existence (uses query or scan, no data returned)
226
+ const hasAdmins = await userRepo.existsGsi("byRole", { query: { role: "admin" } });
227
+ const exists = await postRepo.exists({ query: { authorId: "u1" } });
162
228
  ```
163
229
 
230
+ Filter operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$prefix`, `$includes`, `$exists`, `$size`, `$type`.
231
+
164
232
  #### Batch Operations
165
233
 
166
234
  ```typescript
167
235
  // Batch get
168
- const { items, unprocessed } = await userRepo.batchGet([
169
- { userId: "u1" },
170
- { userId: "u2" },
171
- { userId: "u3" },
172
- ]);
236
+ const { items, unprocessed } = await userRepo.batchGet([{ userId: "u1" }, { userId: "u2" }]);
173
237
 
174
- // Batch write (puts and deletes)
238
+ // Batch get (throws on missing or unprocessed)
239
+ const items = await userRepo.batchGetOrThrow([{ userId: "u1" }, { userId: "u2" }]);
240
+
241
+ // Batch write (puts and deletes mixed)
175
242
  await userRepo.batchWrite([
243
+ {
244
+ type: "PUT",
245
+ item: { userId: "u3", email: "c@d.com", name: "Carol", role: "user", createdAt: Date.now() },
246
+ },
247
+ { type: "DELETE", key: { userId: "u2" } },
248
+ ]);
249
+
250
+ // Batch update (same update applied to multiple keys via PartiQL)
251
+ await userRepo.batchUpdate([{ userId: "u1" }, { userId: "u3" }], { role: "admin" });
252
+ ```
253
+
254
+ #### Transactions
255
+
256
+ `trxWrite` accepts plain request objects and executes them as a single DynamoDB transaction:
257
+
258
+ ```typescript
259
+ await userRepo.trxWrite(
176
260
  {
177
261
  type: "PUT",
178
262
  item: {
179
- userId: "u4",
180
- email: "bob@example.com",
181
- name: "Bob",
263
+ userId: "u5",
264
+ email: "eve@example.com",
265
+ name: "Eve",
182
266
  role: "user",
183
267
  createdAt: Date.now(),
184
268
  },
185
269
  },
186
- { type: "DELETE", key: { userId: "u3" } },
187
- ]);
270
+ { type: "UPDATE", key: { userId: "u1" }, update: { role: "superadmin" } },
271
+ { type: "DELETE", key: { userId: "u2" } },
272
+ { type: "CONDITION", key: { userId: "u3" }, condition: { role: "admin" } },
273
+ );
188
274
  ```
189
275
 
190
- #### Transactions
276
+ Convenience methods operate on multiple keys/items atomically:
191
277
 
192
278
  ```typescript
193
- await userRepo.trxWrite(
194
- userRepo.trxPut({
195
- userId: "u5",
196
- email: "eve@example.com",
197
- name: "Eve",
198
- role: "user",
199
- createdAt: Date.now(),
200
- }),
201
- userRepo.trxUpdate({ userId: "u1" }, { role: "superadmin" }),
202
- userRepo.trxDelete({ userId: "u2" }),
279
+ // Transactional get
280
+ const [u1, u2] = await userRepo.trxGet([{ userId: "u1" }, { userId: "u2" }]);
281
+ const items = await userRepo.trxGetOrThrow([{ userId: "u1" }, { userId: "u2" }]);
282
+
283
+ // Transactional writes
284
+ await userRepo.trxPut([item1, item2]);
285
+ await userRepo.trxUpdate([{ userId: "u1" }, { userId: "u2" }], { role: "admin" });
286
+ await userRepo.trxDelete([{ userId: "u1" }, { userId: "u2" }]);
287
+ await userRepo.trxCreate([item1, item2]); // fails if any item already exists
288
+ ```
289
+
290
+ To build cross-repo transactions, use the `*Request` methods to produce request objects and pass them to `db.trxWrite`:
291
+
292
+ ```typescript
293
+ await db.trxWrite(
294
+ userRepo.trxPutRequest(userItem),
295
+ postRepo.trxDeleteRequest({ authorId: "u1", postId: "p1" }),
203
296
  );
204
297
  ```
205
298
 
206
- ### Default Values
299
+ ## Repository Configuration
207
300
 
208
- To attach defaults like timestamps to every write, subclass `AbstractRepo` and override `defaultPutData` and/or `defaultUpdateData`:
301
+ `makeRepo` accepts a config object that controls defaults, transforms, and attribute rules. Extend the result to create a named repo class:
209
302
 
210
303
  ```typescript
211
- import { AbstractRepo } from "dinah";
304
+ class UserRepo extends makeRepo(UserTable, {
305
+ defaultPutData: () => ({ createdAt: Date.now() }),
306
+ defaultUpdateData: () => ({ updatedAt: Date.now() }),
307
+ }) {}
308
+ ```
212
309
 
213
- class UserRepo extends AbstractRepo<typeof UserTable> {
214
- readonly table = UserTable;
310
+ `defaultPutData` is merged under every `put` / `create` / `batchWrite` put / `trxPut` / `trxCreate`. `defaultUpdateData` is merged under every `update` / `batchUpdate` / `trxUpdate`. Caller-provided values always win.
215
311
 
216
- override get defaultPutData() {
217
- return { createdAt: Date.now() };
218
- }
312
+ ### transformInput / transformOutput
219
313
 
220
- override get defaultUpdateData() {
221
- return { updatedAt: Date.now() };
222
- }
223
- }
314
+ `transformInput` runs on every write (after defaults are merged) and receives a partial of the schema. `transformOutput` runs on every read and maps the stored shape to your desired return type:
224
315
 
225
- const userRepo = new UserRepo(db);
316
+ ```typescript
317
+ class UserRepo extends makeRepo(UserTable, {
318
+ transformInput: (item) => ({
319
+ ...item,
320
+ email: item.email?.toLowerCase(),
321
+ }),
322
+ transformOutput: (item): UserWithDisplayName => ({
323
+ ...item,
324
+ displayName: `${item.name} <${item.email}>`,
325
+ }),
326
+ }) {}
327
+ ```
328
+
329
+ Transforms are skipped when a `projection` option is provided, since only a subset of fields is available.
330
+
331
+ ### derivedAttributes / immutableAttributes
332
+
333
+ `derivedAttributes` lists fields that are computed by `transformInput` and should never be written directly by the caller. They are stripped from put and update inputs:
334
+
335
+ ```typescript
336
+ class UserRepo extends makeRepo(UserTable, {
337
+ transformInput: (item) => ({
338
+ ...item,
339
+ emailDomain: item.email ? item.email.split("@")[1] : undefined,
340
+ }),
341
+ derivedAttributes: ["emailDomain"],
342
+ }) {}
343
+ ```
344
+
345
+ `immutableAttributes` lists fields that may be set on create but must not be changed by updates:
346
+
347
+ ```typescript
348
+ class UserRepo extends makeRepo(UserTable, {
349
+ immutableAttributes: ["createdAt"],
350
+ }) {}
226
351
  ```
227
352
 
228
- `defaultPutData` is merged under every `put` / `create` / `batchWrite` put / `trxPut` / `trxCreate`. `defaultUpdateData` is merged under every `update` / `trxUpdate`. Caller-provided values always win on conflict.
353
+ Both arrays are inferred as literal types no `as const` needed.
229
354
 
230
355
  ## License
231
356
 
package/dist/index.cjs CHANGED
@@ -3,6 +3,25 @@ const require_util = require("./util-D88GS6Gt.cjs");
3
3
  let _aws_sdk_client_dynamodb = require("@aws-sdk/client-dynamodb");
4
4
  let _aws_sdk_lib_dynamodb = require("@aws-sdk/lib-dynamodb");
5
5
  _aws_sdk_lib_dynamodb = require_util.__toESM(_aws_sdk_lib_dynamodb, 1);
6
+ //#region src/error.ts
7
+ function dinahErrorMessage(details) {
8
+ switch (details.type) {
9
+ case "NOT_FOUND": return `${details.resource ?? "Item"} not found: ${JSON.stringify(details.key)}`;
10
+ case "CONDITIONAL_CHECK_FAILED": return details.key ? `${details.resource ?? "Item"} condition check failed: ${JSON.stringify(details.key)}` : `${details.resource ?? "Item"} condition check failed`;
11
+ case "VALIDATION": return `Validation error: ${details.message}`;
12
+ case "TRANSACTION_CANCELED": return `Transaction canceled: ${details.reasons.map((r) => r.type).join(", ")}`;
13
+ }
14
+ }
15
+ var DinahError = class extends Error {
16
+ details;
17
+ constructor(details, options) {
18
+ super(dinahErrorMessage(details), options);
19
+ this.name = "DinahError";
20
+ this.details = details;
21
+ }
22
+ };
23
+ const isDinahError = (err) => err instanceof DinahError;
24
+ //#endregion
6
25
  //#region src/expression-builder.ts
7
26
  const attrTypes = [
8
27
  "S",
@@ -225,6 +244,13 @@ var Repo = class {
225
244
  get tableName() {
226
245
  return `${this.db.config?.tableNamePrefix ?? ""}${this.table.def.name}`;
227
246
  }
247
+ get resourceName() {
248
+ if (this.config.resourceName) return this.config.resourceName;
249
+ const clsName = this.constructor.name;
250
+ if (clsName && clsName !== "Repo") return clsName.replace(/Repo$/i, "") || clsName;
251
+ const t = this.table.def.name;
252
+ return t.charAt(0).toUpperCase() + t.slice(1);
253
+ }
228
254
  get defaultPutData() {
229
255
  return this.config.defaultPutData?.() ?? {};
230
256
  }
@@ -257,6 +283,7 @@ var Repo = class {
257
283
  const item = await this.db.getOrThrow({
258
284
  table: this.tableName,
259
285
  key: this.extractKey(key),
286
+ resource: this.resourceName,
260
287
  ...options
261
288
  });
262
289
  return this.applyTransformIfNeeded(item, options);
@@ -266,6 +293,7 @@ var Repo = class {
266
293
  const result = await this.db.put({
267
294
  table: this.tableName,
268
295
  item: itemWithDefaults,
296
+ resource: this.resourceName,
269
297
  ...options
270
298
  });
271
299
  return this.applyTransformIfNeeded(result);
@@ -278,6 +306,7 @@ var Repo = class {
278
306
  const result = await this.db.update({
279
307
  table: this.tableName,
280
308
  key: this.extractKey(key),
309
+ resource: this.resourceName,
281
310
  update: updateWithDefaults,
282
311
  ...options
283
312
  });
@@ -296,6 +325,7 @@ var Repo = class {
296
325
  const item = await this.db.delete({
297
326
  table: this.tableName,
298
327
  key: this.extractKey(key),
328
+ resource: this.resourceName,
299
329
  ...options
300
330
  });
301
331
  return item && this.applyTransformIfNeeded(item);
@@ -304,6 +334,7 @@ var Repo = class {
304
334
  const item = await this.db.deleteOrThrow({
305
335
  table: this.tableName,
306
336
  key: this.extractKey(key),
337
+ resource: this.resourceName,
307
338
  ...options
308
339
  });
309
340
  return this.applyTransformIfNeeded(item);
@@ -409,6 +440,7 @@ var Repo = class {
409
440
  async batchGetOrThrow(keys, options) {
410
441
  const result = await this.db.batchGetOrThrow({ [this.tableName]: {
411
442
  keys: keys.map((key) => this.extractKey(key)),
443
+ resource: this.resourceName,
412
444
  ...options
413
445
  } });
414
446
  return this.applyTransformsIfNeeded(result[this.tableName] ?? [], options);
@@ -450,6 +482,7 @@ var Repo = class {
450
482
  const items = await this.db.trxGetOrThrow(...keys.map((key) => ({
451
483
  table: this.tableName,
452
484
  key: this.extractKey(key),
485
+ resource: this.resourceName,
453
486
  ...options
454
487
  })));
455
488
  return this.applyTransformsIfNeeded(items, options);
@@ -620,8 +653,8 @@ var Db = class {
620
653
  }
621
654
  this.config = dbConfig;
622
655
  }
623
- createRepo(table) {
624
- return new Repo(this, table);
656
+ makeRepo(table, config) {
657
+ return new Repo(this, table, config);
625
658
  }
626
659
  async createTable(table) {
627
660
  await this.client.send(new _aws_sdk_client_dynamodb.CreateTableCommand(require_util.extractTableDesc(table)));
@@ -657,7 +690,11 @@ var Db = class {
657
690
  }
658
691
  async getOrThrow(data) {
659
692
  const item = await this.get(data);
660
- if (!item) throw new Error(`Item not found in "${data.table}" table.`);
693
+ if (!item) throw new DinahError({
694
+ type: "NOT_FOUND",
695
+ key: data.key,
696
+ resource: data.resource
697
+ });
661
698
  return item;
662
699
  }
663
700
  async put(data) {
@@ -672,8 +709,16 @@ var Db = class {
672
709
  ExpressionAttributeNames: exp.attributeNames,
673
710
  ExpressionAttributeValues: exp.attributeValues
674
711
  });
675
- const output = await this.client.send(input);
676
- return data.returnOld ? output.Attributes : item;
712
+ try {
713
+ const output = await this.client.send(input);
714
+ return data.returnOld ? output.Attributes : item;
715
+ } catch (err) {
716
+ if (err instanceof _aws_sdk_client_dynamodb.ConditionalCheckFailedException) throw new DinahError({
717
+ type: "CONDITIONAL_CHECK_FAILED",
718
+ resource: data.resource
719
+ }, { cause: err });
720
+ throw err;
721
+ }
677
722
  }
678
723
  async update(data) {
679
724
  const exp = new ExpressionBuilder();
@@ -689,7 +734,16 @@ var Db = class {
689
734
  ExpressionAttributeNames: exp.attributeNames,
690
735
  ExpressionAttributeValues: exp.attributeValues
691
736
  });
692
- return (await this.client.send(input)).Attributes;
737
+ try {
738
+ return (await this.client.send(input)).Attributes;
739
+ } catch (err) {
740
+ if (err instanceof _aws_sdk_client_dynamodb.ConditionalCheckFailedException) throw new DinahError({
741
+ type: "CONDITIONAL_CHECK_FAILED",
742
+ key: data.key,
743
+ resource: data.resource
744
+ }, { cause: err });
745
+ throw err;
746
+ }
693
747
  }
694
748
  async delete(data) {
695
749
  const exp = new ExpressionBuilder();
@@ -702,11 +756,24 @@ var Db = class {
702
756
  ExpressionAttributeNames: exp.attributeNames,
703
757
  ExpressionAttributeValues: exp.attributeValues
704
758
  });
705
- return (await this.client.send(input)).Attributes;
759
+ try {
760
+ return (await this.client.send(input)).Attributes;
761
+ } catch (err) {
762
+ if (err instanceof _aws_sdk_client_dynamodb.ConditionalCheckFailedException) throw new DinahError({
763
+ type: "CONDITIONAL_CHECK_FAILED",
764
+ key: data.key,
765
+ resource: data.resource
766
+ }, { cause: err });
767
+ throw err;
768
+ }
706
769
  }
707
770
  async deleteOrThrow(data) {
708
771
  const item = await this.delete(data);
709
- if (!item) throw new Error(`Item not found in "${data.table}" table.`);
772
+ if (!item) throw new DinahError({
773
+ type: "NOT_FOUND",
774
+ key: data.key,
775
+ resource: data.resource
776
+ });
710
777
  return item;
711
778
  }
712
779
  async *queryPaged(data) {
@@ -863,7 +930,11 @@ var Db = class {
863
930
  const { items, unprocessed } = await this.batchGet(data);
864
931
  for (const table of Object.keys(data)) {
865
932
  if (unprocessed?.[table]) throw new Error(`One or more batch get requests were not processed in "${table}" table.`);
866
- if (items[table]?.length !== data[table]?.keys?.length) throw new Error(`One or more items were not found in "${table}" table.`);
933
+ if (items[table]?.length !== data[table]?.keys?.length) throw new DinahError({
934
+ type: "NOT_FOUND",
935
+ key: {},
936
+ resource: data[table]?.resource
937
+ });
867
938
  }
868
939
  return items;
869
940
  }
@@ -983,7 +1054,11 @@ var Db = class {
983
1054
  }
984
1055
  async trxGetOrThrow(...requests) {
985
1056
  const items = await this.trxGet(...requests);
986
- for (let i = 0; i < items.length; i++) if (!items[i]) throw new Error(`One or more items were not found in "${requests[i]?.table}" table.`);
1057
+ for (let i = 0; i < items.length; i++) if (!items[i]) throw new DinahError({
1058
+ type: "NOT_FOUND",
1059
+ key: requests[i]?.key ?? {},
1060
+ resource: requests[i]?.resource
1061
+ });
987
1062
  return items;
988
1063
  }
989
1064
  async trxWrite(...requests) {
@@ -1013,7 +1088,18 @@ var Db = class {
1013
1088
  } };
1014
1089
  });
1015
1090
  const input = new _aws_sdk_lib_dynamodb.TransactWriteCommand({ TransactItems: trxItems });
1016
- await this.client.send(input);
1091
+ try {
1092
+ await this.client.send(input);
1093
+ } catch (err) {
1094
+ if (err instanceof _aws_sdk_client_dynamodb.TransactionCanceledException) throw new DinahError({
1095
+ type: "TRANSACTION_CANCELED",
1096
+ reasons: (err.CancellationReasons ?? []).map((r) => ({
1097
+ type: r.Code ?? "Unknown",
1098
+ message: r.Message
1099
+ }))
1100
+ }, { cause: err });
1101
+ throw err;
1102
+ }
1017
1103
  }
1018
1104
  };
1019
1105
  //#endregion
@@ -1028,10 +1114,12 @@ var Table = class {
1028
1114
  };
1029
1115
  //#endregion
1030
1116
  exports.Db = Db;
1117
+ exports.DinahError = DinahError;
1031
1118
  exports.Repo = Repo;
1032
1119
  exports.Table = Table;
1033
1120
  exports.chunk = require_util.chunk;
1034
1121
  exports.extractTableDesc = require_util.extractTableDesc;
1122
+ exports.isDinahError = isDinahError;
1035
1123
  exports.makeRepo = makeRepo;
1036
1124
  exports.removeUndefined = require_util.removeUndefined;
1037
1125
  exports.resolveAttrType = require_util.resolveAttrType;