feat: migrate from SQLite to PostgreSQL - complete schema and API updates

- Remove better-sqlite3, add pg driver
- Convert schema to PostgreSQL types (serial, timestamp, boolean, jsonb)
- Generate fresh PostgreSQL migrations
- Update database connection layer with pg.Pool
- Fix all API routes: remove JSON.parse/stringify, use native timestamps and booleans
- Update drizzle.config.ts and .env.example for PostgreSQL
This commit is contained in:
Marko Djordjevic 2026-02-17 13:43:06 +01:00
parent 4605283d2b
commit 5f70f13da3
37 changed files with 1164 additions and 1825 deletions

View file

@ -1,28 +1,30 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
// Ensure data directory exists
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
// Read DATABASE_URL from environment
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is not set');
}
const dbPath = path.join(dataDir, 'candles.db');
const sqlite = new Database(dbPath);
export const db = drizzle(sqlite, { schema });
// Create PostgreSQL connection pool
const pool = new Pool({
connectionString: DATABASE_URL,
max: 10,
});
// Run migrations at startup (for local dev).
// In Docker, migrations are run by scripts/run-migrations.js before the app starts,
// so this will be a no-op (all migrations already applied).
export const db = drizzle(pool, { schema });
// Run migrations at startup (skip during build phase)
const isBuildTime = process.env.NEXT_PHASE === 'phase-production-build' || process.env.NEXT_PHASE === 'phase-development-build';
if (!isBuildTime) {
try {
migrate(db, { migrationsFolder: path.join(process.cwd(), 'drizzle') });
await migrate(db, { migrationsFolder: path.join(process.cwd(), 'drizzle') });
console.log('✅ Database migrations completed');
} catch (error) {
console.error('❌ Migration failed:', error);

View file

@ -1,67 +1,67 @@
import { sqliteTable, integer, real, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { pgTable, serial, text, timestamp, doublePrecision, integer, boolean, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
export const charts = sqliteTable('charts', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const charts = pgTable('charts', {
id: serial('id').primaryKey(),
name: text('name').notNull().unique(),
created_at: integer('created_at').notNull(),
created_at: timestamp('created_at').notNull().defaultNow(),
});
export const candles = sqliteTable('candles', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const candles = pgTable('candles', {
id: serial('id').primaryKey(),
chart_id: integer('chart_id').notNull().references(() => charts.id),
time: integer('time').notNull(),
open: real('open').notNull(),
high: real('high').notNull(),
low: real('low').notNull(),
close: real('close').notNull(),
time: timestamp('time').notNull(),
open: doublePrecision('open').notNull(),
high: doublePrecision('high').notNull(),
low: doublePrecision('low').notNull(),
close: doublePrecision('close').notNull(),
}, (table) => [
uniqueIndex('candles_chart_time_unique').on(table.chart_id, table.time),
]);
export const annotationTypes = sqliteTable('annotation_types', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const annotationTypes = pgTable('annotation_types', {
id: serial('id').primaryKey(),
name: text('name').notNull().unique(), // internal name (e.g., 'break_up')
display_name: text('display_name').notNull(), // display name (e.g., 'Break Up')
color: text('color').notNull(), // hex color code
category: text('category').notNull(), // 'marker' or 'line'
icon: text('icon'), // icon name or symbol
is_active: integer('is_active').notNull().default(1), // 1 = active, 0 = inactive
created_at: integer('created_at').notNull(),
is_active: boolean('is_active').notNull().default(true), // true = active, false = inactive
created_at: timestamp('created_at').notNull().defaultNow(),
});
export const annotations = sqliteTable('annotations', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const annotations = pgTable('annotations', {
id: serial('id').primaryKey(),
chart_id: integer('chart_id').notNull().references(() => charts.id),
timestamp: integer('timestamp').notNull(),
timestamp: timestamp('timestamp').notNull(),
label_type: text('label_type').notNull(),
geometry: text('geometry'), // JSON string for line coordinates
geometry: jsonb('geometry'), // JSON for line coordinates
color: text('color').default('#3b82f6'), // hex color code
created_at: integer('created_at').notNull(),
created_at: timestamp('created_at').notNull().defaultNow(),
});
export const spanLabelTypes = sqliteTable('span_label_types', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const spanLabelTypes = pgTable('span_label_types', {
id: serial('id').primaryKey(),
name: text('name').notNull().unique(), // internal name (e.g., 'bull_flag')
display_name: text('display_name').notNull(), // UI label (e.g., 'Bull Flag')
color: text('color').notNull(), // hex color for rectangle fill
hotkey: text('hotkey'), // keyboard shortcut (e.g., '1')
is_active: integer('is_active').notNull().default(1), // 1 = active, 0 = inactive
is_active: boolean('is_active').notNull().default(true), // true = active, false = inactive
sort_order: integer('sort_order').notNull().default(0), // display order
created_at: integer('created_at').notNull(),
created_at: timestamp('created_at').notNull().defaultNow(),
});
export const spanAnnotations = sqliteTable('span_annotations', {
id: integer('id').primaryKey({ autoIncrement: true }),
export const spanAnnotations = pgTable('span_annotations', {
id: serial('id').primaryKey(),
chart_id: integer('chart_id').notNull().references(() => charts.id),
start_time: integer('start_time').notNull(), // Unix timestamp of first candle
end_time: integer('end_time').notNull(), // Unix timestamp of last candle
start_time: timestamp('start_time').notNull(), // timestamp of first candle
end_time: timestamp('end_time').notNull(), // timestamp of last candle
label: text('label').notNull(), // pattern name referencing span_label_types.name
confidence: integer('confidence'), // 1-5 scale, nullable
outcome: text('outcome'), // 'win'|'loss'|'breakeven'|null
notes: text('notes'), // free-text, nullable
sub_spans: text('sub_spans'), // JSON array of sub-span objects, nullable
sub_spans: jsonb('sub_spans'), // JSON array of sub-span objects, nullable
color: text('color').notNull().default('#2196F3'), // hex color
source: text('source').notNull().default('human'), // 'human'|'model'|'human_correction'
model_prediction: text('model_prediction'), // JSON metadata when confirming/correcting predictions
created_at: integer('created_at').notNull(),
model_prediction: jsonb('model_prediction'), // JSON metadata when confirming/correcting predictions
created_at: timestamp('created_at').notNull().defaultNow(),
});