npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sdxc/data-table-sqlstorage

v2026.9.16

Published

A remix/data-table database driver backed by a Cloudflare Durable Object's SQL storage

Readme

@sdxc/data-table-sqlstorage

A remix/data-table database driver backed by a Cloudflare Durable Object's SQL storage.

remix/data-table models reach a database through a driver. This one runs their queries against the SqlStorage handle a Durable Object owns, so a model's rows live inside the object that serves them, with JSON and boolean columns round-tripped on the way through.

Installation

npm add @sdxc/data-table-sqlstorage

The driver plugs into the data-table models of remix, which installs alongside this package. The SqlStorage handle it executes against comes from the Durable Objects runtime, so a TypeScript project also wants @cloudflare/workers-types for that type.

Usage

Build A Driver From A Durable Object's SQL Handle

import { createSQLStorageDatabaseAdapter } from "@sdxc/data-table-sqlstorage";
import { DurableObject } from "cloudflare:workers";
import { Database } from "remix/data-table";

export class Tenant extends DurableObject {
	#db = new Database(createSQLStorageDatabaseAdapter(this.ctx.storage.sql));

	async listUsers() {
		return this.#db.findMany(users, { orderBy: ["id"] });
	}
}

The handle belongs to the object and outlives every request, so the driver is built once per instance rather than per call.

Read And Write Through Models

Models are declared the way every remix/data-table model is, and the driver compiles them to SQLite text:

import { createSQLStorageDatabaseAdapter } from "@sdxc/data-table-sqlstorage";
import { column as c, Database, table } from "remix/data-table";

let users = table({
	name: "users",
	columns: {
		id: c.integer().primaryKey(),
		email: c.varchar(255),
		settings: c.json(),
		active: c.boolean(),
	},
});

let db = new Database(createSQLStorageDatabaseAdapter(sql));

let created = await db.create(users, {
	id: 1,
	email: "[email protected]",
	settings: { theme: "dark" },
	active: true,
});

SQLite stores neither JSON nor booleans natively, so the driver bridges both: a c.json() value is serialized on the way in and parsed back into an object on the way out, and a c.boolean() column reads back as true or false instead of the integers SQLite holds. A nullable column keeps null as its own third state.

Atomicity Without A Transaction Scope

A Durable Object's SQL storage rejects BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE and ROLLBACK TO, so the driver issues none of them and db.transaction() rejects:

await db.transaction(async (tx) => {
	await tx.create(users, { id: 1, email: "[email protected]" });
}); // rejects: Durable Object SQL storage has no transaction statements

Atomicity comes from the object itself instead. Every write an object makes within one turn of its event loop is coalesced into a single atomic commit, so a sequence of writes that stays inside a turn either lands whole or not at all:

async register(email: string) {
	await this.#db.create(users, { id: 1, email });
	await this.#db.create(profiles, { userId: 1, email });
}

The limit is the turn. Awaiting network I/O — a fetch, an RPC call to another object, anything that yields to the runtime — ends the turn, and the writes made before the await commit separately from the ones made after. A scope that spans an await is therefore no longer atomic, so gather what a write needs before the first write rather than between two of them.

Where a scope must also cover key-value storage, the runtime's own ctx.storage.transactionSync(callback) runs synchronous work atomically across both. It is synchronous by design, so the callback cannot await; work that must await belongs outside it.

capabilities.savepoints and capabilities.transactionalDdl both report false, which is what remix/data-table reads to decide what it attempts: a nested transaction() is refused by name, and the migration runner applies each migration without opening a scope.

Run Raw SQL

let result = await db.exec("SELECT email FROM users WHERE id = ?", [2]);
result.rows; // [{ email: "[email protected]" }]

let deleted = await db.exec("DELETE FROM users WHERE id = ?", [1]);
deleted.affectedRows; // 1

A raw statement carries no read/write signal of its own, so the leading keyword decides: SELECT, WITH and PRAGMA come back with rows, and anything else reports how many rows it wrote.

API

createSQLStorageDatabaseAdapter(db: SqlStorage, options?): DatabaseDriver

Builds the DatabaseDriver that new Database(...) takes. db is the SqlStorage handle to execute against, which a Durable Object exposes as ctx.storage.sql once its class is SQLite-backed. The driver reports a dialect of "sqlite".

