@luckystack/login 0.1.3 → 0.1.4

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.
@@ -1,353 +1,353 @@
1
- # User adapter
2
-
3
- Deep-dive on the `UserAdapter` extension point — the abstraction between auth flows and the consumer's user data store. Canonical source: [`./src/userAdapter.ts`](../src/userAdapter.ts).
4
-
5
- ---
6
-
7
- ## Why a User Adapter
8
-
9
- `@luckystack/login` ships a default that reads and writes the framework's recommended Prisma `User` model:
10
-
11
- ```prisma
12
- model User {
13
- id String @id @default(cuid())
14
- email String @unique
15
- provider String // enum: PROVIDERS in your schema
16
- password String?
17
- name String?
18
- avatar String?
19
- avatarFallback String?
20
- language String?
21
- // lastLogin? DateTime? // optional — best-effort updated on each login
22
- }
23
- ```
24
-
25
- That default covers single-tenant apps with the canonical schema. The adapter exists for everything else:
26
-
27
- - **Multi-tenant** — every query should be scoped by `tenantId` read from `AsyncLocalStorage`.
28
- - **Soft delete** — `findByEmail` / `findById` should filter `deletedAt: null`.
29
- - **Renamed columns** — your schema calls it `displayName` instead of `name`, or `emailAddress` instead of `email`.
30
- - **Additional required columns** — your `User` model has a non-nullable `organizationId` that the framework can't supply.
31
- - **Non-Prisma data layer** — Drizzle, Kysely, raw `pg`, a REST microservice, a GraphQL gateway.
32
- - **Read-replica routing** — `findByEmail` hits the replica, `create` / `update` hit the primary.
33
-
34
- The adapter has exactly four methods. Implement them and call `registerUserAdapter(adapter)` at boot.
35
-
36
- ## API surface
37
-
38
- ```ts
39
- interface UserAdapter {
40
- findByEmail: (params: { email: string; provider: string }) => Promise<UserRecord | null>;
41
- findById: (id: string) => Promise<UserRecord | null>;
42
- create: (input: UserAdapterCreateInput) => Promise<UserRecord>;
43
- update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;
44
- }
45
-
46
- registerUserAdapter(adapter: UserAdapter): UserAdapter;
47
- getUserAdapter(): UserAdapter;
48
- isUserAdapterRegistered(): boolean;
49
- defaultPrismaUserAdapter(): UserAdapter;
50
- ```
51
-
52
- `registerUserAdapter` is last-write-wins. `getUserAdapter` returns the registered adapter, or falls back to `defaultPrismaUserAdapter()` lazily on first call. `isUserAdapterRegistered()` returns `true` iff a custom adapter has been registered — useful when an integration package wants to bail with a helpful error if the consumer hasn't yet swapped the default.
53
-
54
- ## Required methods
55
-
56
- ### `findByEmail({ email, provider })`
57
-
58
- Lookup a user by their (email, provider) pair. The pair is the unique identity in the framework — the same email registered via `'credentials'` and via `'google'` are two different `UserRecord`s. Return `null` when no row matches.
59
-
60
- Called from:
61
-
62
- - `registerWithCredentials` — duplicate-email guard.
63
- - `loginWithCredentialsCore` — find-the-user-to-authenticate.
64
- - `loginCallback` (OAuth) — find-or-create branch.
65
- - `sendPasswordResetEmail` — locate the credentials user behind a reset request.
66
-
67
- ### `findById(id)`
68
-
69
- Lookup a user by id. Returns `null` when missing. Called by consumer-side code that needs a fresh user read by id; the framework itself uses `findByEmail` everywhere except for hooks that pass `userId` and want the live record.
70
-
71
- ### `create(input)`
72
-
73
- Insert a new user. `UserAdapterCreateInput` is intentionally loose (string-typed fields) because the framework cannot statically know consumer-side Prisma enums for `provider` / `language`:
74
-
75
- ```ts
76
- interface UserAdapterCreateInput {
77
- email: string;
78
- provider: string;
79
- name?: string | null;
80
- password?: string | null; // bcrypt hash for credentials; null for OAuth
81
- avatar?: string | null;
82
- avatarFallback?: string | null; // hex color string '#aabbcc' for the avatar initials fallback
83
- language?: string | null;
84
- }
85
- ```
86
-
87
- Return the freshly-inserted `UserRecord`. The framework wraps the call in `tryCatch` and surfaces a `login.createUserFailed` reason key when the call resolves to a falsy value (or `api.internalServerError` when it throws).
88
-
89
- ### `update(id, patch)`
90
-
91
- Patch an existing user. The framework calls this for:
92
-
93
- - `lastLogin: new Date()` on every successful login (best-effort, wrapped in `tryCatch`).
94
- - `password: hashedNew` on `updatePasswordHash` from the password-reset / password-change flows.
95
- - `avatar` after image upload (consumer-side).
96
-
97
- `patch` is typed as `Partial<UserRecord>`. The framework casts to `never` at the call site for the `lastLogin` and `password` updates so a schema missing those fields doesn't break compilation — the adapter is responsible for accepting (or ignoring) unknown keys gracefully.
98
-
99
- ## `UserRecord` shape
100
-
101
- ```ts
102
- interface UserRecord extends BaseSessionLayout {
103
- password?: string | null;
104
- provider?: string | null;
105
- lastLogin?: Date | null;
106
- }
107
- ```
108
-
109
- `BaseSessionLayout` (from `@luckystack/core`) covers the standard fields: `id`, `email`, `name`, `avatar`, `avatarFallback`, `admin`, `language`. `UserRecord` adds three things the session layout deliberately doesn't expose:
110
-
111
- - `password` — the bcrypt hash. Stripped before the session is built (`sanitizeUserForSession`).
112
- - `provider` — the auth provider. The session knows this only when the consumer needs it; some apps don't.
113
- - `lastLogin` — best-effort timestamp. Mirrored on the session as both `lastLogin` (the just-written value) and `previousLogin` (the value before the update).
114
-
115
- Consumers who add columns can either:
116
-
117
- 1. **Augment `BaseSessionLayout`** via module declaration so the new field flows to the session, OR
118
- 2. **Keep the field internal** to the adapter implementation and not expose it on `UserRecord`.
119
-
120
- The first option is right for "data the UI should see" (preferences, role, organization id). The second is right for "data the auth layer doesn't care about" (audit fields, internal flags).
121
-
122
- ## `defaultPrismaUserAdapter`
123
-
124
- The framework default. Implementation:
125
-
126
- ```ts
127
- export const defaultPrismaUserAdapter = (): UserAdapter => {
128
- const user = (): PrismaUserDelegate => prisma.user as unknown as PrismaUserDelegate;
129
-
130
- return {
131
- findByEmail: async ({ email, provider }) =>
132
- user().findFirst({ where: { email, provider } }),
133
- findById: async (id) =>
134
- user().findUnique({ where: { id } }),
135
- create: async (input) =>
136
- user().create({
137
- data: {
138
- email: input.email,
139
- provider: input.provider,
140
- name: input.name,
141
- password: input.password ?? null,
142
- avatar: input.avatar ?? '',
143
- avatarFallback: input.avatarFallback,
144
- language: input.language,
145
- },
146
- }),
147
- update: async (id, patch) =>
148
- user().update({ where: { id }, data: patch }),
149
- };
150
- };
151
- ```
152
-
153
- The single `as unknown as PrismaUserDelegate` cast is the only one of its kind in `@luckystack/login` and is intentional + load-bearing. It is the abstraction boundary between the framework's loose `UserAdapterCreateInput` (string-typed fields) and the consumer's strict Prisma model (which may declare enums for `provider` / `language`). The framework cannot know those enum types statically, so it type-erases once at this seam and trusts the runtime `prisma.user` to satisfy the documented `PrismaUserDelegate` shape:
154
-
155
- ```ts
156
- interface PrismaUserDelegate {
157
- findFirst: (args: { where: { email: string; provider: string } }) => Promise<UserRecord | null>;
158
- findUnique: (args: { where: { id: string } }) => Promise<UserRecord | null>;
159
- create: (args: { data: Record<string, unknown> }) => Promise<UserRecord>;
160
- update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<UserRecord>;
161
- }
162
- ```
163
-
164
- Consumers whose schema diverges should NOT widen this cast further — register their own `UserAdapter` instead.
165
-
166
- ## Lazy initialization
167
-
168
- `getUserAdapter` defers default construction until first use:
169
-
170
- ```ts
171
- let registeredAdapter: UserAdapter | null = null;
172
- let cachedDefaultAdapter: UserAdapter | null = null;
173
-
174
- export const getUserAdapter = (): UserAdapter => {
175
- if (registeredAdapter) return registeredAdapter;
176
- if (!cachedDefaultAdapter) {
177
- cachedDefaultAdapter = defaultPrismaUserAdapter();
178
- }
179
- return cachedDefaultAdapter;
180
- };
181
- ```
182
-
183
- This matters because `defaultPrismaUserAdapter` calls `prisma.user` — which is a Prisma proxy that connects on first read. If we instantiated eagerly, modules that just import `@luckystack/login` (without ever logging anyone in) would open a database connection at import time.
184
-
185
- Lazy + cached means:
186
-
187
- - Pure imports never connect to the DB.
188
- - The first login attempt creates one adapter and reuses it forever.
189
- - Custom adapters bypass the cache entirely.
190
-
191
- ## Recipes
192
-
193
- ### Multi-tenant scoping
194
-
195
- ```ts
196
- import { AsyncLocalStorage } from 'node:async_hooks';
197
-
198
- export const tenantContext = new AsyncLocalStorage<{ tenantId: string }>();
199
-
200
- const tenantedAdapter: UserAdapter = {
201
- findByEmail: async ({ email, provider }) => {
202
- const ctx = tenantContext.getStore();
203
- if (!ctx) throw new Error('No tenant context');
204
- return prisma.user.findFirst({
205
- where: { email, provider, tenantId: ctx.tenantId },
206
- });
207
- },
208
- // ... same for findById, create, update
209
- };
210
-
211
- registerUserAdapter(tenantedAdapter);
212
- ```
213
-
214
- The HTTP middleware that resolves `tenantId` from the host header runs `tenantContext.run({ tenantId }, next)` so the adapter reads it without an explicit parameter.
215
-
216
- ### Soft delete
217
-
218
- ```ts
219
- const softDeleteAdapter: UserAdapter = {
220
- ...defaultPrismaUserAdapter(),
221
- findByEmail: async ({ email, provider }) =>
222
- prisma.user.findFirst({
223
- where: { email, provider, deletedAt: null },
224
- }),
225
- findById: async (id) =>
226
- prisma.user.findFirst({
227
- where: { id, deletedAt: null },
228
- }),
229
- };
230
-
231
- registerUserAdapter(softDeleteAdapter);
232
- ```
233
-
234
- The spread keeps `create` and `update` identical to the default — only the read methods get the `deletedAt: null` filter.
235
-
236
- ### Alternative ORM (Drizzle)
237
-
238
- ```ts
239
- import { db } from './db';
240
- import { users } from './schema';
241
- import { and, eq } from 'drizzle-orm';
242
-
243
- const drizzleAdapter: UserAdapter = {
244
- findByEmail: async ({ email, provider }) => {
245
- const rows = await db.select().from(users)
246
- .where(and(eq(users.email, email), eq(users.provider, provider)));
247
- return rows[0] ?? null;
248
- },
249
- findById: async (id) => {
250
- const rows = await db.select().from(users).where(eq(users.id, id));
251
- return rows[0] ?? null;
252
- },
253
- create: async (input) => {
254
- const [row] = await db.insert(users).values(input).returning();
255
- return row;
256
- },
257
- update: async (id, patch) => {
258
- const [row] = await db.update(users).set(patch).where(eq(users.id, id)).returning();
259
- return row;
260
- },
261
- };
262
-
263
- registerUserAdapter(drizzleAdapter);
264
- ```
265
-
266
- ### Additional required column
267
-
268
- If your schema requires `organizationId` on create, override only the `create` method:
269
-
270
- ```ts
271
- const orgScopedAdapter: UserAdapter = {
272
- ...defaultPrismaUserAdapter(),
273
- create: async (input) => {
274
- const orgId = tenantContext.getStore()?.tenantId;
275
- if (!orgId) throw new Error('No org context for create');
276
- return prisma.user.create({
277
- data: { ...input, organizationId: orgId },
278
- });
279
- },
280
- };
281
-
282
- registerUserAdapter(orgScopedAdapter);
283
- ```
284
-
285
- ### Read-replica routing
286
-
287
- ```ts
288
- const replicaAdapter: UserAdapter = {
289
- findByEmail: async (args) => prismaReplica.user.findFirst({ where: args }),
290
- findById: async (id) => prismaReplica.user.findUnique({ where: { id } }),
291
- create: async (input) => prismaPrimary.user.create({ data: { ...input } }),
292
- update: async (id, patch) => prismaPrimary.user.update({ where: { id }, data: patch }),
293
- };
294
-
295
- registerUserAdapter(replicaAdapter);
296
- ```
297
-
298
- The framework reads via `findByEmail` on every login, so routing reads to a replica is a meaningful production optimization. Reads after a write (e.g. `findByEmail` immediately after `create` during register) should still hit the primary — handle that with a read-your-writes timestamp or a per-request "stick to primary for N ms" flag inside your adapter.
299
-
300
- ## `lastLogin` semantics
301
-
302
- The default adapter accepts `lastLogin` in `update` because it casts to `Record<string, unknown>` for the `data` field. The framework writes `{ lastLogin: new Date() }` after a successful credentials or OAuth login, wrapped in `tryCatch`:
303
-
304
- ```ts
305
- await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin } as never));
306
- ```
307
-
308
- The cast to `never` is intentional — it tells TypeScript "we know `lastLogin` may not be in `UserRecord`, accept it anyway". If your schema doesn't have a `lastLogin` column, the call either:
309
-
310
- - Fails inside Prisma's runtime field validation → `tryCatch` swallows the error, the login succeeds.
311
- - Succeeds silently if your adapter ignores unknown keys (recommended).
312
-
313
- The session's `lastLogin` and `previousLogin` are populated from this update — if it fails, both fields will be `undefined` on the session and the UI should treat them as optional.
314
-
315
- For audit-grade tracking, register a `postLogin` hook instead of relying on the column:
316
-
317
- ```ts
318
- registerHook('postLogin', async ({ userId, provider }) => {
319
- await prisma.loginEvent.create({
320
- data: { userId, provider, timestamp: new Date() },
321
- });
322
- });
323
- ```
324
-
325
- The hook gives you a row-per-login with full context, vs. the column which is a single mutating timestamp.
326
-
327
- ## Composing on top of the default
328
-
329
- A common pattern is "the default but with one method overridden":
330
-
331
- ```ts
332
- const base = defaultPrismaUserAdapter();
333
-
334
- registerUserAdapter({
335
- ...base,
336
- findByEmail: async ({ email, provider }) => {
337
- const user = await base.findByEmail({ email, provider });
338
- if (!user) return null;
339
- if (user.disabledAt) return null; // app-level "disabled" flag
340
- return user;
341
- },
342
- });
343
- ```
344
-
345
- This avoids re-implementing the other three methods just to add a single filter.
346
-
347
- ## Related
348
-
349
- - [`./credentials-auth.md`](./credentials-auth.md) — what calls `findByEmail` / `create` for credentials flows.
350
- - [`./oauth-providers.md`](./oauth-providers.md) — `loginCallback` uses the same adapter surface.
351
- - [`./password-reset.md`](./password-reset.md) — `updatePasswordHash` calls `update` with `{ password }`.
352
- - [`./session-management.md`](./session-management.md) — the adapter does NOT touch sessions; sessions are a separate adapter.
353
- - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).
1
+ # User adapter
2
+
3
+ Deep-dive on the `UserAdapter` extension point — the abstraction between auth flows and the consumer's user data store. Canonical source: [`./src/userAdapter.ts`](../src/userAdapter.ts).
4
+
5
+ ---
6
+
7
+ ## Why a User Adapter
8
+
9
+ `@luckystack/login` ships a default that reads and writes the framework's recommended Prisma `User` model:
10
+
11
+ ```prisma
12
+ model User {
13
+ id String @id @default(cuid())
14
+ email String @unique
15
+ provider String // enum: PROVIDERS in your schema
16
+ password String?
17
+ name String?
18
+ avatar String?
19
+ avatarFallback String?
20
+ language String?
21
+ // lastLogin? DateTime? // optional — best-effort updated on each login
22
+ }
23
+ ```
24
+
25
+ That default covers single-tenant apps with the canonical schema. The adapter exists for everything else:
26
+
27
+ - **Multi-tenant** — every query should be scoped by `tenantId` read from `AsyncLocalStorage`.
28
+ - **Soft delete** — `findByEmail` / `findById` should filter `deletedAt: null`.
29
+ - **Renamed columns** — your schema calls it `displayName` instead of `name`, or `emailAddress` instead of `email`.
30
+ - **Additional required columns** — your `User` model has a non-nullable `organizationId` that the framework can't supply.
31
+ - **Non-Prisma data layer** — Drizzle, Kysely, raw `pg`, a REST microservice, a GraphQL gateway.
32
+ - **Read-replica routing** — `findByEmail` hits the replica, `create` / `update` hit the primary.
33
+
34
+ The adapter has exactly four methods. Implement them and call `registerUserAdapter(adapter)` at boot.
35
+
36
+ ## API surface
37
+
38
+ ```ts
39
+ interface UserAdapter {
40
+ findByEmail: (params: { email: string; provider: string }) => Promise<UserRecord | null>;
41
+ findById: (id: string) => Promise<UserRecord | null>;
42
+ create: (input: UserAdapterCreateInput) => Promise<UserRecord>;
43
+ update: (id: string, patch: Partial<UserRecord>) => Promise<UserRecord>;
44
+ }
45
+
46
+ registerUserAdapter(adapter: UserAdapter): UserAdapter;
47
+ getUserAdapter(): UserAdapter;
48
+ isUserAdapterRegistered(): boolean;
49
+ defaultPrismaUserAdapter(): UserAdapter;
50
+ ```
51
+
52
+ `registerUserAdapter` is last-write-wins. `getUserAdapter` returns the registered adapter, or falls back to `defaultPrismaUserAdapter()` lazily on first call. `isUserAdapterRegistered()` returns `true` iff a custom adapter has been registered — useful when an integration package wants to bail with a helpful error if the consumer hasn't yet swapped the default.
53
+
54
+ ## Required methods
55
+
56
+ ### `findByEmail({ email, provider })`
57
+
58
+ Lookup a user by their (email, provider) pair. The pair is the unique identity in the framework — the same email registered via `'credentials'` and via `'google'` are two different `UserRecord`s. Return `null` when no row matches.
59
+
60
+ Called from:
61
+
62
+ - `registerWithCredentials` — duplicate-email guard.
63
+ - `loginWithCredentialsCore` — find-the-user-to-authenticate.
64
+ - `loginCallback` (OAuth) — find-or-create branch.
65
+ - `sendPasswordResetEmail` — locate the credentials user behind a reset request.
66
+
67
+ ### `findById(id)`
68
+
69
+ Lookup a user by id. Returns `null` when missing. Called by consumer-side code that needs a fresh user read by id; the framework itself uses `findByEmail` everywhere except for hooks that pass `userId` and want the live record.
70
+
71
+ ### `create(input)`
72
+
73
+ Insert a new user. `UserAdapterCreateInput` is intentionally loose (string-typed fields) because the framework cannot statically know consumer-side Prisma enums for `provider` / `language`:
74
+
75
+ ```ts
76
+ interface UserAdapterCreateInput {
77
+ email: string;
78
+ provider: string;
79
+ name?: string | null;
80
+ password?: string | null; // bcrypt hash for credentials; null for OAuth
81
+ avatar?: string | null;
82
+ avatarFallback?: string | null; // hex color string '#aabbcc' for the avatar initials fallback
83
+ language?: string | null;
84
+ }
85
+ ```
86
+
87
+ Return the freshly-inserted `UserRecord`. The framework wraps the call in `tryCatch` and surfaces a `login.createUserFailed` reason key when the call resolves to a falsy value (or `api.internalServerError` when it throws).
88
+
89
+ ### `update(id, patch)`
90
+
91
+ Patch an existing user. The framework calls this for:
92
+
93
+ - `lastLogin: new Date()` on every successful login (best-effort, wrapped in `tryCatch`).
94
+ - `password: hashedNew` on `updatePasswordHash` from the password-reset / password-change flows.
95
+ - `avatar` after image upload (consumer-side).
96
+
97
+ `patch` is typed as `Partial<UserRecord>`. The framework casts to `never` at the call site for the `lastLogin` and `password` updates so a schema missing those fields doesn't break compilation — the adapter is responsible for accepting (or ignoring) unknown keys gracefully.
98
+
99
+ ## `UserRecord` shape
100
+
101
+ ```ts
102
+ interface UserRecord extends BaseSessionLayout {
103
+ password?: string | null;
104
+ provider?: string | null;
105
+ lastLogin?: Date | null;
106
+ }
107
+ ```
108
+
109
+ `BaseSessionLayout` (from `@luckystack/core`) covers the standard fields: `id`, `email`, `name`, `avatar`, `avatarFallback`, `admin`, `language`. `UserRecord` adds three things the session layout deliberately doesn't expose:
110
+
111
+ - `password` — the bcrypt hash. Stripped before the session is built (`sanitizeUserForSession`).
112
+ - `provider` — the auth provider. The session knows this only when the consumer needs it; some apps don't.
113
+ - `lastLogin` — best-effort timestamp. Mirrored on the session as both `lastLogin` (the just-written value) and `previousLogin` (the value before the update).
114
+
115
+ Consumers who add columns can either:
116
+
117
+ 1. **Augment `BaseSessionLayout`** via module declaration so the new field flows to the session, OR
118
+ 2. **Keep the field internal** to the adapter implementation and not expose it on `UserRecord`.
119
+
120
+ The first option is right for "data the UI should see" (preferences, role, organization id). The second is right for "data the auth layer doesn't care about" (audit fields, internal flags).
121
+
122
+ ## `defaultPrismaUserAdapter`
123
+
124
+ The framework default. Implementation:
125
+
126
+ ```ts
127
+ export const defaultPrismaUserAdapter = (): UserAdapter => {
128
+ const user = (): PrismaUserDelegate => prisma.user as unknown as PrismaUserDelegate;
129
+
130
+ return {
131
+ findByEmail: async ({ email, provider }) =>
132
+ user().findFirst({ where: { email, provider } }),
133
+ findById: async (id) =>
134
+ user().findUnique({ where: { id } }),
135
+ create: async (input) =>
136
+ user().create({
137
+ data: {
138
+ email: input.email,
139
+ provider: input.provider,
140
+ name: input.name,
141
+ password: input.password ?? null,
142
+ avatar: input.avatar ?? '',
143
+ avatarFallback: input.avatarFallback,
144
+ language: input.language,
145
+ },
146
+ }),
147
+ update: async (id, patch) =>
148
+ user().update({ where: { id }, data: patch }),
149
+ };
150
+ };
151
+ ```
152
+
153
+ The single `as unknown as PrismaUserDelegate` cast is the only one of its kind in `@luckystack/login` and is intentional + load-bearing. It is the abstraction boundary between the framework's loose `UserAdapterCreateInput` (string-typed fields) and the consumer's strict Prisma model (which may declare enums for `provider` / `language`). The framework cannot know those enum types statically, so it type-erases once at this seam and trusts the runtime `prisma.user` to satisfy the documented `PrismaUserDelegate` shape:
154
+
155
+ ```ts
156
+ interface PrismaUserDelegate {
157
+ findFirst: (args: { where: { email: string; provider: string } }) => Promise<UserRecord | null>;
158
+ findUnique: (args: { where: { id: string } }) => Promise<UserRecord | null>;
159
+ create: (args: { data: Record<string, unknown> }) => Promise<UserRecord>;
160
+ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<UserRecord>;
161
+ }
162
+ ```
163
+
164
+ Consumers whose schema diverges should NOT widen this cast further — register their own `UserAdapter` instead.
165
+
166
+ ## Lazy initialization
167
+
168
+ `getUserAdapter` defers default construction until first use:
169
+
170
+ ```ts
171
+ let registeredAdapter: UserAdapter | null = null;
172
+ let cachedDefaultAdapter: UserAdapter | null = null;
173
+
174
+ export const getUserAdapter = (): UserAdapter => {
175
+ if (registeredAdapter) return registeredAdapter;
176
+ if (!cachedDefaultAdapter) {
177
+ cachedDefaultAdapter = defaultPrismaUserAdapter();
178
+ }
179
+ return cachedDefaultAdapter;
180
+ };
181
+ ```
182
+
183
+ This matters because `defaultPrismaUserAdapter` calls `prisma.user` — which is a Prisma proxy that connects on first read. If we instantiated eagerly, modules that just import `@luckystack/login` (without ever logging anyone in) would open a database connection at import time.
184
+
185
+ Lazy + cached means:
186
+
187
+ - Pure imports never connect to the DB.
188
+ - The first login attempt creates one adapter and reuses it forever.
189
+ - Custom adapters bypass the cache entirely.
190
+
191
+ ## Recipes
192
+
193
+ ### Multi-tenant scoping
194
+
195
+ ```ts
196
+ import { AsyncLocalStorage } from 'node:async_hooks';
197
+
198
+ export const tenantContext = new AsyncLocalStorage<{ tenantId: string }>();
199
+
200
+ const tenantedAdapter: UserAdapter = {
201
+ findByEmail: async ({ email, provider }) => {
202
+ const ctx = tenantContext.getStore();
203
+ if (!ctx) throw new Error('No tenant context');
204
+ return prisma.user.findFirst({
205
+ where: { email, provider, tenantId: ctx.tenantId },
206
+ });
207
+ },
208
+ // ... same for findById, create, update
209
+ };
210
+
211
+ registerUserAdapter(tenantedAdapter);
212
+ ```
213
+
214
+ The HTTP middleware that resolves `tenantId` from the host header runs `tenantContext.run({ tenantId }, next)` so the adapter reads it without an explicit parameter.
215
+
216
+ ### Soft delete
217
+
218
+ ```ts
219
+ const softDeleteAdapter: UserAdapter = {
220
+ ...defaultPrismaUserAdapter(),
221
+ findByEmail: async ({ email, provider }) =>
222
+ prisma.user.findFirst({
223
+ where: { email, provider, deletedAt: null },
224
+ }),
225
+ findById: async (id) =>
226
+ prisma.user.findFirst({
227
+ where: { id, deletedAt: null },
228
+ }),
229
+ };
230
+
231
+ registerUserAdapter(softDeleteAdapter);
232
+ ```
233
+
234
+ The spread keeps `create` and `update` identical to the default — only the read methods get the `deletedAt: null` filter.
235
+
236
+ ### Alternative ORM (Drizzle)
237
+
238
+ ```ts
239
+ import { db } from './db';
240
+ import { users } from './schema';
241
+ import { and, eq } from 'drizzle-orm';
242
+
243
+ const drizzleAdapter: UserAdapter = {
244
+ findByEmail: async ({ email, provider }) => {
245
+ const rows = await db.select().from(users)
246
+ .where(and(eq(users.email, email), eq(users.provider, provider)));
247
+ return rows[0] ?? null;
248
+ },
249
+ findById: async (id) => {
250
+ const rows = await db.select().from(users).where(eq(users.id, id));
251
+ return rows[0] ?? null;
252
+ },
253
+ create: async (input) => {
254
+ const [row] = await db.insert(users).values(input).returning();
255
+ return row;
256
+ },
257
+ update: async (id, patch) => {
258
+ const [row] = await db.update(users).set(patch).where(eq(users.id, id)).returning();
259
+ return row;
260
+ },
261
+ };
262
+
263
+ registerUserAdapter(drizzleAdapter);
264
+ ```
265
+
266
+ ### Additional required column
267
+
268
+ If your schema requires `organizationId` on create, override only the `create` method:
269
+
270
+ ```ts
271
+ const orgScopedAdapter: UserAdapter = {
272
+ ...defaultPrismaUserAdapter(),
273
+ create: async (input) => {
274
+ const orgId = tenantContext.getStore()?.tenantId;
275
+ if (!orgId) throw new Error('No org context for create');
276
+ return prisma.user.create({
277
+ data: { ...input, organizationId: orgId },
278
+ });
279
+ },
280
+ };
281
+
282
+ registerUserAdapter(orgScopedAdapter);
283
+ ```
284
+
285
+ ### Read-replica routing
286
+
287
+ ```ts
288
+ const replicaAdapter: UserAdapter = {
289
+ findByEmail: async (args) => prismaReplica.user.findFirst({ where: args }),
290
+ findById: async (id) => prismaReplica.user.findUnique({ where: { id } }),
291
+ create: async (input) => prismaPrimary.user.create({ data: { ...input } }),
292
+ update: async (id, patch) => prismaPrimary.user.update({ where: { id }, data: patch }),
293
+ };
294
+
295
+ registerUserAdapter(replicaAdapter);
296
+ ```
297
+
298
+ The framework reads via `findByEmail` on every login, so routing reads to a replica is a meaningful production optimization. Reads after a write (e.g. `findByEmail` immediately after `create` during register) should still hit the primary — handle that with a read-your-writes timestamp or a per-request "stick to primary for N ms" flag inside your adapter.
299
+
300
+ ## `lastLogin` semantics
301
+
302
+ The default adapter accepts `lastLogin` in `update` because it casts to `Record<string, unknown>` for the `data` field. The framework writes `{ lastLogin: new Date() }` after a successful credentials or OAuth login, wrapped in `tryCatch`:
303
+
304
+ ```ts
305
+ await tryCatch(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin } as never));
306
+ ```
307
+
308
+ The cast to `never` is intentional — it tells TypeScript "we know `lastLogin` may not be in `UserRecord`, accept it anyway". If your schema doesn't have a `lastLogin` column, the call either:
309
+
310
+ - Fails inside Prisma's runtime field validation → `tryCatch` swallows the error, the login succeeds.
311
+ - Succeeds silently if your adapter ignores unknown keys (recommended).
312
+
313
+ The session's `lastLogin` and `previousLogin` are populated from this update — if it fails, both fields will be `undefined` on the session and the UI should treat them as optional.
314
+
315
+ For audit-grade tracking, register a `postLogin` hook instead of relying on the column:
316
+
317
+ ```ts
318
+ registerHook('postLogin', async ({ userId, provider }) => {
319
+ await prisma.loginEvent.create({
320
+ data: { userId, provider, timestamp: new Date() },
321
+ });
322
+ });
323
+ ```
324
+
325
+ The hook gives you a row-per-login with full context, vs. the column which is a single mutating timestamp.
326
+
327
+ ## Composing on top of the default
328
+
329
+ A common pattern is "the default but with one method overridden":
330
+
331
+ ```ts
332
+ const base = defaultPrismaUserAdapter();
333
+
334
+ registerUserAdapter({
335
+ ...base,
336
+ findByEmail: async ({ email, provider }) => {
337
+ const user = await base.findByEmail({ email, provider });
338
+ if (!user) return null;
339
+ if (user.disabledAt) return null; // app-level "disabled" flag
340
+ return user;
341
+ },
342
+ });
343
+ ```
344
+
345
+ This avoids re-implementing the other three methods just to add a single filter.
346
+
347
+ ## Related
348
+
349
+ - [`./credentials-auth.md`](./credentials-auth.md) — what calls `findByEmail` / `create` for credentials flows.
350
+ - [`./oauth-providers.md`](./oauth-providers.md) — `loginCallback` uses the same adapter surface.
351
+ - [`./password-reset.md`](./password-reset.md) — `updatePasswordHash` calls `update` with `{ password }`.
352
+ - [`./session-management.md`](./session-management.md) — the adapter does NOT touch sessions; sessions are a separate adapter.
353
+ - Architecture: [`/docs/ARCHITECTURE_AUTH.md`](../../../docs/ARCHITECTURE_AUTH.md).