@datar-platform/better-auth-dynamodb 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,144 @@
1
1
  # @datar-platform/better-auth-dynamodb
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Added
6
+
7
+ - **`migrateKeys()` — an upgrade path from 0.1.x that does not mean recreating
8
+ the table.** 0.2.0 changed the physical key format, so rows written by 0.1.x
9
+ survive but stop being found. For a development table, recreating it is fine;
10
+ for one holding real users it means an outage. This rewrites the rows in
11
+ place instead, and backfills the uniqueness markers 0.1.x never wrote — so
12
+ existing rows end up protected, not merely readable.
13
+
14
+ ```ts
15
+ import {
16
+ deriveIndexMap,
17
+ migrateKeys,
18
+ } from "@datar-platform/better-auth-dynamodb";
19
+ import { getAuthTables } from "better-auth/db";
20
+
21
+ const indexMap = deriveIndexMap(getAuthTables(betterAuthOptions));
22
+
23
+ // Look first. A dry run writes nothing and names any duplicate that would
24
+ // block the migration.
25
+ console.log(await migrateKeys({ tableName, indexMap, client, dryRun: true }));
26
+
27
+ await migrateKeys({ tableName, indexMap, client });
28
+ ```
29
+
30
+ - **Explicit, never automatic.** Upgrading the package changes nothing on its
31
+ own; rewriting an auth table on first boot is not a decision a library
32
+ should make for you.
33
+ - **A no-op when there is nothing to do**, so it is safe in a deploy step and
34
+ safe to run twice. A table created on 0.2.0+ is left alone.
35
+ - **Stops before writing if two old rows claim the same `unique` value.**
36
+ 0.1.x enforced uniqueness in Better Auth's application layer, which two
37
+ concurrent sign-ups could both pass; choosing which row keeps the email is
38
+ not a migration's decision. The report names the conflicting ids.
39
+ - Writes each new row before deleting the old one, so an interrupted run
40
+ leaves both rather than neither.
41
+
42
+ ## 0.2.0
43
+
44
+ ### Breaking
45
+
46
+ - **The physical key format changed.** Every variable component of a key is now
47
+ written length-prefixed (`s<byteLength>:<value>`) and type-tagged, and a
48
+ value longer than 256 bytes is SHA-256 hashed into the key. Existing tables
49
+ written by 0.1.x will not be found by 0.2.0 and must be recreated. See
50
+ "Delimiter safety" below for why this was worth a break.
51
+ - **Queries no index can serve now throw** instead of silently reading every
52
+ row of the model and filtering in memory. Set `unsafeAllowScan: true` to
53
+ restore the old behaviour, or give the field an index (`unique`,
54
+ `references`, or `index: true` in the Better Auth schema). Native `count` is
55
+ unaffected — it is a keyed `Select: COUNT` query, not a scan.
56
+ - **`create` no longer overwrites.** `put` is conditional on the row not
57
+ already existing, so a colliding id fails loudly rather than replacing data.
58
+ - **Peer range raised to `better-auth >= 1.7.0`**, which is what the adapter is
59
+ now built and conformance-tested against.
60
+ - Draining is capped at `maxPages` (default 25). A query with pages remaining
61
+ at the cap throws rather than returning a partial result — silently dropping
62
+ rows from an auth query is worse than failing.
63
+
64
+ ### Added
65
+
66
+ - **Atomic uniqueness.** `unique` schema fields (`user.email`, `session.token`,
67
+ `organization.slug`, …) are enforced by DynamoDB itself, via marker rows
68
+ written in the same `TransactWriteItems` as the row they describe. Better
69
+ Auth's own enforcement is a check-then-insert, which two concurrent sign-ups
70
+ can both pass. Opt out with `atomicUniqueness: false`.
71
+ - Note for existing tables: markers are only created by writes made on
72
+ 0.2.0+. Uniqueness is enforced going forward; pre-existing duplicates are
73
+ not detected retroactively.
74
+ - **Optimistic concurrency.** Every row carries a hidden revision that guards
75
+ `update`, `delete`, `consumeOne`, and `incrementOne`. A write that lost a
76
+ race retries against the fresh row, then fails with `OptimisticLockError`
77
+ rather than silently clobbering a concurrent change. Rows written before
78
+ 0.2.0 are matched on the revision being absent, so they migrate in place on
79
+ first write.
80
+ - **Adapter-managed TTL** (`ttl: { defaultField: "expiresAt" }`). The
81
+ configured date field is projected into a DynamoDB TTL attribute so expired
82
+ sessions and verifications are reaped for free, and the same attribute is
83
+ treated as _logical_ expiry on read — DynamoDB reaps lazily, so without that
84
+ an expired session would keep working until AWS got round to it.
85
+ `ensureSchema`/`generateSchemaFile` provision the TTL setting.
86
+ - **Client injection.** `documentClient` on the adapter config, so your
87
+ application owns credentials, region, middleware, tracing, and marshalling.
88
+ - `pageSize` for per-request `Limit` tuning.
89
+ - Typed errors, all exported and all extending `DynamoDBAdapterError`:
90
+ `UniqueConstraintError`, `OptimisticLockError`, `UnsupportedQueryError`.
91
+
92
+ ### Fixed
93
+
94
+ - **Delimiter safety.** Keys were joined with a bare `#`, so a value containing
95
+ the delimiter could forge another key: with a composite partition key,
96
+ `("a#b", "c")` and `("a", "b#c")` encoded identically, and a sort-key
97
+ `begins_with("cred#")` probe matched a row whose value was literally
98
+ `cred#ential`. Auth tables hold plenty of values that arrive from outside —
99
+ OAuth `accountId`s, organisation slugs, verification identifiers — so this is
100
+ now structurally impossible rather than merely unlikely.
101
+ - **Oversized key values** produced an opaque DynamoDB `ValidationException`.
102
+ Long values are hashed; an overflow can now only come from an absurd model,
103
+ index, or id name, and says so.
104
+ - **Cancelled transactions were all read as uniqueness violations.** DynamoDB
105
+ cancels for throttling, item contention, and validation failures too, so a
106
+ throttled write could be reported to a user as "that email is taken".
107
+ Cancellations are now classified by their per-action code; contention is
108
+ retried, and only a genuine `ConditionalCheckFailed` becomes
109
+ `UniqueConstraintError`.
110
+ - **`incrementOne` could create the row it was told to increment.** DynamoDB's
111
+ `ADD` is an upsert; the write is now conditional on the row existing and
112
+ returns `null` when it does not.
113
+ - **`incrementOne` left stale index rows** when its `set` moved an indexed or
114
+ TTL field, because a native `ADD` cannot re-encode GSI keys. Those cases now
115
+ take the read-modify-write path.
116
+ - **A `where` naming `id` alongside other predicates ignored the others**, so a
117
+ guarded `update`/`delete` could fire against a row that did not qualify.
118
+ - **`select` was ignored** by `findOne`/`findMany`, and needed mapping through
119
+ the schema's `fieldName` overrides.
120
+ - **`id in [...]` fell through to a model scan.** Better Auth batch-loads rows
121
+ it already has ids for (an organisation's members, for one), which DynamoDB
122
+ serves as a bounded multi-get. It is now planned as one.
123
+ - **`count` used the native fast path for id lookups**, which counts a whole
124
+ model or index — answering "how many of these three ids exist" with the size
125
+ of the table.
126
+ - **Case-insensitive equality was served from an index**, which byte-compares
127
+ keys and so missed every row whose casing differed. Those clauses are now
128
+ matched in memory.
129
+
130
+ ### Testing
131
+
132
+ - Better Auth's **official adapter conformance suites** (`normal`, `uuid`,
133
+ `caseInsensitive`, `authFlow`) now run against real DynamoDB. They found four
134
+ of the bugs listed above.
135
+ - The e2e suite moved from LocalStack + docker-compose to AWS's own DynamoDB
136
+ Local, started per-run by Testcontainers — nothing to start by hand, no port
137
+ collisions, no container surviving a crashed run.
138
+ - New unit suites cover key-collision resistance, transaction-cancellation
139
+ classification, the page cap, the scan guard, and the store's write paths
140
+ against an in-memory DynamoDB double.
141
+
3
142
  ## 0.1.1
