Package

sqlkit

SQLite helpers over db: a parameters-only query builder, keyset pagination, upserts, batched inserts, up/down migrations with checksums, test fixtures

0.1.0 MIT db

Install

sspur add sqlkit@^0.1.0

or in sspur.toml:

sqlkit = { version = "^0.1.0" }

README

sqlkit

Helpers on top of SSPUR's built-in SQLite (db) for the parts of database code every service writes again: a query builder for common CRUD that only ever produces SQL text plus parameters, keyset (seek) pagination with opaque cursors, upserts, batched inserts that stay under SQLite's parameter limit, versioned up/down migrations with checksums and dry runs, and helpers for tests (an in-memory database with migrations applied, fixtures, and a transaction that is always rolled back).

Values never enter the SQL text: every value is a ? parameter, and every table and column name is checked (ASCII letters, digits and underscores, not in SQLite's sqlite_ namespace) and double-quoted, so reserved words such as order work as column names. The builder refuses an UPDATE or DELETE without a condition unless you call update_all or delete_all.

Install

$ sspur add sqlkit

Example

use sqlkit.{vint, vtext, eq, like_substr, asc, desc, values_all, columns, select_where, order_by, update_where}

type Book = {id: Int, title: Str, year: Int}

fn schema() -> List[sqlkit.Migration]
= [sqlkit.migration(1, "books", "create table books (id integer primary key, title text not null, year integer not null)", "drop table books"), sqlkit.migration(2, "by year", "create index books_year on books (year, id)", "drop index books_year")]

fn book_key(b: Book) -> List[sqlkit.Value]
= [vint(b.year), vint(b.id)]

fn main() -> Unit ! db, log, fail[DbErr], fail[sqlkit.SqlkitErr], fail[sqlkit.MigrateErr]
= do
  c = db.open(":memory:")
  ran = sqlkit.migrate_latest(c, schema())
  log("applied: {ran.map(s => s.name)}")
  titles = ["Dune", "Neuromancer", "Hyperion", "Foundation", "Snow Crash", "The Dispossessed"]
  years = [1965, 1984, 1989, 1951, 1992, 1974]
  n = sqlkit.insert_batch(c, sqlkit.insert("books", ["title", "year"]).values_all((0..6).map(i => [vtext(titles[i]), vint(years[i])])))
  q = sqlkit.select_sql(sqlkit.select("books").columns(["title"]).select_where(like_substr("title", "an")).order_by([asc("title")]))
  log(q.sql)
  log("{db.query[Str](c, q.sql, q.params)}")
  up = sqlkit.update_sql(sqlkit.update("books", [("title", vtext("Dune (1965)"))]).update_where(eq("title", vtext("Dune"))))
  log("updated {sqlkit.exec(c, up)}")
  k = sqlkit.keyset(sqlkit.select("books"), [desc("year"), asc("id")], 4)
  first = sqlkit.keyset_sql(k, none)
  p1 = sqlkit.keyset_page(k, none, db.query[Book](c, first.sql, first.params), book_key)
  log("page 1: {p1.items.map(b => b.year)}")
  next = sqlkit.keyset_sql(k, p1.after)
  p2 = sqlkit.keyset_page(k, p1.after, db.query[Book](c, next.sql, next.params), book_key)
  log("page 2: {p2.items.map(b => b.year)}, more: {p2.after.is_some}")
  log("dry run to 1: {sqlkit.plan(c, schema(), 1).map(s => s.sql)}")
$ sspur run main.ssp
applied: ["books", "by year"]
SELECT "title" FROM "books" WHERE "title" LIKE ? ESCAPE '\' ORDER BY "title" ASC
["Neuromancer"]
updated 1
page 1: [1992, 1989, 1984, 1974]
page 2: [1965, 1951], more: false
dry run to 1: ["drop index books_year"]

The builder functions read best as methods (select(...).columns(...)), which works for the names you import with use.

How values are bound

db.query(c, sql, params) accepts a list literal of mixed types, but a list built at run time has one element type, so a builder can't hand over Int, Str and Bytes values as they are. sqlkit sends every value as text (Query.params is a List[Opt[Str]]) behind a typed placeholder, so each column receives exactly the type a native binding gives:

ValuePlaceholderParameter
vint(n)CAST(? AS INTEGER)decimal text (all of Int, including the minimum)
vreal(x)CAST(? AS REAL)shortest round-trip text; infinities and NaN are refused
vtext(s)?the text
vbool(b)CAST(? AS INTEGER)1 or 0
vtime(t)?ISO 8601 with milliseconds, as the stdlib binds a Time (text order is time order)
vblob(b)unhex(?)lower-case hex
vdec(d)?the decimal's text, as the stdlib binds a Dec
vnull()NULLnone

The tests check the round trip of every type (typeof included), exact F64 round trips (0.1, 1/3, 5e-324, the largest double), and that an indexed column compared with a cast placeholder still uses its index (explain query plan).

API

Values

  • Value (SqlNull, SqlInt, SqlReal, SqlText, SqlBool, SqlTime, SqlBlob, SqlDec): a parameter value.
  • vint, vreal, vtext, vbool, vtime, vblob, vdec, vnull: make one. vopt(o): none as NULL.
  • placeholder(v): the placeholder SQL and parameter of one value.

Queries and errors

  • Query{sql, params}: pass to db.query[T](c, q.sql, q.params), db.one, db.opt or exec.
  • exec(c, q): run a statement and return the rows changed.
  • SqlkitErr: BadIdent{name, why}, BadQuery{msg} (malformed or unsafe statement), BadCursor{msg}.

Identifiers

  • ident_problem(s), is_ident(s): whether s is a valid plain identifier, and why not.
  • is_keyword(s): whether s is one of SQLite's 147 keywords.
  • quote_ident(s): s as a quoted identifier, for SQL you write by hand.
  • name_sql(s): a checked name or qualifier.name. column_sql(s): a checked select-list column (*, t.*, name, t.name, ... as alias).

Conditions

  • Cond, Op (OpEq, OpNe, OpLt, OpLe, OpGt, OpGe).
  • eq, ne, lt, le, gt, ge: compare a column with a value. cols_compare(a, op, b): two columns (excluded.n > t.n).
  • row_compare(cols, op, values): SQLite row values, (a, b) > (?, ?).
  • in_list, not_in: membership; an empty list matches nothing (in_list) or everything (not_in).
  • like, not_like: a pattern you wrote, with ESCAPE '\'. escape_like(s): escape %, _ and \. like_substr, like_prefix, like_suffix: literal text matches.
  • is_null, not_null, between, not_between.
  • all_of, any_of, negate: AND (empty is true), OR (empty is false), NOT.
  • raw(sql, args): SQL you wrote with bare ? placeholders (each becomes the argument's typed placeholder; ?NNN, :name and unbalanced quotes are refused).
  • cond_sql(c): the SQL of a condition.

SELECT

  • Select, select(table), columns, distinct, select_where (joins with AND), order_by, limit, offset.
  • Order, Nulls, asc, desc, nulls_first, nulls_last.
  • select_sql, count_sql (count of the rows, ignoring order and limit), exists_sql.

INSERT and upserts

  • Insert, insert(table, cols), values, values_all, insert_returning, insert_sql.
  • Conflict (ConflictFail, DoNothing{target}, DoUpdate{target, from_excluded, assign, only_if}), on_conflict_nothing, on_conflict_update, upsert(table, cols, row, key).
  • insert_chunks(i, max): split rows into statements of at most max parameters. max_params(): 32766, SQLite's limit. insert_batch(c, i): insert any number of rows in one transaction.

UPDATE and DELETE

  • Update, update(table, sets), update_where, update_all, update_returning, update_sql.
  • Delete, delete(table), delete_where, delete_all, delete_returning, delete_sql.

Keyset pagination

  • Keyset, keyset(base, keys, page_size): pages of base ordered by keys (mixed directions allowed; end with a unique column).
  • keyset_sql(k, after), keyset_before_sql(k, before): the query for the next or previous page (one extra row tells whether more follow). A uniform direction uses a row-value comparison that an index on the keys serves; mixed directions expand to (a < ?) OR (a = ? AND b > ?).
  • keyset_page, keyset_page_before: the KeyPage{items, after, before} from the rows.
  • cursor(k, key), decode_cursor(k, c): opaque base64url cursors bound to the keyset (a cursor from another ordering is BadCursor). Cursors are not signed: a forged one only selects another page of the same query, through parameters.

Migrations

  • Migration{version, name, up, down}, migration(...): versions increase along the list, gaps allowed (timestamps work); down is "" when a step can't be undone.
  • migrate_latest(c, ms), migrate_to(c, ms, target) (up or down; 0 undoes everything), rollback(c, ms, n): run and return the Steps, each step in its own transaction with its record. The whole plan is checked first, so an error changes nothing; a step another process ran meanwhile is skipped.
  • plan(c, ms, target): the dry run. status(c, ms): each migration's MigrationState (Applied, Pending, Changed, Missing). current_version(c), latest(ms), validate(ms), checksum(m) (SHA-256 of up), migrations_table() (sqlkit_migrations, separate from db.migrate's sspur_migrations).
  • Step, Direction (StepUp, StepDown), MigrationStatus, MigrateErr: DuplicateVersion, UnorderedVersion, ChangedMigration (an applied up was edited), UnknownApplied (applied but not in the list), OutOfOrder (a pending version older than the newest applied one), Irreversible, NoSuchTarget.

Tests

  • test_db(ms): a fresh in-memory database with the migrations applied.
  • load_fixtures(c, inserts): insert fixture rows in one transaction.
  • rolled_back(c, f): run a test body in a transaction that is always rolled back, and return its Bool.
  • row_count(c, table).

Notes

  • A migration's SQL runs inside a transaction, so statements SQLite refuses there (VACUUM, PRAGMA foreign_keys) don't belong in one.
  • SQLite's LIKE ignores ASCII case only; not_in with a NULL in the list is never true, as in SQL.
  • rolled_back returns a Bool rather than any type: db.tx can't yet be called from a function that is generic in its result type.

Out of scope

Joins, grouping, subqueries and window functions in the builder (write that SQL by hand, with raw conditions or db.query directly); schema introspection and diffing; databases other than SQLite; reading rows generically (db.query[T] needs a concrete T, so the caller reads rows).

License

MIT

API

Value type

pub type Value = SqlNull | SqlInt{v: Int} | SqlReal{v: F64} | SqlText{v: Str} | SqlBool{v: Bool} | SqlTime{v: Time} | SqlBlob{v: Bytes} | SqlDec{v: Dec} derive Eq, Show

A value bound to a ? placeholder. db.query needs one element type in a non-literal parameter list, so every value is sent as text (Opt[Str]) behind a typed placeholder: CAST(? AS INTEGER) for SqlInt and SqlBool, CAST(? AS REAL) for SqlReal, unhex(?) for SqlBlob, ? for text, times (ISO 8601 with milliseconds, as the stdlib binds them) and decimals, and NULL for SqlNull. The column receives the same type as with a native binding.

vint fn

fn vint(n: Int) -> Value

An integer value.

vreal fn

fn vreal(x: F64) -> Value

A real value. Infinities and NaN can't be stored by SQLite and are rejected when the query is built.

vtext fn

fn vtext(s: Str) -> Value

A text value.

vbool fn

fn vbool(b: Bool) -> Value

A boolean value, stored as 0 or 1.

vtime fn

fn vtime(t: Time) -> Value

A time, stored as ISO 8601 text with milliseconds (2026-10-15T09:30:00.000Z), so text order is time order, exactly as the stdlib binds a Time.

vblob fn

fn vblob(b: Bytes) -> Value

A blob value.

vdec fn

fn vdec(d: Dec) -> Value

A decimal, stored as its text (12.50), as the stdlib binds a Dec.

vnull fn

fn vnull() -> Value

The NULL value.

vopt fn

fn vopt(o: Opt[Value]) -> Value

none as NULL, some(v) as v: vopt(email.map(vtext)).

SqlkitErr type

pub type SqlkitErr = BadIdent{name: Str, why: Str} | BadQuery{msg: Str} | BadCursor{msg: Str} derive Eq, Show

Why a statement could not be built: an identifier that is not allowed, a statement that is not well formed (or is unsafe, such as an UPDATE without WHERE), or a pagination cursor that does not belong to the query.

Query type

pub type Query = {sql: Str, params: List[Opt[Str]]} derive Eq, Show

SQL text and its parameters, ready for db.query[T](c, q.sql, q.params), db.one, db.opt or db.exec.

placeholder fn

fn placeholder(v: Value) -> Query ! fail[SqlkitErr]

The placeholder and parameter for a value; see Value.

is_keyword fn

fn is_keyword(s: Str) -> Bool

Whether s is one of SQLite's 147 keywords, in any case. The builder quotes every identifier, so a column named order or key works; use this when you write SQL by hand.

ident_problem fn

fn ident_problem(s: Str) -> Opt[Str]

Why s is not allowed as a plain identifier, or none: 1 to 128 ASCII letters, digits and underscores, not starting with a digit, and not in SQLite's reserved sqlite_ namespace.

is_ident fn

fn is_ident(s: Str) -> Bool

Whether s is a valid plain identifier (see ident_problem).

quote_ident fn

fn quote_ident(s: Str) -> Str

s as a quoted SQLite identifier: double quotes around it, with each " doubled. This is safe for any text; the builder uses it only on identifiers that passed ident_problem.

column_sql fn

fn column_sql(s: Str) -> Str ! fail[SqlkitErr]

The SQL for one select-list column, checked: *, t.*, name, t.name, or any of those names followed by as alias.

name_sql fn

fn name_sql(s: Str) -> Str ! fail[SqlkitErr]

The SQL for a table or column name, name or qualifier.name, checked and quoted.

Op type

pub type Op = OpEq | OpNe | OpLt | OpLe | OpGt | OpGe derive Eq, Show

A comparison operator.

Cond type

pub type Cond = Compare{col: Str, op: Op, v: Value} | CompareCols{a: Str, op: Op, b: Str} | RowCompare{cols: List[Str], op: Op, vs: List[Value]} | InList{col: Str, vs: List[Value], negated: Bool} | Like{col: Str, pattern: Str, negated: Bool} | NullTest{col: Str, negated: Bool} | Between{col: Str, lo: Value, hi: Value, negated: Bool} | AllOf{cs: List[Cond]} | AnyOf{cs: List[Cond]} | Negate{c: Cond} | RawCond{sql: Str, args: List[Value]} derive Eq, Show

A WHERE condition. Build them with the functions below (eq, in_list, all_of, ...); every value becomes a parameter.

eq fn

fn eq(col: Str, v: Value) -> Cond

col = v. Comparing with NULL is never true in SQL: use is_null for that.

ne fn

fn ne(col: Str, v: Value) -> Cond

col <> v.

lt fn

fn lt(col: Str, v: Value) -> Cond

col < v.

le fn

fn le(col: Str, v: Value) -> Cond

col <= v.

gt fn

fn gt(col: Str, v: Value) -> Cond

col > v.

ge fn

fn ge(col: Str, v: Value) -> Cond

col >= v.

cols_compare fn

fn cols_compare(a: Str, op: Op, b: Str) -> Cond

a op b for two columns, such as excluded.version > items.version in an upsert.

row_compare fn

fn row_compare(cols: List[Str], op: Op, vs: List[Value]) -> Cond

(c1, c2, ...) op (v1, v2, ...), SQLite's row-value comparison (lexicographic), which can use an index on those columns.

in_list fn

fn in_list(col: Str, vs: List[Value]) -> Cond

col IN (v1, v2, ...). An empty list matches nothing.

not_in fn

fn not_in(col: Str, vs: List[Value]) -> Cond

col NOT IN (v1, v2, ...). An empty list matches every row. As in SQL, a NULL in the list makes the test unknown (false) for every row.

like fn

fn like(col: Str, pattern: Str) -> Cond

col LIKE pattern ESCAPE '\' with a pattern you wrote, where % and _ are wildcards (escape literal text with escape_like). SQLite's LIKE ignores ASCII case.

not_like fn

fn not_like(col: Str, pattern: Str) -> Cond

col NOT LIKE pattern ESCAPE '\'.

escape_like fn

fn escape_like(s: Str) -> Str

s with the LIKE wildcards % and _ and the escape character \ escaped, so it matches itself literally.

like_substr fn

fn like_substr(col: Str, s: Str) -> Cond

Rows whose col contains s literally (ASCII case ignored).

like_prefix fn

fn like_prefix(col: Str, s: Str) -> Cond

Rows whose col starts with s literally (ASCII case ignored).

like_suffix fn

fn like_suffix(col: Str, s: Str) -> Cond

Rows whose col ends with s literally (ASCII case ignored).

is_null fn

fn is_null(col: Str) -> Cond

col IS NULL.

not_null fn

fn not_null(col: Str) -> Cond

col IS NOT NULL.

between fn

fn between(col: Str, lo: Value, hi: Value) -> Cond

col BETWEEN lo AND hi, both ends included.

not_between fn

fn not_between(col: Str, lo: Value, hi: Value) -> Cond

col NOT BETWEEN lo AND hi.

all_of fn

fn all_of(cs: List[Cond]) -> Cond

Every condition holds; an empty list is true.

any_of fn

fn any_of(cs: List[Cond]) -> Cond

At least one condition holds; an empty list is false.

negate fn

fn negate(c: Cond) -> Cond

NOT (c).

raw fn

fn raw(sql: Str, args: List[Value]) -> Cond

An escape hatch: SQL you wrote, with one bare ? per argument (outside quoted text), such as raw("lower(email) = ?", [vtext(e)]). Each ? becomes the argument's typed placeholder. Never build sql from input; that is what the arguments are for.

cond_sql fn

fn cond_sql(c: Cond) -> Query ! fail[SqlkitErr]

The SQL and parameters of a condition.

Nulls type

pub type Nulls = NullsDefault | NullsFirst | NullsLast derive Eq, Show

Where NULLs sort.

Order type

pub type Order = {col: Str, desc: Bool, nulls: Nulls} derive Eq, Show

One ORDER BY term.

asc fn

fn asc(col: Str) -> Order

Ascending by col.

desc fn

fn desc(col: Str) -> Order

Descending by col.

nulls_first fn

fn nulls_first(o: Order) -> Order

The same order with NULLs first.

nulls_last fn

fn nulls_last(o: Order) -> Order

The same order with NULLs last.

Select type

pub type Select = {table: Str, column_list: List[Str], is_distinct: Bool, where_cond: Opt[Cond], order_terms: List[Order], row_limit: Opt[Int], row_offset: Opt[Int]} derive Eq, Show

A SELECT. Build one with select and the functions after it, or with a literal and with.

select fn

fn select(table: Str) -> Select

SELECT * FROM table.

columns fn

fn columns(s: Select, cols: List[Str]) -> Select

The columns to select (see column_sql); an empty list is *.

distinct fn

fn distinct(s: Select) -> Select

SELECT DISTINCT.

select_where fn

fn select_where(s: Select, c: Cond) -> Select

Adds a condition, joined with AND to any condition already there.

order_by fn

fn order_by(s: Select, os: List[Order]) -> Select

Adds ORDER BY terms after those already there.

limit fn

fn limit(s: Select, n: Int where _ >= 0) -> Select

At most n rows.

offset fn

fn offset(s: Select, n: Int where _ >= 0) -> Select

Skips the first n rows (LIMIT -1 OFFSET n when there is no limit).

select_sql fn

fn select_sql(s: Select) -> Query ! fail[SqlkitErr]

The SQL and parameters of a SELECT.

count_sql fn

fn count_sql(s: Select) -> Query ! fail[SqlkitErr]

SELECT count(*) over the rows s selects, ignoring its order, limit and offset; read it with db.one[Int].

exists_sql fn

fn exists_sql(s: Select) -> Query ! fail[SqlkitErr]

SELECT EXISTS (...) over the rows s selects, 0 or 1; read it with db.one[Bool].

Conflict type

pub type Conflict = ConflictFail | DoNothing{target: List[Str]} | DoUpdate{target: List[Str], from_excluded: List[Str], assign: List[(Str, Value)], only_if: Opt[Cond]} derive Eq, Show

What an INSERT does when a row conflicts with a unique key: fail (the default), do nothing, or update. DoUpdate sets each of from_excluded to the value the insert proposed (excluded.col), each pair of assign to a value, and applies only where only_if holds.

Insert type

pub type Insert = {into: Str, cols: List[Str], rows: List[List[Value]], on_conflict: Conflict, returning_cols: List[Str]} derive Eq, Show

An INSERT of one or more rows.

insert fn

fn insert(table: Str, cols: List[Str]) -> Insert

INSERT INTO table (cols...) with no rows yet.

values fn

fn values(i: Insert, row: List[Value]) -> Insert

Adds a row; it must have one value per column.

values_all fn

fn values_all(i: Insert, rows: List[List[Value]]) -> Insert

Adds rows.

on_conflict_nothing fn

fn on_conflict_nothing(i: Insert, target: List[Str]) -> Insert

ON CONFLICT (target) DO NOTHING; an empty target is any uniqueness conflict.

on_conflict_update fn

fn on_conflict_update(i: Insert, target: List[Str], set_cols: List[Str]) -> Insert

ON CONFLICT (target) DO UPDATE SET c = excluded.c for each of set_cols.

upsert fn

fn upsert(table: Str, cols: List[Str], row: List[Value], key: List[Str]) -> Insert

The upsert of row into table keyed by key: inserts it, or updates every other column of the existing row.

insert_returning fn

fn insert_returning(i: Insert, cols: List[Str]) -> Insert

RETURNING these columns (see column_sql); read the rows with db.query[T] or db.one[T].

insert_sql fn

fn insert_sql(i: Insert) -> Query ! fail[SqlkitErr]

The SQL and parameters of an INSERT.

insert_chunks fn

fn insert_chunks(i: Insert, max_params: Int where _ > 0) -> List[Insert] ! fail[SqlkitErr]

The insert split into inserts of consecutive rows, each with at most max_params parameters (SQLite's limit is 32766). A single row with more parameters is an error.

max_params fn

fn max_params() -> Int

SQLite's limit on parameters in one statement.

insert_batch fn db

fn insert_batch(c: Db, i: Insert) -> Int ! db, fail[DbErr], fail[SqlkitErr]

Inserts all the rows of i in one transaction, in statements under SQLite's parameter limit, and returns the number of rows changed.

Update type

pub type Update = {table: Str, set_list: List[(Str, Value)], where_cond: Opt[Cond], returning_cols: List[Str], all_rows: Bool} derive Eq, Show

An UPDATE. Without a condition it is refused unless all_rows is set (update_all).

update fn

fn update(table: Str, sets: List[(Str, Value)]) -> Update

UPDATE table SET col = value, ....

update_where fn

fn update_where(u: Update, c: Cond) -> Update

Adds a condition, joined with AND to any condition already there.

update_all fn

fn update_all(u: Update) -> Update

Allows the update to change every row (no WHERE).

update_returning fn

fn update_returning(u: Update, cols: List[Str]) -> Update

RETURNING these columns.

update_sql fn

fn update_sql(u: Update) -> Query ! fail[SqlkitErr]

The SQL and parameters of an UPDATE.

Delete type

pub type Delete = {table: Str, where_cond: Opt[Cond], returning_cols: List[Str], all_rows: Bool} derive Eq, Show

A DELETE. Without a condition it is refused unless all_rows is set (delete_all).

delete fn

fn delete(table: Str) -> Delete

DELETE FROM table.

delete_where fn

fn delete_where(d: Delete, c: Cond) -> Delete

Adds a condition, joined with AND to any condition already there.

delete_all fn

fn delete_all(d: Delete) -> Delete

Allows the delete to remove every row (no WHERE).

delete_returning fn

fn delete_returning(d: Delete, cols: List[Str]) -> Delete

RETURNING these columns.

delete_sql fn

fn delete_sql(d: Delete) -> Query ! fail[SqlkitErr]

The SQL and parameters of a DELETE.

exec fn db

fn exec(c: Db, x: Query) -> Int ! db, fail[DbErr]

Runs a statement that returns no rows (db.exec with its parameters) and returns the number of rows changed.

Keyset type

pub type Keyset = {base: Select, keys: List[Order], page_size: Int where _ > 0} derive Eq, Show

Keyset (seek) pagination over base ordered by keys: each page starts after (or before) the key of a row instead of at an offset, so pages are stable under inserts and cost the same deep into the table. The keys must identify a row (end them with the primary key) and must not be NULL; directions may be mixed. base gives the table, columns and filter, and must not set its own order, limit or offset.

keyset fn

fn keyset(base: Select, keys: List[Order], page_size: Int where _ > 0) -> Keyset

Keyset pagination of base by keys, page_size rows a page.

KeyPage type

pub type KeyPage[T] = {items: List[T], after: Opt[Str], before: Opt[Str]} derive Eq, Show

A page: its rows in key order, the cursor for the page after it (after, when there may be more rows) and the cursor for the page before it (before, when this page was reached through a cursor).

cursor fn

fn cursor(k: Keyset, key: List[Value]) -> Str ! fail[SqlkitErr]

The opaque cursor for a row whose key columns hold key (one value per key, in order). It is base64url JSON, not signed: a changed cursor can only select another page of the same query.

decode_cursor fn

fn decode_cursor(k: Keyset, c: Str) -> List[Value] ! fail[SqlkitErr]

The key values in a cursor, checked against the keyset it must belong to.

keyset_sql fn

fn keyset_sql(k: Keyset, after: Opt[Str]) -> Query ! fail[SqlkitErr]

The query for the page after after (the first page for none), ordered by the keys, with one row more than a page so keyset_page can tell whether more follow.

keyset_before_sql fn

fn keyset_before_sql(k: Keyset, before: Opt[Str]) -> Query ! fail[SqlkitErr]

The query for the page before before (the last page for none). Its rows come in reverse key order; keyset_page_before puts them back.

keyset_page fn

fn keyset_page[T](k: Keyset, after: Opt[Str], rows: List[T], key: T -> List[Value]) -> KeyPage[T] ! fail[SqlkitErr]

The page from the rows keyset_sql(k, after) returned; key gives a row's key values.

keyset_page_before fn

fn keyset_page_before[T](k: Keyset, before: Opt[Str], rows: List[T], key: T -> List[Value]) -> KeyPage[T] ! fail[SqlkitErr]

The page from the rows keyset_before_sql(k, before) returned, in key order.

Migration type

pub type Migration = {version: Int where _ > 0, name: Str, up: Str, down: Str} derive Eq, Show

One versioned schema change: up applies it and down undoes it ("" when it can't be undone). Each may hold several statements. Versions are positive and increase along the list; they need not be consecutive (timestamps work).

migration fn

fn migration(version: Int where _ > 0, name: Str, up: Str, down: Str) -> Migration

A migration.

MigrateErr type

pub type MigrateErr = DuplicateVersion{version: Int} | UnorderedVersion{version: Int} | ChangedMigration{version: Int, name: Str} | UnknownApplied{version: Int} | OutOfOrder{version: Int} | Irreversible{version: Int, name: Str} | NoSuchTarget{version: Int} derive Eq, Show

Why migrations can't run: the list is not in order or repeats a version; an applied migration was edited since (its checksum differs) or is no longer in the list; a pending one is older than one already applied; a step down has no down; or the target version is not in the list.

Direction type

pub type Direction = StepUp | StepDown derive Eq, Show

Whether a step applies or undoes its migration.

Step type

pub type Step = {version: Int, name: Str, direction: Direction, sql: Str} derive Eq, Show

One step of a plan: the migration, which way, and the SQL that runs.

MigrationState type

pub type MigrationState = Applied{at: Str} | Pending | Changed{at: Str} | Missing{at: Str} derive Eq, Show

Where a migration stands in a database: applied (with the time it was applied), pending, applied but edited since, or applied but missing from the list.

MigrationStatus type

pub type MigrationStatus = {version: Int, name: Str, state: MigrationState} derive Eq, Show

One migration's state.

migrations_table fn

fn migrations_table() -> Str

The table sqlkit records applied migrations in (db.migrate uses its own, sspur_migrations).

checksum fn

fn checksum(m: Migration) -> Str

The SHA-256 of a migration's up SQL, in hex: what is recorded when it is applied and compared afterwards. Editing down later is allowed; editing up is ChangedMigration.

validate fn

fn validate(ms: List[Migration]) -> Unit ! fail[MigrateErr]

Checks that versions increase strictly along the list.

latest fn

fn latest(ms: List[Migration]) -> Int

The newest version in the list, or 0 for none.

current_version fn db

fn current_version(c: Db) -> Int ! db, fail[DbErr]

The newest applied version, or 0 for none.

status fn db

fn status(c: Db, ms: List[Migration]) -> List[MigrationStatus] ! db, fail[DbErr]

Each migration of the list with its state, then applied versions the list doesn't have.

plan fn db

fn plan(c: Db, ms: List[Migration], target: Int where _ >= 0) -> List[Step] ! db, fail[DbErr], fail[MigrateErr]

The steps that would bring the database to target (0 undoes everything), without changing anything: the dry run. Up steps run oldest first, down steps newest first.

migrate_to fn db

fn migrate_to(c: Db, ms: List[Migration], target: Int where _ >= 0) -> List[Step] ! db, fail[DbErr], fail[MigrateErr]

Brings the database to target: applies or undoes migrations, each step in its own transaction with its record, and returns the steps that ran. The whole plan is checked before the first step, so an error changes nothing. A step another process ran meanwhile is skipped.

migrate_latest fn db

fn migrate_latest(c: Db, ms: List[Migration]) -> List[Step] ! db, fail[DbErr], fail[MigrateErr]

Applies every pending migration.

rollback fn db

fn rollback(c: Db, ms: List[Migration], n: Int where _ >= 0) -> List[Step] ! db, fail[DbErr], fail[MigrateErr]

Undoes the newest n applied migrations.

test_db fn db

fn test_db(ms: List[Migration]) -> Db ! db, fail[DbErr], fail[MigrateErr]

A fresh in-memory database with every migration applied.

load_fixtures fn db

fn load_fixtures(c: Db, fixtures: List[Insert]) -> Int ! db, fail[DbErr], fail[SqlkitErr]

Inserts fixture rows in one transaction and returns the number of rows changed.

row_count fn db

fn row_count(c: Db, table: Str) -> Int ! db, fail[DbErr], fail[SqlkitErr]

The number of rows in table.

rolled_back fn db

fn rolled_back[e](c: Db, f: () -> Bool ! e) -> Bool ! db, fail[DbErr], e

Runs a test body inside a transaction that is always rolled back, and returns its result, so tests can share one database without seeing each other's writes. Nested db.tx calls in f become savepoints. (The result is a Bool, what a test needs: db.tx can't yet be wrapped by a function generic in its result type.)

Versions

VersionPublishedCommitSourceEffects
0.1.0 2026-10-16ed96058c3bsnapshotdb