Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
/.next/
/out/

# OpenNext + Wrangler build artifacts
/.open-next/
/.wrangler/

# production
/build
/dist
Expand All @@ -27,6 +31,10 @@ yarn-error.log*
.env*
!.env.local.example

# wrangler local dev secrets
.dev.vars
.dev.vars.*

# vercel
.vercel

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Clone the repo and install dependencies:
```bash
git clone git@github.com:FrkAk/mymir.git
cd mymir
bun install
bun install --production
cp .env.local.example .env.local
```

Expand Down
1 change: 1 addition & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"!**/.vercel/**",
"!**/*.tsbuildinfo",
"!**/next-env.d.ts",
"!cloudflare-env.d.ts",
"!bun.lock",
"!migrations/**",
"!drizzle/**"
Expand Down
623 changes: 613 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

13,647 changes: 13,647 additions & 0 deletions cloudflare-env.d.ts

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const DB_IMPORT_ALLOWLIST = [
const eslintConfig = [
...baseConfig,
...tsConfig,
{
// Generated outputs: wrangler env types and OpenNext / Wrangler build
// artifacts. Linting these adds no value and surfaces noise from
// generated code.
ignores: ["cloudflare-env.d.ts", ".open-next/**", ".wrangler/**"],
},
{
files: ["**/*.{ts,tsx}"],
rules: {
Expand Down Expand Up @@ -97,12 +103,17 @@ const eslintConfig = [
{
name: "@/lib/db",
message:
"Application code must import from @/lib/data, not @/lib/db. The data layer is defined in lib/data/. Boundary documented in docs/superpowers/plans/2026-05-06-db-access-rework.md.",
"Application code must import from @/lib/data, not @/lib/db. The data layer is defined in lib/data/.",
},
{
name: "@/lib/db/connection",
message:
"Application code must import from @/lib/data, not @/lib/db. The data layer is defined in lib/data/. Boundary documented in docs/superpowers/plans/2026-05-06-db-access-rework.md.",
"Application code must import from @/lib/data, not @/lib/db. The data layer is defined in lib/data/.",
},
{
name: "@cloudflare/workers-types",
message:
"Importing @cloudflare/workers-types pulls its ambient declarations globally and clobbers DOM Request/Response types, breaking unrelated tests. Declare minimal local type stubs in the workers-only file that needs them (see lib/realtime/broker-do.ts for the pattern).",
},
],
},
Expand Down
86 changes: 86 additions & 0 deletions lib/db/_driver.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import "server-only";

import { drizzle as drizzlePg } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as appSchema from "./schema";
import * as authSchema from "./auth-schema";

/**
* Drizzle clients for the postgres-js (Node TCP) driver. Pinned to one driver
* so the type aliases stay narrow; the runtime Proxy in `./connection.ts`
* casts the Workers driver's Drizzle instance to {@link AppDb} where needed
* (driver shape differences in `client.execute()` are normalized by
* `executeRaw` in `./raw.ts`, so the cast is sound at runtime).
*/
export type AppDb = ReturnType<typeof drizzlePg<typeof appSchema>>;
export type AuthDb = ReturnType<typeof drizzlePg<typeof authSchema>>;

/**
* Portable pool shape — the only method `withRequestDb` calls is `end()`.
* Both `postgres.Sql` and `@neondatabase/serverless.Pool` satisfy this.
*/
export interface ClosablePool {
end: () => Promise<unknown>;
}

/** Per-role bundle returned by every pool factory. */
export interface DbBundle<TDb> {
pool: ClosablePool;
db: TDb;
}

const POSTGRES_OPTS = { max: 3, idle_timeout: 10 } as const;

/**
* Build the application Drizzle client backed by postgres-js.
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_URL` is unset.
*/
export function buildAppPool(): DbBundle<AppDb> {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error(
"DATABASE_URL is required for the app runtime connection (app_user role).",
);
}
const pool = postgres(url, POSTGRES_OPTS);
return { pool, db: drizzlePg(pool, { schema: appSchema }) };
}

/**
* Build the Better-auth Drizzle client backed by postgres-js.
*
* @returns Pool + Drizzle instance bound to the neon_auth schema.
* @throws Error when `DATABASE_AUTH_URL` is unset.
*/
export function buildAuthPool(): DbBundle<AuthDb> {
const url = process.env.DATABASE_AUTH_URL;
if (!url) {
throw new Error(
"DATABASE_AUTH_URL is required — Better Auth must connect via auth_role " +
"(DML on neon_auth.*, no public-schema access).",
);
}
const pool = postgres(url, POSTGRES_OPTS);
return { pool, db: drizzlePg(pool, { schema: authSchema }) };
}

/**
* Build the BYPASSRLS Drizzle client backed by postgres-js. Wired against
* `DATABASE_SERVICE_ROLE_URL` (a separate connection string for a role with
* BYPASSRLS).
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_SERVICE_ROLE_URL` is unset.
*/
export function buildServicePool(): DbBundle<AppDb> {
const url = process.env.DATABASE_SERVICE_ROLE_URL;
if (!url) {
throw new Error(
"DATABASE_SERVICE_ROLE_URL is required for service-role data access",
);
}
const pool = postgres(url, POSTGRES_OPTS);
return { pool, db: drizzlePg(pool, { schema: appSchema }) };
}
9 changes: 9 additions & 0 deletions lib/db/_driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Indirection point for the DB driver.
*
* `next.config.ts`'s webpack alias rewrites this import to `_driver.workers`
* on Cloudflare builds (`DEPLOY_TARGET=cloudflare`) and to `_driver.node`
* everywhere else. Re-exporting from `_driver.node` keeps `bun run typecheck`
* and self-host builds working when the alias is not active.
*/
export * from "./_driver.node";
82 changes: 82 additions & 0 deletions lib/db/_driver.workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import "server-only";

import { drizzle as drizzleNeon } from "drizzle-orm/neon-serverless";
import { Pool as NeonPool } from "@neondatabase/serverless";
import * as appSchema from "./schema";
import * as authSchema from "./auth-schema";
import type { AppDb, AuthDb, DbBundle } from "./_driver.node";

export type { AppDb, AuthDb, DbBundle, ClosablePool } from "./_driver.node";

/**
* Per-request Neon Pool tuning. `max: 1` because each pool's lifetime is a
* single request (created in `withRequestDbCore`, ended via
* `ctx.waitUntil(pool.end())`); a larger cap would open extra WebSocket
* connections to Neon that never resolve before teardown. `idleTimeoutMillis`
* is omitted on purpose for the same reason — idle reaping cannot fire
* inside a single request lifetime and the default is sufficient.
*/
const NEON_OPTS = { max: 1 } as const;

/**
* Build the application Drizzle client backed by `@neondatabase/serverless`.
* Creates a fresh `NeonPool` per call; callers MUST close it via
* `ctx.waitUntil(pool.end())` once the request completes.
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_URL` is unset.
*/
export function buildAppPool(): DbBundle<AppDb> {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error(
"DATABASE_URL is required for the app runtime connection (app_user role).",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
};
}

/**
* Build the Better-auth Drizzle client backed by `@neondatabase/serverless`.
*
* @returns Pool + Drizzle instance bound to the neon_auth schema.
* @throws Error when `DATABASE_AUTH_URL` is unset.
*/
export function buildAuthPool(): DbBundle<AuthDb> {
const url = process.env.DATABASE_AUTH_URL;
if (!url) {
throw new Error(
"DATABASE_AUTH_URL is required — Better Auth must connect via auth_role " +
"(DML on neon_auth.*, no public-schema access).",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
return {
pool,
db: drizzleNeon(pool, { schema: authSchema }) as unknown as AuthDb,
};
}

/**
* Build the BYPASSRLS Drizzle client backed by `@neondatabase/serverless`.
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_SERVICE_ROLE_URL` is unset.
*/
export function buildServicePool(): DbBundle<AppDb> {
const url = process.env.DATABASE_SERVICE_ROLE_URL;
if (!url) {
throw new Error(
"DATABASE_SERVICE_ROLE_URL is required for service-role data access",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
};
}
Loading
Loading