@8x/kysely-d1
v1.1.0
Published
A Kysely dialect for Cloudflare D1, with insertId support and typed atomic batches.
Maintainers
Readme
@8x/kysely-d1
Install
bun add @8x/kysely-d1Usage
Create a Kysely instance (recommended)
initD1Kysely takes a D1Database and returns a Kysely instance configured with the D1 dialect.
import { initD1Kysely } from "@8x/kysely-d1";
// Example database type
type Database = {
Comment: {
id: number;
content: string;
isHidden: 0 | 1;
};
};
export default {
async fetch(_req: Request, env: { DB: D1Database }) {
const db = initD1Kysely<Database>(env.DB);
const comments = await db.selectFrom("Comment").selectAll().execute();
const rows = await db
.selectFrom("Comment")
.select(["Comment.content", "id", "isHidden as is"])
.execute();
return Response.json({ comments, rows });
},
};Create manually with D1SQLiteDialect
If you prefer to construct Kysely yourself, you can use D1SQLiteDialect directly.
import { Kysely } from "kysely";
import { D1SQLiteDialect } from "@8x/kysely-d1";
const db = new Kysely<Database>({
dialect: new D1SQLiteDialect(env.DB),
});Run batch (atomic on D1)
runBatch(db, queries) executes an array of Kysely queries (Compilable) using D1's db.batch().
Per D1 behavior, a batch is executed atomically; if any statement fails, the batch is rolled back.
import { runBatch } from "@8x/kysely-d1";
const result = await runBatch(env.DB, [
db.insertInto("Person").values({ first_name: "John" }),
db.insertInto("Pet").values({ name: "Fido", owner_id: 1 }),
db.selectFrom("Person").selectAll(),
]);
console.log(result[0].meta.last_row_id); // insert id of the first query
console.log(result[1].meta.last_row_id); // insert id of the second query
console.log(result[2].results); // rows returned by the third (select) queryFor insert / update / delete queries without returning(), D1 returns an empty
results array (typed as never[]); the affected-row info lives in meta
(last_row_id, changes). Add returning() / returningAll() to get typed rows back.
Chaining a generated id into a later statement in the same batch
All queries passed to runBatch are compiled up front, before any of them run, so a later
statement can't reference the JS value of an id generated earlier in the same call (it doesn't
exist yet at compile time — unlike calling runBatch / .execute() separately per statement and
threading the returned id through your own code).
SQLite's last_insert_rowid(), via Kysely's sql tag, is evaluated while the batch runs instead:
import { sql } from "kysely";
import { runBatch } from "@8x/kysely-d1";
const result = await runBatch(env.DB, [
db.insertInto("Person").values({ first_name: "John" }),
// No need to know Person's new id ahead of time — `last_insert_rowid()` resolves
// to it once the batch actually runs.
db.insertInto("Pet").values({
name: "Fido",
owner_id: sql<number>`last_insert_rowid()`,
}),
]);
console.log(result[0].meta.last_row_id); // Person's generated id — but see the caveatsRead the caveats before using this. last_insert_rowid() does not mean "the id from the
previous statement". It returns the rowid of the most recent successful INSERT into a rowid table
on the database connection, so it is only safe for the narrow shape above: a parent INSERT that
actually inserts exactly one row into a table whose primary key is INTEGER PRIMARY KEY, followed
by a single child statement inserting a single row, with no other insert in between. Each of the
following yields a wrong
id — usually silently, because a wrong rowid is often still a valid parent id, though it surfaces
as a foreign key error when it happens not to be:
- More than one child statement. The second child reads back the first child's rowid, not the parent's, because that insert became the most recent one. If that rowid happens to be a valid parent id, the foreign key still holds and the row commits against the wrong parent.
- A multi-row parent insert.
values([a, b, c])— orINSERT ... SELECT— leaveslast_insert_rowid()pointing atc, not ata. - A multi-row child insert. The value is re-evaluated per row, so rows after the first read back the child's own rowids.
- A parent that inserts nothing (
on conflict do nothing, or an ignoredinsert or ignore). The value stays at whatever it was before. - A parent
on conflict do updatethat takes the UPDATE path.meta.changesis1, but no row was inserted, so the value is stale. Checkingmeta.changes >= 1does not make this safe — the same known limitation thatinsertIdcarries for upserts (seedocs/ROADMAP.md). - A parent whose id column is not
INTEGER PRIMARY KEY. For aTEXT PRIMARY KEY(uuid) or a plainINT PRIMARY KEY, you get the table's hidden rowid, which is unrelated to the key you wanted; for aWITHOUT ROWIDtable the value is not updated at all. - Referencing it before any insert in the batch. The value comes from the connection's history,
not from the batch, and is
0only on a fresh connection. An insert counts towards it even if its batch later rolled back.
Non-INSERT statements in between (SELECT / UPDATE / DELETE) do not disturb the value, but
they do not refresh it either.
If your case doesn't fit the narrow safe shape, use returning() and split the work into separate
round trips, or make the relationship expressible without the generated id (e.g. a natural key).
One more caveat: this relies on all statements in a batch running on the same connection. D1 documents that a batch's statements execute sequentially, non-concurrently, as a single transaction, but it does not explicitly document connection identity — and the test for this is run against local (Miniflare) D1.
Notes / Limitations
- Kysely
transaction()is not supported because D1 does not support SQL transaction statements likeBEGIN,COMMIT, andROLLBACK. streamQueryis not supported.- The package is published as compiled ESM:
dist/*.jsplusdist/*.d.ts, with.as the only entry point.src/ships alongside it so that source maps and "go to definition" land on the original TypeScript, but it is not importable — resolve through the package name. - ESM only. There is no CommonJS build — Workers, wrangler, Vite and Bun are all ESM. Node
versions that support
require(esm)(20.19+ / 22.12+) can stillrequire()it; older ones cannot. D1Database/D1Resultare referenced as ambient globals, not imported. You get them fromwrangler types(worker-configuration.d.ts), which is the usual Workers setup, or from@cloudflare/workers-types— but that package is not under@types/, so installing it is not enough: add"types": ["@cloudflare/workers-types"]to yourtsconfig.json. It is declared as an optional peer dependency to record that requirement; you do not need to install it ifwrangler typesalready gives you the globals. (Do not use both — their declarations collide.)- D1 reports failures by rejecting, so a failed statement rejects out of
execute()rather than coming back as an empty result set. A rejection is passed through untouched. If a D1-compatible stand-in resolves with a failure instead, that is converted to a rejection shaped like the binding's own —D1_ERROR: <message>, with the bare message ascause.
Installing from a git URL
Prefer the registry. A git dependency ships no prebuilt dist/, so it relies on
the prepare lifecycle script to build one — and bun blocks lifecycle scripts
by default, which leaves the package installed but without its entry point.
The install reports success; the failure surfaces later as
Cannot find package '@8x/kysely-d1'.
bun pm untrusted # shows the blocked script
bun pm trust @8x/kysely-d1 # then reinstallnpm, pnpm and yarn install devDependencies before running prepare, so they
build it without extra steps.
Development
bun install
bun run typecheck # wrangler types && tsc
bun run build # tsc -p tsconfig.build.json -> dist/
bun run test # vitest run