Skip to content

Commit 7237bf2

Browse files
authored
Self-host: share one libSQL connection with Better Auth (fix WAL data loss) (#1008)
Better Auth opened its own LibsqlDialect connection (@libsql/core 0.8) to the same file as executor-core's @libsql/client (0.17). Opening second, it unlinked executor-core's -wal/-shm and created new ones, orphaning executor-core onto a deleted WAL inode. Every executor-core write (integrations, connections, tools) then vanished on the next restart while Better Auth's own tables survived — the 'reconnected account, zero tools' data-loss bug. The earlier 'single Better Auth instance' change removed only the throwaway bootstrap connection; the long-lived one unlinked the WAL just the same. Build Better Auth's LibsqlDialect on SelfHostDb's existing client ({ client }, closeClient=false) so there is one connection and one WAL. Also resolves the documented cross-connection read lag.
1 parent 64b3544 commit 7237bf2

4 files changed

Lines changed: 87 additions & 57 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Self-hosted instances no longer lose data on restart. Better Auth now shares
6+
the same libSQL connection as the rest of the instance instead of opening its
7+
own. Previously the two connections each managed their own write-ahead log on
8+
the shared database file, and the second one to open could orphan the first —
9+
so integrations, connections, and tools written after startup landed in a
10+
discarded log and disappeared on the next restart, while sign-in data survived.
11+
This is the "reconnected my account but it has zero tools" failure; a single
12+
shared connection removes the split entirely.

apps/host-selfhost/src/auth/better-auth.ts

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { APIError } from "better-auth/api";
33
import { admin, bearer, mcp, organization } from "better-auth/plugins";
44
import { apiKey } from "@better-auth/api-key";
55
import { type Client } from "@libsql/client";
6-
import { LibsqlDialect } from "@libsql/kysely-libsql";
6+
import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
77
import { Context } from "effect";
88

99
import { loadConfig } from "../config";
@@ -26,42 +26,42 @@ interface SignupGate {
2626
const 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.
199215
export 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

214230
export 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);

apps/host-selfhost/src/auth/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface ResolvedAuthProviders {
3636
export const resolveAuthProviders = async (
3737
dbHandle: SelfHostDbHandle,
3838
): Promise<ResolvedAuthProviders> => {
39-
const betterAuth = await buildBetterAuth(dbHandle.url, dbHandle.client);
39+
const betterAuth = await buildBetterAuth(dbHandle.client);
4040
const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth);
4141

4242
// The consent redirect from Better Auth's authorize only carries the opaque

apps/host-selfhost/src/db/self-host-db.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ import { SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
3232
//
3333
// Driver: libSQL (@libsql/client + drizzle-orm/libsql), not bun:sqlite, so the
3434
// self-host server runs on Node AND Bun (and the same code path serves edge by
35-
// swapping the `file:` URL for an https Turso URL). Better Auth opens its OWN
36-
// libSQL connection (LibsqlDialect) to the SAME file: URL — see better-auth.ts.
37-
// Because libSQL connections are NOT a single shared in-process handle the way
38-
// bun:sqlite's was, the WAL/busy_timeout/synchronous/foreign_keys PRAGMAs are
39-
// re-applied PER connection (here, and again in the Better Auth dialect path).
35+
// swapping the `file:` URL for an https Turso URL). Better Auth SHARES this very
36+
// `@libsql/client` (its LibsqlDialect is built with `{ client }`, not a fresh
37+
// `{ url }` connection) — so the PRAGMAs set here cover auth queries too, and
38+
// there is exactly ONE connection and ONE WAL. That sharing is load-bearing: a
39+
// second libSQL connection to the same file unlinks this one's `-wal`/`-shm` on
40+
// open, orphaning executor-core writes onto a deleted inode that vanishes on
41+
// restart (the self-host data-loss bug — see better-auth.ts's header).
4042
// ---------------------------------------------------------------------------
4143

4244
/**
@@ -55,10 +57,10 @@ export interface SelfHostDbHandle<TTables extends FumaTables = FumaTables> {
5557
readonly fuma: FumaDB<SelfHostFumaSchema<TTables>[]>;
5658
readonly drizzle: LibSQLDatabase<Record<string, unknown>>;
5759
/**
58-
* The libSQL client for this handle's `file:` URL. Better Auth opens its own
59-
* separate connection to the same file via LibsqlDialect; the seed reads
60-
* Better Auth's tables through this client (async), so the URL is carried
61-
* alongside so callers can hand it to the dialect.
60+
* The libSQL client for this handle's `file:` URL. Better Auth's LibsqlDialect
61+
* is built on THIS client (one shared connection — see better-auth.ts), and
62+
* the seed reads Better Auth's tables through it too. `url` is retained for
63+
* callers that still need the `file:` string (diagnostics, edge swap).
6264
*/
6365
readonly client: Client;
6466
readonly url: string;
@@ -82,11 +84,11 @@ export const createSqliteExecutorDb = async <const TTables extends FumaTables>(
8284

8385
const url = toLibsqlFileUrl(options.path);
8486
const client = createClient({ url });
85-
// PER-CONNECTION PRAGMAs: libSQL gives drizzle and Better Auth SEPARATE
86-
// connections to this file (no single shared handle), so these must be set on
87-
// this connection here and again on Better Auth's dialect connection. WAL is a
88-
// file-level mode once any connection enables it; foreign_keys is strictly
89-
// per-connection and MUST be re-set on each.
87+
// Connection PRAGMAs. This is the ONE libSQL connection for the process —
88+
// drizzle (executor tables) and Better Auth's LibsqlDialect both run on this
89+
// same client (see better-auth.ts), so these apply to every query, auth
90+
// included. WAL is a file-level mode; foreign_keys/busy_timeout/synchronous
91+
// are connection-level and set once here.
9092
await client.execute("PRAGMA foreign_keys = ON");
9193
await client.execute("PRAGMA journal_mode = WAL");
9294
// Survive concurrent writes from the multi-user HTTP server, and trade

0 commit comments

Comments
 (0)