@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
33import { admin , bearer , mcp , organization } from "better-auth/plugins" ;
44import { apiKey } from "@better-auth/api-key" ;
55import { type Client } from "@libsql/client" ;
6- import { LibsqlDialect } from "@libsql/kysely-libsql" ;
6+ import { LibsqlDialect , type LibsqlDialectConfig } from "@libsql/kysely-libsql" ;
77import { Context } from "effect" ;
88
99import { loadConfig } from "../config" ;
@@ -26,42 +26,42 @@ interface SignupGate {
2626const SIGNUP_PATH = "/sign-up/email" ;
2727
2828// ---------------------------------------------------------------------------
29- // Better Auth instance over the SAME libSQL `file:` URL as the FumaDB executor
30- // tables ("one file , two schema regions").
29+ // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor
30+ // tables ("one connection , two schema regions").
3131//
32- // Schema-at-boot: passing `{ dialect: new LibsqlDialect({ url }), type: "sqlite" }`
33- // makes Better Auth's createKyselyAdapter take its `"dialect" in db` branch (no
34- // native dep, no bun:sqlite); `runMigrations()` creates the auth tables
35- // idempotently in that file . `makeAuthOptions` is the single source of truth so
36- // the migrator and runtime instance never drift.
32+ // Schema-at-boot: passing `{ dialect: new LibsqlDialect({ client }), type:
33+ // "sqlite" }` makes Better Auth's createKyselyAdapter take its `"dialect" in db`
34+ // branch (no native dep, no bun:sqlite); `runMigrations()` creates the auth
35+ // tables idempotently . `makeAuthOptions` is the single source of truth so the
36+ // migrator and runtime instance never drift.
3737//
38- // CRITICAL: LibsqlDialect opens its OWN libSQL connection to the file — it does
39- // NOT share SelfHostDb's drizzle connection. Both target one file, and a row
40- // Better Auth writes via this dialect is immediately readable through the
41- // drizzle/FumaDB client (proven by seed.ts's reads + better-auth.test.ts). The
42- // per-connection foreign_keys/WAL PRAGMAs SelfHostDb set on its own connection
43- // do NOT carry to this one; for the auth tables that is fine (Kysely issues no
44- // FK-dependent reads at boot and WAL is already a file-level mode), and the
45- // shared file stays consistent because writes go through SQLite's file lock.
38+ // CRITICAL: LibsqlDialect is handed SelfHostDb's EXISTING `@libsql/client` (the
39+ // `{ client }` config branch), NOT a fresh `{ url }` connection. This is the
40+ // crux of the self-host data-loss fix: libSQL connections each manage their own
41+ // `-wal`/`-shm`, and when Better Auth opened a SECOND connection to the same
42+ // file (`{ url }`), its open unlinked SelfHostDb's `-wal`/`-shm` and created new
43+ // ones — orphaning SelfHostDb onto a now-deleted WAL inode. Every executor-core
44+ // write (integrations, connections, tools) then landed in that deleted inode
45+ // and vanished on the next restart, while Better Auth's own writes (on the live
46+ // WAL) survived — the "reconnected account, zero tools" bug, reproducing even
47+ // after the throwaway-bootstrap-instance fix because the LONG-LIVED auth
48+ // connection unlinked it just the same. Sharing one client means one WAL: no
49+ // unlink, and SelfHostDb's foreign_keys/WAL/busy_timeout PRAGMAs now cover auth
50+ // queries too (same connection). `{ client }` sets closeClient=false, so the
51+ // dialect never closes the handle — SelfHostDb owns the file lifecycle and
52+ // closes its client at shutdown. NEVER call .destroy() during normal operation.
4653//
4754// We build exactly ONE auth instance, held for the process lifetime. An earlier
48- // design built a throwaway "bootstrap" instance to run migrations + seed before
49- // the org id was known, then discarded it — but its LibsqlDialect connection
50- // (a DIFFERENT native libSQL build than SelfHostDb's) was GC-closed mid-boot,
51- // and that close unlinked the shared `-wal` out from under SelfHostDb's
52- // still-open connection. Every executor write then landed in a deleted WAL
53- // inode and vanished on the next restart (the "reconnected account, zero tools"
54- // data-loss bug). Keeping one long-lived auth connection — with the org id
55- // late-bound the same way the signup gate's `getAuth` already is — removes the
56- // discarded connection entirely. NEVER call .destroy() during normal operation;
57- // SelfHostDb owns the file lifecycle and closes its client at shutdown.
55+ // design also built a throwaway "bootstrap" instance (discarded mid-boot); that
56+ // is gone too — the org id is late-bound the same way the signup gate's
57+ // `getAuth` already is, so no second instance is ever needed.
5858//
5959// `satisfies BetterAuthOptions` (not a return annotation) keeps the literal
6060// plugin tuple so `betterAuth` infers the plugin-augmented `auth.api` and
6161// session/user shapes (activeOrganizationId, role, createUser, ...).
6262// ---------------------------------------------------------------------------
6363
64- const makeAuthOptions = ( url : string , getOrganizationId : ( ) => string , gate ?: SignupGate ) => {
64+ const makeAuthOptions = ( client : Client , getOrganizationId : ( ) => string , gate ?: SignupGate ) => {
6565 const config = loadConfig ( ) ;
6666 // Always resolved (generated + persisted when no env is set); this guards only
6767 // an explicitly-set env secret that is too weak.
@@ -71,7 +71,22 @@ const makeAuthOptions = (url: string, getOrganizationId: () => string, gate?: Si
7171 throw new Error ( "BETTER_AUTH_SECRET (or AUTH_SECRET), if set, must be at least 32 characters" ) ;
7272 }
7373 return {
74- database : { dialect : new LibsqlDialect ( { url } ) , type : "sqlite" as const } ,
74+ // Hand Better Auth the SAME libSQL client SelfHostDb already opened — NOT a
75+ // fresh `{ url }` connection. `{ client }` makes LibsqlDialect adopt the
76+ // existing handle (closeClient=false, so SelfHostDb keeps ownership). One
77+ // connection means one WAL: see the header comment for why a second
78+ // connection is the self-host data-loss bug.
79+ //
80+ // The cast bridges a dependency skew: @libsql /kysely-libsql pins an older
81+ // @libsql /core (0.8) than @libsql/client (0.17), so the two `Client` types
82+ // differ — only in `.sync()` (embedded-replica replication, unused here).
83+ // The dialect calls execute/batch/transaction/close, which are identical
84+ // across both versions, so sharing the 0.17 client is sound at runtime.
85+ database : {
86+ // oxlint-disable-next-line executor/no-double-cast -- boundary: the two @libsql/core versions' Client types are structurally identical for the calls the dialect makes (see above); no schema/decode applies to a native client handle.
87+ dialect : new LibsqlDialect ( { client } as unknown as LibsqlDialectConfig ) ,
88+ type : "sqlite" as const ,
89+ } ,
7590 secret,
7691 baseURL : config . webBaseUrl ,
7792 // The browser Origin must match this exactly; CLI/MCP bearer requests carry
@@ -191,11 +206,12 @@ const inviteCodeFrom = (context: { body?: unknown }): string | undefined => {
191206 return undefined ;
192207} ;
193208
194- // Count org members via Better Auth's OWN adapter — the SAME connection that
195- // `addMember` writes through. SelfHostDb opens a SEPARATE libSQL connection
196- // whose snapshot can lag Better Auth's writes (observed under Bun: a just-added
197- // member is invisible to that connection for a while), so any membership read
198- // that gates behaviour MUST go through here to stay consistent with the writes.
209+ // Count org members via Better Auth's OWN adapter. Now that auth shares
210+ // SelfHostDb's libSQL client (one connection), this no longer guards against a
211+ // cross-connection snapshot lag — that lag is gone with the second connection.
212+ // It stays the canonical read because the adapter already models the `member`
213+ // table and the count gates the first-run claim; reading through it keeps the
214+ // gate logic next to the writes.
199215export const countOrgMembers = ( auth : Auth , organizationId : string ) : Promise < number > =>
200216 auth . $context . then ( ( { adapter } ) =>
201217 adapter . count ( { model : "member" , where : [ { field : "organizationId" , value : organizationId } ] } ) ,
@@ -208,8 +224,8 @@ const orgHasNoMembers = async (gate: SignupGate): Promise<boolean> => {
208224 return ( await countOrgMembers ( auth , gate . organizationId ) ) === 0 ;
209225} ;
210226
211- const createAuthInstance = ( url : string , getOrganizationId : ( ) => string , gate ?: SignupGate ) =>
212- betterAuth ( makeAuthOptions ( url , getOrganizationId , gate ) ) ;
227+ const createAuthInstance = ( client : Client , getOrganizationId : ( ) => string , gate ?: SignupGate ) =>
228+ betterAuth ( makeAuthOptions ( client , getOrganizationId , gate ) ) ;
213229
214230export type Auth = ReturnType < typeof createAuthInstance > ;
215231
@@ -241,13 +257,13 @@ export class BetterAuth extends Context.Service<BetterAuth, BetterAuthHandle>()(
241257 * `/sign-up/email` path — the seed's admin `createUser`/`createOrganization`
242258 * pass straight through, exactly as the old gate-free bootstrap instance did.
243259 *
244- * `url ` is the SAME libSQL `file:` URL SelfHostDb opened; `client` is
245- * SelfHostDb's drizzle connection to that file, used by the seed for its two
246- * idempotency reads against the auth tables Better Auth just migrated (proving
247- * the cross- connection invariant: Better Auth writes via LibsqlDialect are
248- * visible through SelfHostDb's client on the same file) .
260+ * `client ` is SelfHostDb's libSQL connection. Better Auth's LibsqlDialect is
261+ * built on this SAME client (not a fresh `{ url }` one — see the header
262+ * comment's data-loss note), so auth tables and executor tables share one
263+ * connection and one WAL. The seed also uses it directly for its two
264+ * idempotency reads against the auth tables Better Auth just migrated .
249265 */
250- export const buildBetterAuth = async ( url : string , client : Client ) : Promise < BetterAuthHandle > => {
266+ export const buildBetterAuth = async ( client : Client ) : Promise < BetterAuthHandle > => {
251267 const config = loadConfig ( ) ;
252268
253269 // The org id is resolved by the seed below, AFTER this instance is built; the
@@ -265,7 +281,7 @@ export const buildBetterAuth = async (url: string, client: Client): Promise<Bett
265281 getAuth : ( ) => auth ,
266282 } ;
267283
268- auth = createAuthInstance ( url , ( ) => orgRef . id , gate ) ;
284+ auth = createAuthInstance ( client , ( ) => orgRef . id , gate ) ;
269285 // `runMigrations()` flows through the LibsqlDialect and is idempotent.
270286 await ( await auth . $context ) . runMigrations ( ) ;
271287 await ensureInviteCodeTable ( client ) ;
0 commit comments