The behavior contract and full reference for SqlRite: the paradigm invariants, exhaustive tag and option semantics, security limits, and the rationale behind the shape. README.md is the quickstart; this is the authoritative detail.
Zero-dependency, SQL-first wrapper over node:sqlite (Node >=26). Three facades
share one core:
SqlRiteCore.js— static utilities: file scan, tag parse, templating, PRAGMA/option setup, custom-function registration, write-metadata reads.SqlRiteSync.js— sync facade overDatabaseSync.SqlRite.js+SqlWorker.js— async facade; file-backed databases use a writer Worker plus a host-relative pool of read-only Workers behind a promise-keyed message protocol. In-memory databases use one Worker because SQLite connections do not share:memory:databases.scripts/codegen.js— emitsSqlRite.d.tsfor the generated methods.
These are the contracts that define SqlRite. Changing code in ways that violate them is a paradigm break, not a refactor.
- SQL-first. All database behavior is declared in
.sqltags. Logic that belongs in SQL does not migrate into JS — there is no query builder, no batch API, no JS transaction composition. - One trust boundary.
-- PREPbinds parameters and is the only path for runtime or untrusted values.-- EXECand-- TXstring-interpolate and are for developer-authored SQL only. - Fail-hard, no silent loss. Bad option types throw at construction; integers
past
2^53throw rather than rounding; invalid tuning values throw at open. Contract violations surface — they are not absorbed by fallbacks. - Curated, not pass-everything. The hardened posture and tuning knobs are a small, opinionated set, not a generic PRAGMA/option passthrough. Every default is overridable; the surface stays deliberately narrow.
A block runs from its tag line to the next tag (or end of file). Empty blocks are
skipped. -- INIT names are not deduplicated; duplicate EXEC/PREP/TX names
emit a warning and the last definition wins; duplicate MIGRATE versions throw.
The names constructor, open, close, and ready are reserved — an
EXEC/TX/PREP tag using one throws at load (it could never become a
method). Within a directory set, files are scanned recursively and ordered by
basename numerically (001-*.sql before 002-*.sql) into one execution plan.
Runs once when the DB opens. Use idempotent DDL (CREATE TABLE IF NOT EXISTS)
and PRAGMAs. Supports $var templating from the params option (same rules as
-- EXEC).
-- INIT: configure
PRAGMA cache_size = $cacheSize;await SqlRite.open({ dir: "sql", params: { cacheSize: 5000 } });The only parameterized path for runtime values. Each -- PREP method exposes
three modes:
| Mode | For | Returns (sync / async) |
|---|---|---|
.run(params) |
INSERT/UPDATE/DELETE |
{ changes, lastInsertRowid } |
.get(params) |
single row | row object or undefined |
.all(params) |
multiple rows | array of row objects |
- On the async facade, file-backed
.get()/.all()calls first run on the least-busy connection in a read-only pool while.run()/-- EXEC/-- TXstay on the writer. Long writes and reads therefore do not impose facade-level head-of-line blocking on unrelated WAL-safe reads. If SQLite rejects a.get()/.all()statement withSQLITE_READONLY, SqlRite reroutes that call to the writer. This preserves result-returning mutations such asINSERT ... RETURNINGwithout attempting to parse or classify SQL in JavaScript. - Bind with named parameters (
$name,:name,@name). The JS interface takes an object; a leading$/:/@on keys is stripped, so{ name }binds$name. - Plain-object and array parameter values are
JSON.stringify-ed on input. Output is not parsed — callJSON.parse()yourself. - Booleans bind as
1/0.TypedArrays pass through and bind asBLOB. Anything else non-primitive —Date,Map, class instances,undefined— throws a namedunsupported parameter typeerror at the boundary instead of dying innode:sqlite's generic bind failure. Passdate.toISOString()(or epoch ms) explicitly. - JS
numbervalues bind asREAL— integral or not: storage intoINTEGERcolumns converts losslessly (STRICT included), but SQL arithmetic on the bound value followsREALsemantics —$v / 3divides as floats. Thebigintflag cannot help here; it governs reads only. Pass aBigIntfor integer-exact binding — it binds asINTEGERwithout any flag. Verified on Node 26.3.1. .run()returns{ changes, lastInsertRowid }, as do-- EXECand-- TX. Both fields arenumberby default,BigIntwith thebigintflag; without it, alastInsertRowidpast2^53throws rather than rounding.
db.exec() of the block (one or more statements) after $var templating. For
developer-authored SQL with constant or developer-supplied inputs — DDL,
PRAGMAs, maintenance. It is not a parameterized path: string values are
single-quote escaped, numbers/booleans/null are inlined, and identifiers are
not handled. Never pass untrusted input through -- EXEC; use -- PREP for
runtime values.
After templating, any parameter-shaped token ($x, :x, @x) left outside
string literals, quoted identifiers, and comments throws
unbound parameter … in EXEC <name> — under db.exec it would otherwise
silently bind NULL. Dollar text inside quotes ('cost: $5') and identifiers
containing $ stay legal, and dollar text can also arrive via a param value:
substituted values land single-quoted and replacements are not re-scanned, so
{ msg: "costs $5" } templates clean. The same check guards -- TX and
-- INIT.
-- EXEC: insertKv
INSERT INTO kv (key, val) VALUES ($key, $val);sql.insertKv({ key: "role", val: "admin" }); // val is escaped, not bound
// returns { changes, lastInsertRowid } — number, or BigInt with the `bigint` flag-- EXEC made transactional: the templated multi-statement body runs wrapped in
BEGIN / COMMIT, and any error triggers ROLLBACK before the error is
rethrown (async: rejects). The whole transaction lives in one SQL block — there
is no JS composition step; in the async facade it is one Worker round-trip.
-- TX: transfer
UPDATE acct SET bal = bal - $amt WHERE id = $from;
UPDATE acct SET bal = bal + $amt WHERE id = $to;sql.transfer({ from, to, amt }); // both statements commit, or neither doesTemplating is identical to -- EXEC — values are string-interpolated, not
bound — so the same trusted-input contract applies. A -- TX method returns the
write metadata { changes, lastInsertRowid } (number, or BigInt with the
bigint flag). Because the body runs via db.exec(), it cannot
return result rows: keep intra-transaction data flow in SQL
(last_insert_rowid(), subqueries) and read committed results with a separate
-- PREP afterward.
Once-per-database migrations recorded in the database file's own header via
PRAGMA user_version — no ledger table. The version is the explicit integer in
the tag (a trailing label is cosmetic); file names and ordering don't affect
it, so files can be reorganized freely without rewriting history.
-- MIGRATE: 1 baseline
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;
-- MIGRATE: 2 addEmail
ALTER TABLE users ADD COLUMN email TEXT;At open, before any -- INIT, every block with a version above the database's
user_version runs in ascending order. Each migration executes inside
BEGIN IMMEDIATE together with its user_version bump: SQLite DDL is
transactional, so a failed migration rolls back body and bump together and
fails open() with the migration's error — no partial states. Concurrent
openers serialize on the write lock (the busy_timeout default) and re-check
the version inside it, so a lost race is a no-op, never a double-run.
Rules, all fail-hard:
- Versions are positive integers, unique across the directory set. A duplicate
(e.g. a branch merge where both sides claim
7) throws at load. - No
$vartemplating — migrations are deterministic text, or they are not a history. A parameter-shaped token in a pending migration throws at apply (underdb.execit would silently bindNULL). - Never edit an applied migration. The repo's git history is the audit trail; the database records only how far it has advanced.
- A current database takes zero writes, so
readOnlyconnections keep opening.
Division of labor: -- INIT runs every open (posture, idempotent seeds);
-- MIGRATE runs once per database, forever. Non-idempotent DDL
(ALTER TABLE …) belongs in -- MIGRATE, never -- INIT. Read the current
version with a -- PREP on PRAGMA user_version if you need it.
Integers are read as JS number by default; a value above 2^53 − 1 throws on
read rather than losing precision. Append the bigint flag to a tag to read that
statement's integers as BigInt:
-- PREP: feeBalance bigint
SELECT SUM(amount) AS total FROM ledger WHERE account = $account;The flag is the single switch for all of a statement's integer output, scoped
to that one statement: result columns for -- PREP, and the
{ changes, lastInsertRowid } write metadata returned by -- PREP .run(),
-- EXEC, and -- TX. Flagged → every integer comes back BigInt; unflagged →
number, and anything past 2^53 throws. A flagged value is a BigInt
(typeof === "bigint"): arithmetic cannot mix BigInt and number, and
JSON.stringify throws on BigInt (supply a replacer). For a connection-wide
default, pass readBigInts: true in options (it passes through to
DatabaseSync). Passing a BigInt as a parameter already works without the flag.
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
":memory:" |
SQLite database file path. |
dir |
string | string[] |
"sql" |
Directories scanned for .sql files. |
functions |
string | string[] |
— | Module paths for custom SQL functions. |
params |
object |
— | $var substitutions for -- INIT blocks. |
readers |
non-negative safe integer | max(0, availableParallelism() - 1) |
Async file-backed read-only Worker count. 0 routes every PREP mode through the writer; nonzero values are invalid with :memory:. |
All other keys pass through to the node:sqlite DatabaseSync constructor (e.g.
readOnly, allowExtension). Unknown keys are ignored; invalid option types
throw at construction.
SqlRite applies these via PRAGMA on every connection: journal_mode = WAL,
synchronous = NORMAL.
It also sets these DatabaseSync options, each overridable by passing the same
key in your own options:
| Option | SqlRite default | Effect |
|---|---|---|
enableForeignKeyConstraints |
true |
Enforces foreign keys. |
enableDoubleQuotedStringLiterals |
false |
Rejects double-quoted string literals (a misspelled "identifier" errors instead of becoming a string). |
defensive |
true |
Blocks SQL that can corrupt the file: writable_schema, journal_mode=OFF, schema_version, shadow-table writes. |
timeout |
5000 |
busy_timeout in ms — concurrent writers wait instead of an immediate SQLITE_BUSY, completing the WAL posture. Set 0 to restore the bare-node:sqlite behavior. |
A small, curated set of overridable performance knobs, applied as
integer-validated PRAGMAs on every connection. Each is optional and off unless
you pass it; a non-integer (or out-of-range) value throws at open.
| Option | Type | PRAGMA | Notes |
|---|---|---|---|
cacheSize |
number |
cache_size |
Positive = pages; negative = KiB of memory. |
mmapSize |
number (≥ 0) |
mmap_size |
Bytes of memory-mapped I/O; 0 disables. Inert on :memory: (nothing to map). |
maxPageCount |
number (> 0) |
max_page_count |
Hard db-size ceiling in pages — a disk-fill guard. |
REGEXP—col REGEXP $patternusing JavaScriptRegExp. Compiled patterns are cached per connection in a bounded LRU (256 patterns), so runtime-driven patterns (col REGEXP other_col) cannot grow memory without limit — the bound is not a ReDoS mitigation. ANULLpattern or subject yieldsNULL(SQL three-valued logic), never a match. An optional leading(?flags)sets RegExp flags — e.g.(?i)^foofor case-insensitive.lastIndexis reset per row, so the stateful flags are deterministic:gis a no-op (REGEXP is boolean) andy(sticky) anchors the match at the start. Genuinely invalid flags throw. A native scoped group such as(?i:…)passes through unchanged.uuid()—crypto.randomUUID(). Usable as a column default:id TEXT PRIMARY KEY DEFAULT (uuid()).
Point functions at JS modules. Each module's filename becomes the SQL function
name. Functions are registered before any SQL block loads, so they are available
in -- INIT and prepared statements. Modules resolve dependencies from the host
app's node_modules.
// db/getTokens.js
export const deterministic = true; // optional; enables query optimization
export default (text) => text.length; // required; the handlerconst sql = await SqlRite.open({ dir: "sql", functions: ["./db/getTokens.js"] });-- PREP: longPosts
SELECT * FROM posts WHERE getTokens(body) > 1000;Custom functions need async registration, so new SqlRiteSync() does not load
them — use await SqlRiteSync.open() (or the async facade) when passing
functions.
The package ships SqlRite.d.ts covering the static surface — typed options,
results, and facade lifecycles — plus a permissive [method: string] index
signature so your dynamic methods type as any out of the box. Sharpen to
exact per-method types by generating from your .sql files:
node node_modules/@possumtech/sqlrite/scripts/codegen.js [dir] # default dir "sql"This writes SqlRite.d.ts into your project; point TypeScript at it with a
paths override, which takes precedence over the shipped types:
{ "compilerOptions": { "paths": { "@possumtech/sqlrite": ["./SqlRite.d.ts"] } } }The generated file drops the index signature, so a typo'd method name is a
type error. Re-run after adding or renaming tags. (--base regenerates the
static-only surface; the library's prepack uses it to build the shipped
types.)
- Trusted-SQL boundary.
-- EXECand-- TXare templated, not bound. Only-- PREPparameterizes runtime values. Routing untrusted input through-- EXEC/-- TXis SQL injection. - ReDoS.
REGEXPcompiles patterns with JavaScriptRegExp, which backtracks: a malicious pattern (e.g.(a+)+$) — or attacker-controlled data against a vulnerable pattern — can cause catastrophic backtracking. The match runs synchronously inside SQLite and cannot be interrupted, freezing the process (sync) or wedging the DB worker (async). It is opt-in — only a query you author reaches it — so never pointREGEXPat untrusted patterns or unbounded attacker-controlled input. Safe matching of untrusted patterns needs a linear-time engine (e.g. RE2) or an out-of-band timeout. - Integer precision. Integer columns read as JS
numberunless a statement opts intoBigInt; without it, a value above2^53 − 1throws on read rather than losing precision. -- TXreturns no rows. It returns only{ changes, lastInsertRowid }; read committed results with a separate-- PREP.- Async ordering. File-backed async instances have one FIFO writer and a
least-pending read pool.
.run()/-- EXEC/-- TXuse the writer;.get()/.all()first use a reader and reroute to the writer onSQLITE_READONLY. There is no total order across lanes. A concurrent read sees the last committed WAL snapshot when that statement begins; await an operation before issuing a dependent one. In-memory instances andreaders: 0retain one serialized Worker. - Reader connection scope. Migrations and INIT finish before the async reader
opens, so their committed schema and data are visible. Connection-local state
created by INIT, such as TEMP tables, exists only on the writer and cannot be
used by async
.get()/.all()statements. - Idle instances don't hold the process. The async facade unrefs its Worker
whenever no call is in flight and refs it for each round-trip, so an unclosed
instance can't pin the process while pending work is never dropped. Exiting
without
close()is WAL-crash-safe (the next open recovers), butclose()/await usingremains the clean shutdown. - Async errors are structured-cloned. A rejected call carries the worker's
original error — class, message, stack, and
causesurvive the boundary; non-standard own properties (e.g. anerrcode) do not.
Durable rationale — why the shape is what it is, and what SqlRite deliberately does not do.
- No
db.transaction(fn)closure. The async facade cannotpostMessagea JS closure to its Worker, and a JS-composed transaction would violate SQL-first. Transactions are the declarative-- TXtag instead; the earliertransaction(calls)batch API was removed for the same reason. - Host-relative read pool. The default read count is
max(0, availableParallelism() - 1), leaving one reported execution lane for the writer.availableParallelism()respects affinity and container limits; thereadersoverride lets a host application coordinate SqlRite with its other Worker pools. Dispatch chooses the least-pending reader and rotates ties, so a long read does not attract unrelated work while an idle reader exists. - SQLite classifies optimistic reads. File-backed async
.get()/.all()calls try the read-only pool first.SQLITE_READONLYis an internal routing result, not a swallowed failure: the same call runs on the writer and returns its rows. This preserves... RETURNINGand avoids a JavaScript SQL parser or newGET/ALL/RUNtags. busy_timeoutvia the nativetimeoutoption, not abusyTimeoutknob.DatabaseSyncalready acceptstimeout; SqlRite only defaults it non-zero (5000ms). A second PRAGMA-based option would be redundant.- Tuning knobs are curated.
cacheSize/mmapSize/maxPageCountare exposed with integer validation — SqlRite is not a generic PRAGMA passthrough. PRAGMA dml_strictis fiction. Not a real pragma (it reads backundefined). The DQS-rejection it claimed is already thenode:sqlitedefault viaenableDoubleQuotedStringLiterals: false. Verified on Node 26.3.1.- Foreign keys via constructor option.
enableForeignKeyConstraints: true, not a post-hocPRAGMA foreign_keys = ON. - No
.tssource. A published library cannot ship type-stripped files undernode_modules, so types stay in JSDoc andSqlRite.d.tsis generated. - No down migrations, ledger table, or migration checksums. Rolling back is
restoring a backup — SQLite databases are files.
PRAGMA user_versionplus git history replaces the ledger; timestamp/UUID migration naming is rejected so version collisions surface at load instead of silently interleaving. - No environment configuration. Tuning flows through exactly two channels:
the options object (typed, per-instance, fail-hard) and
-- INIT$varparams (SQL-first). A library readingSQLRITE_*env vars would be a third, process-global, stringly-typed channel; env-driven config belongs to the host app (--env-file+process.envat the call site).
- Discover methods: grep
-- PREP:/-- EXEC:/-- TX:. Discover schema: grep-- INIT:. - Add an operation: add a tagged block to a
.sqlfile, then calldb.<name>(re-runcodegen.jsto refresh types). - Evolve schema: append a
-- MIGRATE: <n>block with the next version (grep-- MIGRATE:for the current max). Never edit an applied migration. - Bind runtime values with
-- PREP+ a named-parameter object; never interpolate untrusted input through-- EXECor-- TX. - Group dependent writes in a single
-- TXblock for atomicity. - Read integers beyond
2^53with abigint-flagged-- PREP.
- Commits: Conventional (
feat:/fix:/docs:/build:/chore:…), scoped to an issue number (#0when none). - Style: one class per file,
#privatefields, ESM, double quotes, tabs,export default class. Biome enforces formatting —npm run lint. - Types: checked JS via JSDoc +
tsconfig(checkJs,strict,noImplicitAny: false). No.tssource — see Design decisions. - Tests:
node --test, native asserts, specific error matchers. Unit alongside source (*.test.js); integration intest/integration/. - Gate:
npm run check(lint + typecheck + tests) must be green; coverage ≥ 80/80/80.