sqlite3-wasm-go
v0.0.1
Published
Run SQLite in a Web Worker for Go programs compiled to WebAssembly
Readme
sqlite3-wasm
sqlite3-wasm is a bridge that enables Go (WASM) code running in the browser to use a separately loaded SQLite3 WASM instance through JavaScript. I hope the WebAssembly Component Model will soon be fully implemented in browsers so that this project can become obsolete by design.
Acknowledgements
- Portions of the implementation were adapted from ncruces/go-sqlite3.
- The type-conversion rules follow mattn/go-sqlite3 and modernc.org/sqlite closely enough that a database file written by one is read the same by the others; the divergences are listed under Types.
- Some code in the
driverpackage was developed with the assistance of AI tooling.
Motivation
The project started as a personal experiment to use SQLite with OPFS directly from Go.
How it works
SQLite runs in its own Web Worker, separate from the worker running your Go program, and the two speak a binary protocol.
Go worker DB worker
database/sql sqlite3 C API
driver/ row encoder
binding/ <--- binary frames --->
<--- SharedArrayBuffer (cancellation)The split is not incidental: the opfs VFS refuses to run outside a Worker and blocks its thread with Atomics.wait on every I/O operation, so putting SQLite in the Go worker would freeze the entire Go runtime — every goroutine and timer — for the duration of each file read.
This library does not reimplement SQLite. It uses @sqlite.org/sqlite-wasm as shipped and replaces only its two topmost JavaScript layers — the oo1 convenience wrapper and the worker1 message protocol — because neither can carry a value's storage class or a column's declared type across the boundary. See docs/DESIGN.md.
Usage
npm i sqlite3-wasm-gorunGoWasm spawns a worker that hosts your Go program with the driver already available to it. The only thing you own is the URL of your own .wasm, because only your bundler can resolve that.
import { runGoWasm } from 'sqlite3-wasm-go/go-worker'
import appWasm from './app.wasm?url' // Vite; use new URL(...) elsewhere
const { worker, exited } = await runGoWasm(appWasm)
console.log('exit code', await exited)wasm_exec.js is vendored, and a build-time check fails loudly if the Go toolchain's copy drifts from it. The ~1.6 MB runtime sits behind a dynamic import, so it is fetched the first time you actually start a program rather than on page load.
If you would rather host the Go program yourself, import the package from inside that worker — the global has to exist in the same realm as the Go program, and importing it on the main thread instead is the mistake that produces a confusing failure at sql.Open:
// your own worker
import 'sqlite3-wasm-go'
import 'sqlite3-wasm-go/wasm_exec.js' // the vendored, drift-checked copy
const go = new Go()
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject)
await go.run(instance)In your Go code:
//go:build js && wasm
package main
import (
sqlitewasm "github.com/lesomnus/sqlite3-wasm"
)
func main() {
db, err := sqlitewasm.OpenDB("file:app.db?vfs=opfs")
if err != nil {
panic(err)
}
defer db.Close()
// ... ordinary database/sql from here
}sqlitewasm.OpenDB is sql.Open plus SetMaxOpenConns(1). Use sql.Open("sqlite3-wasm", dsn) directly if you want to choose the pool settings yourself — but read Concurrency first.
DSN
file:app.db?vfs=opfs&_loc=Asia/Seoul&_fk=1Standard SQLite URI parameters are passed through to sqlite3_open_v2. Driver parameters are _-prefixed and stripped.
| parameter | meaning |
| --- | --- |
| vfs | opfs, opfs-sahpool, memdb, or unset for a transient database |
| _loc / _timezone | location for naive timestamps: UTC (default), auto, or an IANA name |
| _time_format | how a time.Time is written: offset (default), utc, datetime |
| _time_integer_format | write times as integers: unix, unix_milli, unix_micro, unix_nano |
| _fk / _foreign_keys | PRAGMA foreign_keys, default on |
| _txlock | immediate (default), deferred, exclusive |
Two parameters are rejected with an error rather than silently ignored:
cache=shared— this build hasSQLITE_OMIT_SHARED_CACHE, so SQLite accepts the URI and then gives each connection its own invisible database. Usevfs=memdbfor a shared in-memory database._busy_timeout— SQLite's busy handler sleeps withAtomics.waiton the database worker's own thread, and theCOMMITthat would release the lock can only be delivered when that thread returns to its event loop. Any non-zero timeout is a self-deadlock for its full duration.SQLITE_BUSYis retried on the Go side instead.
:memory: and mode=memory are rewritten to file:/<generated>?vfs=memdb, so every connection in a pool sees one database instead of several invisible ones.
Under GOOS=js, time.Local is UTC unless you embed tzdata, so _loc=auto and _loc=UTC behave the same by default.
Types
Values keep their SQLite storage class across the boundary. An INTEGER is an int64 even past 2^53; a REAL stays a float64 even when its value is integral; TEXT keeps embedded NULs; and NULL, '', x'' and zeroblob(0) stay distinct.
Conversion is keyed on the column's declared type, which SQLite reports for real columns and which survives views, joins, CTEs, subqueries, aliases and RETURNING.
| declared type | value |
| --- | --- |
| DATE, DATETIME, TIMESTAMP | time.Time, or the raw string if no layout matches |
| BOOLEAN | bool (from an INTEGER) |
| anything else | the storage class as-is |
Deliberate choices worth knowing:
A failed timestamp parse yields the original string, not the zero time. A silent
0001-01-01is unrecoverable; a string produces a debuggableunsupported Scanat the call site.REALis never read as a time. A Julian day number is indistinguishable from any other float, and both incumbent drivers agree.BOOLis not treated as a boolean, onlyBOOLEAN— mattn/go-sqlite3 does the same, so aBOOLcolumn keeps scanning into an integer for anyone migrating.TIMEis not treated as a timestamp. SQLite'stime()emitsHH:MM:SS, which no layout parses, so including it would turn a usable string into a failed parse.sqlite3_column_decltypeis null for expressions and aggregates, soSELECT MAX(created_at)has no declared type. Rather than guess-parse every date-shaped string — which would turn a user named2024-01-02into a timestamp — conversion there is opt-in per destination:var t sqlitewasm.Time err := db.QueryRow("SELECT MAX(created_at) FROM events").Scan(&t)
ColumnTypeDatabaseTypeName and ColumnTypeScanType are derived from the declared type, so rows.ColumnTypes() is answerable before the first Next. ColumnTypeNullable is not implemented: this wasm build lacks sqlite3_column_origin_name, so a result column cannot be traced back to a base table.
Errors
if errors.Is(err, sqlitewasm.ErrConstraint) { ... }
var e *sqlitewasm.Error
if errors.As(err, &e) {
fmt.Println(e.Code, e.ExtendedCode, e.Offset, e.Message)
}Error carries the primary result code, the extended code (so SQLITE_CONSTRAINT_UNIQUE is distinguishable from SQLITE_CONSTRAINT_FOREIGNKEY), the message, and sqlite3_error_offset for syntax errors. errors.Is compares the primary code only.
Concurrency
Use one connection. sqlitewasm.OpenDB does this for you.
There is a single JavaScript thread behind every connection, so more connections buy no parallelism. What they do buy is SQLITE_BUSY: this build has no WAL — neither OPFS VFS implements xShmMap — so readers block writers under rollback-journal locking. PRAGMA journal_mode=WAL is therefore unavailable; on memdb it reports memory.
Extra connections are allowed, though, and they are not pathological: every connection on one sql.DB shares a worker and therefore one sqlite3 instance, so several of them can address the same OPFS file without contending for its access handle (measured at ~12 ms for the second, not the multi-second stall one might expect — the opfs VFS releases its sync access handle when idle).
Across separate workers or tabs the two persistence VFSes differ: opfs interleaves fine, while opfs-sahpool holds its access handles for the pool's lifetime and a second worker fails immediately with Access Handles cannot be created ....
Cancellation
Cancelling a context interrupts a running statement through a SharedArrayBuffer word polled by sqlite3_progress_handler. sqlite3_interrupt cannot be used: the wasm memory is not shared and the build is THREADSAFE=0.
It requires cross-origin isolation (for SharedArrayBuffer) and a CSP that permits wasm-unsafe-eval. Without either, a cancelled context returns ctx.Err() promptly and the connection is discarded rather than reused, but the statement runs to completion in the worker. Cancellation also cannot interrupt a statement stalled inside VFS I/O, because the progress handler does not fire there.
Browser requirements
The floor is the maximum over the features actually required, and OPFS sync access handles set it: roughly Chrome 102 / Firefox 111 / Safari 17.
Without persistence — a transient or memdb database — what remains is module workers, nested workers and top-level await: roughly Chrome 89 / Firefox 114 / Safari 15.
The test suite runs on Chromium, Firefox and WebKit. All three run the driver, the wire protocol, cancellation and the packaged bundle; Chromium and Firefox additionally round-trip a real OPFS database across workers. Playwright's Linux WebKit build has no OPFS at all — no navigator.storage.getDirectory, no FileSystemFileHandle — so those tiers are skipped there. That is a property of that build and says nothing about Safari, which has supported OPFS since 15.2 and sync access handles since 17.
Headers
The opfs VFS needs cross-origin isolation:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout them, vfs=opfs fails with an error naming the missing header rather than silently falling back to a database that loses everything on reload. vfs=opfs-sahpool is the persistence tier that needs neither.
Content Security Policy
| directive | why |
| --- | --- |
| worker-src blob: | the database worker is a blob URL |
| script-src 'wasm-unsafe-eval' | installing the cancellation progress handler builds a WebAssembly.Module at runtime |
Vite
defineConfig({
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
})Next.js
Set the same two headers in next.config.js under headers(), and load the module client-side only (next/dynamic with ssr: false, or a 'use client' module) — importing it during SSR has no Worker to spawn.
Development
npm install
npx playwright install chromium firefox webkit # and install-deps on Linux
go test ./... # wire codec, DSN, declared types, time, conversion
npm test # builds the examples and the bundle, then runs every tier
npm run check:downstream # packs the package, installs it into a scratch Vite
# app, builds that with its own bundler, and runs a Go
# program in it under COOP/COEPgo test ./... runs on the host: the wire codec, the DSN parser, the declared-type classifier and the time layouts all carry no build tag, so the bulk of the driver's semantics is testable without a browser. Everything that touches sqlite3, workers, OPFS or Go/wasm runs under vitest browser mode.
The wire format has a third implementation in scripts/gen-wire-vectors.py, which produces the golden corpus both codecs are checked against, so neither can bless its own bug as the specification.
See docs/PLAN.md for the state of the rewrite, docs/DESIGN.md for why it is shaped this way, and docs/PROTOCOL.md for the wire format.
