@datar-platform/better-auth-dynamodb 0.1.0 → 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,28 +206,76 @@ 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,
143
213
  DynamoDBDocumentClient,
144
214
  GetCommand,
145
215
  PutCommand,
146
- QueryCommand
216
+ QueryCommand,
217
+ TransactWriteCommand,
218
+ UpdateCommand
147
219
  } from "@aws-sdk/lib-dynamodb";
148
220
 
149
221
  // src/stores/default/key-codec.ts
222
+ import { createHash } from "crypto";
150
223
  var PK = "__ba_pk";
151
224
  var SK = "__ba_sk";
152
225
  var TYPE_PK = "__ba_tpk";
153
226
  var TYPE_SK = "__ba_tsk";
154
227
  var TYPE_INDEX = "byType";
228
+ var REVISION = "__ba_rev";
229
+ var DEFAULT_TTL_ATTRIBUTE = "__ba_ttl";
155
230
  var SK_CONST = "#";
156
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;
157
238
  var gsiName = (slot) => `lookup${slot}`;
158
239
  var gsiPk = (slot) => `__ba_g${slot}pk`;
159
240
  var gsiSk = (slot) => `__ba_g${slot}sk`;
160
- var enc = (v) => String(v);
161
- 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");
162
279
  function assignSlots(indexMap) {
163
280
  const slots = {};
164
281
  let maxSlots = 0;
@@ -173,16 +290,36 @@ function assignSlots(indexMap) {
173
290
  return { slots, maxSlots };
174
291
  }
175
292
  var primaryKey = (model, id) => ({
176
- [PK]: `${model}#${id}`,
293
+ [PK]: partitionKey(
294
+ `${ENTITY_PREFIX}#${component(model)}#${component(id)}`,
295
+ "entity partition key"
296
+ ),
297
+ [SK]: SK_CONST
298
+ });
299
+ var uniqueMarkerKey = (model, index, values) => ({
300
+ [PK]: partitionKey(
301
+ `${UNIQUE_PREFIX}#${component(model)}#${component(index)}#${joinValues(values)}`,
302
+ "unique marker partition key"
303
+ ),
177
304
  [SK]: SK_CONST
178
305
  });
179
- var lookupPkValue = (model, index, values) => `${model}#${index}#${joinValues(values)}`;
306
+ var lookupPkValue = (model, index, values) => partitionKey(
307
+ `${LOOKUP_PREFIX}#${component(model)}#${component(index)}#${joinValues(values)}`,
308
+ "lookup partition key"
309
+ );
180
310
  function encodeKeys(model, item, indexMap, assignment) {
181
311
  const id = String(item.id);
182
312
  const keys = {
183
313
  ...primaryKey(model, id),
184
314
  [TYPE_PK]: model,
185
- [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
+ )
186
323
  };
187
324
  for (const pattern of indexMap[model] ?? []) {
188
325
  const pkValues = pattern.pk.map((f) => item[f]);
@@ -191,14 +328,17 @@ function encodeKeys(model, item, indexMap, assignment) {
191
328
  if (!slot) continue;
192
329
  keys[gsiPk(slot)] = lookupPkValue(model, pattern.index, pkValues);
193
330
  const skValues = (pattern.sk ?? []).map((f) => item[f]);
194
- 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
+ );
195
335
  }
196
336
  return keys;
197
337
  }
198
338
  function encodeLookupQuery(model, index, key, indexMap, assignment) {
199
339
  const slot = assignment.slots[model]?.[index];
200
340
  if (!slot) {
201
- throw new Error(
341
+ throw new DynamoDBAdapterError(
202
342
  `No physical slot for index "${index}" on model "${model}". Ensure the index is present in the adapter's index map.`
203
343
  );
204
344
  }
@@ -226,6 +366,7 @@ function stripReserved(item) {
226
366
  import {
227
367
  CreateTableCommand,
228
368
  ResourceInUseException,
369
+ UpdateTimeToLiveCommand,
229
370
  waitUntilTableExists
230
371
  } from "@aws-sdk/client-dynamodb";
231
372
  function buildTableDefinition(tableName, lookupSlots) {
@@ -282,9 +423,32 @@ async function ensureSchema(opts) {
282
423
  { client: opts.client, maxWaitTime: 60 },
283
424
  { TableName: opts.tableName }
284
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
+ }
285
440
  }
441
+ var isAlreadyEnabled = (error) => typeof error === "object" && error !== null && "name" in error && error.name === "ValidationException" && typeof error.message === "string" && error.message.includes("already");
286
442
  function generateSchemaFile(opts) {
287
- 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
+ };
288
452
  const template = {
289
453
  AWSTemplateFormatVersion: "2010-09-09",
290
454
  Resources: {
@@ -308,6 +472,14 @@ export default dynamoDBSchema;
308
472
  }
309
473
 
310
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
+ };
311
483
  function createSingleTableStore(opts) {
312
484
  const tableName = opts.tableName ?? process.env.DYNAMODB_TABLE_NAME ?? "better-auth";
313
485
  const doc = opts.documentClient ?? DynamoDBDocumentClient.from(
@@ -319,59 +491,400 @@ function createSingleTableStore(opts) {
319
491
  );
320
492
  const indexMap = opts.indexMap;
321
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);
322
643
  const query = async (input, cursor, countOnly = false) => {
323
644
  const res = await doc.send(
324
645
  new QueryCommand({
325
646
  TableName: tableName,
326
647
  ExclusiveStartKey: cursor,
327
648
  ...countOnly ? { Select: "COUNT" } : {},
649
+ ...opts.pageSize ? { Limit: opts.pageSize } : {},
328
650
  ...input
329
651
  })
330
652
  );
331
653
  return {
332
- items: countOnly ? [] : (res.Items ?? []).map((i) => stripReserved(i)),
654
+ items: countOnly ? [] : (res.Items ?? []).filter((item) => !isExpired(item)).map((i) => stripReserved(i)),
333
655
  count: res.Count ?? 0,
334
656
  cursor: res.LastEvaluatedKey
335
657
  };
336
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
+ };
337
698
  return {
338
699
  async put(model, item) {
339
- await doc.send(
340
- new PutCommand({
341
- TableName: tableName,
342
- Item: { ...item, ...encodeKeys(model, item, indexMap, assignment) }
343
- })
344
- );
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
+ }
345
718
  return item;
346
719
  },
347
720
  async getById(model, id) {
348
- const res = await doc.send(
349
- new GetCommand({ TableName: tableName, Key: primaryKey(model, id) })
350
- );
351
- return stripReserved(res.Item);
721
+ const raw = await readRaw(model, id);
722
+ return stripReserved(raw);
352
723
  },
353
724
  async update(model, id, patch) {
354
- const res = await doc.send(
355
- new GetCommand({ TableName: tableName, Key: primaryKey(model, id) })
356
- );
357
- const existing = stripReserved(res.Item);
358
- if (!existing) return null;
359
- const merged = { ...existing, ...patch };
360
- await doc.send(
361
- new PutCommand({
362
- TableName: tableName,
363
- Item: {
364
- ...merged,
365
- ...encodeKeys(model, merged, indexMap, assignment)
366
- }
367
- })
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
368
751
  );
369
- return merged;
370
752
  },
371
753
  async deleteById(model, id) {
372
- await doc.send(
373
- 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
771
+ );
772
+ },
773
+ async consumeOne(model, id) {
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;
804
+ },
805
+ async incrementOne(model, id, { increment, set }) {
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)
374
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() };
849
+ const addClauses = [];
850
+ const setClauses = ["#rev = :rev"];
851
+ let i = 0;
852
+ for (const [field, delta] of Object.entries(increment)) {
853
+ names[`#f${i}`] = field;
854
+ values[`:v${i}`] = delta;
855
+ addClauses.push(`#f${i} :v${i}`);
856
+ i++;
857
+ }
858
+ for (const [field, value] of Object.entries(set ?? {})) {
859
+ names[`#f${i}`] = field;
860
+ values[`:v${i}`] = value;
861
+ setClauses.push(`#f${i} = :v${i}`);
862
+ i++;
863
+ }
864
+ const expression = [
865
+ `SET ${setClauses.join(", ")}`,
866
+ addClauses.length ? `ADD ${addClauses.join(", ")}` : null
867
+ ].filter(Boolean).join(" ");
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
+ }
375
888
  },
376
889
  async queryIndex({ model, index, key, cursor }) {
377
890
  const lookup = encodeLookupQuery(model, index, key, indexMap, assignment);
@@ -407,6 +920,7 @@ function createSingleTableStore(opts) {
407
920
  return { items, cursor: next };
408
921
  },
409
922
  async count({ model, index, key }) {
923
+ if (modelHasTtl(model)) return null;
410
924
  let base;
411
925
  if (index && key) {
412
926
  const lookup = encodeLookupQuery(
@@ -432,10 +946,16 @@ function createSingleTableStore(opts) {
432
946
  }
433
947
  let total = 0;
434
948
  let cursor = void 0;
949
+ let pages = 0;
435
950
  do {
436
951
  const page = await query(base, cursor, true);
437
952
  total += page.count;
438
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
+ }
439
959
  } while (cursor);
440
960
  return total;
441
961
  },
@@ -443,7 +963,8 @@ function createSingleTableStore(opts) {
443
963
  generateSchemaFile({
444
964
  tableName,
445
965
  lookupSlots: assignment.maxSlots,
446
- file
966
+ file,
967
+ ttlAttribute: ttl ? ttlAttribute : void 0
447
968
  })
448
969
  )
449
970
  };
@@ -468,12 +989,18 @@ var dynamoAdapter = (config = {}) => {
468
989
  customTransformInput: ({ data, fieldAttributes }) => fieldAttributes.type === "date" && data instanceof Date ? data.toISOString() : data,
469
990
  customTransformOutput: ({ data, fieldAttributes }) => fieldAttributes.type === "date" && typeof data === "string" ? new Date(data) : data
470
991
  },
471
- adapter: ({ schema, debugLog, getDefaultModelName }) => {
992
+ adapter: ({ schema, debugLog, getDefaultModelName, getFieldName }) => {
472
993
  const indexMap = config.indexMap ?? deriveIndexMap(schema);
994
+ const maxPages = config.maxPages ?? DEFAULT_MAX_PAGES;
473
995
  const store = config.store ?? createSingleTableStore({
474
996
  tableName: config.tableName,
475
997
  region: config.region,
476
998
  endpoint: config.endpoint,
999
+ documentClient: config.documentClient,
1000
+ atomicUniqueness: config.atomicUniqueness,
1001
+ maxPages,
1002
+ pageSize: config.pageSize,
1003
+ ttl: config.ttl,
477
1004
  indexMap
478
1005
  });
479
1006
  const queryAll = async (model, where) => {
@@ -484,6 +1011,12 @@ var dynamoAdapter = (config = {}) => {
484
1011
  if (plan.kind === "byId") {
485
1012
  const one = await store.getById(model, plan.id);
486
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);
487
1020
  } else if (plan.kind === "index") {
488
1021
  candidates = await drainPages(
489
1022
  (cursor) => store.queryIndex({
@@ -491,11 +1024,20 @@ var dynamoAdapter = (config = {}) => {
491
1024
  index: plan.index,
492
1025
  key: plan.key,
493
1026
  cursor
494
- })
1027
+ }),
1028
+ maxPages,
1029
+ `index query on "${model}"`
495
1030
  );
496
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
+ }
497
1037
  candidates = await drainPages(
498
- (cursor) => store.listByType({ model, cursor })
1038
+ (cursor) => store.listByType({ model, cursor }),
1039
+ maxPages,
1040
+ `model scan of "${model}"`
499
1041
  );
500
1042
  }
501
1043
  return candidates.filter(
@@ -504,10 +1046,21 @@ var dynamoAdapter = (config = {}) => {
504
1046
  };
505
1047
  const resolveId = async (model, where) => {
506
1048
  const plan = planQuery(model, where, indexMap);
507
- if (plan.kind === "byId") return plan.id;
1049
+ if (plan.kind === "byId" && plan.residual.length === 0) return plan.id;
508
1050
  const [first] = await queryAll(model, where);
509
1051
  return first ? String(first.id) : null;
510
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
+ };
511
1064
  return {
512
1065
  async create({
513
1066
  model,
@@ -563,10 +1116,11 @@ var dynamoAdapter = (config = {}) => {
563
1116
  },
564
1117
  async findOne({
565
1118
  model,
566
- where
1119
+ where,
1120
+ select
567
1121
  }) {
568
1122
  const m = getDefaultModelName(model);
569
- const [first] = await queryAll(m, where);
1123
+ const [first] = project(m, await queryAll(m, where), select);
570
1124
  return first ?? null;
571
1125
  },
572
1126
  async findMany({
@@ -574,13 +1128,18 @@ var dynamoAdapter = (config = {}) => {
574
1128
  where,
575
1129
  limit,
576
1130
  sortBy,
577
- offset
1131
+ offset,
1132
+ select
578
1133
  }) {
579
1134
  const m = getDefaultModelName(model);
580
- const items = applyWindow(
581
- applySort(await queryAll(m, where), sortBy),
582
- offset,
583
- limit
1135
+ const items = project(
1136
+ m,
1137
+ applyWindow(
1138
+ applySort(await queryAll(m, where), sortBy),
1139
+ offset,
1140
+ limit
1141
+ ),
1142
+ select
584
1143
  );
585
1144
  return items;
586
1145
  },
@@ -592,13 +1151,57 @@ var dynamoAdapter = (config = {}) => {
592
1151
  const clauses = where ?? [];
593
1152
  const plan = planQuery(m, clauses, indexMap);
594
1153
  if (plan.residual.length === 0 && store.count) {
595
- const fast = await store.count(
596
- plan.kind === "index" ? { model: m, index: plan.index, key: plan.key } : { model: m }
597
- );
598
- 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
+ }
599
1165
  }
600
1166
  return (await queryAll(m, clauses)).length;
601
1167
  },
1168
+ async consumeOne({
1169
+ model,
1170
+ where
1171
+ }) {
1172
+ const m = getDefaultModelName(model);
1173
+ const id = await resolveId(m, where);
1174
+ if (!id) return null;
1175
+ if (store.consumeOne)
1176
+ return await store.consumeOne(m, id);
1177
+ const item = await store.getById(m, id);
1178
+ if (!item) return null;
1179
+ await store.deleteById(m, id);
1180
+ return item;
1181
+ },
1182
+ async incrementOne({
1183
+ model,
1184
+ where,
1185
+ increment,
1186
+ set
1187
+ }) {
1188
+ const m = getDefaultModelName(model);
1189
+ const id = await resolveId(m, where);
1190
+ if (!id) return null;
1191
+ if (store.incrementOne) {
1192
+ return await store.incrementOne(m, id, {
1193
+ increment,
1194
+ set
1195
+ });
1196
+ }
1197
+ const current = await store.getById(m, id);
1198
+ if (!current) return null;
1199
+ const patch = { ...set };
1200
+ for (const [field, delta] of Object.entries(increment)) {
1201
+ patch[field] = (Number(current[field]) || 0) + delta;
1202
+ }
1203
+ return await store.update(m, id, patch);
1204
+ },
602
1205
  ...store.createSchema ? {
603
1206
  createSchema: (props) => store.createSchema(props)
604
1207
  } : {}
@@ -606,12 +1209,28 @@ var dynamoAdapter = (config = {}) => {
606
1209
  }
607
1210
  });
608
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
+ }
609
1223
  async function runBatched(items, op, chunkSize = 10) {
610
1224
  for (let i = 0; i < items.length; i += chunkSize) {
611
1225
  await Promise.all(items.slice(i, i + chunkSize).map(op));
612
1226
  }
613
1227
  }
614
1228
  export {
1229
+ DEFAULT_TTL_ATTRIBUTE,
1230
+ DynamoDBAdapterError,
1231
+ OptimisticLockError,
1232
+ UniqueConstraintError,
1233
+ UnsupportedQueryError,
615
1234
  assignSlots,
616
1235
  buildTableDefinition,
617
1236
  createSingleTableStore,
@@ -619,7 +1238,11 @@ export {
619
1238
  dynamoAdapter,
620
1239
  ensureSchema,
621
1240
  generateSchemaFile,
1241
+ isConditionalCheckFailed,
1242
+ isConditionalTransactionCanceled,
1243
+ isTransactionCanceled,
622
1244
  matchesResidual,
623
- planQuery
1245
+ planQuery,
1246
+ transactionCancellationCodes
624
1247
  };
625
1248
  //# sourceMappingURL=index.js.map