4
143
 
5
144
  ### Patch Changes
@@ -10,7 +149,7 @@
10
149
  used by the email-OTP verify flow — and `incrementOne` for atomic guarded
11
150
  counter updates. Without them, any consumer on better-auth >=1.7 hit
12
151
  `BetterAuthError: Adapter "dynamodb" must implement consumeOne for atomic
13
- single-use credential consumption` the first time a plugin exercised that
152
+ single-use credential consumption` the first time a plugin exercised that
14
153
  path (e.g. `emailOTP().signIn`).
15
154
  - `consumeOne` is implemented on the built-in store as a native
16
155
  `DeleteCommand` with `ReturnValues: "ALL_OLD"` (atomic delete-and-return);
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,77 @@ 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
+ ### Upgrading from 0.1.x
89
+
90
+ 0.2.0 changed the physical key format, so rows written by 0.1.x survive but are
91
+ no longer found by any lookup. Recreating the table is fine in development; if
92
+ it holds real users, migrate it instead:
93
+
94
+ ```ts
95
+ import {
96
+ deriveIndexMap,
97
+ migrateKeys,
98
+ } from "@datar-platform/better-auth-dynamodb";
99
+ import { getAuthTables } from "better-auth/db";
100
+
101
+ const indexMap = deriveIndexMap(getAuthTables(betterAuthOptions));
102
+
103
+ // Look before you leap: a dry run writes nothing, and names any duplicate that
104
+ // would block the migration.
105
+ console.log(await migrateKeys({ tableName, indexMap, client, dryRun: true }));
106
+
107
+ await migrateKeys({ tableName, indexMap, client });
108
+ ```
109
+
110
+ It rewrites each row into the current format and backfills the uniqueness
111
+ markers 0.1.x never wrote, so existing rows end up protected rather than merely
112
+ readable. Nothing happens automatically on upgrade — rewriting an auth table on
113
+ first boot is not a decision this package makes for you — and running it on a
114
+ table with nothing to migrate is a no-op, so it is safe in a deploy step and
115
+ safe to run twice.
116
+
117
+ If two old rows claim the same `unique` value, it stops before writing anything
118
+ and reports their ids. 0.1.x enforced uniqueness in Better Auth's application
119
+ layer, which two concurrent sign-ups could both pass; deciding which row keeps
120
+ the email is yours to make, not the migration's.
121
+
122
+ ### Uniqueness
123
+
124
+ Fields the Better Auth schema marks `unique` — `user.email`, `session.token`,
125
+ `organization.slug` — get a marker row written in the same
126
+ `TransactWriteItems` as the row itself, conditional on the marker not already
127
+ existing. That makes uniqueness a property of the database: of two concurrent
128
+ sign-ups for the same email, exactly one commits.
129
+
130
+ Better Auth's own enforcement is a check-then-insert, which both racers can
131
+ pass. Set `atomicUniqueness: false` to fall back to it and halve the write cost
132
+ of creates.
133
+
134
+ Two caveats worth knowing:
135
+
136
+ - Markers are created by writes made on 0.2.0+. Upgrading an existing table
137
+ enforces uniqueness going forward; it does not find duplicates already there.
138
+ - Do not write Better Auth rows into the table with a raw `PutItem`. Entity
139
+ rows, index keys, markers, and TTL attributes have to move together.
140
+
61
141
  ## Bring your own store
62
142
 
63
143
  Implement `DynamoStore` to run the same adapter against your own table layout.
@@ -114,23 +194,42 @@ omit it to auto-derive from the schema.
114
194
 
115
195
  ## Configuration
116
196
 
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. |
197
+ | Option | Default | Description |
198
+ | ------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
199
+ | `store` | built-in single-table store | Storage backend (`DynamoStore`). |
200
+ | `indexMap` | derived from schema | Access-pattern map. |
201
+ | `tableName` | `DYNAMODB_TABLE_NAME` → `better-auth` | Table name (built-in store). |
202
+ | `region` | SDK default | AWS region (built-in store). |
203
+ | `endpoint` | SDK default | Endpoint override for DynamoDB Local / LocalStack, e.g. `http://localhost:8000` (built-in store). |
204
+ | `documentClient` | created internally | Pre-built `DynamoDBDocumentClient`. Preferred in production, so your app owns credentials, region, middleware, and tracing. |
205
+ | `atomicUniqueness` | `true` | Enforce `unique` fields with transactional marker rows. |
206
+ | `ttl` | disabled | Adapter-managed DynamoDB TTL, e.g. `{ defaultField: "expiresAt" }`. |
207
+ | `maxPages` | `25` | Cap on pages drained per query. Throws at the cap rather than returning a partial result. |
208
+ | `pageSize` | SDK default | Per-request DynamoDB `Limit`. Changes request sizing only, not logical results. |
209
+ | `unsafeAllowScan` | `false` | Allow queries no index can serve, which read every row of the model and filter in memory. |
210
+ | `debugLogs` | `false` | Better Auth debug logging. |
125
211
 
