@datar-platform/better-auth-dynamodb 0.1.1 → 0.2.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/dist/index.js CHANGED
@@ -1,20 +1,79 @@
1
1
  // src/adapter.ts
2
2
  import { createAdapterFactory } from "better-auth/adapters";
3
3
 
4
+ // src/errors.ts
5
+ var DynamoDBAdapterError = class extends Error {
6
+ constructor(message, options) {
7
+ super(message, options);
8
+ this.name = "DynamoDBAdapterError";
9
+ }
10
+ };
11
+ var UniqueConstraintError = class extends DynamoDBAdapterError {
12
+ constructor(model, fields, options) {
13
+ super(
14
+ `better-auth-dynamodb: unique constraint violation on "${model}" (duplicate value for ${fields.join(", ")})`,
15
+ options
16
+ );
17
+ this.model = model;
18
+ this.fields = fields;
19
+ this.name = "UniqueConstraintError";
20
+ }
21
+ model;
22
+ fields;
23
+ };
24
+ var UnsupportedQueryError = class extends DynamoDBAdapterError {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "UnsupportedQueryError";
28
+ }
29
+ };
30
+ var OptimisticLockError = class extends DynamoDBAdapterError {
31
+ constructor(model, id, options) {
32
+ super(
33
+ `better-auth-dynamodb: concurrent modification of "${model}" row "${id}" \u2014 the record changed between read and write`,
34
+ options
35
+ );
36
+ this.name = "OptimisticLockError";
37
+ }
38
+ };
39
+ var named = (error, name) => typeof error === "object" && error !== null && "name" in error && error.name === name;
40
+ var isConditionalCheckFailed = (error) => named(error, "ConditionalCheckFailedException");
41
+ var isTransactionCanceled = (error) => named(error, "TransactionCanceledException");
42
+ function transactionCancellationCodes(error) {
43
+ if (typeof error !== "object" || error === null || !("CancellationReasons" in error)) {
44
+ return [];
45
+ }
46
+ const reasons = error.CancellationReasons;
47
+ if (!Array.isArray(reasons)) return [];
48
+ return reasons.map(
49
+ (reason) => typeof reason === "object" && reason !== null && "Code" in reason ? reason.Code : void 0
50
+ ).filter((code) => typeof code === "string");
51
+ }
52
+ var isConditionalTransactionCanceled = (error) => isTransactionCanceled(error) && transactionCancellationCodes(error).includes("ConditionalCheckFailed");
53
+ var isTransactionConflict = (error) => isTransactionCanceled(error) && transactionCancellationCodes(error).includes("TransactionConflict");
54
+
4
55
  // src/pagination.ts
