@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/CHANGELOG.md CHANGED
@@ -1,5 +1,127 @@
1
1
  # @datar-platform/better-auth-dynamodb
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Breaking
6
+
7
+ - **The physical key format changed.** Every variable component of a key is now
8
+ written length-prefixed (`s<byteLength>:<value>`) and type-tagged, and a
9
+ value longer than 256 bytes is SHA-256 hashed into the key. Existing tables
10
+ written by 0.1.x will not be found by 0.2.0 and must be recreated. See
11
+ "Delimiter safety" below for why this was worth a break.
12
+ - **Queries no index can serve now throw** instead of silently reading every
13
+ row of the model and filtering in memory. Set `unsafeAllowScan: true` to
14
+ restore the old behaviour, or give the field an index (`unique`,
15
+ `references`, or `index: true` in the Better Auth schema). Native `count` is
16
+ unaffected — it is a keyed `Select: COUNT` query, not a scan.
17
+ - **`create` no longer overwrites.** `put` is conditional on the row not
18
+ already existing, so a colliding id fails loudly rather than replacing data.
19
+ - **Peer range raised to `better-auth >= 1.7.0`**, which is what the adapter is
20
+ now built and conformance-tested against.
21
+ - Draining is capped at `maxPages` (default 25). A query with pages remaining
22
+ at the cap throws rather than returning a partial result — silently dropping
23
+ rows from an auth query is worse than failing.
24
+
25
+ ### Added
26
+
27
+ - **Atomic uniqueness.** `unique` schema fields (`user.email`, `session.token`,
28
+ `organization.slug`, …) are enforced by DynamoDB itself, via marker rows
29
+ written in the same `TransactWriteItems` as the row they describe. Better
30
+ Auth's own enforcement is a check-then-insert, which two concurrent sign-ups
31
+ can both pass. Opt out with `atomicUniqueness: false`.
32
+ - Note for existing tables: markers are only created by writes made on
33
+ 0.2.0+. Uniqueness is enforced going forward; pre-existing duplicates are
34
+ not detected retroactively.
35
+ - **Optimistic concurrency.** Every row carries a hidden revision that guards
36
+ `update`, `delete`, `consumeOne`, and `incrementOne`. A write that lost a
37
+ race retries against the fresh row, then fails with `OptimisticLockError`
38
+ rather than silently clobbering a concurrent change. Rows written before
39
+ 0.2.0 are matched on the revision being absent, so they migrate in place on
40
+ first write.
41
+ - **Adapter-managed TTL** (`ttl: { defaultField: "expiresAt" }`). The
42
+ configured date field is projected into a DynamoDB TTL attribute so expired
43
+ sessions and verifications are reaped for free, and the same attribute is
44
+ treated as _logical_ expiry on read — DynamoDB reaps lazily, so without that
45
+ an expired session would keep working until AWS got round to it.
46
+ `ensureSchema`/`generateSchemaFile` provision the TTL setting.
47
+ - **Client injection.** `documentClient` on the adapter config, so your
48
+ application owns credentials, region, middleware, tracing, and marshalling.
49
+ - `pageSize` for per-request `Limit` tuning.
50
+ - Typed errors, all exported and all extending `DynamoDBAdapterError`:
51
+ `UniqueConstraintError`, `OptimisticLockError`, `UnsupportedQueryError`.
52
+
53
+ ### Fixed
54
+
55
+ - **Delimiter safety.** Keys were joined with a bare `#`, so a value containing
56
+ the delimiter could forge another key: with a composite partition key,
57
+ `("a#b", "c")` and `("a", "b#c")` encoded identically, and a sort-key
58
+ `begins_with("cred#")` probe matched a row whose value was literally
59
+ `cred#ential`. Auth tables hold plenty of values that arrive from outside —
60
+ OAuth `accountId`s, organisation slugs, verification identifiers — so this is
61
+ now structurally impossible rather than merely unlikely.
62
+ - **Oversized key values** produced an opaque DynamoDB `ValidationException`.
63
+ Long values are hashed; an overflow can now only come from an absurd model,
64
+ index, or id name, and says so.
65
+ - **Cancelled transactions were all read as uniqueness violations.** DynamoDB
66
+ cancels for throttling, item contention, and validation failures too, so a
67
+ throttled write could be reported to a user as "that email is taken".
68
+ Cancellations are now classified by their per-action code; contention is
69
+ retried, and only a genuine `ConditionalCheckFailed` becomes
70
+ `UniqueConstraintError`.
71
+ - **`incrementOne` could create the row it was told to increment.** DynamoDB's
72
+ `ADD` is an upsert; the write is now conditional on the row existing and
73
+ returns `null` when it does not.
74
+ - **`incrementOne` left stale index rows** when its `set` moved an indexed or
75
+ TTL field, because a native `ADD` cannot re-encode GSI keys. Those cases now
76
+ take the read-modify-write path.
77
+ - **A `where` naming `id` alongside other predicates ignored the others**, so a
78
+ guarded `update`/`delete` could fire against a row that did not qualify.
79
+ - **`select` was ignored** by `findOne`/`findMany`, and needed mapping through
80
+ the schema's `fieldName` overrides.
81
+ - **`id in [...]` fell through to a model scan.** Better Auth batch-loads rows
82
+ it already has ids for (an organisation's members, for one), which DynamoDB
83
+ serves as a bounded multi-get. It is now planned as one.
84
+ - **`count` used the native fast path for id lookups**, which counts a whole
85
+ model or index — answering "how many of these three ids exist" with the size
86
+ of the table.
87
+ - **Case-insensitive equality was served from an index**, which byte-compares
88
+ keys and so missed every row whose casing differed. Those clauses are now
89
+ matched in memory.
90
+
91
+ ### Testing
92
+
93
+ - Better Auth's **official adapter conformance suites** (`normal`, `uuid`,
94
+ `caseInsensitive`, `authFlow`) now run against real DynamoDB. They found four
95
+ of the bugs listed above.
96
+ - The e2e suite moved from LocalStack + docker-compose to AWS's own DynamoDB
97
+ Local, started per-run by Testcontainers — nothing to start by hand, no port
98
+ collisions, no container surviving a crashed run.
99
+ - New unit suites cover key-collision resistance, transaction-cancellation
100
+ classification, the page cap, the scan guard, and the store's write paths
101
+ against an in-memory DynamoDB double.
102
+
103
+ ## 0.1.1
104
+
105
+ ### Patch Changes
106
+
107
+ - Implemented `consumeOne` and `incrementOne` on the adapter and the built-in
108
+ single-table store. Better Auth's core adapter factory (`@better-auth/core`
109
+ 1.7.x) requires `consumeOne` for atomic single-use credential consumption —
110
+ used by the email-OTP verify flow — and `incrementOne` for atomic guarded
111
+ counter updates. Without them, any consumer on better-auth >=1.7 hit
112
+ `BetterAuthError: Adapter "dynamodb" must implement consumeOne for atomic
113
+ single-use credential consumption` the first time a plugin exercised that
114
+ path (e.g. `emailOTP().signIn`).
115
+ - `consumeOne` is implemented on the built-in store as a native
116
+ `DeleteCommand` with `ReturnValues: "ALL_OLD"` (atomic delete-and-return);
117
+ `incrementOne` as a native `UpdateCommand` with `ADD`/`SET` expressions and
118
+ `ReturnValues: "ALL_NEW"`.
119
+ - Both are optional on the `DynamoStore` interface — a custom store that
120
+ doesn't implement them still works via a non-atomic get-then-write fallback
121
+ in the adapter, so this is not a breaking change for existing custom
122
+ stores.
123
+ - Added LocalStack e2e coverage for both.
124
+
3
125
  ## 0.1.0
4
126
 
5
127
  First stable release. No code changes since `0.1.0-alpha.1` — the adapter, the
package/README.md CHANGED
@@ -8,7 +8,16 @@ A generic [DynamoDB](https://aws.amazon.com/dynamodb/) adapter for [Better Auth]
8
8
  - **Bring your own store.** The adapter talks to a small `DynamoStore` seam, so
9
9
  you can back it with an existing single-table design (ElectroDB, custom key
10
10
  encoding, a shared table) without changing the adapter.
11
- - **Correct pagination.** `findMany`/`count` drain every page no silent row cap.
11
+ - **Atomic uniqueness.** `unique` fields are enforced by DynamoDB itself, in the
12
+ same transaction as the row — not by a check-then-insert two concurrent
13
+ sign-ups can both pass.
14
+ - **Safe under concurrency.** Every row carries a revision that guards updates,
15
+ deletes, and single-use consumes against lost writes.
16
+ - **No hidden scans, no silent truncation.** A query no index can serve fails
17
+ loudly instead of quietly reading the whole model, and pagination that hits
18
+ its cap throws rather than returning part of an answer.
19
+ - **Passes Better Auth's official adapter conformance suites** against real
20
+ DynamoDB.
12
21
  - Zero dependencies beyond the AWS SDK.
13
22
 
14
23
  ## Install
@@ -58,6 +67,43 @@ You can also generate a CloudFormation template via the Better Auth CLI
58
67
  (`npx @better-auth/cli generate`) — the adapter's `createSchema` emits one sized
59
68
  to your access patterns.
60
69
 
70
+ ### Expiring sessions and verifications
71
+
72
+ Nothing expires on its own. Point the adapter at your expiry field and DynamoDB
73
+ reaps expired rows for free:
74
+
75
+ ```ts
76
+ dynamoAdapter({
77
+ tableName: "better-auth",
78
+ ttl: { defaultField: "expiresAt" },
79
+ });
80
+ ```
81
+
82
+ Pass the same attribute to `ensureSchema({ ..., ttlAttribute: "__ba_ttl" })` —
83
+ the adapter writes the attribute, but only the table setting makes AWS act on
84
+ it. Reads treat it as _logical_ expiry too: DynamoDB reaps lazily, often a day
85
+ or two late, so without that an expired session would keep working until AWS got
86
+ round to deleting it.
87
+
88
+ ### Uniqueness
89
+
90
+ Fields the Better Auth schema marks `unique` — `user.email`, `session.token`,
91
+ `organization.slug` — get a marker row written in the same
92
+ `TransactWriteItems` as the row itself, conditional on the marker not already
93
+ existing. That makes uniqueness a property of the database: of two concurrent
94
+ sign-ups for the same email, exactly one commits.
95
+
96
+ Better Auth's own enforcement is a check-then-insert, which both racers can
97
+ pass. Set `atomicUniqueness: false` to fall back to it and halve the write cost
98
+ of creates.
99
+
100
+ Two caveats worth knowing:
101
+
102
+ - Markers are created by writes made on 0.2.0+. Upgrading an existing table
103
+ enforces uniqueness going forward; it does not find duplicates already there.
104
+ - Do not write Better Auth rows into the table with a raw `PutItem`. Entity
105
+ rows, index keys, markers, and TTL attributes have to move together.
106
+
61
107
  ## Bring your own store
62
108
 
63
109
  Implement `DynamoStore` to run the same adapter against your own table layout.
@@ -114,23 +160,42 @@ omit it to auto-derive from the schema.
114
160
 
115
161
  ## Configuration
116
162
 
117
- | Option | Default | Description |
118
- | ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- |
119
- | `store` | built-in single-table store | Storage backend (`DynamoStore`). |
120
- | `indexMap` | derived from schema | Access-pattern map. |
121
- | `tableName` | `DYNAMODB_TABLE_NAME` → `better-auth` | Table name (built-in store). |
122
- | `region` | SDK default | AWS region (built-in store). |
123
- | `endpoint` | SDK default | Endpoint override for DynamoDB Local / LocalStack, e.g. `http://localhost:4566` (built-in store). |
124
- | `debugLogs` | `false` | Better Auth debug logging. |
163
+ | Option | Default | Description |
164
+ | ------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
165
+ | `store` | built-in single-table store | Storage backend (`DynamoStore`). |
166
+ | `indexMap` | derived from schema | Access-pattern map. |
167
+ | `tableName` | `DYNAMODB_TABLE_NAME` → `better-auth` | Table name (built-in store). |
168
+ | `region` | SDK default | AWS region (built-in store). |
169
+ | `endpoint` | SDK default | Endpoint override for DynamoDB Local / LocalStack, e.g. `http://localhost:8000` (built-in store). |
170
+ | `documentClient` | created internally | Pre-built `DynamoDBDocumentClient`. Preferred in production, so your app owns credentials, region, middleware, and tracing. |
171
+ | `atomicUniqueness` | `true` | Enforce `unique` fields with transactional marker rows. |
172
+ | `ttl` | disabled | Adapter-managed DynamoDB TTL, e.g. `{ defaultField: "expiresAt" }`. |
173
+ | `maxPages` | `25` | Cap on pages drained per query. Throws at the cap rather than returning a partial result. |
174
+ | `pageSize` | SDK default | Per-request DynamoDB `Limit`. Changes request sizing only, not logical results. |
175
+ | `unsafeAllowScan` | `false` | Allow queries no index can serve, which read every row of the model and filter in memory. |
176
+ | `debugLogs` | `false` | Better Auth debug logging. |
125
177
 
126
178
  ## Notes & limitations
127
179
 
128
180
  - IDs are strings (Better Auth generates them); `supportsNumericIds` is `false`.
129
181
  - Dates are stored as ISO strings and re-hydrated on read (`supportsDates: false`).
130
- - No native transactions — multi-row ops run sequentially in small batches.
182
+ - No interactive transactions — DynamoDB has no such API, so the adapter
183
+ reports `transaction: false`. `TransactWriteItems` is used internally for
184
+ single-row atomic operations; multi-row ops run sequentially in small batches.
185
+ - Case-insensitive equality (`mode: "insensitive"`) cannot come from an index,
186
+ because DynamoDB compares keys byte-for-byte. Those clauses are matched in
187
+ memory, so they need `unsafeAllowScan: true` unless another clause in the same
188
+ `where` can be served by an index.
189
+ - Better Auth's verification cleanup issues a range-only
190
+ `deleteMany(expiresAt < now)`, which has no key to work from. Configure `ttl`
191
+ and set `verification: { disableCleanup: true }` — that is the
192
+ DynamoDB-shaped answer to the same problem.
131
193
  - The built-in store's derived index map keys on schema field names; custom
132
194
  `fieldName` mappings are not yet resolved in derivation (pass an explicit
133
195
  `indexMap` if you rename fields).
196
+ - Key values longer than 256 bytes are SHA-256 hashed into the key. Lookups are
197
+ unaffected — both sides hash identically — but a key is no longer always
198
+ readable as plain text when debugging.
134
199
 
135
200
  ## License
136
201
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BetterAuthOptions } from 'better-auth';
2
2
  import { AdapterFactory } from 'better-auth/adapters';
