feat(14-01): rewrite database foundation from SQLite to PostgreSQL

- Replace all 13 sqliteTable definitions with pgTable (pg-core)
- Convert integer timestamps to native timestamp type with defaultNow()
- Convert real columns to doublePrecision, integer used to boolean
- Rewrite db connection to use postgres.js driver with DATABASE_URL
- Rewrite migrate.ts to use postgres-js migrator targeting drizzle-pg/
- Convert seed.ts to async
- Update drizzle.config.ts to postgresql dialect
- Install postgres and @electric-sql/pglite, remove better-sqlite3
This commit is contained in:
2026-04-04 12:17:05 +02:00
parent f7048a267a
commit 3724cf8348
7 changed files with 90 additions and 105 deletions

View File

@@ -5,6 +5,7 @@
"": { "": {
"name": "gearbox", "name": "gearbox",
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.4.3",
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@@ -15,6 +16,7 @@
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hono": "^4.12.8", "hono": "^4.12.8",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"postgres": "^3.4.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.8.0", "recharts": "^3.8.0",
@@ -28,12 +30,10 @@
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7", "@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9", "@tanstack/router-plugin": "^1.166.9",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"vite": "^8.0.0", "vite": "^8.0.0",
@@ -102,6 +102,8 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], "@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
@@ -684,6 +686,8 @@
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],

View File

@@ -1,10 +1,12 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
out: "./drizzle", out: "./drizzle-pg",
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
dialect: "sqlite", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_PATH || "gearbox.db", url:
process.env.DATABASE_URL ||
"postgresql://gearbox:gearbox@localhost:5432/gearbox",
}, },
}); });

View File

@@ -21,12 +21,10 @@
"@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.7", "@tanstack/react-router-devtools": "^1.166.7",
"@tanstack/router-plugin": "^1.166.9", "@tanstack/router-plugin": "^1.166.9",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"vite": "^8.0.0" "vite": "^8.0.0"
@@ -35,6 +33,7 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@electric-sql/pglite": "^0.4.3",
"@hono/zod-validator": "^0.7.6", "@hono/zod-validator": "^0.7.6",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
@@ -45,6 +44,7 @@
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hono": "^4.12.8", "hono": "^4.12.8",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"postgres": "^3.4.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.8.0", "recharts": "^3.8.0",

View File

@@ -1,9 +1,9 @@
import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/postgres-js";
import { drizzle } from "drizzle-orm/bun-sqlite"; import postgres from "postgres";
import * as schema from "./schema.ts"; import * as schema from "./schema.ts";
const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db"); const connectionString =
sqlite.run("PRAGMA journal_mode = WAL"); process.env.DATABASE_URL ||
sqlite.run("PRAGMA foreign_keys = ON"); "postgresql://gearbox:gearbox@localhost:5432/gearbox";
const queryClient = postgres(connectionString);
export const db = drizzle(sqlite, { schema }); export const db = drizzle(queryClient, { schema });

View File

@@ -1,13 +1,14 @@
import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/postgres-js";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import postgres from "postgres";
const sqlite = new Database(process.env.DATABASE_PATH || "gearbox.db"); const connectionString =
sqlite.run("PRAGMA journal_mode = WAL"); process.env.DATABASE_URL ||
sqlite.run("PRAGMA foreign_keys = ON"); "postgresql://gearbox:gearbox@localhost:5432/gearbox";
const migrationClient = postgres(connectionString, { max: 1 });
const db = drizzle(migrationClient);
const db = drizzle(sqlite); await migrate(db, { migrationsFolder: "./drizzle-pg" });
migrate(db, { migrationsFolder: "./drizzle" }); await migrationClient.end();
sqlite.close();
console.log("Migrations applied successfully"); console.log("Migrations applied successfully");

View File

