santoku-sqlite
View on LuaRocksSQLite for Lua: prepared-statement query closures, transactions, zero-copy carray vector binds, FTS5 full-text search over santoku tokens, and fully encrypted databases
santoku-sqlite is the framework's SQLite binding: a small C core (santoku.sqlite.db) over the SQLite C API, and a Lua wrapper (santoku.sqlite) that turns prepared statements into reusable query closures with transaction control. On top sit a carray virtual table that binds santoku-matrix vectors as zero-copy SQL inputs, a full-text index over your own tokens (santoku.sqlite.fts), and an encrypting VFS that seals every byte on disk. The same C core compiles unchanged to WebAssembly, where it additionally registers an OPFS-backed VFS (opfs-coop): synchronous access handles held in a dedicated worker, one cooperative Web Lock shared across tabs, and a pool of slot files. That wasm build is what this page ships, so most examples below run live against in-memory databases in your browser. File persistence and the encryption key setup need a real filesystem, so those examples are shown for reading.
open a database
open, open_memory and open_v2 give a connection userdata, or nil plus SQLite's message and result code when the open fails, which the wrapper raises through santoku.error (a worker that cannot write the file sees "unable to open database file" and the CANTOPEN code). open(path, true) and open_v2(path, vfs, true) open read-only. The wrapper turns the connection into a table of factories. exec runs raw SQL including multi-statement scripts, the raw connection stays reachable at db.db, and close finalizes every cached statement before closing the handle.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec([=[
create table cities (name text, state text);
create index cities_state on cities (state);
]=])
print("tables:", db.getter(
"select count(*) from sqlite_master where type = 'table'")())
print("raw handle:", type(db.db))
db.close()
return "closed"
runner and getter: prepared closures
Each factory prepares its statement once and returns a closure you call with bind values. runner executes for effect and returns how many rows the statement inserted, updated or deleted, so 0 from an update or delete means no row matched; db.changes() reports the same count for the last statement on the connection. getter fetches one row: with prop true a named table, otherwise the column values themselves, and nothing when no row matches. A getter over an update or delete with a RETURNING clause runs the write and returns the row it touched, so nil means no row matched, which is the usual way to answer 404. db.query(sql, ...) runs once without caching and returns every row as an array of value arrays plus the column names. Plain SQL reaches every SQLite feature, including FTS5 tables created with `using fts5`; santoku.sqlite.fts adds learn-tokenized search on top.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table cities (name text, state text)")
local addcity = db.runner("insert into cities (name, state) values (?, ?)")
addcity("Tampa", "Florida")
addcity("Albany", "New York")
local move = db.runner("update cities set state = ? where name = ?")
print("moved:", move("Georgia", "Albany"), move("Georgia", "Nowhere"))
move("New York", "Albany")
local getcity = db.getter("select * from cities where name = ?", true)
local city = getcity("Tampa")
print("row:", city.name, city.state)
local getstate = db.getter("select state from cities where name = ?")
print("value:", getstate("Albany"))
return getstate("Tampa")
Plain FTS5 search over user text
A search box needs no tokenizer setup: an FTS5 table with the built-in tokenizer and bm25() ranking covers it. Never pass user text to MATCH as is, since quotes, OR, NEAR and * are query syntax there. Quote each word as an FTS5 string, doubling any quote inside it, and add * to the last word so a half-typed word still matches. bm25() returns lower scores for better matches, so order ascending.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local str = require("santoku.string")
local arr = require("santoku.array")
local db = sql(sqlite.open_memory())
db.exec("create virtual table docs using fts5(title, body)")
local add = db.runner("insert into docs (title, body) values (?, ?)")
add("Salar de Uyuni", "The largest salt flat on Earth, in Bolivia")
add("Komodo Island", "Home of the komodo dragon")
add("Perito Moreno", "A glacier in Patagonia")
local function match (q)
local terms = {}
for w in str.gmatch(q, "%S+") do
arr.push(terms, "\"" .. str.gsub(w, "\"", "\"\"") .. "\"")
end
if #terms > 0 then
terms[#terms] = terms[#terms] .. "*"
end
return arr.concat(terms, " ")
end
local search = db.all("select title from docs where docs match ? order by bm25(docs) limit 10")
print("salt fl:", arr.concat(search(match("salt fl")), ", "))
print("injected OR:", #search(match("dragon\" OR \"x")))
return search(match("glac"))[1]
prop: how rows come back
The prop argument sets how each row comes back: true yields a { column = value } table, false discards the row (run for effect), and anything else, including omitting it, spreads the columns as multiple return values. all is the exception, since it collects one value per row: there anything other than true collects only the first column.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table people (name text, age integer)")
db.runner("insert into people (name, age) values (?, ?)")("Ada", 36)
local get_cols = db.getter("select name, age from people where name = ?")
local name, age = get_cols("Ada")
print("spread:", name, age)
local get_row = db.getter("select name, age from people where name = ?", true)
local row = get_row("Ada")
print("table:", row.name, row.age)
local discard = db.getter("select name, age from people where name = ?", false)
print("discarded:", discard("Ada"))
return name
named parameters
Pass a single table and its string keys bind to :name parameters; keys without a matching parameter are ignored. Positional and named styles are chosen per call, not per statement.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table cities (name text, state text)")
local add = db.runner("insert into cities (name, state) values (:name, :state)")
add({ name = "Tampa", state = "Florida" })
add({ name = "Miami", state = "Florida" })
local get = db.getter("select state from cities where name = :name")
print("state:", get({ name = "Tampa" }))
return get({ name = "Miami" })
inserter: closures that return the rowid
inserter runs like a runner but returns last_insert_rowid, so integer primary keys come back to the caller.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table notes (id integer primary key, body text)")
local add = db.inserter("insert into notes (body) values (?)")
local id1 = add("first")
local id2 = add("second")
print("rowids:", id1, id2)
local get = db.getter("select body from notes where id = ?")
print("fetched:", get(id2))
return id2
iter: streaming rows
iter returns a bind closure plus a resetter. Calling the closure binds and yields an iterator: default prop spreads the columns per row, prop true yields tables. One closure holds one statement, so use the resetter after abandoning a traversal early: it releases the statement, and the open iterator returns nil from then on. A new bind also ends the iterator before it. Build two closures if you need two live cursors over the same SQL.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table cities (name text, state text)")
local add = db.runner("insert into cities (name, state) values (?, ?)")
add("Tampa", "Florida")
add("Miami", "Florida")
add("Albany", "New York")
local rows = db.iter("select name, state from cities order by name")
for name, state in rows() do
print(name, state)
end
local bystate, reset = db.iter("select name from cities where state = ?")
for name in bystate("Florida") do
print("first hit only:", name)
break
end
reset()
return "done"
all: collect rows
all materialises the result: prop true collects named-table rows, the default collects just the first column of each row into a flat list. Bind values go to the second call, so one factory serves many queries.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local arr = require("santoku.array")
local db = sql(sqlite.open_memory())
db.exec("create table cities (name text, state text)")
local add = db.runner("insert into cities (name, state) values (?, ?)")
add("Tampa", "Florida")
add("Miami", "Florida")
add("Albany", "New York")
local rows = db.all("select name, state from cities order by name", true)()
print("rows:", #rows, "first:", rows[1].name)
local names = db.all("select name from cities where state = ? order by name")
print("florida:", arr.concat(names("Florida"), ", "))
return #rows
transactions
transaction(fn, ...) begins, calls fn with the trailing arguments, commits on return and returns fn's results; on error it rolls back and re-raises. A leading string picks the begin mode, and nested calls simply run inline inside the outer transaction.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table t (n integer)")
local addn = db.runner("insert into t (n) values (?)")
local count = db.getter("select count(*) from t")
local total = db.transaction(function (a, b)
addn(a)
addn(b)
return count()
end, 1, 2)
print("committed:", total)
local ok, e = err.pcall(db.transaction, function ()
addn(3)
err.error("boom")
end)
print("rolled back:", ok, e, "count still", count())
db.transaction("deferred", function ()
db.transaction(function ()
addn(4)
end)
end)
print("nested commits once:", count())
return count()
manual begin, commit, rollback
For control flow that does not fit a closure, drive the transaction yourself. begin defaults to immediate; pass deferred or exclusive to choose.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table t (n integer)")
local addn = db.runner("insert into t (n) values (?)")
local count = db.getter("select count(*) from t")
db.begin("immediate")
addn(1)
addn(2)
db.commit()
print("after commit:", count())
db.begin()
addn(3)
db.rollback()
print("after rollback:", count())
return count()
retry_busy: wait out a locked database without blocking nginx
SQLite's busy_timeout sleeps inside the C call, so under OpenResty a worker waiting on another worker's write lock stalls every request it holds. retry_busy({ sleep }) sets busy_timeout to 0 and retries in Lua instead, calling sleep(ms) between tries; pass ngx.sleep there so the worker serves other requests while it waits. It retries begin, commit, and the first step of a statement run outside a transaction. Inside an open transaction a busy statement raises at once, since retrying one statement could break the transaction. Waits start at first_ms (5) and double up to max_ms (250) until budget_ms (30000) runs out, then SQLITE_BUSY raises as before. ngx.sleep is disabled in init_worker_by_lua, so a call made there must not hit a lock. Since 4.4.0; from 4.5.0 building a statement takes no lock.
local db = sql(sqlite.open(db_file))
db.exec("pragma journal_mode = WAL")
db.retry_busy({
sleep = function (ms) ngx.sleep(ms / 1000) end,
budget_ms = 30000,
})
local add = db.runner("insert into jobs (name) values (?)")
add("nightly")
errors carry the SQLite message and code
The wrapper raises through santoku.error with errmsg and errcode from the connection, so err.pcall recovers them as separate values. A statement that fails to prepare raises from the C layer instead, with a message prefixed prepare: and no code. The module also exports the raw result constants OK, ERROR, ROW, and DONE.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
local ok, msg, code = err.pcall(function ()
return db.exec("not valid sql")
end)
print("ok:", ok)
print("message:", msg)
print("code:", code, code == sqlite.ERROR)
local ok2, msg2 = err.pcall(function ()
return db.runner("select x from nope")
end)
print("prepare fails too:", ok2, msg2)
return code
values across the boundary
nil binds SQL null both ways, booleans bind as 0 or 1, numbers always bind as doubles (exact for integers up to 2^53, and integer columns read back as plain Lua numbers), and strings are byte-exact in both directions, embedded zeros included.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec("create table t (a, b, c, d)")
db.runner("insert into t (a, b, c, d) values (?, ?, ?, ?)")(
nil, true, 1.5, "x\0y")
local a, b, c, d = db.getter("select a, b, c, d from t")()
print("null:", a)
print("boolean as int:", b)
print("number:", c)
print("bytes:", #d, d == "x\0y")
return #d
a module of closures
Prepare each statement once at module setup, then compose the closures inside one transaction, running every mutation path through db.transaction.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local db = sql(sqlite.open_memory())
db.exec([=[
create table users (id integer primary key, email text unique);
create table tokens (user_id integer, token text);
]=])
local get_user = db.getter("select * from users where email = ?", true)
local create_user = db.inserter("insert into users (email) values (?)")
local delete_user_tokens = db.runner("delete from tokens where user_id = ?")
local insert_token = db.runner("insert into tokens (user_id, token) values (?, ?)")
local function login (email, token)
return db.transaction(function ()
local user = get_user(email)
local id = user and user.id or create_user(email)
delete_user_tokens(id)
insert_token(id, token)
return id
end)
end
print("first login:", login("[email protected]", "t1"))
print("second login:", login("[email protected]", "t2"))
return db.getter("select count(*) from tokens")()
migrations
santoku.sqlite.migrate (its own rock, santoku-sqlite-migrate) takes a { filename = sql } table, sorts the names with numeric-aware ordering, and applies unseen ones inside one transaction, recording each in a migrations table so reruns are idempotent. Embed the migration files at build time and run this on every open, server side and in the browser.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local migrate = require("santoku.sqlite.migrate")
local db = sql(sqlite.open_memory())
local migrations = {
["001-init.sql"] = "create table notes (id integer primary key, body text);",
["002-tags.sql"] = [=[
create table tags (note_id integer, tag text);
create index tags_note on tags (note_id);
]=],
}
migrate(db, migrations)
migrate(db, migrations)
migrations["003-drafts.sql"] =
"alter table notes add column draft integer not null default 0;"
migrate(db, migrations)
print("applied:", db.getter("select count(*) from migrations")())
return db.all("select filename from migrations order by id")()
carray: a vector as a table-valued input
Pass a santoku-matrix vec where a parameter is expected and it is bound as a carray table of a single value column, read zero-copy from the vec's backing store in rowid order. ivec binds as int64, svec int32, fvec float, dvec double.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local ivec = require("santoku.ivec")
local fvec = require("santoku.fvec")
local arr = require("santoku.array")
local db = sql(sqlite.open_memory())
db.exec("create table items (id integer primary key, label text)")
local add = db.runner("insert into items (id, label) values (?, ?)")
add(1, "alpha")
add(2, "beta")
add(3, "gamma")
add(4, "delta")
local pick = db.all(
"select label from items where id in (select value from carray(?)) order by id")
local labels = pick(ivec.create({ 2, 4 }))
for i = 1, #labels do
print("hit:", labels[i])
end
local above = db.all("select value from carray(?) where value > ?")
print("filtered:", arr.concat(above(fvec.create({ 0.5, 2.5, 1.5 }), 1.0), ", "))
return #labels
raw statements and carray slices
db.db:prepare exposes the statement object: step, reset (which also clears bindings), get_value, get_values, get_named_values, columns, column_names, bind_values, bind_names, and bind_carray(pidx, vec, start, count), which binds a zero-copy slice and raises on out-of-range bounds. start is 0-based and defaults to 0, and count defaults to the rest of the vector, so start 1 and count 3 below bind 2, 3 and 4. bind_tokens, bind_match and bind_weights encode token ids, OR-joined query terms and query weights for the santoku FTS5 tokenizer and its santoku_bm25 and santoku_bm25_max ranking functions, which every connection registers. Compare step results against the exported ROW and DONE constants.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local ivec = require("santoku.ivec")
local db = sql(sqlite.open_memory())
local stmt = db.db:prepare("select value from carray(?1) order by rowid")
local v = ivec.create({ 1, 2, 3, 4, 5 })
stmt:reset()
stmt:bind_carray(1, v, 1, 3)
while true do
local res = stmt:step()
if res == sqlite.ROW then
print("value:", stmt:get_value(0))
elseif res == sqlite.DONE then
stmt:reset()
break
else
err.error(db.db:errmsg(), db.db:errcode())
end
end
return "done"
fts: full-text search over your own tokens
santoku.sqlite.fts is the search path to reach for. It puts SQLite's FTS5 index underneath and keeps santoku.learn.tokenizer in front of it, so regions, terminals, tags and focus still decide what a token is. FTS5's own tokenizers never see your text. You hand fts a csr of token ids as tokenize returns it. add repeats each id as many times as its value, rounded to the nearest integer and at least once, and replaces any row already stored under the same id. Pass raw counts to add; the ranking applies its own idf. search reads the first row of its csr, ORs the tokens, and ranks with santoku_bm25: bm25 with document-length normalisation, where each query token's value scales its term. Each hit is { id, score, coverage }. score is the negated bm25, so hits come back ascending and the first is the best. coverage divides the score by the query's weighted idf sum, capped at 1. create takes name, an optional schema, detail (full, column or none; full by default), and the bm25 parameters k1 (1.2) and b (0.75). Keep the defaults unless your own judged queries show a better pair. Ids may be text; fts keeps a mapping table because FTS5 keys on an integer rowid. search(query, limit) returns at most limit hits, 50 by default. Query values are optional; when present they must be an fvec. Prefer word tokens over character n-grams. When queries and documents use different words for the same thing, keep the top 100 hits as candidates and rerank them with an embedding model through santoku.learn.retrieval.rerank; the santoku-learn-llama tab shows the full recipe.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local fts = require("santoku.sqlite.fts")
local str = require("santoku.string")
local ivec = require("santoku.ivec")
local fvec = require("santoku.fvec")
local csr = require("santoku.csr")
local db = sql(sqlite.open_memory())
local idx = fts.create(db, { name = "docs" })
idx.add({ "a", "b", "c", "d", "e" }, csr.create({
offsets = ivec.create({ 0, 3, 6, 8, 10, 12 }),
neighbors = ivec.create({ 1, 2, 3, 2, 4, 5, 6, 7, 7, 8, 8, 9 }),
values = fvec.create({ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }),
}))
local q = csr.create({
offsets = ivec.create({ 0, 2 }),
neighbors = ivec.create({ 1, 2 }),
values = fvec.create({ 1, 1 }),
})
local hits = idx.search(q, 10)
for i = 1, #hits do
print(hits[i].id, str.format("score %.3f coverage %.2f", hits[i].score, hits[i].coverage))
end
idx.remove({ "a" })
print("after remove:", #idx.search(q, 10))
idx.clear()
print("after clear:", #idx.search(q, 10))
return #hits
fts: rebuild without blocking readers
rebuild(ids, csr, batch) replaces the whole index. It builds a second pair of tables beside the live one, committing batch rows per transaction (5000 by default), then renames the new pair into place in one short transaction and drops the old pair. Searches keep reading the old index until the swap, and other writers only ever wait for one batch. A failed build leaves the live index intact, and the next rebuild drops the partial copy. Call it outside db.transaction, because a nested transaction runs inline and would hold every batch in one. The table names <name>_new_* and <name>_old_* are reserved.
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local fts = require("santoku.sqlite.fts")
local ivec = require("santoku.ivec")
local fvec = require("santoku.fvec")
local csr = require("santoku.csr")
local db = sql(sqlite.open_memory())
local idx = fts.create(db, { name = "docs" })
idx.add({ "a" }, csr.create({
offsets = ivec.create({ 0, 1 }),
neighbors = ivec.create({ 1 }),
values = fvec.create({ 1 }),
}))
idx.rebuild({ "b", "c", "d" }, csr.create({
offsets = ivec.create({ 0, 1, 2, 3 }),
neighbors = ivec.create({ 1, 1, 2 }),
values = fvec.create({ 1, 1, 1 }),
}), 2)
local hits = idx.search(csr.create({
offsets = ivec.create({ 0, 1 }),
neighbors = ivec.create({ 1 }),
values = fvec.create({ 1 }),
}), 10)
for i = 1, #hits do
print("hit:", hits[i].id)
end
return #hits
files and persistence
open(path, readonly) creates or reopens a database file. On failure open, open_memory and open_v2 return nil, sqlite's message and its result code, so err.checknil or assert surfaces the reason; open_encrypted returns nil and the message. open_v2(path, vfs) additionally picks the VFS by name. The wasm flag reports which build you are on; in the browser the default filesystem is memory-backed, so durable storage goes through the OPFS story below.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local d1 = sql(err.assert(sqlite.open("app.db")))
d1.exec("create table if not exists t (n)")
d1.runner("insert into t (n) values (?)")(42)
d1.close()
local d2 = sql(err.assert(sqlite.open("app.db")))
print("persisted:", d2.getter("select n from t")())
d2.close()
print("wasm build:", sqlite.wasm)
return "done"
encrypted databases
open_encrypted seals every byte on disk with XChaCha20-Poly1305 under a 32-byte key: pages, journal, and WAL all live in the same authenticated container (magic TKSQENC1), each block write draws a fresh nonce, and the AAD binds the block index and a per-file salt, so tampered, reordered, or transplanted blocks fail closed instead of decrypting. A wrong key or a wrong-size key cannot read anything, and closing the connection releases the key.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local str = require("santoku.string")
local key = str.rep("\42", 32)
local db = sql(err.assert(sqlite.open_encrypted("notes.db", key)))
db.exec("create table if not exists notes (id integer primary key, body text)")
db.runner("insert into notes (body) values (?)")("sealed at rest")
print("rows:", db.getter("select count(*) from notes")())
db.close()
local wrong = str.rep("\7", 32)
local ok = err.pcall(function ()
local raw = err.assert(sqlite.open_encrypted("notes.db", wrong))
return sql(raw).getter("select count(*) from notes")()
end)
print("wrong key reads:", ok)
local nope, msg = sqlite.open_encrypted("notes.db", "tooshort")
print("bad key size:", nope, msg)
return "closed, key released"
key registry and merged read connections
Keys register per resolved path in a process-wide table: key_set before ATTACH lets one connection span databases sealed under different keys, and key_clear evicts a key (in-flight writes on a cleared key then fail). Attached databases inherit the connection's VFS, so a plain connection can never read an encrypted attach; enc_vfs names the encrypting VFS so even a :memory: main can host encrypted attaches as a merged read view.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local str = require("santoku.string")
local key_a = str.rep("A", 32)
local key_b = str.rep("B", 32)
err.assert(sqlite.key_set("a.db", key_a))
err.assert(sqlite.key_set("b.db", key_b))
local vfs = err.assert(sqlite.enc_vfs())
local merged = sql(err.assert(sqlite.open_v2(":memory:", vfs)))
merged.exec("attach database 'a.db' as s1")
merged.exec("attach database 'b.db' as s2")
print("all notes:", merged.getter([=[
select count(*) from (
select id from s1.notes union all select id from s2.notes)
]=])())
merged.exec("detach database s1")
merged.exec("detach database s2")
merged.close()
sqlite.key_clear("a.db")
sqlite.key_clear("b.db")
return "done"
encrypted SQLite over OPFS in the browser
In the wasm build the module registers the opfs-coop VFS: OPFS synchronous access handles in a dedicated worker, one cooperative exclusive Web Lock shared across tabs, and a pool of slot files that the encrypting VFS stacks on top of via the third parent-vfs argument. A complete encrypted open sequence: a hashed filename, a derived key, key_set then open_encrypted on the parent VFS, pragmas, then migrations. When another tab held the lock and wrote in between, reset_cache resets statements and drops the page cache of the main and every attached database, so the next read sees the new state. A file holds one open connection per worker under opfs-coop: a second open of the same file in that worker fails with SQLITE_CANTOPEN, so share the one connection.
local err = require("santoku.error")
local sqlite = require("santoku.sqlite.db")
local sql = require("santoku.sqlite")
local str = require("santoku.string")
local path = "/d-0123abcd.db"
local key32 = str.rep("\1", 32)
err.assert(sqlite.key_set(path, key32, "opfs-coop"))
local raw = err.assert(sqlite.open_encrypted(path, key32, "opfs-coop"))
local db = sql(raw)
db.exec("pragma journal_mode = TRUNCATE")
db.exec("pragma synchronous = NORMAL")
db.exec("pragma temp_store = MEMORY")
db.exec("create table if not exists notes (id integer primary key, body text)")
db.runner("insert into notes (body) values (?)")("durable and sealed")
print("rows:", db.getter("select count(*) from notes")())
db.db:reset_cache()
db.close()
sqlite.key_clear(path, "opfs-coop")
return "done"