3
- import { DBAdapterDebugLogOption, CleanedWhere } from '@better-auth/core/db/adapter';
4
3
  import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
4
+ import { DBAdapterDebugLogOption, CleanedWhere } from '@better-auth/core/db/adapter';
5
5
  import { BetterAuthDBSchema } from '@better-auth/core/db';
6
6
  import { CreateTableCommandInput, DynamoDBClient } from '@aws-sdk/client-dynamodb';
7
7
 
@@ -29,6 +29,14 @@ interface AccessPattern {
29
29
  * (as `eq` clauses), and leaves the rest to residual in-memory filtering.
30
30
  */
31
31
  sk?: string[];
32
+ /**
33
+ * True when this pattern backs a *uniqueness constraint* (a schema field
34
+ * marked `unique`, e.g. `user.email`) rather than a plain lookup. The
35
+ * built-in store enforces these atomically with marker rows; a custom store
36
+ * may ignore the flag and fall back to Better Auth's application-layer
37
+ * check-then-insert.
38
+ */
39
+ unique?: boolean;
32
40
  }
33
41
  /** Ordered list of access patterns for a single model, most specific first. */
34
42
  type ModelIndexMap = AccessPattern[];
@@ -94,6 +102,27 @@ interface DynamoStore {
94
102
  index?: string;
95
103
  key?: StoreItem;
96
104
  }): Promise<number | null>;