options.capabilities overrides the feature flags the driver advertises. returning and upsert are on by default and migrationLock is off; those three are what it accepts. savepoints and transactionalDdl are fixed at false and are not overridable, because the platform rejects the statements either one would need.

The returned driver carries the full DatabaseDriver surface. Three members behave in a way worth knowing:

  • beginTransaction(), and every commit, rollback and savepoint method, always rejects. The error names ctx.storage.transactionSync() as the API the platform offers instead.
  • executeScript(sql) runs a multi-statement script one statement at a time, which is what SQL storage's one-statement-per-call exec needs. It cuts the script on the semicolons that terminate a statement; see below for what that covers.
  • wipe() always rejects. The Durable Object owns its database's lifecycle, so a clean slate comes from migrating down or deleting the object's storage.

Reads after a write are best served by a RETURNING clause, which returning enables: insertId falls back to last_insert_rowid(), and it is reported only for a table with a single-column primary key.

Pattern: Running Migrations When A Durable Object Boots

An object's database is created the first time the object runs, so the schema is applied from the constructor or from the first call that needs it. executeScript takes the whole script:

import { createSQLStorageDatabaseAdapter } from "@sdxc/data-table-sqlstorage";
import { DurableObject } from "cloudflare:workers";

export class Tenant extends DurableObject {
	#adapter = createSQLStorageDatabaseAdapter(this.ctx.storage.sql);

	async migrate() {
		await this.#adapter.executeScript(`
			CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT NOT NULL);
			CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users (email);
		`);
	}
}

transactionalDdl is off, so the migration runner applies each script as it stands rather than wrapping it. A migration declared as requiring transactional DDL is refused by name before anything runs, which is what keeps a half-applied schema from being mistaken for a rolled-back one.

Where A Script Is Cut

A semicolon ends a statement everywhere except inside a string literal, a quoted identifier — double-quoted, backtick-quoted or [bracketed], each with its doubled-delimiter escape — a -- line comment, a /* */ block comment, and a CREATE TRIGGER body. So all of this runs as its author wrote it, and a comment explaining a migration is free to use prose punctuation:

-- Pages by keyset on (created_at, id); the seek is a seek only while an index carries both.
CREATE INDEX feeds_subscription_idx ON feeds (created_at, id);
INSERT INTO settings (label) VALUES ('first; second');
CREATE TRIGGER counted AFTER INSERT ON events BEGIN
	UPDATE totals SET n = CASE WHEN n < 10 THEN n + 1 ELSE n END;
END;

A trigger arrives at exec whole, so its body semicolons stay inside it and the BEGIN that opens it reads as the trigger's own rather than as the transaction statement the platform refuses. CASE … END inside a body nests, so the trigger ends at the END that matches its BEGIN.

Fragments holding only whitespace or comments yield no statement, so a doubled ;; and a closing -- done are both fine, and a final statement needs no trailing semicolon.

A script whose string literal, quoted identifier, block comment or trigger body never closes is refused before any statement runs, with an error naming what is open and the line it opened on — a truncated script fails whole rather than applying the part that parsed.

Pattern: Hosting A Storage-Agnostic Library Inside A Durable Object

A library that takes a DatabaseDriver rather than opening its own connection runs wherever a driver can be built. Constructing one here is what lets such a library live inside a Durable Object, keeping each object's data in the object itself:

import { createSQLStorageDatabaseAdapter } from "@sdxc/data-table-sqlstorage";
import { DurableObject } from "cloudflare:workers";

export class Tenant extends DurableObject {
	#engine = createEngine({
		database: createSQLStorageDatabaseAdapter(this.ctx.storage.sql),
	});
}

The same library moves to Cloudflare D1 by swapping in the driver from @sdxc/data-table-d1; the models, queries and migrations above it are unchanged.

Versioning

Releases are dated rather than semantic. A version is the UTC date it was published, written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one release goes out per day.

Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.

Depend on one exact date, and move it when you are ready to take the change:

{
	"dependencies": {
		"@sdxc/data-table-sqlstorage": "2026.9.4"
	}
}

A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.

License

MIT

Author

Sergio Xalambrí