The data layer

D1 with Kysely, types derived from Zod objects, and a codec on every conversion between JavaScript and SQLite.

There is no ORM, and that is a decision rather than a gap. What you write is SQL, in a query builder that knows your schema.

One Zod object is the whole table

Migrations is how one reaches a database, tables is every table the kit creates, and hello world writes one end to end.

export const Note = z
  .object({
    id: z.string().describe("Primary key. A random UUID, so notes cannot be enumerated from a URL."),
    body: z.string().min(1).max(4096).describe("The note's text, as the author typed it."),
    createdAt: SQLiteDate.describe("When it was written. Ms-epoch in SQLite; a Date in app code."),
  })
  .describe("One note.");

export type Note = z.output<typeof Note>;

The database interface Kysely sees is derived from that object’s input side — the SQLite row shape — never a hand-written row interface beside it.

So a column exists in exactly one place. Add one and the query builder knows about it; misspell one and it is a compile error rather than a runtime undefined.

Three conventions, and each is load-bearing

The const and the inferred type share a name. Note is both. There is no NoteSchema, ever — a suffix on one of them is the beginning of two definitions drifting.

Every field carries a description, and so does the object. The schemas are the object model’s documentation. They feed the self-documenting config, the CLI’s help and agent tooling, and a field without one is incomplete rather than merely undocumented.

Every conversion goes through a codec. A raw 0 or 1, an epoch number, a manual date construction, or a JSON.stringify in query code is the smell this removes.

CodecStoresHands you
SQLiteBoolean0 or 1A boolean
SQLiteDateA ms-epoch integerA Date
sqliteJson(schema)Serialized JSONThe parsed, validated value

The conversion is written once, tested once, and cannot be forgotten at a call site — which is where it was always forgotten.

camelCase in TypeScript, snake_case in SQL

A plugin bridges the two, in both directions, and it is mandatory on every Kysely instance the kit builds.

So createdAt in your code is created_at in the database, and your query code never types an underscore. Migrations declare identifiers in camelCase too, and the runner emits the snake_case DDL.

The one exception is raw SQL, which bypasses the plugin — so a partial index or anything else the schema builder cannot model is written with literal snake_case identifiers.

Table prefixes, and why yours have none

Every table the kit provides is prefixed pithy_<capability>_<table>: pithy_auth_users, pithy_email_jobs, pithy_secrets_rotations.

That segment is the same one that namespaces the capability’s migrations and its error codes, which is what makes them incapable of colliding with each other — and the prefix as a whole is what makes them incapable of colliding with yours.

Your tables carry no prefix, because you are the adopter. notes is just notes, sitting in the same database as pithy_auth_users and joinable with plain SQL. That is the property most of the kit’s value comes from: did the players who topped the March board renew is a join rather than two exports.

The prefix only appears in SQL and migrations. The camelCase plugin means your query code never types it.

The bound-parameter ceiling

D1 rejects a statement carrying more than 100 bound parameters. Over it is an error rather than a truncation.

The obvious fix — chunk an IN list at exactly 100 — is wrong whenever the statement binds anything else, because a WHERE name = ? AND id IN (…100 ids) binds 101.

So the guard lives on the one seam every Kysely instance in the kit comes from, rather than at each call site. A budget helper takes the count of fixed parameters and gives back how many are left, and the seeding path sizes each chunk from that table’s own column count.

A rule every call site has to remember is a rule in the wrong place. Several capabilities bound past the cap while the arithmetic to avoid it sat unimported.

What is not here

No ORM. No lazy loading, no entity manager, no Note.findAll(). You write queries.

No foreign keys, with one internal exception. D1 does not enforce them without a per-connection pragma, so relying on them would mean relying on something that silently does nothing. Linkage is by indexed id columns instead, and the single foreign key in the kit is internal to one capability where both tables are certain to be in one database.

No cross-capability references. No capability’s tables reference another’s — which is why migration order across capabilities is otherwise arbitrary.

No generated types file. The types come from the schema map at build time. There is nothing to regenerate and nothing to check in.

Reading it back

c.var.db is the typed registry, one Kysely instance per named database:

const rows = await c.var.db.app
  .selectFrom("notes")
  .select(["id", "body", "createdAt"])
  .orderBy("createdAt", "desc")
  .limit(20)
  .execute();

Autocomplete on every registered table, and a column you did not declare is a red build.

ESC