From cdf3242d0ecf1944b7e4eac540f18849144009b9 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Tue, 14 Apr 2026 14:21:45 +0200 Subject: [PATCH 1/2] refactor(next): simplify RootPage by extracting auth and page-finding logic - Extract createAuthContext() in lib/auth-context.ts for auth/session handling - Extract findAdminPage() in lib/page-finder.ts for pure routing logic - RootPage now only handles rendering (~37 lines vs ~100 before) - Remove auth prop from RootPageProps - uses getDeesse(config) internally - Remove SidebarItemsProvider - no longer needed since AdminDashboardLayout receives items directly - Delete dashboard-page.tsx and sidebar-items-context.tsx (no longer used) - Fix Deesse.auth type to use Auth from better-auth directly - hasAdminUsers now re-throws errors instead of silently returning false - Add error handling with try/catch for getDeesse, getSession, hasAdminUsers - Server-side NODE_ENV checks instead of client-side DevelopmentOnly/ProductionOnly components Co-Authored-By: Claude Opus 4.6 --- .../app/(deesse)/admin/[[...slug]]/page.tsx | 2 - packages/admin/src/lib/admin.ts | 6 +- .../app/(deesse)/admin/[[...slug]]/page.tsx | 2 - packages/deesse/src/server.ts | 15 +-- .../src/components/pages/dashboard-page.tsx | 24 ----- packages/next/src/components/pages/index.ts | 1 - packages/next/src/components/ui/index.ts | 2 - packages/next/src/index.ts | 8 +- packages/next/src/lib/auth-context.ts | 100 ++++++++++++++++++ packages/next/src/lib/page-finder.ts | 24 +++++ .../next/src/lib/sidebar-items-context.tsx | 24 ----- packages/next/src/root-page.tsx | 74 +++---------- 12 files changed, 153 insertions(+), 129 deletions(-) delete mode 100644 packages/next/src/components/pages/dashboard-page.tsx create mode 100644 packages/next/src/lib/auth-context.ts create mode 100644 packages/next/src/lib/page-finder.ts delete mode 100644 packages/next/src/lib/sidebar-items-context.tsx diff --git a/examples/base/src/app/(deesse)/admin/[[...slug]]/page.tsx b/examples/base/src/app/(deesse)/admin/[[...slug]]/page.tsx index 9c8c619..1709d4f 100644 --- a/examples/base/src/app/(deesse)/admin/[[...slug]]/page.tsx +++ b/examples/base/src/app/(deesse)/admin/[[...slug]]/page.tsx @@ -1,6 +1,5 @@ import { RootPage } from "@deessejs/next"; import { config } from "@deesse-config"; -import { auth } from "@/lib/auth"; interface AdminPageProps { params: Promise<{ slug?: string[] }>; @@ -14,7 +13,6 @@ export default async function AdminPage({ params, searchParams }: AdminPageProps return ( diff --git a/packages/admin/src/lib/admin.ts b/packages/admin/src/lib/admin.ts index 9cea47f..2b5ac50 100644 --- a/packages/admin/src/lib/admin.ts +++ b/packages/admin/src/lib/admin.ts @@ -41,8 +41,10 @@ export async function hasAdminUsers(auth: Auth): Promise { const context = await auth.$context; const users = await context.internalAdapter.listUsers(100); return users.some((u: any) => u.role === "admin") ?? false; - } catch { - return false; + } catch (error) { + // Log the error but re-throw - we don't know if admin exists or not + console.error("[deesse] Failed to check admin users:", error); + throw new Error("Failed to check admin users", { cause: error }); } } diff --git a/packages/create-deesse-app/templates/default/src/app/(deesse)/admin/[[...slug]]/page.tsx b/packages/create-deesse-app/templates/default/src/app/(deesse)/admin/[[...slug]]/page.tsx index 606402e..12eeacd 100644 --- a/packages/create-deesse-app/templates/default/src/app/(deesse)/admin/[[...slug]]/page.tsx +++ b/packages/create-deesse-app/templates/default/src/app/(deesse)/admin/[[...slug]]/page.tsx @@ -1,6 +1,5 @@ import { RootPage } from "@deessejs/next"; import { config } from "@deesse-config"; -import { auth } from "@/lib/auth"; interface AdminPageProps { params: Promise<{ slug?: string[] }>; @@ -14,7 +13,6 @@ export default async function AdminPage({ params, searchParams }: AdminPageProps return ( diff --git a/packages/deesse/src/server.ts b/packages/deesse/src/server.ts index c92c50c..fdbfa82 100644 --- a/packages/deesse/src/server.ts +++ b/packages/deesse/src/server.ts @@ -1,20 +1,11 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import type { BetterAuthPlugin } from "better-auth"; +import type { Auth } from "better-auth"; import type { InternalConfig } from "./config/define"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; export type Deesse = { - auth: Awaited; - baseURL: string; - secret: string; - emailAndPassword: { - enabled: true; - }; - trustedOrigins: string[]; - plugins: BetterAuthPlugin[]; - }>>>; + auth: Auth; database: PostgresJsDatabase; }; @@ -30,7 +21,7 @@ export function createDeesse(config: InternalConfig): Deesse { }, trustedOrigins: [config.auth.baseURL], plugins: config.auth.plugins, - }); + }) as Auth; return { auth, diff --git a/packages/next/src/components/pages/dashboard-page.tsx b/packages/next/src/components/pages/dashboard-page.tsx deleted file mode 100644 index fef0bdc..0000000 --- a/packages/next/src/components/pages/dashboard-page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import type { SidebarItem } from "../../lib/to-sidebar-items"; -import { useSidebarItems } from "../../lib/sidebar-items-context"; -import { AdminDashboardLayout, type AdminDashboardUser } from "../layouts/admin-shell"; - -interface DashboardPageProps { - name?: string; - items?: SidebarItem[]; - header?: React.ReactNode; - user?: AdminDashboardUser; - children: React.ReactNode; -} - -export function DashboardPage({ name, items, header, user, children }: DashboardPageProps) { - const contextItems = useSidebarItems(); - const sidebarItems = items ?? contextItems; - - return ( - - {children} - - ); -} diff --git a/packages/next/src/components/pages/index.ts b/packages/next/src/components/pages/index.ts index 6331028..0911f79 100644 --- a/packages/next/src/components/pages/index.ts +++ b/packages/next/src/components/pages/index.ts @@ -1,5 +1,4 @@ export { LoginPage } from './login-page'; -export { DashboardPage } from './dashboard-page'; export { FirstAdminSetup } from './first-admin-setup'; export { NotFoundPage } from './not-found-page'; export { HomePage } from './home-page'; diff --git a/packages/next/src/components/ui/index.ts b/packages/next/src/components/ui/index.ts index 8b6d914..83187cd 100644 --- a/packages/next/src/components/ui/index.ts +++ b/packages/next/src/components/ui/index.ts @@ -1,4 +1,2 @@ export { AppSidebar } from './app-sidebar'; -export { DevelopmentOnly } from './development-only'; -export { ProductionOnly } from './production-only'; export { SidebarNav } from './sidebar-nav'; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 761a5c7..c7347fb 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -12,6 +12,12 @@ export { toSidebarItems } from "@deessejs/admin"; export type { SidebarItem, SidebarPage, SidebarSection } from "@deessejs/admin"; export { findPage, extractSlugParts } from "@deessejs/admin"; export type { FindPageResult } from "@deessejs/admin"; -export { SidebarItemsProvider, useSidebarItems } from "@deessejs/admin"; + +// Lib exports +export { createAuthContext } from "./lib/auth-context"; +export type { AuthContext, CreateAuthContextOptions } from "./lib/auth-context"; +export { findAdminPage } from "./lib/page-finder"; +export type { PageFinderResult } from "./lib/page-finder"; +export { LOGIN_SLUG, ADMIN_LOGIN_PATH, ADMIN_HOME_PATH } from "./lib/auth-context"; export { REST_GET, REST_POST } from "./routes"; diff --git a/packages/next/src/lib/auth-context.ts b/packages/next/src/lib/auth-context.ts new file mode 100644 index 0000000..fbc28f2 --- /dev/null +++ b/packages/next/src/lib/auth-context.ts @@ -0,0 +1,100 @@ +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import type { InternalConfig } from "deesse"; +import { getDeesse } from "deesse"; +import { extractSlugParts, hasAdminUsers } from "@deessejs/admin"; +import type { FindPageResult } from "@deessejs/admin"; + +const LOGIN_SLUG = "login"; +const ADMIN_LOGIN_PATH = "/admin/login"; +const ADMIN_HOME_PATH = "/admin"; + +export interface AuthContext { + auth: Awaited>["auth"]; + session: Awaited>["auth"]["api"]["getSession"]>>; + user: AuthContext["session"] extends { user: infer U } ? U : undefined; + adminExists: boolean; + isLoginPage: boolean; + slugParts: string[]; +} + +export interface CreateAuthContextOptions { + config: InternalConfig; + params: Record; +} + +export async function createAuthContext({ + config, + params, +}: CreateAuthContextOptions): Promise { + let deesse; + let requestHeaders; + + try { + [deesse, requestHeaders] = await Promise.all([ + getDeesse(config), + headers(), + ]); + } catch (error) { + console.error("[deesse] Failed to initialize:", error); + redirect(`${ADMIN_LOGIN_PATH}?error=init_failed`); + } + + const auth = deesse.auth; + const slugParts = extractSlugParts(params); + + // Check if session exists with error handling + let session = null; + try { + session = await auth.api.getSession({ + headers: requestHeaders, + }); + } catch (error) { + console.error("[deesse] Session check failed:", error); + session = null; + } + + // Check if this is the login page + const isLoginPage = slugParts.length === 1 && slugParts[0] === LOGIN_SLUG; + + // If this is the login page, check if user is already authenticated + if (isLoginPage) { + if (session) { + redirect(ADMIN_HOME_PATH); + } + // Return early - login page will be rendered by caller + return { + auth, + session, + user: (session as any)?.user ?? undefined, + adminExists: false, + isLoginPage: true, + slugParts, + }; + } + + // If no session exists, redirect to login + if (!session) { + redirect(ADMIN_LOGIN_PATH); + } + + // Check if admin users exist + let adminExists = false; + try { + adminExists = await hasAdminUsers(auth); + } catch { + adminExists = process.env["NODE_ENV"] !== "production"; + } + + return { + auth, + session, + user: (session as any)?.user ?? undefined, + adminExists, + isLoginPage: false, + slugParts, + }; +} + +export { LOGIN_SLUG, ADMIN_LOGIN_PATH, ADMIN_HOME_PATH }; +export type { FindPageResult }; diff --git a/packages/next/src/lib/page-finder.ts b/packages/next/src/lib/page-finder.ts new file mode 100644 index 0000000..0a64cc9 --- /dev/null +++ b/packages/next/src/lib/page-finder.ts @@ -0,0 +1,24 @@ +import { findPage, toSidebarItems } from "@deessejs/admin"; +import type { PageTree, FindPageResult, SidebarItem } from "@deessejs/admin"; + +export interface PageFinderResult { + result: FindPageResult; + sidebarItems: SidebarItem[]; +} + +/** + * Finds an admin page by slug parts and returns the result with sidebar items. + * Pure function with no side effects. + */ +export function findAdminPage( + allPages: PageTree[], + slugParts: string[] +): PageFinderResult { + const result = findPage(allPages, slugParts); + const sidebarItems = toSidebarItems(allPages); + + return { + result, + sidebarItems, + }; +} diff --git a/packages/next/src/lib/sidebar-items-context.tsx b/packages/next/src/lib/sidebar-items-context.tsx deleted file mode 100644 index 1f4f8fa..0000000 --- a/packages/next/src/lib/sidebar-items-context.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { createContext, useContext } from "react"; -import type { SidebarItem } from "./to-sidebar-items"; - -export const SidebarItemsContext = createContext([]); - -export function SidebarItemsProvider({ - children, - items, -}: { - children: React.ReactNode; - items: SidebarItem[]; -}) { - return ( - - {children} - - ); -} - -export function useSidebarItems() { - return useContext(SidebarItemsContext); -} \ No newline at end of file diff --git a/packages/next/src/root-page.tsx b/packages/next/src/root-page.tsx index 64fb5de..0943342 100644 --- a/packages/next/src/root-page.tsx +++ b/packages/next/src/root-page.tsx @@ -1,81 +1,37 @@ -import { redirect } from "next/navigation"; -import { headers } from "next/headers"; -import type { Auth, BetterAuthOptions } from "better-auth"; -import type { Config } from "deesse"; -import { extractSlugParts, findPage, toSidebarItems, SidebarItemsProvider, hasAdminUsers } from "@deessejs/admin"; +import type { InternalConfig } from "deesse"; +import { createAuthContext } from "./lib/auth-context"; +import { findAdminPage } from "./lib/page-finder"; import { NotFoundPage } from "./components/pages/not-found-page"; import { AdminNotConfigured } from "./components/pages/admin-not-configured"; import { LoginPage } from "./components/pages/login-page"; import { FirstAdminSetup } from "./components/pages/first-admin-setup"; -import { defaultPages } from "./pages/default-pages"; -import { ProductionOnly } from "./components/ui/production-only"; -import { DevelopmentOnly } from "./components/ui/development-only"; import { AdminDashboardLayout } from "./components/layouts/admin-shell"; +import { defaultPages } from "./pages/default-pages"; -export interface RootPageProps< - Options extends BetterAuthOptions = BetterAuthOptions, -> { - config: Config; - auth: Auth; +export interface RootPageProps { + config: InternalConfig; params: Record; - searchParams?: Record; } -export async function RootPage({ - config, - auth, - params, -}: RootPageProps) { - const slugParts = extractSlugParts(params); - - // Check if this is the login page - const isLoginPage = slugParts.length === 1 && slugParts[0] === "login"; +export async function RootPage({ config, params }: RootPageProps) { + const { user, adminExists, isLoginPage, slugParts } = await createAuthContext({ config, params }); - // If this is the login page, always show LoginPage if (isLoginPage) { return ; } - // Get the request headers - const requestHeaders = await headers(); - - // Check if session exists - const session = await (auth.api as any).getSession({ - headers: requestHeaders, - }); - - // If no session exists, redirect to login - if (!session) { - redirect("/admin/login"); - } - - // In development: check if we need to show first-admin-setup - const adminExists = await hasAdminUsers(auth as any); - - // If no admin users exist, show first admin setup (development only) - const showFirstAdminSetup = !adminExists; - - // Show the protected page - const result = findPage([...defaultPages, ...(config.pages ?? [])], slugParts); + const allPages = [...defaultPages, ...(config.pages ?? [])]; + const { result, sidebarItems } = findAdminPage(allPages, slugParts); if (!result) { return ; } - // Generate sidebar items for context - const sidebarItems = toSidebarItems([...defaultPages, ...(config.pages ?? [])]); - return ( - - - - {showFirstAdminSetup && } - - - {!adminExists && } - - {result.page.content} - - + + {process.env["NODE_ENV"] !== "production" && !adminExists && } + {process.env["NODE_ENV"] === "production" && !adminExists && } + {result.page.content} + ); } From 22db5649faf223bf92c0c1274f0877daf16b3078 Mon Sep 17 00:00:00 2001 From: AliiiBenn Date: Fri, 15 May 2026 11:10:47 +0200 Subject: [PATCH 2/2] feat(db): add @deessejs/postgres package for Turbopack compatibility Extract postgres adapter into separate server-only package to prevent Turbopack from bundling Node.js dependencies (fs, net, tls) into client. - Add packages/db-postgres with postgres-js adapter - Add documentation for drizzle type compatibility issue - Update deesse config types with generic schema support Co-Authored-By: Claude Opus 4.7 --- docs/plans/drizzle-type-compatibility.md | 180 ++++++++++ docs/reports/issues/config-type-inference.md | 118 +++++++ .../issues/drizzle-type-incompatibility.md | 330 ++++++++++++++++++ packages/db-postgres/.gitignore | 5 + packages/db-postgres/package.json | 28 ++ packages/db-postgres/src/index.ts | 58 +++ packages/db-postgres/tsconfig.json | 12 + packages/deesse/src/config/define.ts | 4 +- packages/deesse/src/config/types.ts | 5 +- packages/deesse/src/index.ts | 35 +- packages/deesse/tsconfig.json | 3 +- 11 files changed, 758 insertions(+), 20 deletions(-) create mode 100644 docs/plans/drizzle-type-compatibility.md create mode 100644 docs/reports/issues/config-type-inference.md create mode 100644 docs/reports/issues/drizzle-type-incompatibility.md create mode 100644 packages/db-postgres/.gitignore create mode 100644 packages/db-postgres/package.json create mode 100644 packages/db-postgres/src/index.ts create mode 100644 packages/db-postgres/tsconfig.json diff --git a/docs/plans/drizzle-type-compatibility.md b/docs/plans/drizzle-type-compatibility.md new file mode 100644 index 0000000..0d5ac5d --- /dev/null +++ b/docs/plans/drizzle-type-compatibility.md @@ -0,0 +1,180 @@ +# Plan: Create `@deessejs/postgres` Package + +**Date:** 2026-04-28 +**Status:** Pending approval + +## Problem + +When `postgres` is exported from `packages/deesse`, Turbopack analyzes all exports client-side and attempts to bundle the `postgres` package (which uses Node.js built-ins like `fs`, `net`, `tls`) into client bundles, causing build failures. + +## Solution + +Create a separate package `@deessejs/postgres` that contains only the database adapter. This allows: + +1. `packages/deesse` to remain client-safe (no Node.js dependencies) +2. `@deessejs/postgres` to contain server-only code with full `postgres` dependency +3. Users to install `@deessejs/postgres` as a server-only dependency + +## Implementation + +### Step 1: Create `packages/db-postgres` + +``` +packages/db-postgres/ +├── package.json +├── tsconfig.json +└── src/ + └── index.ts # exports postgres() +``` + +**package.json:** +```json +{ + "name": "@deessejs/postgres", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "deesse": "workspace:*", + "postgres": "^3.4.9", + "drizzle-orm": "^0.38.0" + } +} +``` + +### Step 2: Move database adapter to `@deessejs/postgres` + +Create `packages/db-postgres/src/index.ts`: +```typescript +import { postgres as createPostgres } from 'postgres'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type { Config } from 'deesse'; + +export interface PostgresOptions { + connectionString: string; + schema?: Record; + pool?: { + max?: number; + idleTimeout?: number; + connectTimeout?: number; + }; + disablePreparedStatements?: boolean; +} + +export function postgres = Record>( + options: PostgresOptions +): PostgresJsDatabase { + const sql = createPostgres(options.connectionString, { + max: options.pool?.max, + idle_timeout: options.pool?.idleTimeout, + connect_timeout: options.pool?.connectTimeout, + prepare: options.disablePreparedStatements ? false : true, + }); + + return drizzle(sql, { schema: options.schema }) as PostgresJsDatabase; +} +``` + +### Step 3: Remove `postgres` from `packages/deesse` + +Remove the export from `packages/deesse/src/index.ts`: +```typescript +// REMOVE this line: +// export { postgres, type PostgresOptions } from "./database/index.js"; +``` + +Remove `postgres` dependency from `packages/deesse/package.json` if not needed elsewhere. + +### Step 4: Create `withDeesse()` helper (Optional) + +For easier Next.js integration, create `withDeesse()` in `packages/next`: + +```javascript +// packages/next/src/withDeesse.js +export const withDeesse = (nextConfig = {}) => ({ + ...nextConfig, + serverExternalPackages: [ + ...(nextConfig.serverExternalPackages || []), + '@deessejs/postgres', + ], +}); +``` + +### Step 5: Update `examples/base` + +Update `examples/base/src/deesse.config.tsx`: +```typescript +import { defineConfig } from 'deesse'; +import { postgres } from '@deessejs/postgres'; // Changed import +import { deessePages } from './deesse.pages'; +import { schema } from './db/schema/auth-schema'; +import { ThemeToggle } from './components/theme-toggle'; + +export const config = defineConfig({ + database: postgres({ + connectionString: process.env.DATABASE_URL!, + schema, + }), + // ... +}); +``` + +Update `examples/base/package.json`: +```json +{ + "dependencies": { + "@deessejs/postgres": "workspace:*" + } +} +``` + +### Step 6: Document in `docs/plans/drizzle-type-compatibility.md` + +Update the plan document to reflect the new approach. + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/db-postgres/package.json` | Create new package | +| `packages/db-postgres/tsconfig.json` | Create new tsconfig | +| `packages/db-postgres/src/index.ts` | Create adapter | +| `packages/deesse/src/index.ts` | Remove postgres export | +| `packages/deesse/package.json` | Remove postgres dependency | +| `examples/base/src/deesse.config.tsx` | Update import | +| `examples/base/package.json` | Add @deessejs/postgres dependency | +| `docs/plans/drizzle-type-compatibility.md` | Update plan | + +## Alternative: Keep Single Package with Workaround + +If separate package is too complex, users can work around this by: + +1. Creating database outside of config import +2. Using `react-server` directive to mark files server-only + +But this degrades DX significantly. + +## Validation + +- `pnpm build` on `packages/deesse` succeeds without postgres in exports +- `pnpm build` on `packages/db-postgres` succeeds +- `pnpm build` on `examples/base` succeeds without Turbopack errors + +## Breaking Changes + +- Users must install `@deessejs/postgres` separately if they want the adapter +- Existing code using `import { postgres } from 'deesse'` will break + +## Migration Path + +1. Install `@deessejs/postgres` as a new dependency +2. Change import from `import { postgres } from 'deesse'` to `import { postgres } from '@deessejs/postgres'` +3. Optionally use `withDeesse()` in `next.config.mjs` to externalize the package \ No newline at end of file diff --git a/docs/reports/issues/config-type-inference.md b/docs/reports/issues/config-type-inference.md new file mode 100644 index 0000000..e032828 --- /dev/null +++ b/docs/reports/issues/config-type-inference.md @@ -0,0 +1,118 @@ +# Issue: Config Type Inference Causes TypeScript Errors in Examples + +**Date:** 2026-04-28 +**Updated:** 2026-04-28 +**Project:** nesalia/deessejs +**Component:** deesse (type system) +**Severity:** Low +**Status:** FIXED — generic added to `getDeesse()` and `Deesse` types + +## Problem Summary + +When a schema is passed to `postgres()` from `@deessejs/postgres`, the inferred schema type `Record` is incompatible with `InternalConfig`'s default `Record`. + +However, this issue is now **mitigated in practice** by using `@deessejs/postgres` instead of direct `drizzle()` calls. The examples build successfully. + +## Resolution + +The fix adds generic parameters to `getDeesse()`, `createDeesse()`, and `Deesse` types: + +```typescript +// packages/deesse/src/index.ts +export const getDeesse = async = Record>( + config?: InternalConfig +): Promise> => { + // ... +} + +// packages/deesse/src/server.ts +export const createDeesse = = Record>( + config: InternalConfig +): Deesse => { + // ... +} + +// packages/deesse/src/config/types.ts +export type Deesse = Record> = { + auth: Auth; + database: PostgresJsDatabase; +}; +``` + +This allows callers to pass their schema-bearing config without type errors, while preserving the database type safety throughout the call chain. + +## Root Cause (Still Exists Theoretically) + +### Type Hierarchy Issue + +```typescript +// getDeesse signature: +export const getDeesse = async (config?: InternalConfig): Promise +// expects InternalConfig = InternalConfig> +``` + +When a schema is passed to `postgres()`: +```typescript +postgres({ connectionString: ..., schema }) +// schema causes TSchema = Record +``` + +`Record` is not assignable to `Record`: + +| Type | Assignable to | +|------|---------------| +| `Record` | `Record` — (never is bottom type) | +| `Record` | `Record` — (unknown is top type) | + +### Why Examples Work Now + +The examples work because the actual schema type inference doesn't cause issues in practice — likely because: + +1. The schema is defined inline and TypeScript infers a concrete object type, not `Record` +2. The `as any` workaround was needed with `pg` driver, but `postgres-js` driver doesn't have the same type incompatibility + +## Historical Context + +This issue was initially observed when using `drizzle-orm/node-postgres` with the `pg` driver: + +```typescript +// OLD problematic code +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; + +export const config = defineConfig({ + database: drizzle({ + client: new Pool({ connectionString: ... }), + schema, + }) as any, // Required workaround +}); +``` + +The `as any` was needed for two reasons: +1. `pg` driver returns `NodePgDatabase` which is incompatible with `PostgresJsDatabase` +2. TypeScript schema inference issues + +## Files Affected + +- `examples/base/src/deesse.config.tsx` — uses `@deessejs/postgres` now +- `examples/without-admin/src/deesse.config.ts` — uses `@deessejs/postgres` now +- `packages/deesse/src/index.ts` — `getDeesse()` function +- `packages/deesse/src/config/types.ts` — type definitions +- `packages/db-postgres/src/index.ts` — new postgres adapter package + +## Open Issue + +The type inference issue could still surface if someone: +1. Uses `getDeesse(config)` with a schema-bearing config +2. Passes the config from a separate module + +If this occurs, the workaround is: +```typescript +export const deesse = await getDeesse(config as InternalConfig); +``` + +## Environment + +- Next.js 16.2.4 with Turbopack +- TypeScript strict checks enabled +- `@deessejs/postgres` 0.1.0 published to npm \ No newline at end of file diff --git a/docs/reports/issues/drizzle-type-incompatibility.md b/docs/reports/issues/drizzle-type-incompatibility.md new file mode 100644 index 0000000..7c65aad --- /dev/null +++ b/docs/reports/issues/drizzle-type-incompatibility.md @@ -0,0 +1,330 @@ +# Drizzle-ORM Type Incompatibility Investigation + +**Date:** 2026-04-28 +**Issue:** Type mismatch between `postgres-js` and `node-postgres` drivers in DeesseJS +**Status:** Open + +## Problem Summary + +When a project uses `drizzle-orm/node-postgres` with the `pg` driver, TypeScript reports type incompatibilities with DeesseJS's `Config.database` type which expects `PostgresJsDatabase`. + +## Error Messages + +```typescript +// src/deesse.config.ts:8:3 - error TS2322: Type 'NodePgDatabase<...>' is not assignable to type 'PostgresJsDatabase<...>' + +// The types of '_.session' are incompatible between these types. +// Property 'dialect' is protected but type 'PgSession<...>' is not a class derived from 'PgSession<...>'. +``` + +## Actual Code That Causes the Error + +```typescript +// examples/base/src/deesse.config.tsx +import { defineConfig } from 'deesse'; +import { drizzle } from 'drizzle-orm/node-postgres'; // Uses pg driver +import { Pool } from 'pg'; +import { schema } from './db/schema/auth-schema'; + +export const config = defineConfig({ + name: "DeesseJS App", + database: drizzle({ + client: new Pool({ + connectionString: process.env.DATABASE_URL, + }), + schema, + }), // ❌ Type 'NodePgDatabase' is not assignable to 'PostgresJsDatabase' + secret: process.env.DEESSE_SECRET!, + auth: { baseURL: 'http://localhost:3000' }, +}); +``` + +The `drizzle()` call with `pg`'s Pool returns a `NodePgDatabase`, but DeesseJS's `Config` type expects `PostgresJsDatabase`. Both are valid Drizzle database instances, but TypeScript sees them as incompatible types. + +## Root Cause + +### Drizzle-ORM Type Hierarchy + +Both database types extend `PgDatabase` but with different HKT (Higher-Kinded Types): + +``` +PgDatabase +├── PostgresJsDatabase extends PgDatabase +└── NodePgDatabase extends PgDatabase +``` + +### Why Union Doesn't Solve the Generic Propagation Problem + +When we tried: +```typescript +type CompatibleDatabase = PostgresJsDatabase | NodePgDatabase; + +type Config = { + database: CompatibleDatabase; +}; +``` + +TypeScript error: `TSchema is declared but its value is never read` because the union doesn't actually use the generic - both types use it independently and TypeScript can't "match" them. + +### Why PgDatabase Base Class Doesn't Work + +Even though both extend `PgDatabase`, each specialization has different HKT parameters: +```typescript +PgDatabase +PgDatabase +``` + +These are structurally incompatible in TypeScript because the HKT is part of the type identity. + +## What Works at Runtime + +Better-auth's `drizzleAdapter` accepts any database type because it uses: +```typescript +db: { [key: string]: any } +``` + +This means both drivers work at runtime - the problem is only compile-time type checking. + +## Solution Approaches Considered + +### 1. Union Type Without Generics (Current Attempt) +```typescript +type CompatibleDatabase = PostgresJsDatabase | NodePgDatabase; +type Config = { database: CompatibleDatabase; }; +``` + +**Problem:** Loses schema generic propagation. When a user passes `Config<{ user: UserTable }>`, the internal `_.schema` type becomes incompatible with `Config>`. + +**Error seen:** +``` +Type 'InternalConfig<{ user: PgTableWithColumns<...> }>' is not assignable to type 'InternalConfig'. +``` + +### 2. Using `as any` Workaround (Current State in examples/base) +```typescript +database: drizzle({ client: new Pool(...) }) as any +``` + +**Problem:** Bypasses type safety, hard to maintain. + +### 3. Accept Both Drivers with `unknown` Pool Extraction +```typescript +const extractPool = (db: PostgresJsDatabase | NodePgDatabase): unknown => { + return (db as unknown as { $client?: unknown }).$client; +} +``` + +This part works - the pool extraction handles both types. + +## The Real Issue + +The incompatibility happens at the **schema generic level** not the driver level: + +1. User defines: `database: NodePgDatabase<{ user: UserTable, session: SessionTable }>` +2. Deesse internal type expects: `database: PostgresJsDatabase<{ ...schema... }>` +3. Even if drivers match, the HKT QueryResult types differ + +## Files Involved + +| File | Role | +|------|------| +| `packages/deesse/src/config/types.ts` | Defines `Config` and `InternalConfig` | +| `packages/deesse/src/config/define.ts` | Creates config via `defineConfig()` | +| `packages/next/src/root-page.tsx` | Uses `InternalConfig` as prop type | +| `examples/base/src/deesse.config.tsx` | User config using `pg` driver | + +## Possible Solutions + +1. **Keep generics but make database type accept union** - Still causes the TSchema issue +2. **Use two separate config types** - One for postgres-js, one for pg +3. **Accept the schema generic is lost** - Document that database type is intentionally loose +4. **Deep generic solution with conditional types** - Complex, may not be possible + +## Drizzle-ORM Native PostgreSQL Support + +Drizzle has native support for PostgreSQL via two drivers: + +### node-postgres (`drizzle-orm/node-postgres`) +```typescript +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; + +const db = drizzle({ client: new Pool({ connectionString: ... }) }); +``` +- Can use `pg-native` for ~10% speed boost +- Supports per-query type parsers without global patching +- Returns `NodePgDatabase` + +### postgres.js (`drizzle-orm/postgres-js`) +```typescript +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; + +const db = drizzle(postgres(process.env.DATABASE_URL)); +``` +- Uses prepared statements by default (may need to opt out for AWS) +- Simpler API, no native addon support +- Returns `PostgresJsDatabase` + +### Key Differences + +| Feature | node-postgres | postgres.js | +|---------|---------------|-------------| +| Speed | pg-native boost (~10%) | Standard | +| Type parsers | Per-query support | Global only | +| Prepared statements | No | Yes (default) | +| Native addon | pg-native | No | +| Returns | `NodePgDatabase` | `PostgresJsDatabase` | + +**Important:** Both are valid Drizzle database clients that work at runtime. The problem is purely TypeScript's type system not recognizing them as compatible. + +## Proposed Solution: Database Adapter Pattern (PayloadCMS-inspired) + +Inspired by [PayloadCMS's postgresAdapter](https://payloadcms.com/docs/database/postgres), we could create a first-party database adapter that abstracts away the driver complexity. + +### How PayloadCMS Does It + +```typescript +import { postgresAdapter } from '@payloadcms/db-postgres' + +export default buildConfig({ + db: postgresAdapter({ + pool: { connectionString: process.env.DATABASE_URL }, + }), +}) +``` + +The adapter handles: +- Creating the database client internally +- Managing the pool lifecycle +- Exposing Drizzle for direct access +- Handling schema migrations + +### Proposed DeesseJS Approach + +Create a `postgres()` function in `packages/deesse` that: + +1. **Uses `postgres-js` driver** (returns `PostgresJsDatabase` - the native type Deesse expects) +2. **Accepts connection options** including pool config +3. **Returns properly typed database instance** + +```typescript +// packages/deesse/src/database/postgres.ts +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; + +export interface PostgresOptions { + connectionString: string; + schema?: Record; + pool?: { + max?: number; + idleTimeout?: number; + connectTimeout?: number; + }; +} + +export const postgres = = Record>( + options: PostgresOptions +): PostgresJsDatabase => { + const client = postgres(options.connectionString, { + max: options.pool?.max, + idle_timeout: options.pool?.idleTimeout, + connect_timeout: options.pool?.connectTimeout, + }); + + return drizzle(client, { schema: options.schema }) as PostgresJsDatabase; +} +``` + +### User Experience (Before vs After) + +**Before (current workaround):** +```typescript +import { defineConfig } from 'deesse'; +import { drizzle } from 'drizzle-orm/node-postgres'; // pg driver +import { Pool } from 'pg'; +import { schema } from './db/schema/auth-schema'; + +export const config = defineConfig({ + database: drizzle({ + client: new Pool({ connectionString: process.env.DATABASE_URL }), + schema, + }) as any, // Workaround needed + secret: process.env.DEESSE_SECRET!, + auth: { baseURL: 'http://localhost:3000' }, +}); +``` + +**After (with postgres adapter):** +```typescript +import { defineConfig, postgres } from 'deesse'; +import { schema } from './db/schema/auth-schema'; + +export const config = defineConfig({ + database: postgres({ + connectionString: process.env.DATABASE_URL, + schema, + }), + secret: process.env.DEESSE_SECRET!, + auth: { baseURL: 'http://localhost:3000' }, +}); +``` + +### Advantages + +1. **Type safety preserved** - Uses `PostgresJsDatabase` directly, no casting needed +2. **Single driver** - Uses `postgres-js` which is already supported by Deesse +3. **Better DX** - Users don't need to know about Drizzle drivers +4. **Consistent API** - Matches patterns like PayloadCMS, Prisma adapters +5. **Pool management** - Handles connection lifecycle internally + +### Implementation Location + +``` +packages/deesse/src/database/ +├── index.ts # Exports postgres(), postgresJs() +├── postgres.ts # postgres-js driver adapter +└── types.ts # Database adapter types +``` + +### Changes to Config Type + +The `Config.database` type would accept `PostgresJsDatabase` directly (no change needed), while users using the `postgres()` helper get seamless integration. + +### Alternative: Keep pg Support + +If we want to keep `pg` driver support, we could offer both: +```typescript +import { postgres, postgresPg } from 'deesse'; + +// Uses postgres-js (recommended) +postgres({ connectionString: ... }) + +// Uses pg driver +postgresPg({ pool: new Pool({ connectionString: ... }) }) +``` + +But this adds complexity and the original HKT problem remains. + +## Implementation Notes from Drizzle Docs + +When using `postgres-js`: +- Connection string: `postgres(connectionString, options)` +- Pool options: `max`, `idle_timeout`, `connect_timeout` +- Prepared statements are enabled by default (may need to disable for AWS Lambda) + +When using `node-postgres`: +- Pool: `new Pool({ connectionString: ... })` +- Can use `pg-native` module for performance boost +- Supports per-query type parsers + +## Current Status + +Attempted to fix by removing generics from `Config` and `InternalConfig`. Build of `packages/deesse` succeeds. Build of `examples/base` fails because `RootPage` props type is too strict. + +## Next Steps + +1. Evaluate whether the schema generic propagation is actually required +2. Consider if `RootPage` should accept a looser config type +3. Determine acceptable trade-off between type safety and driver compatibility \ No newline at end of file diff --git a/packages/db-postgres/.gitignore b/packages/db-postgres/.gitignore new file mode 100644 index 0000000..f71b69b --- /dev/null +++ b/packages/db-postgres/.gitignore @@ -0,0 +1,5 @@ +# Dependencies +node_modules/ + +# Build output +dist/ \ No newline at end of file diff --git a/packages/db-postgres/package.json b/packages/db-postgres/package.json new file mode 100644 index 0000000..f573bb4 --- /dev/null +++ b/packages/db-postgres/package.json @@ -0,0 +1,28 @@ +{ + "name": "@deessejs/postgres", + "version": "0.1.1", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist" + }, + "dependencies": { + "deesse": "^0.2.13", + "postgres": "^3.4.9", + "drizzle-orm": "^0.38.0" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts new file mode 100644 index 0000000..720455a --- /dev/null +++ b/packages/db-postgres/src/index.ts @@ -0,0 +1,58 @@ +// Database adapter using postgres-js driver +// This package is server-only and should be added to serverExternalPackages + +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgresJs from 'postgres'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; + +/** + * Options for creating a postgres-js database connection. + */ +export interface PostgresOptions { + /** Database connection string */ + connectionString: string; + /** Drizzle schema for type-safe queries */ + schema?: Record; + /** Pool configuration options */ + pool?: { + /** Maximum number of connections in the pool */ + max?: number; + /** Idle connection timeout in milliseconds */ + idleTimeout?: number; + /** Connection timeout in milliseconds */ + connectTimeout?: number; + }; + /** Disable prepared statements (useful for AWS Lambda) */ + disablePreparedStatements?: boolean; +} + +/** + * Create a PostgresJsDatabase instance using the postgres-js driver. + * + * @example + * ```typescript + * import { defineConfig } from 'deesse'; + * import { postgres } from '@deessejs/postgres'; + * import { schema } from './db/schema'; + * + * export const config = defineConfig({ + * database: postgres({ + * connectionString: process.env.DATABASE_URL, + * schema, + * }), + * // ... + * }); + * ``` + */ +export function postgres = Record>( + options: PostgresOptions +): PostgresJsDatabase { + const sql = postgresJs(options.connectionString, { + max: options.pool?.max, + idle_timeout: options.pool?.idleTimeout, + connect_timeout: options.pool?.connectTimeout, + prepare: options.disablePreparedStatements ? false : true, + }); + + return drizzle(sql, { schema: options.schema }) as PostgresJsDatabase; +} diff --git a/packages/db-postgres/tsconfig.json b/packages/db-postgres/tsconfig.json new file mode 100644 index 0000000..9ce63c2 --- /dev/null +++ b/packages/db-postgres/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": false, + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/deesse/src/config/define.ts b/packages/deesse/src/config/define.ts index f2fef99..a432f1b 100644 --- a/packages/deesse/src/config/define.ts +++ b/packages/deesse/src/config/define.ts @@ -56,8 +56,8 @@ export const defineConfig = >( auth: { ...config.auth, plugins: mergedAuth.plugins, - emailAndPassword: mergedAuth.emailAndPassword as InternalConfig['auth']['emailAndPassword'], - session: mergedAuth.session as InternalConfig['auth']['session'], + emailAndPassword: mergedAuth.emailAndPassword as Config['auth']['emailAndPassword'], + session: mergedAuth.session as Config['auth']['session'], trustedOrigins: mergedAuth.trustedOrigins, }, }; diff --git a/packages/deesse/src/config/types.ts b/packages/deesse/src/config/types.ts index 2dfe266..3a70b8b 100644 --- a/packages/deesse/src/config/types.ts +++ b/packages/deesse/src/config/types.ts @@ -30,8 +30,9 @@ export type InternalConfig = Record = Record> = { auth: Auth; - database: PostgresJsDatabase; + database: PostgresJsDatabase; }; diff --git a/packages/deesse/src/index.ts b/packages/deesse/src/index.ts index d6f29c6..8ff97ea 100644 --- a/packages/deesse/src/index.ts +++ b/packages/deesse/src/index.ts @@ -1,10 +1,10 @@ // @deessejs/deesse core package -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { createDeesse } from "./server.js"; import type { Deesse } from "./config/types.js"; import { getGlobalConfig } from "./config/define.js"; import type { InternalConfig } from "./config/types.js"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; export { defineConfig } from "./config/index.js"; export type { Config, InternalConfig } from "./config/index.js"; @@ -29,15 +29,15 @@ export type { DeesseClientOptions } from "./client.js"; */ const DEESSE_GLOBAL_KEY = Symbol.for("@deessejs/core.instance"); -interface GlobalDeesseCache { - instance: Deesse | undefined; - config: InternalConfig | undefined; +interface GlobalDeesseCache = Record> { + instance: Deesse | undefined; + config: InternalConfig | undefined; pool: unknown | undefined; } -const getGlobalCache = (): GlobalDeesseCache => { +const getGlobalCache = = Record>(): GlobalDeesseCache => { const g = global as typeof global & { - [DEESSE_GLOBAL_KEY]?: GlobalDeesseCache; + [DEESSE_GLOBAL_KEY]?: GlobalDeesseCache; }; if (!g[DEESSE_GLOBAL_KEY]) { g[DEESSE_GLOBAL_KEY] = { @@ -55,7 +55,10 @@ const getGlobalCache = (): GlobalDeesseCache => { * Note: We do NOT compare database pools - the pool reference from $client * may return new wrapper objects on each access, causing false positives. */ -const isConfigEqual = (a: InternalConfig, b: InternalConfig): boolean => { +const isConfigEqual = >( + a: InternalConfig, + b: InternalConfig +): boolean => { if (a.secret !== b.secret) return false; if (a.name !== b.name) return false; if (a.auth.baseURL !== b.auth.baseURL) return false; @@ -75,7 +78,7 @@ const isConfigEqual = (a: InternalConfig, b: InternalConfig): boolean => { // Compare optional top-level fields if (JSON.stringify(a.plugins) !== JSON.stringify(b.plugins)) return false; - if (JSON.stringify(a.pages) !== JSON.stringify(b.pages)) return false; + if (JSON.stringify(a.pages) !== JSON.stringify(a.pages)) return false; if (JSON.stringify(a.admin) !== JSON.stringify(b.admin)) return false; return true; @@ -83,9 +86,9 @@ const isConfigEqual = (a: InternalConfig, b: InternalConfig): boolean => { /** * Extract pool reference from database. - * For pg Pool passed to drizzle-orm/node-postgres, the pool is stored in $client. + * The pool reference is stored in $client for both postgres-js and node-postgres. */ -const extractPool = (db: PostgresJsDatabase): unknown => { +const extractPool = >(db: PostgresJsDatabase): unknown => { return (db as unknown as { $client?: unknown }).$client; } @@ -96,11 +99,13 @@ const extractPool = (db: PostgresJsDatabase): unknown => { * Can be called without arguments if defineConfig() was called first, * or with a config for explicit instantiation. */ -export const getDeesse = async ( - config?: InternalConfig -): Promise => { - const effectiveConfig = config ?? getGlobalConfig(); - const cache = getGlobalCache(); +export const getDeesse = async < + TSchema extends Record = Record +>( + config?: InternalConfig +): Promise> => { + const effectiveConfig = config ?? (getGlobalConfig() as InternalConfig); + const cache = getGlobalCache(); // Case 1: Instance exists and config is semantically equal if (cache.instance && cache.config && isConfigEqual(cache.config, effectiveConfig)) { diff --git a/packages/deesse/tsconfig.json b/packages/deesse/tsconfig.json index 9ce63c2..aa5a487 100644 --- a/packages/deesse/tsconfig.json +++ b/packages/deesse/tsconfig.json @@ -5,7 +5,8 @@ "module": "ESNext", "moduleResolution": "bundler", "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "verbatimModuleSyntax": false }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]