126
212
  ## Notes & limitations
127
213
 
128
214
  - IDs are strings (Better Auth generates them); `supportsNumericIds` is `false`.
129
215
  - 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.
216
+ - No interactive transactions — DynamoDB has no such API, so the adapter
217
+ reports `transaction: false`. `TransactWriteItems` is used internally for
218
+ single-row atomic operations; multi-row ops run sequentially in small batches.
219
+ - Case-insensitive equality (`mode: "insensitive"`) cannot come from an index,
220
+ because DynamoDB compares keys byte-for-byte. Those clauses are matched in
221
+ memory, so they need `unsafeAllowScan: true` unless another clause in the same
222
+ `where` can be served by an index.
223
+ - Better Auth's verification cleanup issues a range-only
224
+ `deleteMany(expiresAt < now)`, which has no key to work from. Configure `ttl`
225
+ and set `verification: { disableCleanup: true }` — that is the
226
+ DynamoDB-shaped answer to the same problem.
131
227
  - The built-in store's derived index map keys on schema field names; custom
132
228
  `fieldName` mappings are not yet resolved in derivation (pass an explicit
133
229
  `indexMap` if you rename fields).
230
+ - Key values longer than 256 bytes are SHA-256 hashed into the key. Lookups are
231
+ unaffected — both sides hash identically — but a key is no longer always
232
+ readable as plain text when debugging.
134
233
 
