feat: US-002 — SQLite + Drizzle ORM schema and migrations

- Install better-sqlite3 + drizzle-orm as runtime deps; drizzle-kit + @types/better-sqlite3 as devDeps
- Define 5 tables in src/main/db/schema.ts: clients, projects, tasks, checkpoints, notes
- All IDs are TEXT (UUID); types inferred via InferSelectModel/InferInsertModel
- initDb() in src/main/db/index.ts opens adiuva.db at app.getPath('userData'), runs CREATE TABLE IF NOT EXISTS (non-destructive push), enables WAL mode
- Call initDb() in main process app ready handler
- Externalize better-sqlite3 in vite.main.config.mts; add AutoUnpackNativesPlugin to forge.config.ts
- Add drizzle.config.ts for drizzle-kit CLI support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-19 16:37:24 +01:00
parent bc778f9a8f
commit 8a869f90ad
8 changed files with 1488 additions and 26 deletions

83
src/main/db/index.ts Normal file
View File

@@ -0,0 +1,83 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { app } from 'electron';
import path from 'node:path';
import * as schema from './schema';
// SQL to create all tables if they don't exist (non-destructive push strategy)
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
parent_id TEXT,
name TEXT NOT NULL,
industry TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
client_id TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
ai_summary TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo',
priority TEXT NOT NULL DEFAULT 'medium',
assignee TEXT,
due_date INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS checkpoints (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT NOT NULL,
date INTEGER NOT NULL,
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
is_approved INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`;
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
let dbInstance: DbInstance | null = null;
export function initDb(): DbInstance {
const userDataPath = app.getPath('userData');
const dbPath = path.join(userDataPath, 'adiuva.db');
const sqlite = new Database(dbPath);
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
// Run non-destructive migrations on every start
sqlite.exec(MIGRATION_SQL);
dbInstance = drizzle(sqlite, { schema });
return dbInstance;
}
export function getDb(): DbInstance {
if (!dbInstance) {
throw new Error('Database not initialized. Call initDb() first.');
}
return dbInstance;
}

66
src/main/db/schema.ts Normal file
View File

@@ -0,0 +1,66 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
export const clients = sqliteTable('clients', {
id: text('id').primaryKey(),
parentId: text('parent_id'),
name: text('name').notNull(),
industry: text('industry'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
clientId: text('client_id'),
name: text('name').notNull(),
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
aiSummary: text('ai_summary'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export const tasks = sqliteTable('tasks', {
id: text('id').primaryKey(),
projectId: text('project_id'),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('todo'),
priority: text('priority').notNull().default('medium'),
assignee: text('assignee'),
dueDate: integer('due_date', { mode: 'number' }),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export const checkpoints = sqliteTable('checkpoints', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
title: text('title').notNull(),
date: integer('date', { mode: 'number' }).notNull(),
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(0),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
});
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
projectId: text('project_id'),
title: text('title').notNull(),
content: text('content').notNull().default(''),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
});
// Inferred TypeScript types — no manual duplication
export type Client = InferSelectModel<typeof clients>;
export type NewClient = InferInsertModel<typeof clients>;
export type Project = InferSelectModel<typeof projects>;
export type NewProject = InferInsertModel<typeof projects>;
export type Task = InferSelectModel<typeof tasks>;
export type NewTask = InferInsertModel<typeof tasks>;
export type Checkpoint = InferSelectModel<typeof checkpoints>;
export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
export type Note = InferSelectModel<typeof notes>;
export type NewNote = InferInsertModel<typeof notes>;

View File

@@ -1,6 +1,7 @@
import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import started from 'electron-squirrel-startup';
import { initDb } from './db';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -40,7 +41,10 @@ const createWindow = () => {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);
app.on('ready', () => {
initDb();
createWindow();
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits