npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 driver package 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-go

runGoWasm 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=1

Standard 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 has SQLITE_OMIT_SHARED_CACHE, so SQLite accepts the URI and then gives each connection its own invisible database. Use vfs=memdb for a shared in-memory database.
  • _busy_timeout — SQLite's busy handler sleeps with Atomics.wait on the database worker's own thread, and the COMMIT that 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_BUSY is 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-01 is unrecoverable; a string produces a debuggable unsupported Scan at the call site.

  • REAL is never read as a time. A Julian day number is indistinguishable from any other float, and both incumbent drivers agree.

  • BOOL is not treated as a boolean, only BOOLEAN — mattn/go-sqlite3 does the same, so a BOOL column keeps scanning into an integer for anyone migrating.

  • TIME is not treated as a timestamp. SQLite's time() emits HH:MM:SS, which no layout parses, so including it would turn a usable string into a failed parse.

  • sqlite3_column_decltype is null for expressions and aggregates, so SELECT MAX(created_at) has no declared type. Rather than guess-parse every date-shaped string — which would turn a user named 2024-01-02 into 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-corp

Without 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/COEP

go 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.