Appearance
Node — better-sqlite3
better-sqlite3 is the most common synchronous SQLite driver for Node, and the one the project's optional shim targets. Because it's synchronous, the "bind context → run query" pair is atomic — no async can interleave between them.
Load and set up
ts
import Database from "better-sqlite3";
const db = new Database("app.db");
db.loadExtension("./dist/rls.so"); // .dylib on macOS, .dll on Windows
db.exec(`
CREATE TABLE documents(id INTEGER PRIMARY KEY, owner_id INT NOT NULL, title TEXT);
INSERT INTO documents(owner_id, title) VALUES (1,'Alice'), (2,'Bob');
`);
db.prepare("SELECT rls_protect(?)").run(JSON.stringify({
table: "documents",
select: "owner_id = rls_ctx('user_id') OR rls_ctx('role') = 'admin'",
insert: "NEW.owner_id = rls_ctx('user_id')",
}));
db.prepare("SELECT rls_seal(?)").run(process.env.RLS_TOKEN);
db.prepare("SELECT rls_guard()").run();Serve queries
ts
db.prepare("SELECT rls_set_context(?)").run(JSON.stringify({ user_id: 1, role: "user" }));
db.prepare("SELECT title FROM documents").all(); // [{ title: 'Alice' }]The RlsConnection shim
The project ships an optional wrapper (src/index.ts) that folds context binding and the query into one call, and — importantly — arms the guard before returning the connection, so your application code never holds an unguarded or pre-guard handle.
ts
import Database from "better-sqlite3";
import { RlsConnection } from "sqlite-rls";
const rls = RlsConnection.attach(new Database("app.db"), {
extensionPath: "./dist/rls.so",
token: process.env.RLS_TOKEN!,
setup(db) { // runs privileged, before guarding
db.prepare("SELECT rls_protect(?)").run(JSON.stringify(policy));
db.prepare("SELECT rls_config('resolver', ?)").run(resolverSql);
},
});
// per request — one call binds the context and runs the query:
rls.as({ user_id: 42, role: "user" }).all("SELECT * FROM documents");
rls.as({ user_id: 42 }).run("INSERT INTO documents(owner_id, title) VALUES (42, ?)", "New");
const id = rls.lastInsertRowid(); // rls_last_rowid(), the reliable one
// resolve claims from the configured resolver instead of passing them:
rls.auth(sessionToken).all("SELECT * FROM documents");
// run with no user (protected tables fail closed):
rls.as(null).all("SELECT * FROM documents"); // throws "no user context set"Write results
Remember that changes is 0 for writes through a policy view (SQLite doesn't count trigger work). Use RETURNING, rls_last_rowid(), or a re-query to confirm:
ts
const rows = db.prepare(
"UPDATE documents SET title = ? WHERE id = ? RETURNING title"
).all("v2", 1);Connection pools
better-sqlite3 connections are cheap and synchronous, so most apps use one per worker. If you pool, do the load + rls_protect + rls_config + rls_seal + rls_guard when creating each connection, and bind the context per request. Never leave an unguarded connection in the pool — it's a privileged handle. The concurrency rule: keep the synchronous .as(claims).all(sql) pair intact, or give each concurrent request its own connection.
Notes
loadExtensionthrows if the file is missing or the build is for a different platform — point it at thedist/rls.*you built withmake.- better-sqlite3 doesn't expose SQLite's authorizer to JS, which is exactly why enforcement lives inside the extension — you get it for free here.