santoku
santoku

santoku

A Lua framework for building applications end to end

Lua 5.1 from the build system to the server to the browser. Every example on this site runs in your browser. Edit one and run it.


santoku targets Lua 5.1, because 5.1 is the version its platforms actually share. LuaJIT implements 5.1 and OpenResty embeds LuaJIT, so server code, client code and the build harness all run the same language. Compiled to WebAssembly, the 5.1 core is smaller to embed, and its single number type sidesteps the integer and float split introduced in 5.3, which keeps numeric values crossing the JavaScript boundary simple. Support for newer versions is meant to come from compatibility shims layered over the 5.1 core rather than version branches in the tree; contributions and design ideas for that layer are welcome.

santoku-sqlite-migrate

View on LuaRocks

versioned, idempotent SQL migrations for santoku-sqlite: one function that applies missing migrations in version order, forward-only, in a single transaction

santoku-sqlite-migrate is versioned schema migration in one function: pass a santoku-sqlite handle and a table mapping names to SQL strings, and it applies the ones that have not run yet, in version order, inside a single transaction, recording each in a bookkeeping table. There is no down direction: migrations are forward-only, append a new numbered one and never edit an applied one, and when a change is too deep to express as SQL over live data, wipe and rebuild under a schema epoch instead, shown at the end. The first five examples run live in this page against an in-memory database; the consumer patterns after them touch files or build machinery and are shown for reading.

the whole API: migrate(db, migrations)

requiring santoku.sqlite.migrate returns one function. Keys name the migrations, values are SQL scripts. It ensures a migrations(id integer primary key, filename text not null) table, applies what is missing in version order, and records each name; id preserves application order.

local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local db = sql(sqlite.open_memory()) migrate(db, { ["0.0.1"] = "create table items (id integer primary key, name text);", ["0.0.2"] = "alter table items add column done integer not null default 0;", }) db.runner("insert into items (name) values (?)")("write docs") local applied = db.all("select id, filename from migrations order by id", true)() for i = 1, #applied do print(applied[i].id, applied[i].filename) end return db.getter("select count(*) from items")()

idempotent: re-running is a no-op

Applied names are skipped, so calling migrate again with the same table does nothing, even when a migration has side effects like inserting rows. That makes it safe to run unconditionally at every startup.

local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local db = sql(sqlite.open_memory()) local m = { ["0.0.1"] = "create table c (n integer); insert into c (n) values (1);", } migrate(db, m) migrate(db, m) migrate(db, m) return db.getter("select count(*) from c")()

forward-only: append, never edit

A later call with a superset applies only the new keys. Since applied migrations are matched by name alone, editing the body of an already-applied migration changes nothing on existing databases: new schema work is always a new numbered entry.

local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local db = sql(sqlite.open_memory()) local first = "create table d (n integer); insert into d (n) values (1);" migrate(db, { ["0.0.1"] = first }) migrate(db, { ["0.0.1"] = first, ["0.0.2"] = "insert into d (n) values (2);", }) local rows = db.all("select n from d order by n", true)() for i = 1, #rows do print(rows[i].n) end return #rows

version-aware ordering

Keys sort with a comparator that compares digit runs as numbers, so 0.0.2 < 0.0.10 < 0.1.0 rather than the lexical 0.0.10 first.

local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local db = sql(sqlite.open_memory()) local function log (v) return "create table if not exists log (seq integer primary key, name text);" .. " insert into log (name) values ('" .. v .. "');" end migrate(db, { ["0.0.2"] = log("0.0.2"), ["0.0.10"] = log("0.0.10"), ["0.1.0"] = log("0.1.0"), }) local rows = db.all("select name from log order by seq", true)() for i = 1, #rows do print(rows[i].name) end return #rows

one transaction: all or nothing

The whole batch runs inside one db.transaction. A failing migration rolls back every change from that call, including migrations earlier in the same batch and their bookkeeping rows, so a database is never left half-migrated. A non-table migrations argument raises immediately.

local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local err = require("santoku.error") local db = sql(sqlite.open_memory()) migrate(db, { ["0.0.1"] = "create table a (n);" }) local ok = err.pcall(migrate, db, { ["0.0.1"] = "create table a (n);", ["0.0.2"] = "create table e (n);", ["0.0.3"] = "this is not valid sql;", }) print("batch applied:", ok) print("table e exists:", err.pcall(function () db.exec("insert into e (n) values (1)") end)) print("recorded:", db.getter("select count(*) from migrations")()) return err.pcall(migrate, db, "not a table")

