From 741e197e3609b4ff9f9b674cdc7e3bd614ce0a99 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:09:53 -0700 Subject: [PATCH 1/4] fix(storage): serialize first-run database setup across concurrent processes Concurrent fresh starts against one data directory failed with "database is locked". The core `sqlite.bun.ts` opener ran `PRAGMA journal_mode = WAL` before any busy handler existed, and both the legacy `Database.Client` and the core `Database` layer ran migrations whose deferred read-to-write upgrade fails immediately with `SQLITE_BUSY` instead of waiting. - Install `busy_timeout` before the WAL switch in both openers. - Add `setup-lock.ts`: an exclusive transaction on a sentinel database is a cross-process mutex that the OS releases if the holder dies. Synchronous sections block on it; the effect-based migration polls so in-process peers keep progressing. - Add a regression test that opens both database layers from 4 simultaneous processes against a fresh data directory. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/database.ts | 39 +++++---- packages/core/src/database/setup-lock.ts | 85 +++++++++++++++++++ packages/core/src/database/sqlite.bun.ts | 24 +++++- packages/core/src/database/sqlite.node.ts | 23 ++++- packages/opencode/src/storage/db.ts | 21 ++++- .../test/storage/concurrent-open.test.ts | 40 +++++++++ .../test/storage/fixture/open-database.ts | 19 +++++ 7 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/database/setup-lock.ts create mode 100644 packages/opencode/test/storage/concurrent-open.test.ts create mode 100644 packages/opencode/test/storage/fixture/open-database.ts diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 6879212c41..419b235ff7 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,6 +2,9 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "./setup-lock" +// altimate_change end import { Context, Effect, Layer } from "effect" import { Global } from "../global" import { Flag } from "../flag/flag" @@ -19,26 +22,32 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/storage/Database") {} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const db = yield* makeDatabase +// altimate_change start — upstream_fix: `layer` takes the database filename so first-run setup can be serialized +export const layer = (filename?: string) => + Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* makeDatabase - yield* db.run("PRAGMA journal_mode = WAL") - yield* db.run("PRAGMA synchronous = NORMAL") - yield* db.run("PRAGMA busy_timeout = 5000") - yield* db.run("PRAGMA cache_size = -64000") - yield* db.run("PRAGMA foreign_keys = ON") - yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") - yield* DatabaseMigration.apply(db) + yield* db.run("PRAGMA busy_timeout = 5000") + const setup = Effect.gen(function* () { + yield* db.run("PRAGMA journal_mode = WAL") + yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run("PRAGMA cache_size = -64000") + yield* db.run("PRAGMA foreign_keys = ON") + yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + yield* DatabaseMigration.apply(db) + }) + yield* filename ? SetupLock.hold(filename, setup) : setup - return { db } - }).pipe(Effect.orDie), -) + return { db } + }).pipe(Effect.orDie), + ) export function layerFromPath(filename: string) { - return layer.pipe(Layer.provide(sqliteLayer({ filename }))) + return layer(filename).pipe(Layer.provide(sqliteLayer({ filename }))) } +// altimate_change end export function path() { if (Flag.OPENCODE_DB) { diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts new file mode 100644 index 0000000000..ba7eedbb91 --- /dev/null +++ b/packages/core/src/database/setup-lock.ts @@ -0,0 +1,85 @@ +import { Effect, Semaphore } from "effect" +import { openSetupLock } from "#sqlite" + +/** + * Cross-process mutex for first-run database setup (WAL switch and migrations). + * + * SQLite cannot queue a deferred read-to-write upgrade, so concurrent fresh starts failed at once with + * "database is locked". An exclusive transaction on a sentinel database next to the real one serializes + * them, and the OS releases it if the holder dies. + * + * `acquireSync` blocks the thread, so it is only for sections that never yield. A section that yields + * (the effect-based migration) uses `hold`, which polls without blocking. A synchronous caller in the + * same process as an active `hold` skips the lock: blocking would deadlock the holder, and connections + * in one process already queue through their own busy handlers. + */ +const semaphores = new Map() +const active = new Set() + +const sentinel = (filename: string) => filename + ".setup-lock" +const SYNC_TIMEOUT_MS = 60_000 +const POLL_MS = 25 +const HOLD_TIMEOUT_MS = 60_000 + +export function acquireSync(filename: string): () => void { + if (filename === ":memory:" || active.has(filename)) return () => {} + const lock = openSetupLock(sentinel(filename), SYNC_TIMEOUT_MS) + try { + lock.exec("BEGIN EXCLUSIVE") + } catch (error) { + lock.close() + throw error + } + return () => { + try { + lock.exec("ROLLBACK") + } finally { + lock.close() + } + } +} + +function isBusy(error: unknown) { + return /locked|busy/i.test(error instanceof Error ? error.message : String(error)) +} + +/** Runs `body` holding the lock; waiting is async so in-process peers keep making progress. */ +export function hold(filename: string, body: Effect.Effect): Effect.Effect { + if (filename === ":memory:") return body + let semaphore = semaphores.get(filename) + if (!semaphore) { + semaphore = Semaphore.makeUnsafe(1) + semaphores.set(filename, semaphore) + } + const acquire = Effect.gen(function* () { + const started = Date.now() + while (true) { + const lock = openSetupLock(sentinel(filename), 0) + const result = yield* Effect.try({ try: () => lock.exec("BEGIN EXCLUSIVE"), catch: (error) => error }).pipe( + Effect.map(() => lock), + Effect.catch((error) => + Effect.sync(() => lock.close()).pipe( + Effect.flatMap(() => + isBusy(error) && Date.now() - started < HOLD_TIMEOUT_MS ? Effect.succeed(undefined) : Effect.die(error), + ), + ), + ), + ) + if (result) { + active.add(filename) + return result + } + yield* Effect.sleep(POLL_MS) + } + }) + const release = (lock: { exec: (sql: string) => void; close: () => void }) => + Effect.sync(() => { + active.delete(filename) + try { + lock.exec("ROLLBACK") + } finally { + lock.close() + } + }) + return semaphore.withPermits(1)(Effect.acquireUseRelease(acquire, () => body, release)) +} diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index e15f4c117e..a99db33330 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "./setup-lock" +// altimate_change end const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -161,7 +164,18 @@ const nativeLayer = (config: Config) => create: config.create ?? true, }) yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) - if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;") + // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with + // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. + native.run("PRAGMA busy_timeout = 5000;") + if (config.disableWAL !== true) { + const release = SetupLock.acquireSync(config.filename) + try { + native.run("PRAGMA journal_mode = WAL;") + } finally { + release() + } + } + // altimate_change end return native }), ) @@ -181,3 +195,11 @@ export const layer = (config: Config) => { Layer.provide(Reactivity.layer), ) } + +// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) +export function openSetupLock(filename: string, timeoutMs: number) { + const lock = new Database(filename, { create: true }) + lock.run(`PRAGMA busy_timeout = ${timeoutMs}`) + return { exec: (sql: string) => void lock.run(sql), close: () => lock.close() } +} +// altimate_change end diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26..ca9e6c4f9e 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -14,6 +14,9 @@ import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" import { Sqlite } from "./sqlite" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "./setup-lock" +// altimate_change end const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -156,7 +159,18 @@ const nativeLayer = (config: Config) => open: true, }) yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) - if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;") + // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with + // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. + native.exec("PRAGMA busy_timeout = 5000;") + if (config.disableWAL !== true && config.readonly !== true) { + const release = SetupLock.acquireSync(config.filename) + try { + native.exec("PRAGMA journal_mode = WAL;") + } finally { + release() + } + } + // altimate_change end return native }), ) @@ -176,3 +190,10 @@ export const layer = (config: Config) => { Layer.provide(Reactivity.layer), ) } + +// altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) +export function openSetupLock(filename: string, timeoutMs: number) { + const lock = new DatabaseSync(filename, { timeout: timeoutMs }) + return { exec: (sql: string) => void lock.exec(sql), close: () => lock.close() } +} +// altimate_change end diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index a6d9154361..d263f5ffda 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -14,6 +14,9 @@ import { readFileSync, readdirSync, existsSync } from "fs" import * as schema from "./schema" import { InstallationChannel } from "@opencode-ai/core/installation/version" import { Flag } from "../flag/flag" +// altimate_change start — upstream_fix: cross-process first-run setup lock +import * as SetupLock from "@opencode-ai/core/database/setup-lock" +// altimate_change end import { iife } from "@/util/iife" import { Effect } from "effect" import freshSchema from "@opencode-ai/core/database/schema.gen" @@ -394,13 +397,27 @@ export namespace Database { export const Client = lazy(() => { log.info("opening database", { path: Path }) + // altimate_change start — upstream_fix: serialize first-run setup across processes (see core setup-lock.ts) + const releaseSetupLock = SetupLock.acquireSync(Path) + try { + return open() + } finally { + releaseSetupLock() + } + // altimate_change end + }) + function open() { const sqlite = new BunDatabase(Path, { create: true }) state.sqlite = sqlite + // altimate_change start — upstream_fix: install the busy handler before the first statement that + // takes a lock. Switching a fresh file to WAL needs an exclusive lock, so concurrent first-run + // processes failed immediately with "database is locked" when this came after journal_mode. + sqlite.run("PRAGMA busy_timeout = 5000") + // altimate_change end sqlite.run("PRAGMA journal_mode = WAL") sqlite.run("PRAGMA synchronous = NORMAL") - sqlite.run("PRAGMA busy_timeout = 5000") sqlite.run("PRAGMA cache_size = -64000") sqlite.run("PRAGMA foreign_keys = ON") sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") @@ -446,7 +463,7 @@ export namespace Database { } return db - }) + } export function close() { const sqlite = state.sqlite diff --git a/packages/opencode/test/storage/concurrent-open.test.ts b/packages/opencode/test/storage/concurrent-open.test.ts new file mode 100644 index 0000000000..9d13179ea1 --- /dev/null +++ b/packages/opencode/test/storage/concurrent-open.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" + +const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") + +async function launch(data: string) { + const proc = Bun.spawn(["bun", "run", fixture], { + cwd: path.join(import.meta.dir, "..", ".."), + env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "" }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + return { stdout, stderr, code } +} + +describe("concurrent first-run database setup", () => { + test("simultaneous fresh processes never fail with SQLITE_BUSY", async () => { + // Fresh data directories exercise the WAL switch and migrations, where concurrent starts raced. + for (let round = 0; round < 3; round++) { + const data = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-concurrent-db-")) + try { + const results = await Promise.all(Array.from({ length: 4 }, () => launch(data))) + for (const result of results) { + expect(result.stdout + result.stderr).not.toMatch(/database is locked|SQLITE_BUSY/i) + expect(result.code).toBe(0) + expect(result.stdout).toContain("opened") + } + } finally { + await fs.rm(data, { recursive: true, force: true }) + } + } + }, 120_000) +}) diff --git a/packages/opencode/test/storage/fixture/open-database.ts b/packages/opencode/test/storage/fixture/open-database.ts new file mode 100644 index 0000000000..999ed9396e --- /dev/null +++ b/packages/opencode/test/storage/fixture/open-database.ts @@ -0,0 +1,19 @@ +// Child process for concurrent-open.test.ts: opens both database layers against the shared data directory. +import { Effect } from "effect" +import { Database } from "../../../src/storage/db" +import { Database as CoreDatabase } from "@opencode-ai/core/database/database" + +try { + Database.Client() + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* CoreDatabase.Service + }).pipe(Effect.provide(CoreDatabase.layerFromPath(Database.Path))), + ), + ) + console.log("opened") +} catch (error) { + console.log("FAILED: " + (error instanceof Error ? error.message : String(error))) + process.exitCode = 1 +} From 31d9ea1a93d71557e1826f270212d3b72ea4c5bc Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:37:49 -0700 Subject: [PATCH 2/4] fix(storage): address review findings on the first-run setup lock - Run the legacy adopt/initialize schema checks inside one `BEGIN IMMEDIATE` transaction so a same-process core migration cannot create the schema between a check and the `CREATE TABLE`s. - Make the setup-lock polling interruptible so cancelling startup does not wait for the sentinel lock. - Use the configured `timeout` for `PRAGMA busy_timeout` in the Node opener and skip the WAL switch and lock for read-only Bun opens. - Spawn the regression fixture with `process.execPath`. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/setup-lock.ts | 3 ++- packages/core/src/database/sqlite.bun.ts | 2 +- packages/core/src/database/sqlite.node.ts | 3 ++- packages/opencode/src/storage/db.ts | 13 +++++++++++-- .../opencode/test/storage/concurrent-open.test.ts | 2 +- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/core/src/database/setup-lock.ts b/packages/core/src/database/setup-lock.ts index ba7eedbb91..739e4936dc 100644 --- a/packages/core/src/database/setup-lock.ts +++ b/packages/core/src/database/setup-lock.ts @@ -69,7 +69,8 @@ export function hold(filename: string, body: Effect.Effect): E active.add(filename) return result } - yield* Effect.sleep(POLL_MS) + // `acquireUseRelease` makes `acquire` uninterruptible; no handle is open while sleeping, so cancel here. + yield* Effect.interruptible(Effect.sleep(POLL_MS)) } }) const release = (lock: { exec: (sql: string) => void; close: () => void }) => diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index a99db33330..600575bb7f 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -167,7 +167,7 @@ const nativeLayer = (config: Config) => // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. native.run("PRAGMA busy_timeout = 5000;") - if (config.disableWAL !== true) { + if (config.disableWAL !== true && config.readonly !== true) { const release = SetupLock.acquireSync(config.filename) try { native.run("PRAGMA journal_mode = WAL;") diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index ca9e6c4f9e..b9efd5a4d4 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -161,7 +161,7 @@ const nativeLayer = (config: Config) => yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. - native.exec("PRAGMA busy_timeout = 5000;") + native.exec(`PRAGMA busy_timeout = ${config.timeout ?? 5000};`) if (config.disableWAL !== true && config.readonly !== true) { const release = SetupLock.acquireSync(config.filename) try { @@ -194,6 +194,7 @@ export const layer = (config: Config) => { // altimate_change start — upstream_fix: sentinel database used as a cross-process mutex (see setup-lock.ts) export function openSetupLock(filename: string, timeoutMs: number) { const lock = new DatabaseSync(filename, { timeout: timeoutMs }) + lock.exec(`PRAGMA busy_timeout = ${timeoutMs}`) return { exec: (sql: string) => void lock.exec(sql), close: () => lock.close() } } // altimate_change end diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index d263f5ffda..8bd48f63b8 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -439,8 +439,17 @@ export namespace Database { item.sql = "select 1;" } } - const adopted = adoptCoreOwnedDatabase(sqlite, entries) - const initialized = adopted ? false : initializeFreshDatabase(sqlite, entries) + // altimate_change start — upstream_fix: the schema-state checks share the write transaction, so a + // same-process core migration cannot create the schema between a check and the CREATE TABLEs + let adopted = false + let initialized = false + sqlite + .transaction(() => { + adopted = adoptCoreOwnedDatabase(sqlite, entries) + initialized = adopted ? false : initializeFreshDatabase(sqlite, entries) + }) + .immediate() + // altimate_change end const repaired = adopted || initialized ? false : repairLegacyDatabase(sqlite, entries) // altimate_change start — backfill migration names before migrate backfillMigrationNames(sqlite, entries) diff --git a/packages/opencode/test/storage/concurrent-open.test.ts b/packages/opencode/test/storage/concurrent-open.test.ts index 9d13179ea1..5677a46aec 100644 --- a/packages/opencode/test/storage/concurrent-open.test.ts +++ b/packages/opencode/test/storage/concurrent-open.test.ts @@ -6,7 +6,7 @@ import path from "path" const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") async function launch(data: string) { - const proc = Bun.spawn(["bun", "run", fixture], { + const proc = Bun.spawn([process.execPath, "run", fixture], { cwd: path.join(import.meta.dir, "..", ".."), env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "" }, stdout: "pipe", From 5a1f1c44d85559659d412b432face6c59587c93d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:44:40 -0700 Subject: [PATCH 3/4] fix(storage): mark the extracted open() as a fork change and isolate the alias env in the concurrent-open test Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/storage/db.ts | 2 ++ packages/opencode/test/storage/concurrent-open.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 8bd48f63b8..8bff197bfe 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -407,6 +407,7 @@ export namespace Database { // altimate_change end }) + // altimate_change start — upstream_fix: setup extracted from the lazy Client so it runs under the setup lock function open() { const sqlite = new BunDatabase(Path, { create: true }) state.sqlite = sqlite @@ -473,6 +474,7 @@ export namespace Database { return db } + // altimate_change end export function close() { const sqlite = state.sqlite diff --git a/packages/opencode/test/storage/concurrent-open.test.ts b/packages/opencode/test/storage/concurrent-open.test.ts index 5677a46aec..628090a260 100644 --- a/packages/opencode/test/storage/concurrent-open.test.ts +++ b/packages/opencode/test/storage/concurrent-open.test.ts @@ -8,7 +8,7 @@ const fixture = path.join(import.meta.dir, "fixture", "open-database.ts") async function launch(data: string) { const proc = Bun.spawn([process.execPath, "run", fixture], { cwd: path.join(import.meta.dir, "..", ".."), - env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "" }, + env: { ...process.env, XDG_DATA_HOME: data, OPENCODE_DB: "", ALTIMATE_CLI_DB: "" }, stdout: "pipe", stderr: "pipe", }) From 13951136f9dc3328f422b09fcbe04b650051d3e5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 08:56:01 -0700 Subject: [PATCH 4/4] fix(storage): make read-only Bun opens actually read-only Bun's `Database` honours `readwrite`/`create` over `readonly`, so the default `readwrite: true, create: true` let a `readonly: true` open write and create a missing file. Disable both when `readonly` is set; the WAL/setup-lock skip for read-only opens now matches what the connection can do. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/database/sqlite.bun.ts | 7 +++- packages/core/test/sqlite-readonly.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/sqlite-readonly.test.ts diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index 600575bb7f..5421933c5f 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -158,11 +158,14 @@ const nativeLayer = (config: Config) => Layer.effect( Sqlite.Native, Effect.gen(function* () { + // altimate_change start — upstream_fix: Bun honours readwrite/create over readonly, so a read-only open + // with the defaults could still write; read-only now disables both. const native = new Database(config.filename, { readonly: config.readonly, - readwrite: config.readwrite ?? true, - create: config.create ?? true, + readwrite: config.readonly === true ? false : (config.readwrite ?? true), + create: config.readonly === true ? false : (config.create ?? true), }) + // altimate_change end yield* Effect.addFinalizer(() => Effect.sync(() => native.close())) // altimate_change start — upstream_fix: the WAL switch needs a write lock and can fail at once with // SQLITE_BUSY, so install the busy handler first and serialize the switch across processes. diff --git a/packages/core/test/sqlite-readonly.test.ts b/packages/core/test/sqlite-readonly.test.ts new file mode 100644 index 0000000000..cb07160439 --- /dev/null +++ b/packages/core/test/sqlite-readonly.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { Database as BunDatabase } from "bun:sqlite" +import path from "path" +import { Effect } from "effect" +import { layer } from "../src/database/sqlite.bun" +import { Sqlite } from "../src/database/sqlite" +import { tmpdir } from "./fixture/tmpdir" + +describe("sqlite.bun read-only open", () => { + test("rejects writes and leaves the journal mode alone", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "ro.sqlite") + const seed = new BunDatabase(filename, { create: true }) + seed.run("CREATE TABLE t (x INTEGER)") + seed.close() + + const result = await Effect.runPromise( + Effect.gen(function* () { + const native = (yield* Sqlite.Native) as BunDatabase + const mode = native.query("PRAGMA journal_mode").get() as { journal_mode: string } + const write = (() => { + try { + native.run("INSERT INTO t VALUES (1)") + return "written" + } catch (error) { + return (error as Error).message + } + })() + return { mode: mode.journal_mode, write } + }).pipe(Effect.provide(layer({ filename, readonly: true })), Effect.scoped), + ) + + expect(result.mode).toBe("delete") + expect(result.write).toContain("readonly") + }) + + test("does not create a missing file", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "missing.sqlite") + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + yield* Sqlite.Native + }).pipe(Effect.provide(layer({ filename, readonly: true })), Effect.scoped), + ) + expect(exit._tag).toBe("Failure") + expect(await Bun.file(filename).exists()).toBe(false) + }) +})