@tetsujs/sql puts Postgres behind an application on Tetsu, through Bun’s
own client. The schema is a directory of SQL migrations. The types are
generated from the database those migrations build, as Postgres describes
it, so a column the database can leave NULL is | null in the code, and a
constraint is known by the name the database gives it. Queries are written
with Kysely’s builder, on a driver of the package’s own, or as SQL files,
whose functions Postgres types as well.
Terminal window
bunadd@tetsujs/sqlkysely
The package needs Bun 1.4.4 or later and Postgres 14 or later. Kysely is a
peer dependency: queries are written with it, and the package gives it a
driver on Bun.SQL that keeps every value what the types say.
customer_id int8 not nullreferences customers (id),
status order_status not nulldefault'new',
total numeric(12, 2) not nullcheck (total >=0),
tags text[] not nulldefault'{}',
note text,
placed_at timestamptznot nulldefaultnow()
);
bun run db:migrate applies it to the database DATABASE_URL names.
bun run db:types builds the migrations on a scratch database of the same
server, reads it back, and writes src/db.generated.ts:
src/db.generated.ts
exporttypeOrderStatus="new"|"paid"|"shipped";
exportinterfaceDB {
orders: {
id:GeneratedAlways<bigint>;
customer_id:bigint;
status:Generated<OrderStatus>;
total:string;
tags:Generated<ArrayOfNullable<string>>;
note:string|null;
placed_at:Generated<Timestamp>;
};
// the other tables, the constraints' names, and `schema` for the runtime
}
database() opens a pool on the database, and a controller takes it as a
dependency, as it would take anything else:
: the method is
half of a route's identity, and an application that remembers its
routes — for a generated client, for tooling — needs to know which one
this is.
method: "GET",
path: "/orders/open"
Route path with :param segments, e.g. "/orders/:id/cancel".
Must start with /, contain no empty segments and no trailing slash;
a malformed literal is a compile error.
path: "/orders/open",
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>Promise<{
id:string;
total:string;
placed_at:Date;
}[]>
The endpoint logic; ctx is fully inferred, never annotate it.
The return type is inferred rather than demanded, and checked twice
over. Against the route's own contract, by the
HandlerResult
bound on R: answering with something response never declared is a
compile error that says so, instead of a structural diff against
Response. And against what the framework can serialize at all, by
the intersected
ValidateResult
: a stream handed over bare is
refused whether or not the route declared anything, because that is
the case no contract covers — without a response schema
HandlerResult is unknown and accepts every value there is.
handler: async () => {
const
constorders: {
id:bigint;
total:string;
placed_at:Date;
}[]
orders=await
db: Kysely<DB>
db
.selectFrom("orders")
.select(["id", "total", "placed_at"])
.where("status", "=", "new")
.execute();
return orders.map((
order: {
id: bigint;
total: string;
placed_at: Date;
}
order) => ({ ...
order: {
id: bigint;
total: string;
placed_at: Date;
}
order,
id: string
id: String(
order: {
id: bigint;
total: string;
placed_at: Date;
}
order.
id: bigint
id) }));
constorders: {
id:bigint;
total:string;
placed_at:Date;
}[]
},
}),
}));
const
constdb:Kysely<DB>
db=database(schema, {
url: string
A postgres:// URL of the database: the server, the user and the
database it names are the ones used. The database is given apart from
the URL as well, since Bun 1.4.4 would take PGDATABASE over it.
Nothing is annotated: the row’s type is the table’s columns as the database
reads them. where("status", "=", "open") would not compile, since open is
not a label of the enum. An int8 is read as a bigint, which JSON cannot
hold, so the handler hands the id on as a string:
Recipes has the ways to do it.
The files the commands write are part of the code: commit them, and let CI
check that they match the migrations, as Deploy and
CI shows.
Not an ORM. There are no models and no relations: the schema is
written in SQL, and the types follow the database, never the other way
round.
Not a generator of migrations. A migration is written by hand, as the
SQL Postgres runs. types:generate reads the migrations; it never writes
one.
Postgres only, on Bun.SQL. There is no driver of Node’s, such as pg,
and no other database.
Columns keep their names. A column is placed_at in the code as in
the database. A plugin that renames keys on the way out renames the keys
inside jsonb values too, and a query of the code would no longer read
as the SQL it runs. A linter’s rule of names, such as Biome’s
useNamingConvention or ESLint’s camelcase, then flags the keys of the
rows the code writes, { placed_at }: let it take snake_case for the
properties of objects and of types.