5
- async function drainPages(fetch) {
56
+ var DEFAULT_MAX_PAGES = 25;
57
+ async function drainPages(fetch, maxPages = DEFAULT_MAX_PAGES, label = "query") {
6
58
  const out = [];
7
59
  let cursor = void 0;
60
+ let pages = 0;
8
61
  do {
9
62
  const page = await fetch(cursor);
10
63
  out.push(...page.items);
11
64
  cursor = page.cursor;
65
+ if (++pages >= maxPages && cursor) {
66
+ throw new DynamoDBAdapterError(
67
+ `better-auth-dynamodb: ${label} exceeded maxPages (${maxPages}) with more pages remaining. Raise maxPages or narrow the query \u2014 returning a partial result here would silently drop rows.`
68
+ );
69
+ }
12
70
  } while (cursor);
13
71
  return out;
14
72
  }
15
73
  function matchOne(item, w) {
16
- const actual = item[w.field];
17
- const expected = w.value;
74
+ const fold = (v) => w.mode === "insensitive" && typeof v === "string" ? v.toLowerCase() : v;
75
+ const actual = fold(item[w.field]);
76
+ const expected = Array.isArray(w.value) ? w.value.map(fold) : fold(w.value);
18
77
  switch (w.operator) {
19
78
  case "eq":
20
79
  return actual === expected;
@@ -67,7 +126,7 @@ function applyWindow(items, offset, limit) {
67
126
 
68
127
  // src/planner.ts
69
128
  function isKeyable(w) {
70
- return w.operator === "eq" && w.connector === "AND" && w.value != null;
129
+ return w.operator === "eq" && w.connector === "AND" && w.value != null && w.mode !== "insensitive";
71
130
  }
72
131
  function planQuery(model, where, indexMap) {
73
132
  const eqByField = /* @__PURE__ */ new Map();
@@ -82,6 +141,16 @@ function planQuery(model, where, indexMap) {
82
141
  residual: where.filter((w) => w !== idClause)
83
142
  };
84
143
  }
144
+ const idIn = where.find(
145
+ (w) => w.field === "id" && w.operator === "in" && w.connector === "AND" && Array.isArray(w.value)
146
+ );
147
+ if (idIn) {
148
+ return {
149
+ kind: "byIds",
150
+ ids: idIn.value.map(String),
151
+ residual: where.filter((w) => w !== idIn)
152
+ };
153
+ }
85
154
  const patterns = indexMap[model] ?? [];
86
155
  for (const pattern of patterns) {
87
156
  if (!pattern.pk.every((f) => eqByField.has(f))) continue;
@@ -117,13 +186,13 @@ function deriveIndexMap(schema) {
117
186
  for (const [model, table] of Object.entries(schema)) {
118
187
  const patterns = [];
119
188
  const seen = /* @__PURE__ */ new Set();
120
- const add = (field) => {
189
+ const add = (field, unique = false) => {
121
190
  if (field === "id" || seen.has(field)) return;
122
191
  seen.add(field);
123
- patterns.push({ index: lookupIndexName(field), pk: [field] });
192
+ patterns.push({ index: lookupIndexName(field), pk: [field], unique });
124
193
  };
125
194
  for (const [field, attr] of Object.entries(table.fields)) {
126
- if (attr.unique) add(field);
195
+ if (attr.unique) add(field, true);
127
196
  }
128
197
  for (const [field, attr] of Object.entries(table.fields)) {
129
198
  if (attr.references) add(field);
@@ -137,6 +206,7 @@ function deriveIndexMap(schema) {
137
206
  }
138
207
 
139
208
  // src/stores/default/single-table-store.ts
209
+ import { randomUUID } from "crypto";
140
210
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
141
211
  import {
142
212
  DeleteCommand,
@@ -144,22 +214,68 @@ import {
144
214
  GetCommand,
145
215
  PutCommand,
146
216
  QueryCommand,
217
+ TransactWriteCommand,
147
218
  UpdateCommand
148
219
  } from "@aws-sdk/lib-dynamodb";
149
220
 
150
221
  // src/stores/default/key-codec.ts
222
+ import { createHash } from "crypto";
151
223
  var PK = "__ba_pk";
152
224
  var SK = "__ba_sk";
153
225
  var TYPE_PK = "__ba_tpk";
154
226
  var TYPE_SK = "__ba_tsk";
155
227
  var TYPE_INDEX = "byType";
228
+ var REVISION = "__ba_rev";
229
+ var DEFAULT_TTL_ATTRIBUTE = "__ba_ttl";
156
230
  var SK_CONST = "#";
157
231
  var RESERVED_PREFIX = "__ba_";
232
+ var ENTITY_PREFIX = "E";
233
+ var LOOKUP_PREFIX = "L";
234
+ var UNIQUE_PREFIX = "U";
235
+ var MAX_PARTITION_KEY_BYTES = 2048;
236
+ var MAX_SORT_KEY_BYTES = 1024;
237
+ var MAX_VALUE_COMPONENT_BYTES = 256;
158
238
  var gsiName = (slot) => `lookup${slot}`;
159
239
  var gsiPk = (slot) => `__ba_g${slot}pk`;
160
240
  var gsiSk = (slot) => `__ba_g${slot}sk`;
161
- var enc = (v) => String(v);
162
- var joinValues = (values) => values.map(enc).join("#");
241
+ var byteLength = (value) => Buffer.byteLength(value, "utf8");
242
+ var component = (value) => `s${byteLength(value)}:${value}`;
243
+ function encodeValue(value) {
244
+ if (value === null || value === void 0) return "null:";
245
+ if (value instanceof Date) return `date:${value.toISOString()}`;
246
+ switch (typeof value) {
247
+ case "string":
248
+ return `string:${value}`;
249
+ case "number":
250
+ return `number:${value}`;
251
+ case "boolean":
252
+ return `boolean:${value}`;
253
+ case "bigint":
254
+ return `bigint:${value}`;
255
+ case "symbol":
256
+ return `symbol:${value.description ?? ""}`;
257
+ case "function":
258
+ return "function:";
259
+ default:
260
+ return `object:${JSON.stringify(value)}`;
261
+ }
262
+ }
263
+ function valueComponent(value) {
264
+ const typed = encodeValue(value);
265
+ if (byteLength(typed) > MAX_VALUE_COMPONENT_BYTES) {
266
+ return `hs:${createHash("sha256").update(typed).digest("hex")}`;
267
+ }
268
+ return component(typed);
269
+ }
270
+ var joinValues = (values) => values.map(valueComponent).join("#");
271
+ function checkedKey(value, label, maxBytes, keyType) {
272
+ if (byteLength(value) <= maxBytes) return value;
273
+ throw new DynamoDBAdapterError(
274
+ `better-auth-dynamodb: ${label} exceeds DynamoDB's ${maxBytes}-byte ${keyType} key limit. Field values are hashed past ${MAX_VALUE_COMPONENT_BYTES} bytes, so this means the model name, index name, or record id is itself too long.`
275
+ );
276
+ }
277
+ var partitionKey = (value, label) => checkedKey(value, label, MAX_PARTITION_KEY_BYTES, "partition");
278
+ var sortKey = (value, label) => checkedKey(value, label, MAX_SORT_KEY_BYTES, "sort");
163
279
  function assignSlots(indexMap) {
164
280
  const slots = {};
165
281
  let maxSlots = 0;
@@ -174,16 +290,36 @@ function assignSlots(indexMap) {
174
290
  return { slots, maxSlots };
175
291
  }
176
292
  var primaryKey = (model, id) => ({
177
- [PK]: `${model}#${id}`,
293
+ [PK]: partitionKey(
294
+ `${ENTITY_PREFIX}#${component(model)}#${component(id)}`,
295
+ "entity partition key"
296
+ ),
178
297
  [SK]: SK_CONST
179
298
  });
180
- var lookupPkValue = (model, index, values) => `${model}#${index}#${joinValues(values)}`;
299
+ var uniqueMarkerKey = (model, index, values) => ({
300
+ [PK]: partitionKey(
301
+ `${UNIQUE_PREFIX}#${component(model)}#${component(index)}#${joinValues(values)}`,
302
+ "unique marker partition key"
303
+ ),
304
+ [SK]: SK_CONST
305
+ });
306
+ var lookupPkValue = (model, index, values) => partitionKey(
307
+ `${LOOKUP_PREFIX}#${component(model)}#${component(index)}#${joinValues(values)}`,
308
+ "lookup partition key"
309
+ );
181
310
  function encodeKeys(model, item, indexMap, assignment) {
182
311
  const id = String(item.id);
183
312
  const keys = {
184
313
  ...primaryKey(model, id),
185
314
  [TYPE_PK]: model,
186
- [TYPE_SK]: `${enc(item.createdAt ?? "")}#${id}`
315
+ // Deliberately *not* length-prefixed: this sort key exists to order a
316
+ // model's rows by creation time, and a byte-length prefix would sort
317
+ // lexicographically by length instead of chronologically. It is never used
318
+ // for `begins_with` lookups, so it carries no collision risk.
319
+ [TYPE_SK]: sortKey(
320
+ `${String(item.createdAt ?? "")}#${id}`,
321
+ "type sort key"
322
+ )
187
323
  };
188
324
  for (const pattern of indexMap[model] ?? []) {
189
325
  const pkValues = pattern.pk.map((f) => item[f]);
@@ -192,14 +328,17 @@ function encodeKeys(model, item, indexMap, assignment) {
192
328
  if (!slot) continue;
193
329
  keys[gsiPk(slot)] = lookupPkValue(model, pattern.index, pkValues);
194
330
  const skValues = (pattern.sk ?? []).map((f) => item[f]);
195
- keys[gsiSk(slot)] = skValues.some((v) => v == null) ? `${id}` : `${joinValues(skValues)}#${id}`;
331
+ keys[gsiSk(slot)] = sortKey(
332
+ skValues.some((v) => v == null) ? component(id) : `${joinValues(skValues)}#${component(id)}`,
333
+ "lookup sort key"
334
+ );
196
335
  }
197
336
  return keys;
198
337
  }
199
338
  function encodeLookupQuery(model, index, key, indexMap, assignment) {
200
339
  const slot = assignment.slots[model]?.[index];
201
340
  if (!slot) {
202
- throw new Error(
341
+ throw new DynamoDBAdapterError(
203
342
  `No physical slot for index "${index}" on model "${model}". Ensure the index is present in the adapter's index map.`
204
343
  );
205
344
  }
@@ -227,6 +366,7 @@ function stripReserved(item) {
227
366
  import {
228
367
  CreateTableCommand,
229
368
  ResourceInUseException,
369
+ UpdateTimeToLiveCommand,
230
370
  waitUntilTableExists
231
371
  } from "@aws-sdk/client-dynamodb";
232
372
  function buildTableDefinition(tableName, lookupSlots) {
@@ -283,9 +423,32 @@ async function ensureSchema(opts) {
283
423
  { client: opts.client, maxWaitTime: 60 },
284
424
  { TableName: opts.tableName }
285
425
  );
426
+ if (!opts.ttlAttribute) return;
427
+ try {
428
+ await opts.client.send(
429
+ new UpdateTimeToLiveCommand({
430
+ TableName: opts.tableName,
431
+ TimeToLiveSpecification: {
432
+ Enabled: true,
433
+ AttributeName: opts.ttlAttribute
434
+ }
435
+ })
436
+ );
437
+ } catch (error) {
438
+ if (!isAlreadyEnabled(error)) throw error;
439
+ }
286
440
  }
441
+ var isAlreadyEnabled = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "ValidationException" && typeof error.message === "string" && error.message.includes("already");
287
442
  function generateSchemaFile(opts) {
288
- const table = buildTableDefinition(opts.tableName, opts.lookupSlots);
443
+ const table = {
444
+ ...buildTableDefinition(opts.tableName, opts.lookupSlots),
445
+ ...opts.ttlAttribute ? {
446
+ TimeToLiveSpecification: {
447
+ Enabled: true,
448
+ AttributeName: opts.ttlAttribute
449
+ }
450
+ } : {}
451
+ };
289
452
  const template = {
290
453
  AWSTemplateFormatVersion: "2010-09-09",
291
454
  Resources: {
@@ -309,6 +472,14 @@ export default dynamoDBSchema;
309
472
  }
310
473
 
311
474
  // src/stores/default/single-table-store.ts
475
+ var MAX_TRANSACT_ITEMS = 100;
476
+ var MAX_WRITE_ATTEMPTS = 3;
477
+ var DEFAULT_MAX_PAGES2 = 25;
478
+ var epochSeconds = (value) => {
479
+ const date = value instanceof Date ? value : typeof value === "string" || typeof value === "number" ? new Date(value) : void 0;
480
+ if (!date || Number.isNaN(date.getTime())) return void 0;
481
+ return Math.floor(date.getTime() / 1e3);
482
+ };
312
483
  function createSingleTableStore(opts) {
313
484
  const tableName = opts.tableName ?? process.env.DYNAMODB_TABLE_NAME ?? "better-auth";
314
485
  const doc = opts.documentClient ?? DynamoDBDocumentClient.from(
@@ -320,107 +491,400 @@ function createSingleTableStore(opts) {
320
491
  );
321
492
  const indexMap = opts.indexMap;
322
493
  const assignment = assignSlots(indexMap);
494
+ const atomicUniqueness = opts.atomicUniqueness ?? true;
495
+ const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES2;
496
+ const ttl = opts.ttl === false ? void 0 : opts.ttl;
497
+ const ttlAttribute = ttl?.attributeName ?? DEFAULT_TTL_ATTRIBUTE;
498
+ const ttlFieldFor = (model) => ttl ? ttl.fields?.[model] ?? ttl.defaultField : void 0;
499
+ const ttlAttributesFor = (model, item) => {
500
+ const field = ttlFieldFor(model);
501
+ if (!field) return {};
502
+ const seconds = epochSeconds(item[field]);
503
+ return seconds === void 0 ? {} : { [ttlAttribute]: seconds };
504
+ };
505
+ const isExpired = (item) => {
506
+ if (!item || !ttl) return false;
507
+ const seconds = item[ttlAttribute];
508
+ return typeof seconds === "number" && seconds <= Math.floor(Date.now() / 1e3);
509
+ };
510
+ const modelHasTtl = (model) => Boolean(ttlFieldFor(model));
511
+ const uniquePatternsFor = (model) => atomicUniqueness ? (indexMap[model] ?? []).filter((p) => p.unique) : [];
512
+ const uniqueFieldNames = (model) => uniquePatternsFor(model).flatMap((p) => p.pk);
513
+ const markerItem = (model, pattern, values, ownerId, source) => ({
514
+ ...uniqueMarkerKey(model, pattern.index, values),
515
+ __ba_owner: ownerId,
516
+ ...ttlAttributesFor(model, source)
517
+ });
518
+ const applicableValues = (pattern, item) => {
519
+ const values = pattern.pk.map((f) => item[f]);
520
+ return values.some((v) => v == null) ? null : values;
521
+ };
522
+ const uniqueMarkerPuts = (model, item, id) => uniquePatternsFor(model).flatMap((pattern) => {
523
+ const values = applicableValues(pattern, item);
524
+ if (!values) return [];
525
+ return [
526
+ {
527
+ Put: {
528
+ TableName: tableName,
529
+ Item: markerItem(model, pattern, values, id, item),
530
+ ConditionExpression: "attribute_not_exists(#pk)",
531
+ ExpressionAttributeNames: { "#pk": PK }
532
+ }
533
+ }
534
+ ];
535
+ });
536
+ const uniqueMarkerDeletes = (model, item) => {
537
+ if (!item) return [];
538
+ return uniquePatternsFor(model).flatMap((pattern) => {
539
+ const values = applicableValues(pattern, item);
540
+ if (!values) return [];
541
+ return [
542
+ {
543
+ Delete: {
544
+ TableName: tableName,
545
+ Key: uniqueMarkerKey(model, pattern.index, values)
546
+ }
547
+ }
548
+ ];
549
+ });
550
+ };
551
+ const uniqueMarkerDiff = (model, before, after, id) => {
552
+ const items = [];
553
+ for (const pattern of uniquePatternsFor(model)) {
554
+ const oldValues = applicableValues(pattern, before);
555
+ const newValues = applicableValues(pattern, after);
556
+ if (oldValues?.length === newValues?.length && oldValues?.every((v, i) => v === newValues[i])) {
557
+ continue;
558
+ }
559
+ if (oldValues) {
560
+ items.push({
561
+ Delete: {
562
+ TableName: tableName,
563
+ Key: uniqueMarkerKey(model, pattern.index, oldValues)
564
+ }
565
+ });
566
+ }
567
+ if (newValues) {
568
+ items.push({
569
+ Put: {
570
+ TableName: tableName,
571
+ Item: markerItem(model, pattern, newValues, id, after),
572
+ ConditionExpression: "attribute_not_exists(#pk)",
573
+ ExpressionAttributeNames: { "#pk": PK }
574
+ }
575
+ });
576
+ }
577
+ }
578
+ return items;
579
+ };
580
+ const keyOf = (action) => {
581
+ const target = action.Put?.Item ?? action.Delete?.Key ?? action.Update?.Key ?? {};
582
+ return `${String(target[PK])}`;
583
+ };
584
+ const validateTransaction = (model, items) => {
585
+ if (items.length > MAX_TRANSACT_ITEMS) {
586
+ throw new DynamoDBAdapterError(
587
+ `better-auth-dynamodb: a single write to "${model}" would need ${items.length} transaction actions, over DynamoDB's ${MAX_TRANSACT_ITEMS}-action limit. Reduce the number of unique fields on this model.`
588
+ );
589
+ }
590
+ const seen = /* @__PURE__ */ new Set();
591
+ for (const item of items) {
592
+ const key = keyOf(item);
593
+ if (seen.has(key)) {
594
+ throw new DynamoDBAdapterError(
595
+ `better-auth-dynamodb: internal error \u2014 two actions in one transaction target the same item on "${model}". Please report this.`
596
+ );
597
+ }
598
+ seen.add(key);
599
+ }
600
+ };
601
+ const sendTransaction = async (model, items) => {
602
+ validateTransaction(model, items);
603
+ await doc.send(
604
+ new TransactWriteCommand({
605
+ TransactItems: items,
606
+ // Gives the SDK's internal retries of this one send a stable token.
607
+ // Not cross-invocation idempotency.
608
+ ClientRequestToken: randomUUID()
609
+ })
610
+ );
611
+ };
612
+ const asUniqueConflict = (model, err) => {
613
+ if (isConditionalTransactionCanceled(err)) {
614
+ throw new UniqueConstraintError(model, uniqueFieldNames(model), {
615
+ cause: err
616
+ });
617
+ }
618
+ throw err;
619
+ };
620
+ const readRaw = async (model, id) => {
621
+ const res = await doc.send(
622
+ new GetCommand({
623
+ TableName: tableName,
624
+ Key: primaryKey(model, id),
625
+ ConsistentRead: true
626
+ })
627
+ );
628
+ if (!res.Item || isExpired(res.Item)) return null;
629
+ return res.Item;
630
+ };
631
+ const revisionGuard = (existing) => {
632
+ const revision = existing[REVISION];
633
+ return revision === void 0 ? {
634
+ ConditionExpression: "attribute_exists(#pk) AND attribute_not_exists(#rev)",
635
+ ExpressionAttributeNames: { "#pk": PK, "#rev": REVISION }
636
+ } : {
637
+ ConditionExpression: "attribute_exists(#pk) AND #rev = :rev",
638
+ ExpressionAttributeNames: { "#pk": PK, "#rev": REVISION },
639
+ ExpressionAttributeValues: { ":rev": revision }
640
+ };
641
+ };
642
+ const rejectedByGuard = (err) => isConditionalCheckFailed(err) || isConditionalTransactionCanceled(err);
323
643
  const query = async (input, cursor, countOnly = false) => {
324
644
  const res = await doc.send(
325
645
  new QueryCommand({
326
646
  TableName: tableName,
327
647
  ExclusiveStartKey: cursor,
328
648
  ...countOnly ? { Select: "COUNT" } : {},
649
+ ...opts.pageSize ? { Limit: opts.pageSize } : {},
329
650
  ...input
330
651
  })
331
652
  );
332
653
  return {
333
- items: countOnly ? [] : (res.Items ?? []).map((i) => stripReserved(i)),
654
+ items: countOnly ? [] : (res.Items ?? []).filter((item) => !isExpired(item)).map((i) => stripReserved(i)),
334
655
  count: res.Count ?? 0,
335
656
  cursor: res.LastEvaluatedKey
336
657
  };
337
658
  };
659
+ const putEntity = (model, item) => ({
660
+ Put: {
661
+ TableName: tableName,
662
+ Item: {
663
+ ...item,
664
+ ...encodeKeys(model, item, indexMap, assignment),
665
+ ...ttlAttributesFor(model, item),
666
+ [REVISION]: randomUUID()
667
+ },
668
+ ConditionExpression: "attribute_not_exists(#pk)",
669
+ ExpressionAttributeNames: { "#pk": PK }
670
+ }
671
+ });
672
+ const guardedWrite = async (model, id, build, onMissing) => {
673
+ for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
674
+ const existing = await readRaw(model, id);
675
+ if (!existing) return onMissing();
676
+ const { items, result } = build(existing);
677
+ try {
678
+ await sendTransaction(model, items);
679
+ return result;
680
+ } catch (err) {
681
+ const guarded = rejectedByGuard(err);
682
+ if (!guarded && !isTransactionConflict(err)) throw err;
683
+ if (guarded && await lostToUniqueness(model, id, existing)) {
684
+ return asUniqueConflict(model, err);
685
+ }
686
+ if (attempt === MAX_WRITE_ATTEMPTS) {
687
+ throw new OptimisticLockError(model, id, { cause: err });
688
+ }
689
+ }
690
+ }
691
+ throw new OptimisticLockError(model, id);
692
+ };
693
+ const lostToUniqueness = async (model, id, previous) => {
694
+ if (uniquePatternsFor(model).length === 0) return false;
695
+ const current = await readRaw(model, id);
696
+ return Boolean(current) && current[REVISION] === previous[REVISION];
697
+ };
338
698
  return {
339
699
  async put(model, item) {
340
- await doc.send(
341
- new PutCommand({
342
- TableName: tableName,
343
- Item: { ...item, ...encodeKeys(model, item, indexMap, assignment) }
344
- })
345
- );
700
+ const id = String(item.id);
701
+ const entity = putEntity(model, item);
702
+ const markers = uniqueMarkerPuts(model, item, id);
703
+ try {
704
+ if (markers.length === 0) {
705
+ await doc.send(new PutCommand({ ...entity.Put }));
706
+ } else {
707
+ await sendTransaction(model, [entity, ...markers]);
708
+ }
709
+ } catch (err) {
710
+ if (isConditionalCheckFailed(err)) {
711
+ throw new DynamoDBAdapterError(
712
+ `better-auth-dynamodb: a "${model}" row with id "${id}" already exists.`,
713
+ { cause: err }
714
+ );
715
+ }
716
+ asUniqueConflict(model, err);
717
+ }
346
718
  return item;
347
719
  },
348
720
  async getById(model, id) {
349
- const res = await doc.send(
350
- new GetCommand({ TableName: tableName, Key: primaryKey(model, id) })
351
- );
352
- return stripReserved(res.Item);
721
+ const raw = await readRaw(model, id);
722
+ return stripReserved(raw);
353
723
  },
354
724
  async update(model, id, patch) {
355
- const res = await doc.send(
356
- new GetCommand({ TableName: tableName, Key: primaryKey(model, id) })
357
- );
358
- const existing = stripReserved(res.Item);
359
- if (!existing) return null;
360
- const merged = { ...existing, ...patch };
361
- await doc.send(
362
- new PutCommand({
363
- TableName: tableName,
364
- Item: {
365
- ...merged,
366
- ...encodeKeys(model, merged, indexMap, assignment)
367
- }
368
- })
725
+ return guardedWrite(
726
+ model,
727
+ id,
728
+ (existing) => {
729
+ const clean = stripReserved(existing);
730
+ const merged = { ...clean, ...patch };
731
+ return {
732
+ items: [
733
+ {
734
+ Put: {
735
+ TableName: tableName,
736
+ Item: {
737
+ ...merged,
738
+ ...encodeKeys(model, merged, indexMap, assignment),
739
+ ...ttlAttributesFor(model, merged),
740
+ [REVISION]: randomUUID()
741
+ },
742
+ ...revisionGuard(existing)
743
+ }
744
+ },
745
+ ...uniqueMarkerDiff(model, clean, merged, id)
746
+ ],
747
+ result: merged
748
+ };
749
+ },
750
+ () => null
369
751
  );
370
- return merged;
371
752
  },
372
753
  async deleteById(model, id) {
373
- await doc.send(
374
- new DeleteCommand({ TableName: tableName, Key: primaryKey(model, id) })
754
+ await guardedWrite(
755
+ model,
756
+ id,
757
+ (existing) => ({
758
+ items: [
759
+ {
760
+ Delete: {
761
+ TableName: tableName,
762
+ Key: primaryKey(model, id),
763
+ ...revisionGuard(existing)
764
+ }
765
+ },
766
+ ...uniqueMarkerDeletes(model, stripReserved(existing))
767
+ ],
768
+ result: void 0
769
+ }),
770
+ () => void 0
375
771
  );
376
772
  },
377
773
  async consumeOne(model, id) {
378
- const res = await doc.send(
379
- new DeleteCommand({
380
- TableName: tableName,
381
- Key: primaryKey(model, id),
382
- ReturnValues: "ALL_OLD"
383
- })
384
- );
385
- return stripReserved(res.Attributes);
774
+ if (uniquePatternsFor(model).length === 0) {
775
+ const res = await doc.send(
776
+ new DeleteCommand({
777
+ TableName: tableName,
778
+ Key: primaryKey(model, id),
779
+ ReturnValues: "ALL_OLD"
780
+ })
781
+ );
782
+ const item = res.Attributes;
783
+ return isExpired(item) ? null : stripReserved(item);
784
+ }
785
+ const existing = await readRaw(model, id);
786
+ if (!existing) return null;
787
+ const clean = stripReserved(existing);
788
+ try {
789
+ await sendTransaction(model, [
790
+ {
791
+ Delete: {
792
+ TableName: tableName,
793
+ Key: primaryKey(model, id),
794
+ ...revisionGuard(existing)
795
+ }
796
+ },
797
+ ...uniqueMarkerDeletes(model, clean)
798
+ ]);
799
+ } catch (err) {
800
+ if (rejectedByGuard(err)) return null;
801
+ throw err;
802
+ }
803
+ return clean;
386
804
  },
387
805
  async incrementOne(model, id, { increment, set }) {
388
- const names = {};
389
- const values = {};
806
+ const indexedFields = new Set(
807
+ (indexMap[model] ?? []).flatMap((p) => [...p.pk, ...p.sk ?? []])
808
+ );
809
+ const touchesIndex = Object.keys(set ?? {}).some(
810
+ (f) => indexedFields.has(f)
811
+ );
812
+ const ttlField = ttlFieldFor(model);
813
+ const touchesTtl = Boolean(ttlField && set && ttlField in set);
814
+ if (touchesIndex || touchesTtl) {
815
+ return guardedWrite(
816
+ model,
817
+ id,
818
+ (existing) => {
819
+ const clean = stripReserved(existing);
820
+ const merged = { ...clean, ...set ?? {} };
821
+ for (const [field, delta] of Object.entries(increment)) {
822
+ const current = merged[field];
823
+ merged[field] = (typeof current === "number" ? current : 0) + delta;
824
+ }
825
+ return {
826
+ items: [
827
+ {
828
+ Put: {
829
+ TableName: tableName,
830
+ Item: {
831
+ ...merged,
832
+ ...encodeKeys(model, merged, indexMap, assignment),
833
+ ...ttlAttributesFor(model, merged),
834
+ [REVISION]: randomUUID()
835
+ },
836
+ ...revisionGuard(existing)
837
+ }
838
+ },
839
+ ...uniqueMarkerDiff(model, clean, merged, id)
840
+ ],
841
+ result: merged
842
+ };
843
+ },
844
+ () => null
845
+ );
846
+ }
847
+ const names = { "#pk": PK, "#rev": REVISION };
848
+ const values = { ":rev": randomUUID() };
390
849
  const addClauses = [];
391
- const setClauses = [];
850
+ const setClauses = ["#rev = :rev"];
392
851
  let i = 0;
393
852
  for (const [field, delta] of Object.entries(increment)) {
394
- const nameKey = `#f${i}`;
395
- const valueKey = `:v${i}`;
396
- names[nameKey] = field;
397
- values[valueKey] = delta;
398
- addClauses.push(`${nameKey} ${valueKey}`);
853
+ names[`#f${i}`] = field;
854
+ values[`:v${i}`] = delta;
855
+ addClauses.push(`#f${i} :v${i}`);
399
856
  i++;
400
857
  }
401
858
  for (const [field, value] of Object.entries(set ?? {})) {
402
- const nameKey = `#f${i}`;
403
- const valueKey = `:v${i}`;
404
- names[nameKey] = field;
405
- values[valueKey] = value;
406
- setClauses.push(`${nameKey} = ${valueKey}`);
859
+ names[`#f${i}`] = field;
860
+ values[`:v${i}`] = value;
861
+ setClauses.push(`#f${i} = :v${i}`);
407
862
  i++;
408
863
  }
409
864
  const expression = [
410
- setClauses.length ? `SET ${setClauses.join(", ")}` : null,
865
+ `SET ${setClauses.join(", ")}`,
411
866
  addClauses.length ? `ADD ${addClauses.join(", ")}` : null
412
867
  ].filter(Boolean).join(" ");
413
- const res = await doc.send(
414
- new UpdateCommand({
415
- TableName: tableName,
416
- Key: primaryKey(model, id),
417
- UpdateExpression: expression,
418
- ExpressionAttributeNames: names,
419
- ExpressionAttributeValues: values,
420
- ReturnValues: "ALL_NEW"
421
- })
422
- );
423
- return stripReserved(res.Attributes);
868
+ try {
869
+ const res = await doc.send(
870
+ new UpdateCommand({
871
+ TableName: tableName,
872
+ Key: primaryKey(model, id),
873
+ UpdateExpression: expression,
874
+ // Without this, `ADD` would happily create the row it was told to
875
+ // increment, turning "increment an existing counter" into an upsert.
876
+ ConditionExpression: "attribute_exists(#pk)",
877
+ ExpressionAttributeNames: names,
878
+ ExpressionAttributeValues: values,
879
+ ReturnValues: "ALL_NEW"
880
+ })
881
+ );
882
+ const item = res.Attributes;
883
+ return isExpired(item) ? null : stripReserved(item);
884
+ } catch (err) {
885
+ if (isConditionalCheckFailed(err)) return null;
886
+ throw err;
887
+ }
424
888
  },
425
889
  async queryIndex({ model, index, key, cursor }) {
426
890
  const lookup = encodeLookupQuery(model, index, key, indexMap, assignment);
@@ -456,6 +920,7 @@ function createSingleTableStore(opts) {
456
920
  return { items, cursor: next };
457
921
  },
458
922
  async count({ model, index, key }) {
923
+ if (modelHasTtl(model)) return null;
459
924
  let base;
460
925
  if (index && key) {
461
926
  const lookup = encodeLookupQuery(
@@ -481,10 +946,16 @@ function createSingleTableStore(opts) {
481
946
  }
482
947
  let total = 0;
483
948
  let cursor = void 0;
949
+ let pages = 0;
484
950
  do {
485
951
  const page = await query(base, cursor, true);
486
952
  total += page.count;
487
953
  cursor = page.cursor;
954
+ if (++pages >= maxPages && cursor) {
955
+ throw new DynamoDBAdapterError(
956
+ `better-auth-dynamodb: counting "${model}" exceeded maxPages (${maxPages}) with more pages remaining. Raise maxPages or narrow the query \u2014 returning a partial count would be wrong.`
957
+ );
958
+ }
488
959
  } while (cursor);
489
960
  return total;
490
961
  },
@@ -492,7 +963,8 @@ function createSingleTableStore(opts) {
492
963
  generateSchemaFile({
493
964
  tableName,
494
965
  lookupSlots: assignment.maxSlots,
495
- file
966
+ file,
967
+ ttlAttribute: ttl ? ttlAttribute : void 0
496
968
  })
497
969
  )
498
970
  };
@@ -517,12 +989,18 @@ var dynamoAdapter = (config = {}) => {
517
989
  customTransformInput: ({ data, fieldAttributes }) => fieldAttributes.type === "date" && data instanceof Date ? data.toISOString() : data,
518
990
  customTransformOutput: ({ data, fieldAttributes }) => fieldAttributes.type === "date" && typeof data === "string" ? new Date(data) : data
519
991
  },
520
- adapter: ({ schema, debugLog, getDefaultModelName }) => {
992
+ adapter: ({ schema, debugLog, getDefaultModelName, getFieldName }) => {
521
993
  const indexMap = config.indexMap ?? deriveIndexMap(schema);
994
+ const maxPages = config.maxPages ?? DEFAULT_MAX_PAGES;
522
995
  const store = config.store ?? createSingleTableStore({
523
996
  tableName: config.tableName,
524
997
  region: config.region,
525
998
  endpoint: config.endpoint,
999
+ documentClient: config.documentClient,
1000
+ atomicUniqueness: config.atomicUniqueness,
1001
+ maxPages,
1002
+ pageSize: config.pageSize,
1003
+ ttl: config.ttl,
526
1004
  indexMap
527
1005
  });
528
1006
  const queryAll = async (model, where) => {
@@ -533,6 +1011,12 @@ var dynamoAdapter = (config = {}) => {
533
1011
  if (plan.kind === "byId") {
534
1012
  const one = await store.getById(model, plan.id);
535
1013
  candidates = one ? [one] : [];
1014
+ } else if (plan.kind === "byIds") {
1015
+ const found = await mapBatched(
1016
+ plan.ids,
1017
+ (id) => store.getById(model, id)
1018
+ );
1019
+ candidates = found.filter((item) => item !== null);
536
1020
  } else if (plan.kind === "index") {
537
1021
  candidates = await drainPages(
538
1022
  (cursor) => store.queryIndex({
@@ -540,11 +1024,20 @@ var dynamoAdapter = (config = {}) => {
540
1024
  index: plan.index,
541
1025
  key: plan.key,
542
1026
  cursor
543
- })
1027
+ }),
1028
+ maxPages,
1029
+ `index query on "${model}"`
544
1030
  );
545
1031
  } else {
1032
+ if (!config.unsafeAllowScan) {
1033
+ throw new UnsupportedQueryError(
1034
+ `better-auth-dynamodb: no index can serve this query on "${model}" (${describeWhere(clauses)}), so it would have to read every row of the model and filter in memory. Add the field to the index map \u2014 or to the Better Auth schema as \`unique\`, \`references\`, or \`index: true\` \u2014 or set \`unsafeAllowScan: true\` to accept the cost.`
1035
+ );
1036
+ }
546
1037
  candidates = await drainPages(
547
- (cursor) => store.listByType({ model, cursor })
1038
+ (cursor) => store.listByType({ model, cursor }),
1039
+ maxPages,
1040
+ `model scan of "${model}"`
548
1041
  );
549
1042
  }
550
1043
  return candidates.filter(
@@ -553,10 +1046,21 @@ var dynamoAdapter = (config = {}) => {
553
1046
  };
554
1047
  const resolveId = async (model, where) => {
555
1048
  const plan = planQuery(model, where, indexMap);
556
- if (plan.kind === "byId") return plan.id;
1049
+ if (plan.kind === "byId" && plan.residual.length === 0) return plan.id;
557
1050
  const [first] = await queryAll(model, where);
558
1051
  return first ? String(first.id) : null;
559
1052
  };
1053
+ const project = (model, items, select) => {
1054
+ if (!select?.length) return items;
1055
+ const stored = select.map((field) => getFieldName({ model, field }));
1056
+ return items.map((item) => {
1057
+ const picked = {};
1058
+ for (const field of stored) {
1059
+ if (field in item) picked[field] = item[field];
1060
+ }
1061
+ return picked;
1062
+ });
1063
+ };
560
1064
  return {
561
1065
  async create({
562
1066
  model,
@@ -612,10 +1116,11 @@ var dynamoAdapter = (config = {}) => {
612
1116
  },
613
1117
  async findOne({
614
1118
  model,
615
- where
1119
+ where,
1120
+ select
616
1121
  }) {
617
1122
  const m = getDefaultModelName(model);
618
- const [first] = await queryAll(m, where);
1123
+ const [first] = project(m, await queryAll(m, where), select);
619
1124
  return first ?? null;
620
1125
  },
621
1126
  async findMany({
@@ -623,13 +1128,18 @@ var dynamoAdapter = (config = {}) => {
623
1128
  where,
624
1129
  limit,
625
1130
  sortBy,
626
- offset
1131
+ offset,
1132
+ select
627
1133
  }) {
628
1134
  const m = getDefaultModelName(model);
629
- const items = applyWindow(
630
- applySort(await queryAll(m, where), sortBy),
631
- offset,
632
- limit
1135
+ const items = project(
1136
+ m,
1137
+ applyWindow(
1138
+ applySort(await queryAll(m, where), sortBy),
1139
+ offset,
1140
+ limit
1141
+ ),
1142
+ select
633
1143
  );
634
1144
  return items;
635
1145
  },
@@ -641,10 +1151,17 @@ var dynamoAdapter = (config = {}) => {
641
1151
  const clauses = where ?? [];
642
1152
  const plan = planQuery(m, clauses, indexMap);
643
1153
  if (plan.residual.length === 0 && store.count) {
644
- const fast = await store.count(
645
- plan.kind === "index" ? { model: m, index: plan.index, key: plan.key } : { model: m }
646
- );
647
- if (fast != null) return fast;
1154
+ if (plan.kind === "index") {
1155
+ const fast = await store.count({
1156
+ model: m,
1157
+ index: plan.index,
1158
+ key: plan.key
1159
+ });
1160
+ if (fast != null) return fast;
1161
+ } else if (plan.kind === "listByType") {
1162
+ const fast = await store.count({ model: m });
1163
+ if (fast != null) return fast;
1164
+ }
648
1165
  }
649
1166
  return (await queryAll(m, clauses)).length;
650
1167
  },
@@ -655,7 +1172,8 @@ var dynamoAdapter = (config = {}) => {
655
1172
  const m = getDefaultModelName(model);
656
1173
  const id = await resolveId(m, where);
657
1174
  if (!id) return null;
658
- if (store.consumeOne) return await store.consumeOne(m, id);
1175
+ if (store.consumeOne)
1176
+ return await store.consumeOne(m, id);
659
1177
  const item = await store.getById(m, id);
660
1178
  if (!item) return null;
661
1179
  await store.deleteById(m, id);
@@ -671,7 +1189,10 @@ var dynamoAdapter = (config = {}) => {
671
1189
  const id = await resolveId(m, where);
672
1190
  if (!id) return null;
673
1191
  if (store.incrementOne) {
674
- return await store.incrementOne(m, id, { increment, set });
1192
+ return await store.incrementOne(m, id, {
1193
+ increment,
1194
+ set
1195
+ });
675
1196
  }
676
1197
  const current = await store.getById(m, id);
677
1198
  if (!current) return null;
@@ -688,12 +1209,28 @@ var dynamoAdapter = (config = {}) => {
688
1209
  }
689
1210
  });
690
1211
  };
1212
+ function describeWhere(where) {
1213
+ if (where.length === 0) return "no where clause";
1214
+ return where.map((w) => `${w.field} ${w.operator}`).join(", ");
1215
+ }
1216
+ async function mapBatched(items, op, chunkSize = 10) {
1217
+ const out = [];
1218
+ for (let i = 0; i < items.length; i += chunkSize) {
1219
+ out.push(...await Promise.all(items.slice(i, i + chunkSize).map(op)));
1220
+ }
1221
+ return out;
1222
+ }
691
1223
  async function runBatched(items, op, chunkSize = 10) {
692
1224
  for (let i = 0; i < items.length; i += chunkSize) {
693
1225
  await Promise.all(items.slice(i, i + chunkSize).map(op));
694
1226
  }
695
1227
  }
696
1228
  export {
1229
+ DEFAULT_TTL_ATTRIBUTE,
1230
+ DynamoDBAdapterError,
1231
+ OptimisticLockError,
1232
+ UniqueConstraintError,
1233
+ UnsupportedQueryError,
697
1234
  assignSlots,
698
1235
  buildTableDefinition,
699
1236
  createSingleTableStore,
@@ -701,7 +1238,11 @@ export {
701
1238
  dynamoAdapter,
702
1239
  ensureSchema,
703
1240
  generateSchemaFile,
1241
+ isConditionalCheckFailed,
1242
+ isConditionalTransactionCanceled,
1243
+ isTransactionCanceled,
704
1244
  matchesResidual,
705
- planQuery
1245
+ planQuery,
1246
+ transactionCancellationCodes
706
1247
  };
707
1248
  //# sourceMappingURL=index.js.map