Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
26387ca
feat: postgres
magne4000 Jun 2, 2026
062e596
feat(postgres): register PostgreSQL feature + Database engine axis
magne4000 Jun 2, 2026
f9bfb11
feat(postgres): flesh out standalone postgres.js boilerplate
magne4000 Jun 2, 2026
b15fc1c
feat(postgres): add PostgreSQL engine support to Drizzle
magne4000 Jun 2, 2026
e2d70fa
feat(postgres): add PostgreSQL engine support to Kysely
magne4000 Jun 2, 2026
14f4a20
feat(postgres): wire Postgres into the shared DB demo + env
magne4000 Jun 2, 2026
d66692f
feat(postgres): add postgres service to docker-compose + Dockerfile m…
magne4000 Jun 2, 2026
3f187c9
feat(postgres): rules + e2e tests
magne4000 Jun 2, 2026
a610156
fix(postgres): use if/else in drizzle getAllTodos so scaffold leaves …
magne4000 Jun 2, 2026
a5d0dfa
fix(postgres): complete db-type maps + postgres tsconfig node types
magne4000 Jun 2, 2026
72f2d14
fix(postgres): declare postgres_data volume via transformer, not temp…
magne4000 Jun 2, 2026
669d131
tests
magne4000 Jun 2, 2026
fa88a6b
chore(postgres): bump postgres image to v18 (compose + CI service)
magne4000 Jun 2, 2026
0df6280
fix(core): evaluate magic comment on first entry of a YAML mapping
magne4000 Jun 2, 2026
cacfd77
lint
magne4000 Jun 2, 2026
eb22050
remove comments
magne4000 Jun 2, 2026
1f4f35a
refactor(postgres): drop dead flexibility and redundant aliases
magne4000 Jun 2, 2026
7abce38
refactor(db): split into Database (engines) and ORM / Query builder c…
magne4000 Jun 2, 2026
cc81d3b
feat(db): ORM/query builder requires an explicit engine
magne4000 Jun 2, 2026
47f8162
refactor(db): explicit SQLite engine guards
magne4000 Jun 2, 2026
2d596a6
refactor(db): raw-engine demo arms guard on !hasOrm
magne4000 Jun 2, 2026
d48a47c
feat(prisma): engine-aware DATABASE_URL and init provider
magne4000 Jun 2, 2026
fd55201
test(db): pair ORMs with an explicit engine in the matrices
magne4000 Jun 2, 2026
94365d1
fix(db): handle Prisma (engine without Bati demo) in remaining gates
magne4000 Jun 2, 2026
feb6ea8
refactor(db): gate telefunc db-context augmentation on !prisma
magne4000 Jun 2, 2026
7bdcbf5
lint
magne4000 Jun 2, 2026
e630603
ci
magne4000 Jun 2, 2026
09fbebb
fix: postgres data
magne4000 Jun 3, 2026
0a13093
fix: docker-compose generation
magne4000 Jun 3, 2026
c728c6d
new hasDbDemo helper
magne4000 Jun 3, 2026
7c076d4
chore: update postgres logo
magne4000 Jun 3, 2026
c8d62a8
feat(website): tweak required
magne4000 Jun 3, 2026
551c216
fix: do not use tsx when using bun
magne4000 Jun 3, 2026
90d3637
idem
magne4000 Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/reusable.run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ jobs:
run: bun run build
working-directory: ${{ runner.temp }}/${{ inputs.destination }}

# PostgreSQL-backed apps connect to the default DATABASE_URL
# (postgresql://postgres:postgres@localhost:5432/app). Spin up a matching server.
- name: Start PostgreSQL
if: contains(inputs.flags, '--postgres')
shell: bash
run: |
docker run -d --name bati-postgres \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=app \
-p 5432:5432 postgres:18-alpine
for i in $(seq 1 30); do
if docker exec bati-postgres pg_isready -U postgres -d app >/dev/null 2>&1; then
echo "PostgreSQL is ready"; break
fi
sleep 1
done

- name: Run tests
shell: bash
# Bun hangs on Windows
Expand Down
571 changes: 309 additions & 262 deletions .github/workflows/tests-entry.yml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion boilerplates/d1-sqlite/bati.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from "@batijs/core/config";

export default defineConfig({
if(meta) {
return meta.BATI.hasD1 && meta.BATI.has("sqlite");
// Raw D1 queries: the SQLite engine on Cloudflare with no ORM/query builder.
return meta.BATI.hasD1 && !meta.BATI.hasOrm;
},
});
4 changes: 2 additions & 2 deletions boilerplates/docker-compose/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ const registry: EnvRegistry = [
];

