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

7
drizzle.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/main/db/schema.ts',
out: './src/main/db/migrations',
dialect: 'sqlite',
});

View File

@@ -3,6 +3,7 @@ import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip'; import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb'; import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm'; import { MakerRpm } from '@electron-forge/maker-rpm';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
import { VitePlugin } from '@electron-forge/plugin-vite'; import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses'; import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses';
@@ -19,6 +20,7 @@ const config: ForgeConfig = {
new MakerDeb({}), new MakerDeb({}),
], ],
plugins: [ plugins: [
new AutoUnpackNativesPlugin({}),
new VitePlugin({ new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar. // If you are familiar with Vite configuration, it will look really familiar.

1344
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@
"@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/plugin-vite": "^7.11.1",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@tanstack/router-vite-plugin": "^1.161.1", "@tanstack/router-vite-plugin": "^1.161.1",
"@types/better-sqlite3": "^7.6.13",
"@types/electron-squirrel-startup": "^1.0.2", "@types/electron-squirrel-startup": "^1.0.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -33,6 +34,7 @@
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"drizzle-kit": "^0.31.9",
"electron": "40.6.0", "electron": "40.6.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -43,8 +45,10 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.161.1", "@tanstack/react-router": "^1.161.1",
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",

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

View File

@@ -4,6 +4,8 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
// Externalize native Node modules — they're rebuilt by electron-forge
external: ['better-sqlite3'],
output: { output: {
entryFileNames: 'main.js', entryFileNames: 'main.js',
}, },