105
+ /**
106
+ * Optional atomic single-use consume: delete the record with the given `id`
107
+ * and return what was deleted (or `null` if it was already gone), in one
108
+ * operation. Better Auth's email-OTP/verification-token flows require this
109
+ * for correctness under concurrent verify attempts. When a store doesn't
110
+ * provide it, the adapter falls back to a non-atomic get-then-delete.
111
+ */
112
+ consumeOne?(model: string, id: string): Promise<StoreItem | null>;
113
+ /**
114
+ * Optional atomic increment/set: apply `increment` (field -> delta) and
115
+ * `set` (field -> value) to the record with the given `id` in one operation
116
+ * and return the updated record (or `null` if it doesn't exist). Better
117
+ * Auth's guarded-counter flows (e.g. rate-limit style attempt counters)
118
+ * require this for correctness under concurrent updates. When a store
119
+ * doesn't provide it, the adapter falls back to a non-atomic
120
+ * get-then-merge-then-update.
121
+ */
122
+ incrementOne?(model: string, id: string, req: {
123
+ increment: Record<string, number>;
124
+ set?: StoreItem;
125
+ }): Promise<StoreItem | null>;
97
126
  /**
98
127
  * Optional schema generator, wired to the Better Auth CLI `generate` command.
99
128
  * The built-in store emits a portable CloudFormation template; a custom store
@@ -108,6 +137,25 @@ interface DynamoStore {
108
137
  overwrite?: boolean;
109
138
  }>;
110
139
  }
140
+ /**
141
+ * Adapter-managed DynamoDB TTL.
142
+ *
143
+ * A configured date field (e.g. `session.expiresAt`) is projected into a
144
+ * numeric epoch-seconds attribute that DynamoDB's own TTL reaper understands,
145
+ * so expired auth rows are deleted for free instead of accumulating.
146
+ *
147
+ * Reads treat the same attribute as *logical* expiry. DynamoDB reaps lazily —
148
+ * often a day or two late — so without that, an expired session would keep
149
+ * working until AWS got round to deleting it.
150
+ */
151
+ interface TtlOptions {
152
+ /** DynamoDB TTL attribute to write. Defaults to `__ba_ttl`. */
153
+ attributeName?: string;
154
+ /** Per-model date field, e.g. `{ session: "expiresAt" }`. */
155
+ fields?: Record<string, string>;
156
+ /** Date field used for any model not named in `fields`, e.g. `"expiresAt"`. */
157
+ defaultField?: string;
158
+ }
111
159
  /**
112
160
  * Public configuration for {@link dynamoAdapter}.
113
161
  *
@@ -129,6 +177,41 @@ interface DynamoAdapterConfig {
129
177
  * at DynamoDB Local or LocalStack, e.g. `http://localhost:4566`.
130
178
  */
