From 7cd169320791984bb4b33f26612d7afdf3748aa9 Mon Sep 17 00:00:00 2001 From: Carlo Taleon <38070918+Blankeos@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:26:26 +0800 Subject: [PATCH] Switch database setup to Postgres --- .env.example | 3 +- README.md | 8 +++-- bun.lock | 3 +- docker-compose.yml | 19 ++++++++++ package.json | 3 +- .../20251120203018_auth/migration.sql | 24 ++++++------- prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 3 +- src/env.private.ts | 6 ---- src/server/db/kysely.ts | 29 ++++----------- src/server/db/types.ts | 26 +++++++------- src/server/modules/auth/auth.dao.ts | 35 ++++++++++--------- src/server/modules/auth/auth.utilities.ts | 8 +++-- .../modules/organization/organization.dao.ts | 16 ++++----- .../organization/organization.service.ts | 2 +- 15 files changed, 95 insertions(+), 92 deletions(-) create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example index 97bd18f..6cd4063 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,7 @@ PUBLIC_BASE_URL="http://localhost:3000" -DATABASE_URL="file://local.db" -DATABASE_AUTH_TOKEN="" +DATABASE_URL="postgresql://solid:solid@localhost:5432/solid_launch?schema=public" GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" diff --git a/README.md b/README.md index 163734f..7995bcb 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,15 @@ cd cp .env.example .env ``` -3. Replace the `` in the local database with: +3. Start Postgres (or point `DATABASE_URL` at your existing database): ```sh - pwd # If it outputs: /User/Projects/solid-launch + docker compose up -d + ``` + ```sh # Replace the .env with: - DATABASE_URL="file:/User/Projects/solid-launch/local.db" + DATABASE_URL="postgresql://solid:solid@localhost:5432/solid_launch?schema=public" ``` 4. Generate diff --git a/bun.lock b/bun.lock index a9d000b..950f044 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,6 @@ "@corvu/drawer": "^0.2.4", "@hono/standard-validator": "^0.2.2", "@kobalte/core": "^0.13.11", - "@libsql/kysely-libsql": "^0.4.1", "@node-rs/argon2": "^2.0.2", "@paralleldrive/cuid2": "^3.3.0", "@photonjs/hono": "^0.1.12", @@ -34,7 +33,7 @@ "hono-openapi": "^1.2.0", "hono-rate-limiter": "^0.5.3", "kysely": "^0.28.10", - "kysely-bun-worker": "^1.2.1", + "pg": "^8.16.3", "prisma": "^7.3.0", "prisma-kysely": "^3.0.0", "solid-js": "1.9.11", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8ce6656 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.8" + +services: + postgres: + image: postgres:16 + container_name: solid-launch-postgres + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_USER: "solid" + POSTGRES_PASSWORD: "solid" + POSTGRES_DB: "solid_launch" + # DATABASE_URL="postgresql://solid:solid@localhost:5432/solid_launch?schema=public" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/package.json b/package.json index 3229b13..f015bae 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "@corvu/drawer": "^0.2.4", "@hono/standard-validator": "^0.2.2", "@kobalte/core": "^0.13.11", - "@libsql/kysely-libsql": "^0.4.1", "@node-rs/argon2": "^2.0.2", "@paralleldrive/cuid2": "^3.3.0", "@photonjs/hono": "^0.1.12", @@ -51,7 +50,7 @@ "hono-openapi": "^1.2.0", "hono-rate-limiter": "^0.5.3", "kysely": "^0.28.10", - "kysely-bun-worker": "^1.2.1", + "pg": "^8.16.3", "prisma": "^7.3.0", "prisma-kysely": "^3.0.0", "solid-js": "1.9.11", diff --git a/prisma/migrations/20251120203018_auth/migration.sql b/prisma/migrations/20251120203018_auth/migration.sql index 8c9e127..42128a9 100644 --- a/prisma/migrations/20251120203018_auth/migration.sql +++ b/prisma/migrations/20251120203018_auth/migration.sql @@ -5,15 +5,15 @@ CREATE TABLE "user" ( "email_verified" BOOLEAN NOT NULL DEFAULT false, "password_hash" TEXT NOT NULL, "metadata" JSONB, - "joined_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + "joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- CreateTable CREATE TABLE "session" ( "id" TEXT NOT NULL PRIMARY KEY, "user_id" TEXT NOT NULL, - "expires_at" DATETIME NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, "revoke_id" TEXT NOT NULL, "active_organization_id" TEXT, "ip_address" TEXT, @@ -36,7 +36,7 @@ CREATE TABLE "oauth_account" ( CREATE TABLE "onetime_token" ( "token" TEXT NOT NULL PRIMARY KEY, "code" TEXT, - "expires_at" DATETIME NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, "identifier" TEXT NOT NULL, "purpose" TEXT NOT NULL, "metadata" JSONB @@ -49,8 +49,8 @@ CREATE TABLE "organization" ( "slug" TEXT, "logo_object_id" TEXT, "metadata" JSONB, - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- CreateTable @@ -58,8 +58,8 @@ CREATE TABLE "organization_member" ( "user_id" TEXT NOT NULL, "organization_id" TEXT NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY ("organization_id", "user_id"), CONSTRAINT "organization_member_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, @@ -73,10 +73,10 @@ CREATE TABLE "organization_invitation" ( "email" TEXT NOT NULL, "role" TEXT NOT NULL DEFAULT 'member', "invited_by_id" TEXT NOT NULL, - "expires_at" DATETIME NOT NULL, - "accepted_at" DATETIME, - "rejected_at" DATETIME, - "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "accepted_at" TIMESTAMP(3), + "rejected_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "organization_invitation_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "organization_invitation_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2a5a444..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "sqlite" +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f5becf..fb6306d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,5 +16,6 @@ generator kysely { } datasource db { - provider = "sqlite" + provider = "postgresql" + url = env("DATABASE_URL") } diff --git a/src/env.private.ts b/src/env.private.ts index 715090f..08f6236 100644 --- a/src/env.private.ts +++ b/src/env.private.ts @@ -12,12 +12,6 @@ export const privateEnv = createEnv({ // Database /** Development|Prod. Url of the database. */ DATABASE_URL: z.string(), - /** Development(Optional)|Prod. https://docs.turso.tech/local-development#sqlite. */ - DATABASE_AUTH_TOKEN: z - .string() - .optional() - .refine((val) => (process.env.NODE_ENV !== "development" ? !!val : true)), - // Auth /** Development|Prod. GitHub OAuth client ID. */ GITHUB_CLIENT_ID: z.string(), diff --git a/src/server/db/kysely.ts b/src/server/db/kysely.ts index 9f69946..f82642b 100644 --- a/src/server/db/kysely.ts +++ b/src/server/db/kysely.ts @@ -4,30 +4,15 @@ // = For develop, we use the Bun dialect. // =========================================================================== -import { LibsqlDialect } from "@libsql/kysely-libsql" -import { Kysely } from "kysely" -import { BunWorkerDialect } from "kysely-bun-worker" +import { Kysely, PostgresDialect } from "kysely" +import { Pool } from "pg" import { privateEnv } from "@/env.private" import type { DB } from "./types" // Generated by prisma. -const getDialect = () => { - const isLocal = privateEnv.DATABASE_URL.includes("file:") - - if (isLocal) { - console.log("Found file local database. Using BunWorkerDialect.") - // Can swap this with better-sqlite-3 if not Bun. (Node). - return new BunWorkerDialect({ - url: privateEnv.DATABASE_URL, - }) - } - - console.log("Found remote database. Using LibsqlDialect.") - return new LibsqlDialect({ - authToken: privateEnv.DATABASE_AUTH_TOKEN, - url: privateEnv.DATABASE_URL, - }) -} - export const db = new Kysely({ - dialect: getDialect(), + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: privateEnv.DATABASE_URL, + }), + }), }) diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 8c8afce..871fc5a 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -22,7 +22,7 @@ export type OneTimeToken = { * Optional shorter digit alias, scoped to user (so not globally unique) */ code: string | null - expires_at: string + expires_at: Timestamp identifier: string /** * e.g. 'password_reset', 'magic_link', 'otp', etc. (managed in application layer) @@ -45,8 +45,8 @@ export type Organization = { * For additional metadata i.e. org settings, billing, features, handled in application layer */ metadata: unknown | null - created_at: Generated - updated_at: Generated + created_at: Generated + updated_at: Generated } export type OrganizationInvitation = { id: string @@ -54,10 +54,10 @@ export type OrganizationInvitation = { email: string role: Generated invited_by_id: string - expires_at: string - accepted_at: string | null - rejected_at: string | null - created_at: Generated + expires_at: Timestamp + accepted_at: Timestamp | null + rejected_at: Timestamp | null + created_at: Generated } export type OrganizationMember = { user_id: string @@ -66,8 +66,8 @@ export type OrganizationMember = { * owner, admin, member (handled in application layer) */ role: Generated - created_at: Generated - updated_at: Generated + created_at: Generated + updated_at: Generated } export type Session = { /** @@ -75,7 +75,7 @@ export type Session = { */ id: string user_id: string - expires_at: string + expires_at: Timestamp /** * an alternative id strictly for revoking (not validating) so it's safe to send to frontend */ @@ -93,11 +93,11 @@ export type Session = { export type User = { id: string email: string - email_verified: Generated + email_verified: Generated password_hash: string metadata: unknown | null - joined_at: Generated - updated_at: Generated + joined_at: Generated + updated_at: Generated } export type DB = { oauth_account: OAuthAccount diff --git a/src/server/modules/auth/auth.dao.ts b/src/server/modules/auth/auth.dao.ts index 141d271..56f14c0 100644 --- a/src/server/modules/auth/auth.dao.ts +++ b/src/server/modules/auth/auth.dao.ts @@ -7,6 +7,7 @@ import { getSimpleDeviceName, hashPassword, jsonDecode, + jsonEncode, } from "@/server/modules/auth/auth.utilities" import { assertDTO } from "@/server/utils/assert-dto" import { AUTH_CONFIG } from "./auth.config" @@ -32,7 +33,7 @@ export class AuthDAO { user_id: userId, expires_at: new Date( Date.now() + AUTH_CONFIG.session.expiresInDays * 24 * 60 * 60 * 1000 - ).toISOString(), + ), }) .returningAll() .executeTakeFirst() @@ -114,7 +115,7 @@ export class AuthDAO { ) { session.expires_at = new Date( Date.now() + 1000 * 60 * 60 * 24 * AUTH_CONFIG.session.expiresInDays - ).toISOString() + ) await db .updateTable("session") @@ -240,7 +241,7 @@ export class AuthDAO { .selectFrom("session") .select(["id", "revoke_id", "expires_at", "ip_address", "session.user_agent_hash"]) .where("session.user_id", "=", params.userId) - .where("session.expires_at", ">", new Date().toISOString()) + .where("session.expires_at", ">", new Date()) .execute(), ]) @@ -271,8 +272,8 @@ export class AuthDAO { } async updateUserMetadata(params: { userId: string; metadata?: Partial }) { - const updates: Partial<{ metadata: string; updated_at: string }> = { - updated_at: new Date().toISOString(), + const updates: Partial<{ metadata: UserMetaDTO; updated_at: Date }> = { + updated_at: new Date(), } if (params.metadata !== undefined) { @@ -289,7 +290,7 @@ export class AuthDAO { const mergedMeta: UserMetaDTO = { ...existingMeta, ...params.metadata } - updates.metadata = JSON.stringify(mergedMeta) + updates.metadata = jsonEncode(mergedMeta) } await db.updateTable("user").set(updates).where("user.id", "=", params.userId).execute() @@ -312,7 +313,7 @@ export class AuthDAO { id: userId, password_hash: await hashPassword(params.password), email: params.email, - metadata: params.metadata ? JSON.stringify(params.metadata) : undefined, + metadata: params.metadata ? jsonEncode(params.metadata) : undefined, }) .returningAll() .executeTakeFirst() @@ -325,7 +326,7 @@ export class AuthDAO { .updateTable("user") .set({ password_hash: await hashPassword(params.password), - updated_at: new Date().toISOString(), + updated_at: new Date(), }) .where("user.id", "=", params.userId) .execute() @@ -337,8 +338,8 @@ export class AuthDAO { await db .updateTable("user") .set({ - email_verified: 1, - updated_at: new Date().toISOString(), + email_verified: true, + updated_at: new Date(), }) .where("user.id", "=", params.userId) .execute() @@ -361,9 +362,9 @@ export class AuthDAO { .values({ id: userId, email: params.email, - email_verified: 1, // oAuth users are always really verified + email_verified: true, // oAuth users are always really verified password_hash: await hashPassword(generateId()), // Just a random password, that's never guessable usually. - metadata: params.metadata ? JSON.stringify(params.metadata) : undefined, + metadata: params.metadata ? jsonEncode(params.metadata) : undefined, }) .returningAll() .execute() @@ -458,9 +459,9 @@ export class AuthDAO { .values({ id: userId, email, - email_verified: 0, + email_verified: false, password_hash: await hashPassword(generateId()), // random placeholder - metadata: metadata ? JSON.stringify(metadata) : undefined, + metadata: metadata ? jsonEncode(metadata) : undefined, }) .returningAll() .executeTakeFirst() @@ -503,10 +504,10 @@ export class AuthDAO { .values({ token, code, - expires_at: expiresAt.toISOString(), + expires_at: expiresAt, identifier: params.identifier, purpose: params.purpose, - metadata: params.metadata ? JSON.stringify(params.metadata) : undefined, + metadata: params.metadata ? jsonEncode(params.metadata) : undefined, }) .returning(["token", "code"]) .executeTakeFirst() @@ -533,7 +534,7 @@ export class AuthDAO { let query = trx .selectFrom("onetime_token") .selectAll() - .where("onetime_token.expires_at", ">", new Date().toISOString()) + .where("onetime_token.expires_at", ">", new Date()) if (params.token) { query = query.where("onetime_token.token", "=", params.token) diff --git a/src/server/modules/auth/auth.utilities.ts b/src/server/modules/auth/auth.utilities.ts index efb0d2d..eebed68 100644 --- a/src/server/modules/auth/auth.utilities.ts +++ b/src/server/modules/auth/auth.utilities.ts @@ -6,7 +6,11 @@ import { publicEnv } from "@/env.public" import { ApiError } from "@/server/lib/error" import { AUTH_CONFIG } from "./auth.config" -export function setSessionTokenCookie(context: Context, token: string, expiresAt: string): void { +export function setSessionTokenCookie( + context: Context, + token: string, + expiresAt: Date | string +): void { /** * NOTE: If you're surprised that auth fails in an http ipv4 address after building and previewing. This is the reason. * @@ -222,7 +226,7 @@ export function getOAuthRedirectUrl(redirectUrl?: string): string { /** * Little util so JSON.parse() can technically work for sqlite vs postgres and mysql without changing much code. - * sqlite stores as text. + * SQLite stores JSON as text, Postgres returns jsonb objects. */ export function jsonDecode(input: any): any { if (typeof input === "string") { diff --git a/src/server/modules/organization/organization.dao.ts b/src/server/modules/organization/organization.dao.ts index a45b62d..190e6dc 100644 --- a/src/server/modules/organization/organization.dao.ts +++ b/src/server/modules/organization/organization.dao.ts @@ -2,7 +2,7 @@ import type { Insertable } from "kysely" import { db } from "@/server/db/kysely" import type { OrganizationInvitation, OrganizationMember } from "@/server/db/types" import { ApiError } from "@/server/lib/error" -import { generateId, jsonDecode } from "@/server/modules/auth/auth.utilities" +import { generateId, jsonDecode, jsonEncode } from "@/server/modules/auth/auth.utilities" import { assertDTO } from "@/server/utils/assert-dto" import { getUserResponseMetaDTO, type UserMetaDTO } from "../auth/auth.dto" import { type OrgMetaDTO, orgMetaDTO } from "./organization.dto" @@ -56,7 +56,7 @@ export class OrganizationDAO { id: generateId(), name: data.name, slug: data.slug, - metadata: JSON.stringify(data.metadata || {}), + metadata: jsonEncode(data.metadata || {}), }) .returningAll() .executeTakeFirstOrThrow() @@ -71,8 +71,8 @@ export class OrganizationDAO { const updates: Record = {} if (data.name !== undefined) updates.name = data.name if (data.slug !== undefined) updates.slug = data.slug - if (data.metadata !== undefined) updates.metadata = JSON.stringify(data.metadata) - updates.updated_at = new Date().toISOString() + if (data.metadata !== undefined) updates.metadata = jsonEncode(data.metadata) + updates.updated_at = new Date() return await db .updateTable("organization") @@ -186,7 +186,7 @@ export class OrganizationDAO { async updateMembership(organizationId: string, userId: string, role: string) { return await db .updateTable("organization_member") - .set({ role, updated_at: new Date().toISOString() }) + .set({ role, updated_at: new Date() }) .where("organization_id", "=", organizationId) .where("user_id", "=", userId) .returningAll() @@ -277,7 +277,7 @@ export class OrganizationDAO { .where("organization_invitation.email", "=", params.email) .where("organization_invitation.accepted_at", "is", null) .where("organization_invitation.rejected_at", "is", null) - .where("organization_invitation.expires_at", ">", new Date().toISOString()) + .where("organization_invitation.expires_at", ">", new Date()) .executeTakeFirst() if (!result) return undefined @@ -325,7 +325,7 @@ export class OrganizationDAO { .where("id", "=", id) .where("accepted_at", "is", null) .where("rejected_at", "is", null) - .where("expires_at", ">", new Date().toISOString()) + .where("expires_at", ">", new Date()) .executeTakeFirst() if (!invitation) { @@ -346,7 +346,7 @@ export class OrganizationDAO { // Mark invitation as accepted await trx .updateTable("organization_invitation") - .set({ accepted_at: new Date().toISOString() }) + .set({ accepted_at: new Date() }) .where("id", "=", id) .execute() diff --git a/src/server/modules/organization/organization.service.ts b/src/server/modules/organization/organization.service.ts index 189c46b..d78b2e5 100644 --- a/src/server/modules/organization/organization.service.ts +++ b/src/server/modules/organization/organization.service.ts @@ -178,7 +178,7 @@ export class OrganizationService { email: email.toLowerCase(), role, invited_by_id: invitedByUserId, - expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }) // Fetch the organization details