Adapters / SQLite
SQLite
SQLite through node:sqlite, better-sqlite3 or anything with the same prepare().all()/run() shape. One writer, so the claim is one UPDATE.
Setup
import { createSqliteTables, sqliteAdapter, sqliteQuery, sqliteTransaction } from "easy-ping/adapters/sqlite";
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("./app.db");
const query = sqliteQuery(db);
await createSqliteTables(query);
export const notify = easyPing({
database: sqliteAdapter(query, { transaction: sqliteTransaction(db) }),
// ...
});node:sqlite ships with Node 22.13 and later, no install. The library itself never imports it —
you hand it the database object — so the package's Node 20 floor is unchanged and nothing native
enters the dependency tree.
Drivers
sqliteQuery takes anything with prepare(sql) returning a statement that has
all(...params) and run(...params) => { changes }, plus exec(sql) for transactions. That is
node:sqlite's DatabaseSync and better-sqlite3's Database, unchanged:
import Database from "better-sqlite3";
const db = new Database("./app.db");
sqliteAdapter(sqliteQuery(db), { transaction: sqliteTransaction(db) });
sqliteTransaction queues transactions one behind another. The driver is synchronous but the
adapter awaits between statements, and SQLite refuses a BEGIN while one is open; the queue is
what lets two concurrent send() calls both be atomic.
How a claim works here
UPDATE notification_delivery SET status = 'claimed', claimed_at = ?, claimed_by = ?
WHERE id IN (SELECT id FROM notification_delivery WHERE … ORDER BY not_before, id LIMIT ?)
One statement. SQLite has exactly one writer at a time, so two sweeps cannot interleave inside
it, which is the guarantee FOR UPDATE SKIP LOCKED exists to give on Postgres. The rowLock
conformance case does not apply — there is no lock held across statements for a second sweep to
skip — and the suite filters it out rather than faking it, the same as MongoDB.
How things are stored
| declared | SQLite column |
|---|---|
| string, json | TEXT (JSON as text; the adapter parses on read) |
| number, boolean | INTEGER (booleans as 0/1; the adapter reads them back as booleans) |
| date | TEXT, ISO-8601 with milliseconds and a trailing Z |
Dates are the same 24 characters Date#toISOString produces, so <= on the text is <= on the
instant, and a DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) writes the identical shape.
createSqliteTables is bootstrap-only, like the MySQL one; there is no migration planner for
SQLite yet.