131
179
  endpoint?: string;
180
+ /**
181
+ * Pre-built DynamoDB document client (used only by the built-in store).
182
+ * Preferred in production: your application keeps ownership of credentials,
183
+ * region, middleware, tracing, and marshalling behaviour.
184
+ */
185
+ documentClient?: DynamoDBDocumentClient;
186
+ /**
187
+ * Enforce `unique` schema fields with transactional marker rows (used only
188
+ * by the built-in store). Default `true`.
189
+ */
190
+ atomicUniqueness?: boolean;
191
+ /**
192
+ * Maximum DynamoDB pages drained for one logical query. Default 25. When a
193
+ * query still has pages left at the cap, the adapter throws rather than
194
+ * returning a silently truncated result — a partial answer to an auth query
195
+ * is worse than a loud failure.
196
+ */
197
+ maxPages?: number;
198
+ /**
199
+ * Per-request DynamoDB `Limit` (used only by the built-in store). Pagination
200
+ * is still drained up to `maxPages`, so this changes request sizing only.
201
+ */
202
+ pageSize?: number;
203
+ /** Adapter-managed DynamoDB TTL. Omit (or `false`) to disable entirely. */
204
+ ttl?: TtlOptions | false;
205
+ /**
206
+ * Allow queries that no index can serve, which fall back to draining every
207
+ * row of the model and filtering in memory. Default `false`, so an access
208
+ * pattern nobody designed for fails at development time instead of quietly
209
+ * costing a full model read on every call.
210
+ *
211
+ * Native `count` is unaffected: it is a keyed `Select: COUNT` query, bounded
212
+ * by `maxPages`, and returns a number rather than every row.
213
+ */
214
+ unsafeAllowScan?: boolean;
132
215
  /** Better Auth debug logging, forwarded to the adapter factory. */
133
216
  debugLogs?: DBAdapterDebugLogOption;
134
217
  }