135
234
  ## License
136
235
 
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
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
- import { CreateTableCommandInput, DynamoDBClient } from '@aws-sdk/client-dynamodb';
6
+ import { DynamoDBClient, CreateTableCommandInput } from '@aws-sdk/client-dynamodb';
7
7
 
8
8
  /**
9
9
  * Declarative description of a DynamoDB access pattern (one logical index).
@@ -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[];
@@ -129,6 +137,25 @@ interface DynamoStore {
129
137
  overwrite?: boolean;
130
138
  }>;
131
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
+ }
132
159
  /**
133
160
  * Public configuration for {@link dynamoAdapter}.
134
161
  *
@@ -150,6 +177,41 @@ interface DynamoAdapterConfig {
150
177
  * at DynamoDB Local or LocalStack, e.g. `http://localhost:4566`.
151
178
  */
152
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;
153
215
  /** Better Auth debug logging, forwarded to the adapter factory. */
154
216
  debugLogs?: DBAdapterDebugLogOption;
155
217
  }
@@ -170,11 +232,67 @@ interface DynamoAdapterConfig {
170
232
  */
171
233
  declare const dynamoAdapter: (config?: DynamoAdapterConfig) => AdapterFactory<BetterAuthOptions>;
172
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
+
173
290
  /**
174
291
  * A resolved query plan: how the adapter will fetch candidate rows for a
175
292
  * `where` clause before applying any residual (in-memory) filtering.
176
293
  *
177
294
  * - `byId` — a direct primary-key get (the cheapest path).
295
+ * - `byIds` — a bounded set of primary-key gets (`id in [...]`).
178
296
  * - `index` — a logical index query with a resolved partition/sort key.
179
297
  * - `listByType`— no index matched; list all rows of the model and filter.
180
298
  *
@@ -185,6 +303,10 @@ type QueryPlan = {
185
303
  kind: "byId";
186
304
  id: string;
187
305
  residual: CleanedWhere[];
306
+ } | {
307
+ kind: "byIds";
308
+ ids: string[];
309
+ residual: CleanedWhere[];
188
310
  } | {
189
311
  kind: "index";
190
312
  index: string;
@@ -219,8 +341,24 @@ interface SingleTableStoreOptions {
219
341
  endpoint?: string;
220
342
  /** Resolved logical access-pattern map (derived or user-supplied). */
221
343
  indexMap: IndexMap;
222
- /** 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
+ */
223
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;
224
362
  }
