betterauth-dynamodb-adapter 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 rokku-x
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,233 @@
1
+ # betterauth-dynamodb-adapter
2
+
3
+ [![npm version](https://img.shields.io/npm/v/betterauth-dynamodb-adapter.svg)](https://www.npmjs.com/package/betterauth-dynamodb-adapter)
4
+ [![license](https://img.shields.io/npm/l/betterauth-dynamodb-adapter.svg)](LICENSE)
5
+ ![TS](https://img.shields.io/badge/TypeScript-%E2%9C%93-blue)
6
+
7
+ A DynamoDB adapter for [Better Auth](https://www.better-auth.com/). Uses a single-table design with composite keys and a GSI for model-based queries.
8
+
9
+ ## Features
10
+
11
+ - Single-table design — all Better Auth models stored in one DynamoDB table
12
+ - Composite primary keys (`_pk` / `_sk`) for direct item access
13
+ - GSI-based queries via a `_table` attribute for model-level scans
14
+ - Full filter expression support (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `starts_with`, `ends_with`)
15
+ - Fast path `GetItem` for single-ID lookups
16
+ - In-memory sorting and pagination for `findMany`
17
+ - Dual CJS/ESM build with full TypeScript declarations
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install betterauth-dynamodb-adapter
23
+ # or
24
+ bun add betterauth-dynamodb-adapter
25
+ ```
26
+
27
+ ### Peer dependencies
28
+
29
+ You also need these installed in your project:
30
+
31
+ ```bash
32
+ npm install better-auth @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
33
+ ```
34
+
35
+ ## DynamoDB Table Setup
36
+
37
+ Create a DynamoDB table with the following schema:
38
+
39
+ | Attribute | Type | Role |
40
+ |-----------|--------|---------------|
41
+ | `_pk` | String | Partition key |
42
+ | `_sk` | String | Sort key |
43
+
44
+ Then create a Global Secondary Index:
45
+
46
+ | Index Name | Partition Key | Type |
47
+ |----------------|---------------|--------|
48
+ | `_table-index` | `_table` | String |
49
+
50
+
51
+ ### AWS CLI
52
+
53
+ ```bash
54
+ aws dynamodb create-table \
55
+ --table-name my-auth-table \
56
+ --attribute-definitions \
57
+ AttributeName=_pk,AttributeType=S \
58
+ AttributeName=_sk,AttributeType=S \
59
+ AttributeName=_table,AttributeType=S \
60
+ --key-schema \
61
+ AttributeName=_pk,KeyType=HASH \
62
+ AttributeName=_sk,KeyType=RANGE \
63
+ --global-secondary-indexes \
64
+ '[{
65
+ "IndexName": "_table-index",
66
+ "KeySchema": [{"AttributeName": "_table", "KeyType": "HASH"}],
67
+ "Projection": {"ProjectionType": "ALL"}
68
+ }]' \
69
+ --billing-mode PAY_PER_REQUEST
70
+ ```
71
+
72
+ ### CDK
73
+
74
+ ```ts
75
+ import { Table, AttributeType, BillingMode, ProjectionType } from "aws-cdk-lib/aws-dynamodb";
76
+
77
+ const table = new Table(this, "AuthTable", {
78
+ tableName: "my-auth-table",
79
+ partitionKey: { name: "_pk", type: AttributeType.STRING },
80
+ sortKey: { name: "_sk", type: AttributeType.STRING },
81
+ billingMode: BillingMode.PAY_PER_REQUEST,
82
+ });
83
+
84
+ table.addGlobalSecondaryIndex({
85
+ indexName: "_table-index",
86
+ partitionKey: { name: "_table", type: AttributeType.STRING },
87
+ projectionType: ProjectionType.ALL,
88
+ });
89
+ ```
90
+
91
+ ## Usage
92
+
93
+ > Your DynamoDB table must already exist before using this adapter. See [DynamoDB Table Setup](#dynamodb-table-setup) above.
94
+
95
+ ### Basic setup
96
+
97
+ ```ts
98
+ // auth.ts
99
+ import { betterAuth } from "better-auth";
100
+ import dynamoDBAdapter from "betterauth-dynamodb-adapter";
101
+
102
+ export const auth = betterAuth({
103
+ database: dynamoDBAdapter({
104
+ tableName: "my-auth-table",
105
+ region: "us-east-1",
106
+ }),
107
+ emailAndPassword: {
108
+ enabled: true,
109
+ },
110
+ });
111
+ ```
112
+
113
+ ### With social providers
114
+
115
+ ```ts
116
+ // auth.ts
117
+ import { betterAuth } from "better-auth";
118
+ import dynamoDBAdapter from "betterauth-dynamodb-adapter";
119
+
120
+ export const auth = betterAuth({
121
+ database: dynamoDBAdapter({
122
+ tableName: "my-auth-table",
123
+ region: "us-east-1",
124
+ }),
125
+ emailAndPassword: {
126
+ enabled: true,
127
+ },
128
+ socialProviders: {
129
+ google: {
130
+ clientId: process.env.GOOGLE_CLIENT_ID!,
131
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
132
+ },
133
+ github: {
134
+ clientId: process.env.GITHUB_CLIENT_ID!,
135
+ clientSecret: process.env.GITHUB_CLIENT_SECRET!,
136
+ },
137
+ },
138
+ });
139
+ ```
140
+
141
+ ### With Next.js
142
+
143
+ ```ts
144
+ // app/api/auth/[...all]/route.ts
145
+ import { auth } from "@/auth";
146
+ import { toNextJsHandler } from "better-auth/next-js";
147
+
148
+ export const { GET, POST } = toNextJsHandler(auth);
149
+ ```
150
+
151
+ ### Client-side
152
+
153
+ ```ts
154
+ // lib/auth-client.ts
155
+ import { createAuthClient } from "better-auth/client";
156
+
157
+ export const authClient = createAuthClient();
158
+
159
+ // Sign up
160
+ await authClient.signUp.email({
161
+ email: "[email]",
162
+ password: "[password]",
163
+ name: "[name]",
164
+ });
165
+
166
+ // Sign in
167
+ await authClient.signIn.email({
168
+ email: "[email]",
169
+ password: "[password]",
170
+ });
171
+ ```
172
+
173
+ ## How It Works
174
+
175
+ ### Single-table design
176
+
177
+ All Better Auth models (users, sessions, accounts, etc.) are stored in a single DynamoDB table. Each item has three internal attributes managed by the adapter:
178
+
179
+ | Attribute | Format | Purpose |
180
+ |-----------|----------------|---------------------------------|
181
+ | `_pk` | `{model}#{id}` | Partition key for direct access |
182
+ | `_sk` | `{model}#{id}` | Sort key (same as `_pk`) |
183
+ | `_table` | `{model}` | GSI partition key for queries |
184
+
185
+ These internal attributes are automatically stripped from results returned to Better Auth.
186
+
187
+ ### Query strategy
188
+
189
+ - **Single ID lookup** → `GetItem` (fastest, single read unit)
190
+ - **Filtered queries** → GSI query on `_table-index` with DynamoDB `FilterExpression`
191
+ - **Sorting & pagination** → performed in-memory after query results are returned
192
+
193
+ ### Supported filter operators
194
+
195
+ | Operator | DynamoDB expression |
196
+ |---------------|-----------------------------|
197
+ | `eq` | `field = value` |
198
+ | `ne` | `field <> value` |
199
+ | `gt` | `field > value` |
200
+ | `gte` | `field >= value` |
201
+ | `lt` | `field < value` |
202
+ | `lte` | `field <= value` |
203
+ | `in` | `field IN (values)` |
204
+ | `not_in` | `NOT field IN (values)` |
205
+ | `contains` | `contains(field, value)` |
206
+ | `starts_with` | `begins_with(field, value)` |
207
+ | `ends_with` | `contains(field, value)` * |
208
+
209
+ > \* DynamoDB does not natively support `ends_with`, so it falls back to `contains`.
210
+
211
+ ## Configuration
212
+
213
+ ```ts
214
+ dynamoDBAdapter({
215
+ tableName: string; // DynamoDB table name
216
+ region: string; // AWS region (e.g. "us-east-1")
217
+ })
218
+ ```
219
+
220
+ ## Adapter Capabilities
221
+
222
+ | Capability | Supported |
223
+ |-----------------|-----------|
224
+ | JSON fields | No |
225
+ | Date fields | No |
226
+ | Boolean fields | No |
227
+ | Numeric IDs | No |
228
+
229
+ All values are stored as DynamoDB strings/numbers. Dates should be stored as ISO strings or Unix timestamps by Better Auth before reaching the adapter.
230
+
231
+ ## License
232
+
233
+ [MIT](LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ var e=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);let t=require(`better-auth/adapters`),n=require(`@aws-sdk/client-dynamodb`);var r=e((e=>{var t=class e{value;constructor(e){typeof e==`object`&&`N`in e?this.value=String(e.N):this.value=String(e);let t=typeof e.valueOf()==`number`?e.valueOf():0;if(t>2**53-1||t<-(2**53-1)||Math.abs(t)===1/0||Number.isNaN(t))throw Error(`NumberValue should not be initialized with an imprecise number=${t}. Use a string instead.`)}static from(t){return new e(t)}toAttributeValue(){return{N:this.toString()}}toBigInt(){let e=this.toString();return BigInt(e)}toString(){return String(this.value)}valueOf(){return this.toString()}};let n=(e,n)=>{if(e===void 0)throw Error(`Pass options.removeUndefinedValues=true to remove undefined values from map/array/set.`);if(e===null&&typeof e==`object`)return s();if(Array.isArray(e))return r(e,n);if(e?.constructor?.name===`Set`)return i(e,n);if(e?.constructor?.name===`Map`)return a(e,n);if(e?.constructor?.name===`Object`||!e.constructor&&typeof e==`object`)return o(e,n);if(p(e))return e.length===0&&n?.convertEmptyValues?s():c(e);if(typeof e==`boolean`||e?.constructor?.name===`Boolean`)return{BOOL:e.valueOf()};if(typeof e==`number`||e?.constructor?.name===`Number`)return f(e,n);if(e instanceof t)return e.toAttributeValue();if(typeof e==`bigint`)return u(e);if(typeof e==`string`||e?.constructor?.name===`String`)return e.length===0&&n?.convertEmptyValues?s():l(e);if(n?.convertClassInstanceToMap&&typeof e==`object`)return o(e,n);throw Error(`Unsupported type passed: ${e}. Pass options.convertClassInstanceToMap=true to marshall typeof object as map attribute.`)},r=(e,t)=>({L:e.filter(e=>typeof e!=`function`&&(!t?.removeUndefinedValues||t?.removeUndefinedValues&&e!==void 0)).map(e=>n(e,t))}),i=(e,n)=>{let r=n?.removeUndefinedValues?new Set([...e].filter(e=>e!==void 0)):e;if(!n?.removeUndefinedValues&&r.has(void 0))throw Error(`Pass options.removeUndefinedValues=true to remove undefined values from map/array/set.`);if(r.size===0){if(n?.convertEmptyValues)return s();throw Error(`Pass a non-empty set, or options.convertEmptyValues=true.`)}let i=r.values().next().value;if(i instanceof t)return{NS:Array.from(r).map(e=>e.toString())};if(typeof i==`number`)return{NS:Array.from(r).map(e=>f(e,n)).map(e=>e.N)};if(typeof i==`bigint`)return{NS:Array.from(r).map(u).map(e=>e.N)};if(typeof i==`string`)return{SS:Array.from(r).map(l).map(e=>e.S)};if(p(i))return{BS:Array.from(r).map(c).map(e=>e.B)};throw Error(`Only Number Set (NS), Binary Set (BS) or String Set (SS) are allowed.`)},a=(e,t)=>({M:(e=>{let r={};for(let[i,a]of e)typeof a!=`function`&&(a!==void 0||!t?.removeUndefinedValues)&&(r[i]=n(a,t));return r})(e)}),o=(e,t)=>({M:(e=>{let r={};for(let i in e){let a=e[i];typeof a!=`function`&&(a!==void 0||!t?.removeUndefinedValues)&&(r[i]=n(a,t))}return r})(e)}),s=()=>({NULL:!0}),c=e=>({B:e}),l=e=>({S:e.toString()}),u=e=>({N:e.toString()}),d=e=>{throw Error(`${e} Use NumberValue from @aws-sdk/lib-dynamodb.`)},f=(e,t)=>{if([NaN,1/0,-1/0].map(e=>e.toString()).includes(e.toString()))throw Error(`Special numeric value ${e.toString()} is not allowed`);return t?.allowImpreciseNumbers||(Number(e)>2**53-1?d(`Number ${e.toString()} is greater than Number.MAX_SAFE_INTEGER.`):Number(e)<-(2**53-1)&&d(`Number ${e.toString()} is lesser than Number.MIN_SAFE_INTEGER.`)),{N:e.toString()}},p=e=>e?.constructor?[`ArrayBuffer`,`Blob`,`Buffer`,`DataView`,`File`,`Int8Array`,`Uint8Array`,`Uint8ClampedArray`,`Int16Array`,`Uint16Array`,`Int32Array`,`Uint32Array`,`Float32Array`,`Float64Array`,`BigInt64Array`,`BigUint64Array`].includes(e.constructor.name):!1,m=(e,t)=>{for(let[n,r]of Object.entries(e))if(r!==void 0)switch(n){case`NULL`:return null;case`BOOL`:return!!r;case`N`:return h(r,t);case`B`:return _(r);case`S`:return g(r);case`L`:return v(r,t);case`M`:return y(r,t);case`NS`:return new Set(r.map(e=>h(e,t)));case`BS`:return new Set(r.map(_));case`SS`:return new Set(r.map(g));default:throw Error(`Unsupported type passed: ${n}`)}throw Error(`No value defined: ${JSON.stringify(e)}`)},h=(e,n)=>{if(typeof n?.wrapNumbers==`function`)return n?.wrapNumbers(e);if(n?.wrapNumbers)return t.from(e);let r=Number(e);if((r>2**53-1||r<-(2**53-1))&&![1/0,-1/0].includes(r))if(typeof BigInt==`function`)try{return BigInt(e)}catch{throw Error(`${e} can't be converted to BigInt. Set options.wrapNumbers to get string value.`)}else throw Error(`${e} is outside SAFE_INTEGER bounds. Set options.wrapNumbers to get string value.`);return r},g=e=>e,_=e=>e,v=(e,t)=>e.map(e=>m(e,t)),y=(e,t)=>Object.entries(e).reduce((e,[n,r])=>(e[n]=m(r,t),e),{});function b(e,t){let r=n(e,t),[i,a]=Object.entries(r)[0];switch(i){case`M`:case`L`:return t?.convertTopLevelContainer?r:a;default:return r}}e.marshall=b,e.unmarshall=(e,t)=>t?.convertWithoutMapWrapper?m(e,t):m({M:e},t)}))();const i=e=>{let{tableName:i,region:a}=e,o=new n.DynamoDBClient({region:a});function s(e){let{_pk:t,_sk:n,_table:r,...i}=e;return i}function c(e){let t=[],n={},r={};return e.forEach((e,i)=>{let a=`#w${i}`,o=`:w${i}`;r[a]=e.field;let s;switch(e.operator){case`ne`:n[o]=e.value,s=`${a} <> ${o}`;break;case`gt`:n[o]=e.value,s=`${a} > ${o}`;break;case`gte`:n[o]=e.value,s=`${a} >= ${o}`;break;case`lt`:n[o]=e.value,s=`${a} < ${o}`;break;case`lte`:n[o]=e.value,s=`${a} <= ${o}`;break;case`in`:case`not_in`:{let t=e.value,r=t.map((e,t)=>`:w${i}_${t}`);t.forEach((e,t)=>{n[`:w${i}_${t}`]=e});let o=`${a} IN (${r.join(`,`)})`;s=e.operator===`not_in`?`NOT ${o}`:o;break}case`contains`:n[o]=e.value,s=`contains(${a}, ${o})`;break;case`starts_with`:n[o]=e.value,s=`begins_with(${a}, ${o})`;break;case`ends_with`:n[o]=e.value,s=`contains(${a}, ${o})`;break;default:n[o]=e.value,s=`${a} = ${o}`;break}i>0&&t.push(e.connector===`OR`?`OR`:`AND`),t.push(s)}),{expression:t.join(` `),vals:n,names:r}}async function l(e,t){let a={"#_table":`_table`},l={":_table":e},u,d=a,f=l;if(t&&t.length>0){let e=c(t);u=e.expression,d={...a,...e.names},f={...l,...e.vals}}return((await o.send(new n.QueryCommand({TableName:i,IndexName:`_table-index`,KeyConditionExpression:`#_table = :_table`,FilterExpression:u,ExpressionAttributeNames:d,ExpressionAttributeValues:(0,r.marshall)(f)}))).Items||[]).map(e=>s((0,r.unmarshall)(e)))}return(0,t.createAdapterFactory)({config:{adapterId:`dynamodb`,adapterName:`DynamoDB Adapter`,supportsJSON:!1,supportsDates:!1,supportsBooleans:!1,supportsNumericIds:!1},adapter:()=>({create:async({model:e,data:t})=>{let a=t.id||crypto.randomUUID(),s={...t,id:a,_pk:`${e}#${a}`,_sk:`${e}#${a}`,_table:e};return await o.send(new n.PutItemCommand({TableName:i,Item:(0,r.marshall)(s,{removeUndefinedValues:!0})})),{...t,id:a}},findOne:async({model:e,where:t})=>{if(t.length===1&&t[0].field===`id`&&t[0].operator===`eq`){let a=t[0].value,c=await o.send(new n.GetItemCommand({TableName:i,Key:(0,r.marshall)({_pk:`${e}#${a}`,_sk:`${e}#${a}`})}));return c.Item?s((0,r.unmarshall)(c.Item)):null}return(await l(e,t))[0]||null},findMany:async({model:e,where:t,limit:n,sortBy:r,offset:i})=>{let a=await l(e,t);if(r){let e=r.direction===`desc`?-1:1;a.sort((t,n)=>t[r.field]<n[r.field]?-1*e:t[r.field]>n[r.field]?1*e:0)}return i&&(a=a.slice(i)),n&&(a=a.slice(0,n)),a},update:async({model:e,where:t,update:a})=>{let s=await l(e,t);if(s.length===0)return null;let c=s[0],u=c.id,d=Object.entries(a),f=[],p={},m={};return d.forEach(([e,t],n)=>{e===`id`||e.startsWith(`_`)||(f.push(`#u${n} = :u${n}`),m[`#u${n}`]=e,p[`:u${n}`]=t)}),f.length===0?c:(await o.send(new n.UpdateItemCommand({TableName:i,Key:(0,r.marshall)({_pk:`${e}#${u}`,_sk:`${e}#${u}`}),UpdateExpression:`SET ${f.join(`, `)}`,ExpressionAttributeNames:m,ExpressionAttributeValues:(0,r.marshall)(p,{removeUndefinedValues:!0})})),{...c,...a})},updateMany:async({model:e,where:t,update:a})=>{let s=await l(e,t),c=0;for(let t of s){let s=Object.entries(a),l=[],u={},d={};s.forEach(([e,t],n)=>{e===`id`||e.startsWith(`_`)||(l.push(`#u${n} = :u${n}`),d[`#u${n}`]=e,u[`:u${n}`]=t)}),l.length!==0&&(await o.send(new n.UpdateItemCommand({TableName:i,Key:(0,r.marshall)({_pk:`${e}#${t.id}`,_sk:`${e}#${t.id}`}),UpdateExpression:`SET ${l.join(`, `)}`,ExpressionAttributeNames:d,ExpressionAttributeValues:(0,r.marshall)(u,{removeUndefinedValues:!0})})),c++)}return c},delete:async({model:e,where:t})=>{let a=await l(e,t);a.length!==0&&await o.send(new n.DeleteItemCommand({TableName:i,Key:(0,r.marshall)({_pk:`${e}#${a[0].id}`,_sk:`${e}#${a[0].id}`})}))},deleteMany:async({model:e,where:t})=>{let a=await l(e,t);for(let t of a)await o.send(new n.DeleteItemCommand({TableName:i,Key:(0,r.marshall)({_pk:`${e}#${t.id}`,_sk:`${e}#${t.id}`})}));return a.length},count:async({model:e,where:t})=>(await l(e,t)).length})})};module.exports=i;