@@ -149,11 +232,67 @@ interface DynamoAdapterConfig {
149
232
  */
150
233
  declare const dynamoAdapter: (config?: DynamoAdapterConfig) => AdapterFactory<BetterAuthOptions>;
151
234
 
235
+ /**
236
+ * Error types raised by this adapter.
237
+ *
238
+ * Everything thrown from the adapter core or the built-in store derives from
239
+ * {@link DynamoDBAdapterError}, so a consumer can distinguish "the adapter
240
+ * refused/failed" from a raw AWS SDK error escaping the seam.
241
+ */
242
+ declare class DynamoDBAdapterError extends Error {
243
+ constructor(message: string, options?: ErrorOptions);
244
+ }
245
+ /**
246
+ * A write lost a race against a uniqueness constraint — another row already
247
+ * holds the value for a `unique` field (e.g. `user.email`, `organization.slug`).
248
+ */
249
+ declare class UniqueConstraintError extends DynamoDBAdapterError {
250
+ readonly model: string;
251
+ readonly fields: string[];
252
+ constructor(model: string, fields: string[], options?: ErrorOptions);
253
+ }
254
+ /**
255
+ * The requested access pattern cannot be served without a table/model scan, and
256
+ * scans have not been explicitly enabled.
257
+ */
258
+ declare class UnsupportedQueryError extends DynamoDBAdapterError {
259
+ constructor(message: string);
260
+ }
261
+ /**
262
+ * A guarded write was rejected because the row changed between the adapter's
263
+ * read and its write. Better Auth's own retry/null semantics decide what
264
+ * happens next; this exists so the cause is legible rather than an opaque
265
+ * `TransactionCanceledException`.
266
+ */
267
+ declare class OptimisticLockError extends DynamoDBAdapterError {
268
+ constructor(model: string, id: string, options?: ErrorOptions);
269
+ }
270
+ /** True for a bare `ConditionalCheckFailedException` from a non-transactional write. */
271
+ declare const isConditionalCheckFailed: (error: unknown) => boolean;
272
+ /** True for any cancelled `TransactWriteItems`, whatever the reason. */
273
+ declare const isTransactionCanceled: (error: unknown) => boolean;
274
+ /**
275
+ * The per-action cancellation codes AWS attaches to a cancelled transaction
276
+ * (e.g. `ConditionalCheckFailed`, `TransactionConflict`, `ThrottlingError`,
277
+ * `ValidationError`, or `None` for actions that were fine).
278
+ */
279
+ declare function transactionCancellationCodes(error: unknown): string[];
280
+ /**
281
+ * True only when a transaction was cancelled *because a condition failed* —
282
+ * not because of throttling, a transaction conflict, or a validation error.
283
+ *
284
+ * This distinction matters: reporting a throttled write as "email already
285
+ * taken" is a user-visible lie, and it is the failure mode of any code that
286
+ * treats `TransactionCanceledException` as a uniqueness violation wholesale.
287
+ */
288
+ declare const isConditionalTransactionCanceled: (error: unknown) => boolean;
289
+
152
290
  /**
153
291
  * A resolved query plan: how the adapter will fetch candidate rows for a
154
292
  * `where` clause before applying any residual (in-memory) filtering.
155
293
  *
156
294
  * - `byId` — a direct primary-key get (the cheapest path).
295
+ * - `byIds` — a bounded set of primary-key gets (`id in [...]`).
157
296
  * - `index` — a logical index query with a resolved partition/sort key.
158
297
  * - `listByType`— no index matched; list all rows of the model and filter.
159
298
  *
@@ -164,6 +303,10 @@ type QueryPlan = {
164
303
  kind: "byId";
165
304
  id: string;
166
305
  residual: CleanedWhere[];
306
+ } | {
307
+ kind: "byIds";
308
+ ids: string[];
309
+ residual: CleanedWhere[];
167
310
  } | {
168
311
  kind: "index";
169
312
  index: string;
@@ -198,8 +341,24 @@ interface SingleTableStoreOptions {
198
341
  endpoint?: string;
199
342
  /** Resolved logical access-pattern map (derived or user-supplied). */
200
343
  indexMap: IndexMap;
201
- /** Pre-built document client (used by tests, e.g. against DynamoDB Local). */
344
+ /**
345
+ * Pre-built document client. Preferred in production, so your application
346
+ * owns credentials, region, middleware, tracing, and marshalling.
347
+ */
202
348
  documentClient?: DynamoDBDocumentClient;
349
+ /**
350
+ * Enforce `unique` schema fields with transactional marker rows. Default
351
+ * `true`. Turning it off halves the write cost of creates and restores
352
+ * Better Auth's application-layer check-then-insert, which can admit
353
+ * duplicates under concurrency.
354
+ */
355
+ atomicUniqueness?: boolean;
356
+ /** Max DynamoDB pages drained for one internal count. Default 25. */
357
+ maxPages?: number;
358
+ /** Optional per-request DynamoDB `Limit`. Does not change logical results. */
359
+ pageSize?: number;
360
+ /** Adapter-managed DynamoDB TTL. Omit (or `false`) to disable entirely. */
361
+ ttl?: TtlOptions | false;
203
362
  }