225
363
  /**
226
364
  * A zero-dependency (beyond the AWS SDK) DynamoDB store for Better Auth.
@@ -230,6 +368,10 @@ interface SingleTableStoreOptions {
230
368
  * through logical index names only. Works with any Better Auth model or plugin
231
369
  * whose looked-up fields are described by the (typically schema-derived) index
232
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.
233
375
  */
234
376
  declare function createSingleTableStore(opts: SingleTableStoreOptions): DynamoStore;
235
377
 
@@ -247,6 +389,76 @@ declare function createSingleTableStore(opts: SingleTableStoreOptions): DynamoSt
247
389
  */
248
390
  declare function deriveIndexMap(schema: BetterAuthDBSchema): IndexMap;
249
391
 
392
+ /** Default DynamoDB TTL attribute (epoch seconds). Configurable per store. */
393
+ declare const DEFAULT_TTL_ATTRIBUTE = "__ba_ttl";
394
+ /** Physical slot assignment: model -> (logical index name -> GSI slot number). */
395
+ interface SlotAssignment {
396
+ slots: Record<string, Record<string, number>>;
397
+ maxSlots: number;
398
+ }
399
+ /**
400
+ * Assign each logical index to a physical GSI slot. Because indexes on
401
+ * different models never coexist on one item, a model's Nth index always maps
402
+ * to slot N — so the table needs only `max(indexes per model)` lookup GSIs.
403
+ */
404
+ declare function assignSlots(indexMap: IndexMap): SlotAssignment;
405
+
406
+ interface MigrateKeysOptions {
407
+ /** Table to migrate. */
408
+ tableName: string;
409
+ /**
410
+ * The same access-pattern map the adapter runs with — normally
411
+ * `deriveIndexMap(getAuthTables(betterAuthOptions))`. Keys and markers are
412
+ * rebuilt from it, so a mismatch here produces a table the adapter cannot
413
+ * read.
414
+ */
415
+ indexMap: IndexMap;
416
+ /** Pre-built document client. Preferred, for the usual credential reasons. */
417
+ documentClient?: DynamoDBDocumentClient;
418
+ /** Raw client, wrapped internally. Ignored when `documentClient` is given. */
419
+ client?: DynamoDBClient;
420
+ /**
421
+ * Report what would change without writing anything. Run this first — the
422
+ * report names any duplicate that would block the migration.
423
+ */
424
+ dryRun?: boolean;
425
+ /** Backfill uniqueness markers. Default `true`. */
426
+ atomicUniqueness?: boolean;
427
+ /** Same TTL configuration the adapter uses, so migrated rows carry it too. */
428
+ ttl?: TtlOptions | false;
429
+ /** Called after each page, for progress on a large table. */
430
+ onProgress?: (progress: {
431
+ scanned: number;
432
+ migrated: number;
433
+ }) => void;
434
+ }
435
+ /** A value that more than one row claims, so a unique marker cannot cover it. */
436
+ interface UniquenessConflict {
437
+ model: string;
438
+ index: string;
439
+ /** Ids of every row holding the duplicated value. */
440
+ ids: string[];
441
+ }
442
+ interface MigrationReport {
443
+ /** Rows examined. */
444
+ scanned: number;
445
+ /** Rows rewritten into the current key format (or that would be, on a dry run). */
446
+ migrated: number;
447
+ /** Rows already in the current format, left untouched. */
448
+ alreadyCurrent: number;
449
+ /** Uniqueness marker rows created. */
450
+ markersCreated: number;
451
+ /**
452
+ * Duplicates found. Non-empty means the migration did not run: 0.1.x could
453
+ * admit two rows with the same `unique` value, and there is no correct way
454
+ * for a migration to choose which one keeps it.
455
+ */
456
+ conflicts: UniquenessConflict[];
457
+ /** True when nothing needed doing. */
458
+ noop: boolean;
459
+ }
460
+ declare function migrateKeys(opts: MigrateKeysOptions): Promise<MigrationReport>;
461
+
250
462
  /** Build the CreateTable input for the built-in store's single-table layout. */
