dinah 0.10.0 → 0.11.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/README.md CHANGED
@@ -99,16 +99,29 @@ 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. For a plain repo with no configuration, use `db.createRepo`:
103
103
 
104
104
  ```typescript
105
- const userRepo = db.createRepo(UserTable);
105
+ const userRepo = db.makeRepo(UserTable);
106
+ ```
107
+
108
+ For repos with defaults, transforms, or attribute rules, use `makeRepo` to define a class (see [Repository Configuration](#repository-configuration)):
109
+
110
+ ```typescript
111
+ import { makeRepo } from "dinah";
112
+
113
+ class UserRepo extends makeRepo(UserTable, {
114
+ defaultPutData: () => ({ createdAt: Date.now() }),
115
+ defaultUpdateData: () => ({ updatedAt: Date.now() }),
116
+ }) {}
117
+
118
+ const userRepo = new UserRepo(db);
106
119
  ```
107
120
 
108
121
  #### CRUD
109
122
 
110
123
  ```typescript
111
- // Create (conditional put - fails if item already exists)
124
+ // Create (conditional put fails if item already exists)
112
125
  const user = await userRepo.create({
113
126
  userId: "u1",
114
127
  email: "alice@example.com",
@@ -126,14 +139,47 @@ const partial = await userRepo.get({ userId: "u1" }, { projection: ["name", "ema
126
139
  // getOrThrow (throws if item not found)
127
140
  const aliceOrThrow = await userRepo.getOrThrow({ userId: "u1" });
128
141
 
129
- // Update
142
+ // Put (upsert)
143
+ await userRepo.put({
144
+ userId: "u1",
145
+ email: "alice@example.com",
146
+ name: "Alice",
147
+ role: "admin",
148
+ createdAt: Date.now(),
149
+ });
150
+
151
+ // Update (throws if item does not exist)
130
152
  const updated = await userRepo.update(
131
153
  { userId: "u1" },
132
154
  { name: "Alice Smith", updatedAt: Date.now() },
133
155
  );
134
156
 
135
- // Delete
136
- await userRepo.delete({ userId: "u1" });
157
+ // Delete (returns old item or undefined)
158
+ const deleted = await userRepo.delete({ userId: "u1" });
159
+
160
+ // deleteOrThrow (throws if item not found)
161
+ const item = await userRepo.deleteOrThrow({ userId: "u1" });
162
+ ```
163
+
164
+ #### Update Expressions
165
+
166
+ The update argument supports MongoDB-style operators:
167
+
168
+ ```typescript
169
+ await userRepo.update(
170
+ { userId: "u1" },
171
+ {
172
+ name: "Alice", // set
173
+ age: undefined, // remove
174
+ score: { $plus: 10 }, // increment
175
+ score: { $minus: 5 }, // decrement
176
+ score: { $ifNotExists: 0 }, // set only if missing
177
+ tags: { $append: "vip" }, // list_append to end
178
+ tags: { $prepend: "featured" }, // list_append to front
179
+ followers: { $setAdd: "user-99" }, // ADD to DynamoDB set
180
+ followers: { $setDel: "user-99" }, // DELETE from DynamoDB set
181
+ },
182
+ );
137
183
  ```
138
184
 
139
185
  #### Querying
@@ -142,7 +188,10 @@ Queries use a MongoDB-like syntax with operators like `$gt`, `$between`, `$prefi
142
188
 
143
189
  ```typescript
144
190
  // Query by partition key
145
- const admins = await userRepo.query({ userId: "u1" });
191
+ const posts = await postRepo.query({ authorId: "u1" });
192
+
193
+ // Query with sort key condition
194
+ const recent = await postRepo.query({ authorId: "u1" }, { postId: { $gte: "2024-" } });
146
195
 
147
196
  // Query a GSI
148
197
  const adminsByDate = await userRepo.queryGsi("byRole", {
@@ -150,82 +199,153 @@ const adminsByDate = await userRepo.queryGsi("byRole", {
150
199
  createdAt: { $gt: 1700000000000 },
151
200
  });
152
201
 
202
+ // Paginated query (async generator, one page at a time)
203
+ for await (const page of postRepo.queryPaged({ authorId: "u1" }, { limit: 20 })) {
204
+ console.log(page);
205
+ }
206
+
207
+ // Paginated GSI query
208
+ for await (const page of userRepo.queryGsiPaged("byRole", { role: "admin" })) {
209
+ console.log(page);
210
+ }
211
+
153
212
  // Scan with filters
154
213
  const recentUsers = await userRepo.scan({
155
214
  filter: { createdAt: { $gte: 1700000000000 } },
156
215
  });
157
216
 
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
- }
217
+ // Scan a GSI
218
+ const allByStatus = await postRepo.scanGsi("byStatus");
219
+
220
+ // Check existence (uses query or scan, no data returned)
221
+ const hasAdmins = await userRepo.existsGsi("byRole", { query: { role: "admin" } });
222
+ const exists = await postRepo.exists({ query: { authorId: "u1" } });
162
223
  ```
163
224
 
225
+ Filter operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$prefix`, `$includes`, `$exists`, `$size`, `$type`.
226
+
164
227
  #### Batch Operations
165
228
 
166
229
  ```typescript
167
230
  // Batch get
168
- const { items, unprocessed } = await userRepo.batchGet([
169
- { userId: "u1" },
170
- { userId: "u2" },
171
- { userId: "u3" },
172
- ]);
231
+ const { items, unprocessed } = await userRepo.batchGet([{ userId: "u1" }, { userId: "u2" }]);
232
+
233
+ // Batch get (throws on missing or unprocessed)
234
+ const items = await userRepo.batchGetOrThrow([{ userId: "u1" }, { userId: "u2" }]);
173
235
 
174
- // Batch write (puts and deletes)
236
+ // Batch write (puts and deletes mixed)
175
237
  await userRepo.batchWrite([
238
+ {
239
+ type: "PUT",
240
+ item: { userId: "u3", email: "c@d.com", name: "Carol", role: "user", createdAt: Date.now() },
241
+ },
242
+ { type: "DELETE", key: { userId: "u2" } },
243
+ ]);
244
+
245
+ // Batch update (same update applied to multiple keys via PartiQL)
246
+ await userRepo.batchUpdate([{ userId: "u1" }, { userId: "u3" }], { role: "admin" });
247
+ ```
248
+
249
+ #### Transactions
250
+
251
+ `trxWrite` accepts plain request objects and executes them as a single DynamoDB transaction:
252
+
253
+ ```typescript
254
+ await userRepo.trxWrite(
176
255
  {
177
256
  type: "PUT",
178
257
  item: {
179
- userId: "u4",
180
- email: "bob@example.com",
181
- name: "Bob",
258
+ userId: "u5",
259
+ email: "eve@example.com",
260
+ name: "Eve",
182
261
  role: "user",
183
262
  createdAt: Date.now(),
184
263
  },
185
264
  },
186
- { type: "DELETE", key: { userId: "u3" } },
187
- ]);
265
+ { type: "UPDATE", key: { userId: "u1" }, update: { role: "superadmin" } },
266
+ { type: "DELETE", key: { userId: "u2" } },
267
+ { type: "CONDITION", key: { userId: "u3" }, condition: { role: "admin" } },
268
+ );
188
269
  ```
189
270
 
190
- #### Transactions
271
+ Convenience methods operate on multiple keys/items atomically:
191
272
 
192
273
  ```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" }),
274
+ // Transactional get
275
+ const [u1, u2] = await userRepo.trxGet([{ userId: "u1" }, { userId: "u2" }]);
276
+ const items = await userRepo.trxGetOrThrow([{ userId: "u1" }, { userId: "u2" }]);
277
+
278
+ // Transactional writes
279
+ await userRepo.trxPut([item1, item2]);
280
+ await userRepo.trxUpdate([{ userId: "u1" }, { userId: "u2" }], { role: "admin" });
281
+ await userRepo.trxDelete([{ userId: "u1" }, { userId: "u2" }]);
282
+ await userRepo.trxCreate([item1, item2]); // fails if any item already exists
283
+ ```
284
+
285
+ To build cross-repo transactions, use the `*Request` methods to produce request objects and pass them to `db.trxWrite`:
286
+
287
+ ```typescript
288
+ await db.trxWrite(
289
+ userRepo.trxPutRequest(userItem),
290
+ postRepo.trxDeleteRequest({ authorId: "u1", postId: "p1" }),
203
291
  );
204
292
  ```
205
293
 
206
- ### Default Values
294
+ ## Repository Configuration
207
295
 
208
- To attach defaults like timestamps to every write, subclass `AbstractRepo` and override `defaultPutData` and/or `defaultUpdateData`:
296
+ `makeRepo` accepts a config object that controls defaults, transforms, and attribute rules. Extend the result to create a named repo class:
209
297
 
210
298
  ```typescript
211
- import { AbstractRepo } from "dinah";
299
+ class UserRepo extends makeRepo(UserTable, {
300
+ defaultPutData: () => ({ createdAt: Date.now() }),
301
+ defaultUpdateData: () => ({ updatedAt: Date.now() }),
302
+ }) {}
303
+ ```
212
304
 
213
- class UserRepo extends AbstractRepo<typeof UserTable> {
214
- readonly table = UserTable;
305
+ `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
306
 
216
- override get defaultPutData() {
217
- return { createdAt: Date.now() };
218
- }
307
+ ### transformInput / transformOutput
219
308
 
220
- override get defaultUpdateData() {
221
- return { updatedAt: Date.now() };
222
- }
223
- }
309
+ `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
310
 
225
- const userRepo = new UserRepo(db);
311
+ ```typescript
312
+ class UserRepo extends makeRepo(UserTable, {
313
+ transformInput: (item) => ({
314
+ ...item,
315
+ email: item.email?.toLowerCase(),
316
+ }),
317
+ transformOutput: (item): UserWithDisplayName => ({
318
+ ...item,
319
+ displayName: `${item.name} <${item.email}>`,
320
+ }),
321
+ }) {}
322
+ ```
323
+
324
+ Transforms are skipped when a `projection` option is provided, since only a subset of fields is available.
325
+
326
+ ### derivedAttributes / immutableAttributes
327
+
328
+ `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:
329
+
330
+ ```typescript
331
+ class UserRepo extends makeRepo(UserTable, {
332
+ transformInput: (item) => ({
333
+ ...item,
334
+ emailDomain: item.email ? item.email.split("@")[1] : undefined,
335
+ }),
336
+ derivedAttributes: ["emailDomain"],
337
+ }) {}
338
+ ```
339
+
340
+ `immutableAttributes` lists fields that may be set on create but must not be changed by updates:
341
+
342
+ ```typescript
343
+ class UserRepo extends makeRepo(UserTable, {
344
+ immutableAttributes: ["createdAt"],
345
+ }) {}
226
346
  ```
227
347
 
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.
348
+ Both arrays are inferred as literal types no `as const` needed.
229
349
 
230
350
  ## License
231
351
 
package/dist/index.cjs CHANGED
@@ -216,24 +216,26 @@ var ExpressionBuilder = class {
216
216
  var Repo = class {
217
217
  table;
218
218
  db;
219
- constructor(db, table) {
219
+ config;
220
+ constructor(db, table, config) {
220
221
  this.db = db;
221
222
  this.table = table;
223
+ this.config = config ?? {};
222
224
  }
223
225
  get tableName() {
224
226
  return `${this.db.config?.tableNamePrefix ?? ""}${this.table.def.name}`;
225
227
  }
226
228
  get defaultPutData() {
227
- return {};
229
+ return this.config.defaultPutData?.() ?? {};
228
230
  }
229
231
  get defaultUpdateData() {
230
- return {};
232
+ return this.config.defaultUpdateData?.() ?? {};
231
233
  }
232
234
  transformOutput(item) {
233
- return item;
235
+ return this.config.transformOutput?.(item) ?? item;
234
236
  }
235
237
  transformInput(item) {
236
- return item;
238
+ return this.config.transformInput?.(item) ?? item;
237
239
  }
238
240
  extractKey(item) {
239
241
  const { partitionKey, sortKey } = this.table.def;
@@ -260,10 +262,7 @@ var Repo = class {
260
262
  return this.applyTransformIfNeeded(item, options);
261
263
  }
262
264
  async put(item, options) {
263
- const itemWithDefaults = this.transformInput({
264
- ...this.defaultPutData,
265
- ...item
266
- });
265
+ const itemWithDefaults = this.applyPutTransforms(item);
267
266
  const result = await this.db.put({
268
267
  table: this.tableName,
269
268
  item: itemWithDefaults,
@@ -422,10 +421,7 @@ var Repo = class {
422
421
  };
423
422
  else return {
424
423
  type: "PUT",
425
- item: this.transformInput({
426
- ...this.defaultPutData,
427
- ...request.item
428
- })
424
+ item: this.applyPutTransforms(request.item)
429
425
  };
430
426
  }) });
431
427
  return {
@@ -518,10 +514,7 @@ var Repo = class {
518
514
  };
519
515
  }
520
516
  trxPutRequest(item, options) {
521
- const itemWithDefaults = this.transformInput({
522
- ...this.defaultPutData,
523
- ...item
524
- });
517
+ const itemWithDefaults = this.applyPutTransforms(item);
525
518
  return {
526
519
  table: this.tableName,
527
520
  type: "PUT",
@@ -546,10 +539,7 @@ var Repo = class {
546
539
  const { condition: otherCondition, ...otherOptions } = options ?? {};
547
540
  const condition = { $and: [{ [this.table.def.partitionKey]: { $exists: false } }] };
548
541
  if (otherCondition) condition.$and.push(otherCondition);
549
- const itemWithDefaults = this.transformInput({
550
- ...this.defaultPutData,
551
- ...item
552
- });
542
+ const itemWithDefaults = this.applyPutTransforms(item);
553
543
  return {
554
544
  table: this.tableName,
555
545
  type: "PUT",
@@ -558,28 +548,39 @@ var Repo = class {
558
548
  ...otherOptions
559
549
  };
560
550
  }
551
+ applyPutTransforms(item) {
552
+ const result = { ...this.transformInput({
553
+ ...this.defaultPutData,
554
+ ...item
555
+ }) };
556
+ for (const key of this.config.derivedAttributes ?? []) delete result[key];
557
+ return result;
558
+ }
561
559
  applyNormalizersToExpression(expression) {
562
560
  const partial = {};
563
- for (const [key, val] of Object.entries(expression)) {
564
- if (val === void 0) continue;
565
- if (!isOperation(val)) partial[key] = val;
566
- else if (val.$set !== void 0) partial[key] = val.$set;
567
- else if (val.$ifNotExists !== void 0) partial[key] = Array.isArray(val.$ifNotExists) ? val.$ifNotExists[1] : val.$ifNotExists;
568
- }
561
+ for (const [key, val] of Object.entries(expression)) if (val === void 0 || isOperation(val) && val.$remove === true) partial[key] = void 0;
562
+ else if (!isOperation(val)) partial[key] = val;
563
+ else if (val.$set !== void 0) partial[key] = val.$set;
564
+ else if (val.$ifNotExists !== void 0) partial[key] = Array.isArray(val.$ifNotExists) ? val.$ifNotExists[1] : val.$ifNotExists;
569
565
  const normalized = this.transformInput(partial);
570
566
  const result = { ...expression };
571
- for (const [key, normalizedVal] of Object.entries(normalized)) if (key in partial) {
572
- const original = expression[key];
573
- if (!isOperation(original)) result[key] = normalizedVal;
574
- else if (original.$set !== void 0) result[key] = {
575
- ...original,
576
- $set: normalizedVal
577
- };
578
- else if (original.$ifNotExists !== void 0) result[key] = {
579
- ...original,
580
- $ifNotExists: Array.isArray(original.$ifNotExists) ? [original.$ifNotExists[0], normalizedVal] : normalizedVal
581
- };
582
- } else if (normalizedVal !== void 0) result[key] = normalizedVal;
567
+ for (const [key, normalizedVal] of Object.entries(normalized)) {
568
+ if (normalizedVal === void 0) continue;
569
+ if (key in partial && partial[key] === void 0 || !(key in partial)) result[key] = normalizedVal;
570
+ else {
571
+ const original = expression[key];
572
+ if (!isOperation(original)) result[key] = normalizedVal;
573
+ else if (original.$set !== void 0) result[key] = {
574
+ ...original,
575
+ $set: normalizedVal
576
+ };
577
+ else if (original.$ifNotExists !== void 0) result[key] = {
578
+ ...original,
579
+ $ifNotExists: Array.isArray(original.$ifNotExists) ? [original.$ifNotExists[0], normalizedVal] : normalizedVal
580
+ };
581
+ }
582
+ }
583
+ for (const key of [...this.config.derivedAttributes ?? [], ...this.config.immutableAttributes ?? []]) delete result[key];
583
584
  return result;
584
585
  }
585
586
  applyTransformsIfNeeded(items, options) {
@@ -595,11 +596,11 @@ var Repo = class {
595
596
  return transformedItem;
596
597
  }
597
598
  };
598
- const makeRepo = (table) => {
599
+ const makeRepo = (table, config) => {
599
600
  return class extends Repo {
600
601
  static table = table;
601
602
  constructor(db) {
602
- super(db, table);
603
+ super(db, table, config);
603
604
  }
604
605
  };
605
606
  };
@@ -619,8 +620,8 @@ var Db = class {
619
620
  }
620
621
  this.config = dbConfig;
621
622
  }
622
- createRepo(table) {
623
- return new Repo(this, table);
623
+ makeRepo(table, config) {
624
+ return new Repo(this, table, config);
624
625
  }
625
626
  async createTable(table) {
626
627
  await this.client.send(new _aws_sdk_client_dynamodb.CreateTableCommand(require_util.extractTableDesc(table)));