204
363
  /**
205
364
  * A zero-dependency (beyond the AWS SDK) DynamoDB store for Better Auth.
@@ -209,6 +368,10 @@ interface SingleTableStoreOptions {
209
368
  * through logical index names only. Works with any Better Auth model or plugin
210
369
  * whose looked-up fields are described by the (typically schema-derived) index
211
370
  * map.
371
+ *
372
+ * Every mutation is guarded: creates are conditional on the row not already
373
+ * existing, updates and deletes carry an optimistic revision check, and
374
+ * uniqueness markers move in the same transaction as the row they describe.
212
375
  */
213
376
  declare function createSingleTableStore(opts: SingleTableStoreOptions): DynamoStore;
214
377
 
@@ -236,6 +399,12 @@ declare function ensureSchema(opts: {
236
399
  client: DynamoDBClient;
237
400
  tableName: string;
238
401
  lookupSlots: number;
402
+ /**
403
+ * Enable DynamoDB TTL on this attribute. Pass the same value the adapter is
404
+ * configured with (`ttl.attributeName`, default `__ba_ttl`) — the adapter
405
+ * writes the attribute, but only the table setting makes AWS act on it.
406
+ */
407
+ ttlAttribute?: string;
239
408
  }): Promise<void>;
240
409
  /**
241
410
  * Better Auth CLI `generate` hook: emit a portable CloudFormation template for
@@ -247,12 +416,16 @@ declare function generateSchemaFile(opts: {
247
416
  tableName: string;
248
417
  lookupSlots: number;
249
418
  file?: string;
419
+ /** When set, the template enables DynamoDB TTL on this attribute. */
420
+ ttlAttribute?: string;
250
421
  }): {
251
422
  code: string;
252
423
  path: string;
253
424
  overwrite: boolean;
254
425
  };
255
426
 
427
+ /** Default DynamoDB TTL attribute (epoch seconds). Configurable per store. */
428
+ declare const DEFAULT_TTL_ATTRIBUTE = "__ba_ttl";
256
429
  /** Physical slot assignment: model -> (logical index name -> GSI slot number). */
257
430
  interface SlotAssignment {
258
431
  slots: Record<string, Record<string, number>>;
@@ -265,4 +438,4 @@ interface SlotAssignment {
265
438
  */
266
439
  declare function assignSlots(indexMap: IndexMap): SlotAssignment;
267
440
 
268
- export { type AccessPattern, type DynamoAdapterConfig, type DynamoStore, type IndexMap, type ModelIndexMap, type QueryPage, type QueryPlan, type SingleTableStoreOptions, type StoreItem, assignSlots, buildTableDefinition, createSingleTableStore, deriveIndexMap, dynamoAdapter, ensureSchema, generateSchemaFile, matchesResidual, planQuery };
441
+ export { type AccessPattern, DEFAULT_TTL_ATTRIBUTE, type DynamoAdapterConfig, DynamoDBAdapterError, type DynamoStore, type IndexMap, type ModelIndexMap, OptimisticLockError, type QueryPage, type QueryPlan, type SingleTableStoreOptions, type StoreItem, type TtlOptions, UniqueConstraintError, UnsupportedQueryError, assignSlots, buildTableDefinition, createSingleTableStore, deriveIndexMap, dynamoAdapter, ensureSchema, generateSchemaFile, isConditionalCheckFailed, isConditionalTransactionCanceled, isTransactionCanceled, matchesResidual, planQuery, transactionCancellationCodes };