251
463
  declare function buildTableDefinition(tableName: string, lookupSlots: number): CreateTableCommandInput;
252
464
  /**
@@ -257,6 +469,12 @@ declare function ensureSchema(opts: {
257
469
  client: DynamoDBClient;
258
470
  tableName: string;
259
471
  lookupSlots: number;
472
+ /**
473
+ * Enable DynamoDB TTL on this attribute. Pass the same value the adapter is
474
+ * configured with (`ttl.attributeName`, default `__ba_ttl`) — the adapter
475
+ * writes the attribute, but only the table setting makes AWS act on it.
476
+ */
477
+ ttlAttribute?: string;
260
478
  }): Promise<void>;
261
479
  /**
262
480
  * Better Auth CLI `generate` hook: emit a portable CloudFormation template for
@@ -268,22 +486,12 @@ declare function generateSchemaFile(opts: {
268
486
  tableName: string;
269
487
  lookupSlots: number;
270
488
  file?: string;
489
+ /** When set, the template enables DynamoDB TTL on this attribute. */
490
+ ttlAttribute?: string;
271
491
  }): {
272
492
  code: string;
273
493
  path: string;
274
494
  overwrite: boolean;
275
495
  };
276
496
 
277
- /** Physical slot assignment: model -> (logical index name -> GSI slot number). */
278
- interface SlotAssignment {
279
- slots: Record<string, Record<string, number>>;
280
- maxSlots: number;
281
- }
282
- /**
283
- * Assign each logical index to a physical GSI slot. Because indexes on
284
- * different models never coexist on one item, a model's Nth index always maps
285
- * to slot N — so the table needs only `max(indexes per model)` lookup GSIs.
286
- */
287
- declare function assignSlots(indexMap: IndexMap): SlotAssignment;
288
-
289
- 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 };
497
+ export { type AccessPattern, DEFAULT_TTL_ATTRIBUTE, type DynamoAdapterConfig, DynamoDBAdapterError, type DynamoStore, type IndexMap, type MigrateKeysOptions, type MigrationReport, type ModelIndexMap, OptimisticLockError, type QueryPage, type QueryPlan, type SingleTableStoreOptions, type StoreItem, type TtlOptions, UniqueConstraintError, type UniquenessConflict, UnsupportedQueryError, assignSlots, buildTableDefinition, createSingleTableStore, deriveIndexMap, dynamoAdapter, ensureSchema, generateSchemaFile, isConditionalCheckFailed, isConditionalTransactionCanceled, isTransactionCanceled, matchesResidual, migrateKeys, planQuery, transactionCancellationCodes };