migrations are scripts, not statements

Each value goes through db.exec, which runs multi-statement scripts, so one migration can create tables, build indexes, backfill data, and drop what it replaced. Below, 0.0.2 adds a parent column and its index, and 0.0.6 replaces the sequence table with durable hybrid-logical-clock state and seeds it.

local migrate = require("santoku.sqlite.migrate") migrate(db, { ["0.0.2.sql"] = [=[ alter table records add column parent_id text; create index records_parent on records(parent_id); ]=], ["0.0.6.sql"] = [=[ drop table hlc_seq; create table hlc_state ( id integer primary key check (id = 1), pt integer not null, c integer not null ); insert into hlc_state values (1, 0, 0); ]=], })
Illustrative: runs outside the browser.

loading migrations from files at build time

Consumers keep migrations as numbered .sql files and inline them with a santoku template: a .tk.lua preamble reads res/migrations at build time, keys by basename (the version comparator ignores the .sql suffix), and serializes the table into the emitted source. The shipped bundle carries its schema with no filesystem access at runtime, which is what lets the same pattern work inside a browser worker.

<% local fs = require("santoku.fs") local serialize = require("santoku.serialize") local migrations = {} for fp in fs.files(depend("res/migrations")) do migrations[fs.basename(fp)] = readfile(fp) end t_migrations = serialize(migrations, true) %> local migrate = require("santoku.sqlite.migrate") migrate(db, <% return t_migrations %>) -- luacheck: ignore
Illustrative: runs outside the browser.

two migration sets, two moments

A client that keeps one small plaintext index database plus many encrypted per-account databases migrates them at different times. The index migrates once at worker boot, right after its pragmas. Each account database migrates at open, inside a pcall that closes the handle and clears the VFS key on failure, so a bad migration never leaves a key registered for a broken database.

local err = require("santoku.error") local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local index_migrations = { ["0.0.1"] = "create table accounts (id text primary key);", } local account_migrations = { ["0.0.1"] = "create table notes (id integer primary key, body text);", } local index_db = sql(err.assert(sqlite.open("index.db"))) index_db.exec("pragma journal_mode = TRUNCATE") index_db.exec("pragma synchronous = NORMAL") migrate(index_db, index_migrations) local function open_account (path, key32, parent_vfs) local db = sql(err.assert(sqlite.open_encrypted(path, key32, parent_vfs))) local ok, e = err.pcall(function () db.exec("pragma journal_mode = TRUNCATE") db.exec("pragma synchronous = NORMAL") migrate(db, account_migrations) end) if not ok then pcall(db.close) sqlite.key_clear(path, parent_vfs) err.error("account database open failed", tostring(e)) end return db end return open_account
Illustrative: runs outside the browser.

server side: migrate on connection open

A server wrapper opens its file database, sets pragmas (WAL, busy_timeout for concurrent writers), and migrates unconditionally unless the caller opts out. Idempotence plus the single transaction make this safe under OpenResty where many workers race to open the same file: one applies, the rest skip.

local err = require("santoku.error") local sqlite = require("santoku.sqlite.db") local sql = require("santoku.sqlite") local migrate = require("santoku.sqlite.migrate") local migrations = { ["0.0.1"] = "create table events (id integer primary key, at integer);", } return function (db_file, opts) opts = opts or {} local db = sql(err.assert(sqlite.open(db_file))) db.exec("pragma busy_timeout = 30000") db.exec("pragma journal_mode = WAL") db.exec("pragma synchronous = NORMAL") if not opts.no_migrate then migrate(db, migrations) end return db end
Illustrative: runs outside the browser.

beyond migrations: the schema epoch wipe

When a format change leaves existing rows unrepresentable, no SQL over them can produce valid new ones. Instead of a data migration, record a schema epoch in the migrated database, and when the stored epoch is behind the code's, delete every derived database minted under the old one and let the source of truth rebuild them. The meta table read below is created by those same index migrations.

local migrate = require("santoku.sqlite.migrate") local EPOCH = "4" return function (db, index_migrations, store) migrate(db, index_migrations) local get_epoch = db.getter("select value from meta where key = 'schema_epoch'") local set_epoch = db.runner( "insert or replace into meta (key, value) values ('schema_epoch', ?)") if get_epoch() ~= EPOCH then local paths = store.list_files() for i = 1, #paths do if paths[i]:match("^/d%-%x+%.db$") then store.delete_file(paths[i]) end end set_epoch(EPOCH) end end
Illustrative: runs outside the browser.