@@ -1,18 +1,24 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import {
boolean,
doublePrecision,
integer,
pgTable,
serial,
text,
timestamp,
} from "drizzle-orm/pg-core";
export const categories = sqliteTable("categories", { export const categories = pgTable("categories", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
name: text("name").notNull().unique(), name: text("name").notNull().unique(),
icon: text("icon").notNull().default("package"), icon: text("icon").notNull().default("package"),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull()
.$defaultFn(() => new Date()),
}); });
export const items = sqliteTable("items", { export const items = pgTable("items", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
weightGrams: real("weight_grams"), weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"), priceCents: integer("price_cents"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
@@ -22,37 +28,29 @@ export const items = sqliteTable("items", {
imageFilename: text("image_filename"), imageFilename: text("image_filename"),
imageSourceUrl: text("image_source_url"), imageSourceUrl: text("image_source_url"),
quantity: integer("quantity").notNull().default(1), quantity: integer("quantity").notNull().default(1),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull() updatedAt: timestamp("updated_at").notNull().defaultNow(),
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); });
export const threads = sqliteTable("threads", { export const threads = pgTable("threads", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
resolvedCandidateId: integer("resolved_candidate_id"), resolvedCandidateId: integer("resolved_candidate_id"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
.references(() => categories.id), .references(() => categories.id),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull() updatedAt: timestamp("updated_at").notNull().defaultNow(),
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); });
export const threadCandidates = sqliteTable("thread_candidates", { export const threadCandidates = pgTable("thread_candidates", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
threadId: integer("thread_id") threadId: integer("thread_id")
.notNull() .notNull()
.references(() => threads.id, { onDelete: "cascade" }), .references(() => threads.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
weightGrams: real("weight_grams"), weightGrams: doublePrecision("weight_grams"),
priceCents: integer("price_cents"), priceCents: integer("price_cents"),
categoryId: integer("category_id") categoryId: integer("category_id")
.notNull() .notNull()
@@ -64,28 +62,20 @@ export const threadCandidates = sqliteTable("thread_candidates", {
status: text("status").notNull().default("researching"), status: text("status").notNull().default("researching"),
pros: text("pros"), pros: text("pros"),
cons: text("cons"), cons: text("cons"),
sortOrder: real("sort_order").notNull().default(0), sortOrder: doublePrecision("sort_order").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull() updatedAt: timestamp("updated_at").notNull().defaultNow(),
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); });
export const setups = sqliteTable("setups", { export const setups = pgTable("setups", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull() updatedAt: timestamp("updated_at").notNull().defaultNow(),
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); });
export const setupItems = sqliteTable("setup_items", { export const setupItems = pgTable("setup_items", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
setupId: integer("setup_id") setupId: integer("setup_id")
.notNull() .notNull()
.references(() => setups.id, { onDelete: "cascade" }), .references(() => setups.id, { onDelete: "cascade" }),
@@ -95,69 +85,59 @@ export const setupItems = sqliteTable("setup_items", {
classification: text("classification").notNull().default("base"), classification: text("classification").notNull().default("base"),
}); });
export const settings = sqliteTable("settings", { export const settings = pgTable("settings", {
key: text("key").primaryKey(), key: text("key").primaryKey(),
value: text("value").notNull(), value: text("value").notNull(),
}); });
export const users = sqliteTable("users", { export const users = pgTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull()
.$defaultFn(() => new Date()),
}); });
export const sessions = sqliteTable("sessions", { export const sessions = pgTable("sessions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), expiresAt: timestamp("expires_at").notNull(),
}); });
export const apiKeys = sqliteTable("api_keys", { export const apiKeys = pgTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
keyHash: text("key_hash").notNull(), keyHash: text("key_hash").notNull(),
keyPrefix: text("key_prefix").notNull(), keyPrefix: text("key_prefix").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull()
.$defaultFn(() => new Date()),
}); });
export const oauthClients = sqliteTable("oauth_clients", { export const oauthClients = pgTable("oauth_clients", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
clientId: text("client_id").notNull().unique(), clientId: text("client_id").notNull().unique(),
clientName: text("client_name"), clientName: text("client_name"),
redirectUris: text("redirect_uris").notNull(), // JSON array redirectUris: text("redirect_uris").notNull(), // JSON array
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: timestamp("created_at").notNull().defaultNow(),
.notNull()
.$defaultFn(() => new Date()),
}); });
export const oauthCodes = sqliteTable("oauth_codes", { export const oauthCodes = pgTable("oauth_codes", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
code: text("code").notNull().unique(), code: text("code").notNull().unique(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
codeChallenge: text("code_challenge").notNull(), codeChallenge: text("code_challenge").notNull(),
codeChallengeMethod: text("code_challenge_method").notNull().default("S256"), codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
redirectUri: text("redirect_uri").notNull(), redirectUri: text("redirect_uri").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), expiresAt: timestamp("expires_at").notNull(),
used: integer("used").notNull().default(0), used: boolean("used").notNull().default(false),
}); });
export const oauthTokens = sqliteTable("oauth_tokens", { export const oauthTokens = pgTable("oauth_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }), id: serial("id").primaryKey(),
accessTokenHash: text("access_token_hash").notNull().unique(), accessTokenHash: text("access_token_hash").notNull().unique(),
refreshTokenHash: text("refresh_token_hash").notNull().unique(), refreshTokenHash: text("refresh_token_hash").notNull().unique(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // access token expiry expiresAt: timestamp("expires_at").notNull(), // access token expiry
refreshExpiresAt: integer("refresh_expires_at", { refreshExpiresAt: timestamp("refresh_expires_at").notNull(), // refresh token expiry
mode: "timestamp", createdAt: timestamp("created_at").notNull().defaultNow(),
}).notNull(), // refresh token expiry
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
}); });

View File

@@ -1,14 +1,12 @@
import { db } from "./index.ts"; import { db } from "./index.ts";
import { categories } from "./schema.ts"; import { categories } from "./schema.ts";
export function seedDefaults() { export async function seedDefaults() {
const existing = db.select().from(categories).all(); const existing = await db.select().from(categories);
if (existing.length === 0) { if (existing.length === 0) {
db.insert(categories) await db.insert(categories).values({
.values({ name: "Uncategorized",
name: "Uncategorized", icon: "package",
icon: "package", });
})
.run();
} }
} }