describe("composeEnvEntries", () => {
test("secrets pull from host, defaulted vars are host-overridable, public is omitted", () => {
test("secrets pull from host, defaulted vars are pinned to their compose value, public is omitted", () => {
expect(composeEnvEntries(registry)).toEqual([
"DATABASE_URL=${DATABASE_URL:-/app/data/db.sqlite}",
"DATABASE_URL=/app/data/db.sqlite",
"AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID}",
"SENTRY_DSN=${SENTRY_DSN}",
]);
Expand Down
11 changes: 6 additions & 5 deletions boilerplates/docker-compose/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import { committedValue, type EnvRecord, type EnvRegistry, isServerVar } from "@

/**
* `KEY=<expr>` lines for the docker-compose `services.<app>.environment` list:
* secrets are pulled from the host (`${KEY}`), defaulted vars are host-overridable
* (`${KEY:-<default>}`).
* secrets are pulled from the host (`${KEY}`); defaulted vars are pinned to their
* compose value. Pinning (not `${KEY:-default}`) is deliberate: compose loads the
* project `.env` for interpolation, and `.env` carries the local-dev value (e.g.
* `DATABASE_URL` on `localhost`), which would otherwise shadow the in-container
* value (the app reaching the `postgres` service over the compose network).
*/
export function composeEnvEntries(registry: EnvRegistry): string[] {
return registry
.filter(isServerVar)
.map((def) =>
def.scope === "secret"
? `${def.key}=\${${def.key}}`
: `${def.key}=\${${def.key}:-${committedValue(def, "compose")}}`,
def.scope === "secret" ? `${def.key}=\${${def.key}}` : `${def.key}=${committedValue(def, "compose")}`,
);
}

Expand Down
14 changes: 10 additions & 4 deletions boilerplates/docker-compose/files/$Dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ export default async function getDockerfile(props: TransformerProps): Promise<st
// migrating — plus the source files each migration script must find in the runner.
const startupMigrations: string[] = [];
const migrationCopies: { sources: string[]; dest: string; from: string }[] = [];
if (meta.BATI.has("sqlite")) {
startupMigrations.push(`${run} sqlite:migrate`);
migrationCopies.push({ sources: ["/app/database/sqlite"], dest: "./database/sqlite", from: "builder" });
}
// Each ORM owns its migration; the raw-client engines (sqlite/postgres) run their
// own schema script, and only when no ORM is selected (Prisma is self-managed too).
if (meta.BATI.has("drizzle")) {
startupMigrations.push(`${run} drizzle:migrate`);
migrationCopies.push({ sources: ["/app/database/migrations"], dest: "./database/migrations", from: "deps-dev" });
Expand All @@ -38,6 +36,14 @@ export default async function getDockerfile(props: TransformerProps): Promise<st
from: "builder",
});
}
if (meta.BATI.has("sqlite") && !meta.BATI.hasOrm) {
startupMigrations.push(`${run} sqlite:migrate`);
migrationCopies.push({ sources: ["/app/database/sqlite"], dest: "./database/sqlite", from: "builder" });
}
if (meta.BATI.has("postgres") && !meta.BATI.hasOrm) {
startupMigrations.push(`${run} postgres:migrate`);
migrationCopies.push({ sources: ["/app/database/postgres"], dest: "./database/postgres", from: "builder" });
}

// Run migrations before the server when present; otherwise launch it directly.
const startCmd =
Expand Down
27 changes: 25 additions & 2 deletions boilerplates/docker-compose/files/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,34 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
# BATI.hasDatabase
# BATI.hasDatabase && !BATI.has("postgres")
volumes:
- sqlite_data:/app/data
# BATI.has("postgres")
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped

# BATI.has("postgres")
postgres:
image: postgres:18-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-app}
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped

# BATI.hasDatabase
volumes:
sqlite_data:
# BATI.hasDatabase && !BATI.has("postgres")
sqlite_data: {}
# BATI.has("postgres")
postgres_data: {}
9 changes: 7 additions & 2 deletions boilerplates/drizzle/files/$package.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { loadPackageJson, type TransformerProps } from "@batijs/core";
export default async function getPackageJson(props: TransformerProps): Promise<unknown> {
const packageJson = await loadPackageJson(props, await import("../package.json").then((x) => x.default));

const hasPostgres = props.meta.BATI.has("postgres");
// better-sqlite3 only for the embedded SQLite engine (not D1, not Postgres).
const hasSqliteEngine = !props.meta.BATI.hasD1 && !hasPostgres;

return packageJson
.setScript("drizzle:generate", {
value: "drizzle-kit generate",
Expand All @@ -19,6 +23,7 @@ export default async function getPackageJson(props: TransformerProps): Promise<u
precedence: 20,
})
.addDependencies(["drizzle-kit", "drizzle-orm", "dotenv"])
.addDevDependencies(["@types/better-sqlite3"], !props.meta.BATI.hasD1)
.addDependencies(["better-sqlite3"], !props.meta.BATI.hasD1);
.addDevDependencies(["@types/better-sqlite3"], hasSqliteEngine)
.addDependencies(["better-sqlite3"], hasSqliteEngine)
.addDependencies(["postgres"], hasPostgres);
}
12 changes: 11 additions & 1 deletion boilerplates/drizzle/files/database/drizzle/db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Database from "better-sqlite3";
import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleD1 } from "drizzle-orm/d1";
import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js";
import postgres from "postgres";

//# !BATI.hasD1
//# BATI.has("sqlite") && !BATI.hasD1
export function dbSqlite() {
const sqlite = new Database(process.env.DATABASE_URL);
return drizzleSqlite(sqlite);
Expand All @@ -12,3 +14,11 @@ export function dbSqlite() {
export function dbD1(d1: D1Database) {
return drizzleD1(d1);
}

//# BATI.has("postgres")
export function dbPostgres() {
if (!process.env.DATABASE_URL) {
throw new Error("Missing DATABASE_URL in .env file");
}
return drizzlePostgres(postgres(process.env.DATABASE_URL));
}
9 changes: 6 additions & 3 deletions boilerplates/drizzle/files/database/drizzle/queries/todos.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
/*# BATI include-if-imported #*/

import type { dbD1, dbSqlite } from "../db";
import type { dbD1, dbPostgres, dbSqlite } from "../db";
import { todoTable } from "../schema/todos";

export function insertTodo(
db: BATI.If<{
'BATI.has("postgres")': ReturnType<typeof dbPostgres>;
"!BATI.hasD1": ReturnType<typeof dbSqlite>;
_: ReturnType<typeof dbD1>;
}>,
text: string,
) {
return db.insert(todoTable).values({ text });
return (db as BATI.Any).insert(todoTable).values({ text });
}

export async function getAllTodos(
db: BATI.If<
{
'BATI.has("postgres")': ReturnType<typeof dbPostgres>;
"!BATI.hasD1": ReturnType<typeof dbSqlite>;
_: ReturnType<typeof dbD1>;
},
"union"
>,
) {
return db.select().from(todoTable).all();
const query = (db as BATI.Any).select().from(todoTable);
return BATI.has("postgres") ? query : query.all();
}
16 changes: 11 additions & 5 deletions boilerplates/drizzle/files/database/drizzle/schema/todos.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
/*# BATI include-if-imported #*/
import { pgTable, serial, varchar } from "drizzle-orm/pg-core";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

// Example of defining a schema in Drizzle ORM:
export const todoTable = sqliteTable("todos", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
text: text("text", { length: 50 }).notNull(),
});
// Example of defining a schema in Drizzle ORM.
export const todoTable = BATI.has("postgres")
? pgTable("todos", {
id: serial("id").primaryKey(),
text: varchar("text", { length: 50 }).notNull(),
})
: sqliteTable("todos", {
id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
text: text("text", { length: 50 }).notNull(),
});

// You can then infer the types for selecting and inserting
export type TodoItem = typeof todoTable.$inferSelect;
Expand Down
2 changes: 1 addition & 1 deletion boilerplates/drizzle/files/drizzle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ if (!BATI.hasD1) {
}

export default defineConfig({
dialect: "sqlite",
dialect: BATI.has("postgres") ? "postgresql" : "sqlite",
schema: "./database/drizzle/schema/*",
out: "./database/migrations",
//# !BATI.hasD1
Expand Down
3 changes: 2 additions & 1 deletion boilerplates/drizzle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"better-sqlite3": "^12.10.0",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2"
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.7"
},
"files": [
"dist/"
Expand Down
2 changes: 1 addition & 1 deletion boilerplates/express/files/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function getHandler() {
const app = express();

vike(app, [
//# BATI.hasDatabase
//# BATI.hasDbDemo
// Make database available in Context as `context.db`
dbMiddleware,
//# BATI.has("authjs") || BATI.has("auth0")
Expand Down
2 changes: 1 addition & 1 deletion boilerplates/fastify/files/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async function getHandler() {
await app.register(rawBody);

await vike(app, [
//# BATI.hasDatabase
//# BATI.hasDbDemo
// Make database available in Context as `context.db`
dbMiddleware,
//# BATI.has("authjs") || BATI.has("auth0")
Expand Down
2 changes: 1 addition & 1 deletion boilerplates/h3/files/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function getHandler() {
const app = createApp();

vike(app, [
//# BATI.hasDatabase
//# BATI.hasDbDemo
// Make database available in Context as `context.db`
dbMiddleware,
//# BATI.has("authjs") || BATI.has("auth0")
Expand Down
2 changes: 1 addition & 1 deletion boilerplates/hono/files/server/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function getApp() {
const app = new Hono();

vike(app, [
//# BATI.hasDatabase
//# BATI.hasDbDemo
// Make database available in Context as `context.db`
dbMiddleware,
//# BATI.has("authjs") || BATI.has("auth0")
Expand Down
17 changes: 12 additions & 5 deletions boilerplates/kysely/files/$package.json.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadPackageJson, type TransformerProps } from "@batijs/core";
import { loadPackageJson, packageManager, type TransformerProps } from "@batijs/core";

export default async function getPackageJson(props: TransformerProps): Promise<unknown> {
const packageJson = await loadPackageJson(props, await import("../package.json").then((x) => x.default));
Expand All @@ -8,12 +8,19 @@ export default async function getPackageJson(props: TransformerProps): Promise<u
return packageJson.addDependencies(["kysely", "kysely-d1"]);
}

const hasPostgres = props.meta.BATI.has("postgres");
// Bun runs TypeScript directly; Node needs tsx. SQLite stays on tsx even under
// Bun because the migration loads better-sqlite3, which has no Bun build.
const bunDirect = packageManager().name === "bun" && hasPostgres;

return packageJson
.setScript("kysely:migrate", {
value: "tsx ./database/kysely/migrate.ts",
value: `${bunDirect ? "bun" : "tsx"} ./database/kysely/migrate.ts`,
precedence: 20,
})
.addDevDependencies(["@types/better-sqlite3"])
.addDevDependencies(["tsx"], ["kysely:migrate"])
.addDependencies(["better-sqlite3", "dotenv", "kysely"]);
.addDevDependencies(["@types/better-sqlite3"], !hasPostgres)
.addDevDependencies(["tsx"], ["kysely:migrate"], !bunDirect)
.addDependencies(["dotenv", "kysely"])
.addDependencies(["better-sqlite3"], !hasPostgres)
.addDependencies(["postgres", "kysely-postgres-js"], hasPostgres);
}
14 changes: 13 additions & 1 deletion boilerplates/kysely/files/database/kysely/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import "dotenv/config";
import SQLite from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { D1Dialect } from "kysely-d1";
import { PostgresJSDialect } from "kysely-postgres-js";
import postgres from "postgres";
import type { Database } from "./types";

//# !BATI.hasD1
//# BATI.has("sqlite") && !BATI.hasD1
export function dbKysely() {
const dialect = new SqliteDialect({
database: new SQLite(process.env.DATABASE_URL),
Expand All @@ -23,3 +25,13 @@ export function dbKyselyD1(d1: D1Database) {
dialect: new D1Dialect({ database: d1 }),
});
}

//# BATI.has("postgres")
export function dbKyselyPostgres() {
if (!process.env.DATABASE_URL) {
throw new Error("Missing DATABASE_URL in .env file");
}
return new Kysely<Database>({
dialect: new PostgresJSDialect({ postgres: postgres(process.env.DATABASE_URL) }),
});
}
4 changes: 2 additions & 2 deletions boilerplates/kysely/files/database/kysely/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import * as path from "node:path";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { FileMigrationProvider, Migrator } from "kysely/migration";
import { dbKysely } from "./db";
import { dbKysely, dbKyselyPostgres } from "./db";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

async function migrateToLatest() {
const db = dbKysely();
const db = BATI.has("postgres") ? dbKyselyPostgres() : dbKysely();
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import type { Kysely } from "kysely";
import type { Database } from "../types";

export async function up(db: Kysely<Database>): Promise<void> {
await db.schema
.createTable("todos")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
.addColumn("text", "text", (col) => col.notNull())
.execute();
const withId = BATI.has("postgres")
? db.schema.createTable("todos").addColumn("id", "serial", (col) => col.primaryKey())
: db.schema.createTable("todos").addColumn("id", "integer", (col) => col.primaryKey().autoIncrement());
await withId.addColumn("text", "text", (col) => col.notNull()).execute();
}

export async function down(db: Kysely<Database>): Promise<void> {
Expand Down
4 changes: 3 additions & 1 deletion boilerplates/kysely/files/database/kysely/queries/todos.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { dbKysely, dbKyselyD1 } from "../db";
import type { dbKysely, dbKyselyD1, dbKyselyPostgres } from "../db";

export async function insertTodo(
db: BATI.If<{
'BATI.has("postgres")': ReturnType<typeof dbKyselyPostgres>;
"!BATI.hasD1": ReturnType<typeof dbKysely>;
"BATI.hasD1": ReturnType<typeof dbKyselyD1>;
}>,
Expand All @@ -12,6 +13,7 @@ export async function insertTodo(

export async function getAllTodos(
db: BATI.If<{
'BATI.has("postgres")': ReturnType<typeof dbKyselyPostgres>;
"!BATI.hasD1": ReturnType<typeof dbKysely>;
"BATI.hasD1": ReturnType<typeof dbKyselyD1>;
}>,
Expand Down
Loading
Loading