diff --git a/.gitignore b/.gitignore index 34c7517..e5fd2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,10 +29,6 @@ build/ *.sqlite *.sqlite3 -# Drizzle ORM -drizzle/ -migrations/ - # =================== # Environment & Secrets # =================== diff --git a/drizzle.config.ts b/drizzle.config.ts index eaf1504..7aeb36f 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,7 @@ -import type { Config } from 'drizzle-kit'; +import { defineConfig } from 'drizzle-kit'; -export default { +export default defineConfig({ schema: './src/main/database/schema.ts', out: './drizzle', - driver: 'libsql', - dbCredentials: { - url: 'file:./data/bds.db', - }, -} satisfies Config; + dialect: 'sqlite', +}); diff --git a/drizzle/0000_initial.sql b/drizzle/0000_initial.sql new file mode 100644 index 0000000..58f240b --- /dev/null +++ b/drizzle/0000_initial.sql @@ -0,0 +1,134 @@ +CREATE TABLE `chat_conversations` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `model` text, + `copilot_session_id` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `chat_messages` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `conversation_id` text NOT NULL, + `role` text NOT NULL, + `content` text, + `tool_call_id` text, + `tool_calls` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `import_definitions` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `wxr_file_path` text, + `uploads_folder_path` text, + `last_analysis_result` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `media` ( + `project_id` text NOT NULL, + `id` text PRIMARY KEY NOT NULL, + `filename` text NOT NULL, + `original_name` text NOT NULL, + `mime_type` text NOT NULL, + `size` integer NOT NULL, + `width` integer, + `height` integer, + `alt` text, + `caption` text, + `file_path` text NOT NULL, + `sidecar_path` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `sync_status` text DEFAULT 'pending' NOT NULL, + `synced_at` integer, + `checksum` text, + `tags` text +); +--> statement-breakpoint +CREATE TABLE `post_links` ( + `id` text PRIMARY KEY NOT NULL, + `source_post_id` text NOT NULL, + `target_post_id` text NOT NULL, + `link_text` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `post_media` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `post_id` text NOT NULL, + `media_id` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `post_media_post_media_idx` ON `post_media` (`post_id`,`media_id`);--> statement-breakpoint +CREATE TABLE `posts` ( + `project_id` text NOT NULL, + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `slug` text NOT NULL, + `excerpt` text, + `content` text, + `status` text DEFAULT 'draft' NOT NULL, + `author` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `published_at` integer, + `file_path` text DEFAULT '' NOT NULL, + `sync_status` text DEFAULT 'pending' NOT NULL, + `synced_at` integer, + `checksum` text, + `tags` text, + `categories` text, + `published_title` text, + `published_content` text, + `published_tags` text, + `published_categories` text, + `published_excerpt` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `posts_project_slug_idx` ON `posts` (`project_id`,`slug`);--> statement-breakpoint +CREATE TABLE `projects` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `description` text, + `data_path` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `is_active` integer DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `projects_slug_unique` ON `projects` (`slug`);--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sync_log` ( + `id` text PRIMARY KEY NOT NULL, + `entity_type` text NOT NULL, + `entity_id` text NOT NULL, + `operation` text NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `timestamp` integer NOT NULL, + `error_message` text, + `retry_count` integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE `tags` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `color` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tags_project_name_idx` ON `tags` (`project_id`,`name`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..f23f2f6 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,850 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "af3c1207-a667-495d-833d-26f7d3451829", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "chat_conversations": { + "name": "chat_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_session_id": { + "name": "copilot_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_definitions": { + "name": "import_definitions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wxr_file_path": { + "name": "wxr_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploads_folder_path": { + "name": "uploads_folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_analysis_result": { + "name": "last_analysis_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sidecar_path": { + "name": "sidecar_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_links": { + "name": "post_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_post_id": { + "name": "source_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link_text": { + "name": "link_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_media": { + "name": "post_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_media_post_media_idx": { + "name": "post_media_post_media_idx", + "columns": [ + "post_id", + "media_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_title": { + "name": "published_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_content": { + "name": "published_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_tags": { + "name": "published_tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_categories": { + "name": "published_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_excerpt": { + "name": "published_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "posts_project_slug_idx": { + "name": "posts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_path": { + "name": "data_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_log": { + "name": "sync_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_project_name_idx": { + "name": "tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..63f0f84 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1771081481654, + "tag": "0000_initial", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 873d063..6a408f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "@vitest/ui": "^4.0.18", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "drizzle-kit": "^1.0.0-beta.9-e89174b", + "drizzle-kit": "^0.31.9", "electron": "^40.4.0", "electron-builder": "^26.7.0", "electron-store": "^11.0.2", @@ -1001,9 +1001,9 @@ } }, "node_modules/@drizzle-team/brocli": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.11.0.tgz", - "integrity": "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "dev": true, "license": "Apache-2.0" }, @@ -1444,6 +1444,442 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2636,19 +3072,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@js-temporal/polyfill": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", - "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "jsbi": "^4.3.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -6834,16 +7257,16 @@ } }, "node_modules/drizzle-kit": { - "version": "1.0.0-beta.9-e89174b", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-1.0.0-beta.9-e89174b.tgz", - "integrity": "sha512-Xrw3k8E2CbSZr+kqe3k5W4oxd2fbEyczjKtyGIkAq0x9Wqpa/VtAT6Mkh83sIzqG4OSN7lOoUafsDxSE/AR7RA==", + "version": "0.31.9", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", + "integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", "dev": true, "license": "MIT", "dependencies": { - "@drizzle-team/brocli": "^0.11.0", - "@js-temporal/polyfill": "^0.5.1", - "esbuild": "^0.25.10", - "tsx": "^4.20.6" + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" @@ -7403,6 +7826,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7438,6 +7862,19 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -8603,13 +9040,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", - "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/jsdom": { "version": "28.0.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", diff --git a/package.json b/package.json index 5b055cc..e7ed096 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "start": "concurrently --kill-others \"npm run dev:renderer\" \"npm run start:electron\"", "start:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", - "build": "npm run build:main && npm run build:renderer", + "build": "npm run db:generate && npm run build:main && npm run build:renderer", "build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json", "build:renderer": "node ./node_modules/vite/bin/vite.js build", "start:prod": "node ./node_modules/electron/cli.js .", @@ -40,7 +40,7 @@ "@vitest/ui": "^4.0.18", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "drizzle-kit": "^1.0.0-beta.9-e89174b", + "drizzle-kit": "^0.31.9", "electron": "^40.4.0", "electron-builder": "^26.7.0", "electron-store": "^11.0.2", @@ -92,8 +92,15 @@ }, "files": [ "dist/**/*", + "drizzle/**/*", "node_modules/**/*" ], + "extraResources": [ + { + "from": "drizzle", + "to": "drizzle" + } + ], "win": { "target": "nsis" }, diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index f659fd5..b7217dc 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -1,6 +1,9 @@ import { createClient, Client } from '@libsql/client'; import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import { eq, sql } from 'drizzle-orm'; import * as schema from './schema'; +import { projects } from './schema'; import { app } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; @@ -77,421 +80,78 @@ export class DatabaseConnection { } async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> { - if (!this.localClient) return null; - const result = await this.localClient.execute('SELECT id, name, slug FROM projects WHERE is_active = 1 LIMIT 1'); - if (result.rows.length === 0) return null; - const row = result.rows[0]; - return { - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - }; + if (!this.localDb) return null; + const rows = await this.localDb + .select({ id: projects.id, name: projects.name, slug: projects.slug }) + .from(projects) + .where(eq(projects.isActive, true)) + .limit(1); + if (rows.length === 0) return null; + return rows[0]; } async setActiveProject(projectId: string): Promise { - if (!this.localClient) return; - await this.localClient.execute('UPDATE projects SET is_active = 0'); - await this.localClient.execute({ - sql: 'UPDATE projects SET is_active = 1 WHERE id = ?', - args: [projectId], - }); + if (!this.localDb) return; + // Deactivate all projects + await this.localDb + .update(projects) + .set({ isActive: false }); + // Activate the selected project + await this.localDb + .update(projects) + .set({ isActive: true }) + .where(eq(projects.id, projectId)); } private async runMigrations(): Promise { - if (!this.localClient) return; + if (!this.localClient || !this.localDb) return; - // Create tables if they don't exist using batch execution - await this.localClient.executeMultiple(` - CREATE TABLE IF NOT EXISTS projects ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT NOT NULL UNIQUE, - description TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - is_active INTEGER NOT NULL DEFAULT 0 - ); + // Determine migrations folder path (works in both dev and production) + // In production, migrations are bundled in the app resources + const isDev = !app.isPackaged; + const migrationsFolder = isDev + ? path.join(app.getAppPath(), 'drizzle') + : path.join(process.resourcesPath, 'drizzle'); - CREATE TABLE IF NOT EXISTS posts ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL DEFAULT 'default', - title TEXT NOT NULL, - slug TEXT NOT NULL, - excerpt TEXT, - status TEXT NOT NULL DEFAULT 'draft', - author TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - published_at INTEGER, - file_path TEXT NOT NULL, - sync_status TEXT NOT NULL DEFAULT 'pending', - synced_at INTEGER, - checksum TEXT, - tags TEXT, - categories TEXT, - published_title TEXT, - published_content TEXT, - published_tags TEXT, - published_categories TEXT, - published_excerpt TEXT - ); + // Run Drizzle migrations (creates __drizzle_migrations table automatically) + await migrate(this.localDb, { migrationsFolder }); - CREATE TABLE IF NOT EXISTS media ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL DEFAULT 'default', - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - mime_type TEXT NOT NULL, - size INTEGER NOT NULL, - width INTEGER, - height INTEGER, - alt TEXT, - caption TEXT, - file_path TEXT NOT NULL, - sidecar_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - sync_status TEXT NOT NULL DEFAULT 'pending', - synced_at INTEGER, - checksum TEXT, - tags TEXT - ); - - CREATE TABLE IF NOT EXISTS sync_log ( - id TEXT PRIMARY KEY, - entity_type TEXT NOT NULL, - entity_id TEXT NOT NULL, - operation TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - timestamp INTEGER NOT NULL, - error_message TEXT, - retry_count INTEGER NOT NULL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS post_links ( - id TEXT PRIMARY KEY, - source_post_id TEXT NOT NULL, - target_post_id TEXT NOT NULL, - link_text TEXT, - created_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS post_media ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - post_id TEXT NOT NULL, - media_id TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); - CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); - CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status); - CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); - CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status); - CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status); - CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id); - CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id); - CREATE INDEX IF NOT EXISTS idx_post_media_post ON post_media(post_id); - CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id); - CREATE UNIQUE INDEX IF NOT EXISTS post_media_post_media_idx ON post_media(post_id, media_id); - CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug); - - CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_tags_project_id ON tags(project_id); - CREATE UNIQUE INDEX IF NOT EXISTS tags_project_name_idx ON tags(project_id, name); - `); - - // Check if project_id column exists in posts table, add if missing (migration) - const postsColumns = await this.localClient.execute( - "SELECT name FROM pragma_table_info('posts') WHERE name = 'project_id'" - ); - if (postsColumns.rows.length === 0) { - await this.localClient.execute( - "ALTER TABLE posts ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'" - ); - await this.localClient.execute( - "CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)" - ); - } else { - await this.localClient.execute( - "CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)" - ); - } - - // Check if project_id column exists in media table, add if missing (migration) - const mediaColumns = await this.localClient.execute( - "SELECT name FROM pragma_table_info('media') WHERE name = 'project_id'" - ); - if (mediaColumns.rows.length === 0) { - await this.localClient.execute( - "ALTER TABLE media ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'" - ); - await this.localClient.execute( - "CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)" - ); - } else { - await this.localClient.execute( - "CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)" - ); - } - - // Migration: Add published snapshot columns for discard functionality - const publishedContentCol = await this.localClient.execute( - "SELECT name FROM pragma_table_info('posts') WHERE name = 'published_content'" - ); - if (publishedContentCol.rows.length === 0) { - await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_title TEXT"); - await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_content TEXT"); - await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_tags TEXT"); - await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_categories TEXT"); - await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT"); - } - - // Migration: Add content column for draft body text stored in DB - const contentCol = await this.localClient.execute( - "SELECT name FROM pragma_table_info('posts') WHERE name = 'content'" - ); - if (contentCol.rows.length === 0) { - await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT"); - } - - // Migration: Update slug unique constraint to be project-scoped - // SQLite doesn't allow dropping column-level UNIQUE constraints, so we must recreate the table - // Check if the posts table has a column-level UNIQUE on slug (from the table definition) - const tableInfo = await this.localClient.execute( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='posts'" - ); - const tableSql = tableInfo.rows[0]?.sql as string || ''; - const hasColumnLevelUnique = tableSql.includes('slug TEXT NOT NULL UNIQUE') || - tableSql.includes('slug TEXT UNIQUE') || - /slug\s+TEXT[^,]*UNIQUE/i.test(tableSql); - - if (hasColumnLevelUnique) { - console.log('Migrating posts table to remove column-level UNIQUE constraint on slug...'); - - // Create new table without the UNIQUE constraint - await this.localClient.execute(` - CREATE TABLE IF NOT EXISTS posts_new ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL DEFAULT 'default', - title TEXT NOT NULL, - slug TEXT NOT NULL, - excerpt TEXT, - content TEXT, - status TEXT NOT NULL DEFAULT 'draft', - author TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - published_at INTEGER, - file_path TEXT NOT NULL DEFAULT '', - sync_status TEXT NOT NULL DEFAULT 'pending', - synced_at INTEGER, - checksum TEXT, - tags TEXT, - categories TEXT, - published_title TEXT, - published_content TEXT, - published_tags TEXT, - published_categories TEXT, - published_excerpt TEXT - ) - `); - - // Copy data - await this.localClient.execute(` - INSERT INTO posts_new - SELECT id, project_id, title, slug, excerpt, content, status, author, - created_at, updated_at, published_at, file_path, sync_status, - synced_at, checksum, tags, categories, published_title, - published_content, published_tags, published_categories, published_excerpt - FROM posts - `); - - // Drop old table and rename new one - await this.localClient.execute('DROP TABLE posts'); - await this.localClient.execute('ALTER TABLE posts_new RENAME TO posts'); - - // Recreate indexes - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)'); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status)'); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status)'); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at)'); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)'); - await this.localClient.execute('CREATE UNIQUE INDEX IF NOT EXISTS posts_project_slug_idx ON posts(project_id, slug)'); - - console.log('Posts table migration complete'); - } else { - // Just ensure the composite unique index exists - const compositeSlugIndex = await this.localClient.execute( - "SELECT name FROM sqlite_master WHERE type='index' AND name='posts_project_slug_idx' AND tbl_name='posts'" - ); - if (compositeSlugIndex.rows.length === 0) { - await this.localClient.execute( - "CREATE UNIQUE INDEX posts_project_slug_idx ON posts(project_id, slug)" - ); - } - } - - // Create FTS5 virtual table for full-text search - // Stores: id (unindexed, for lookups), project_id (unindexed, for filtering), content (stemmed text for matching) - // Post data for display comes from the posts table or filesystem files + // Create FTS5 virtual tables (not supported by Drizzle schema) + // These use IF NOT EXISTS so they're safe to run every time await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( id UNINDEXED, project_id UNINDEXED, content, content_rowid=rowid - ); + ) `); - // Migration: Check if old FTS schema exists and recreate with project_id - // Old schema had: id, content (or even older: id, title, content, excerpt, tags, categories) - // New schema has: id, project_id, content (for project-scoped search) - try { - // Try to query project_id - if it doesn't exist, we need to migrate - await this.localClient.execute("SELECT project_id FROM posts_fts LIMIT 0"); - // project_id exists, check for old multi-column schema - try { - await this.localClient.execute("SELECT title FROM posts_fts LIMIT 0"); - // Old multi-column schema exists - recreate - console.log('Migrating posts_fts table to new schema with project_id...'); - await this.localClient.execute('DROP TABLE IF EXISTS posts_fts'); - await this.localClient.execute(` - CREATE VIRTUAL TABLE posts_fts USING fts5( - id UNINDEXED, - project_id UNINDEXED, - content, - content_rowid=rowid - ); - `); - console.log('FTS table migrated - rebuild index required'); - } catch { - // No title column - we have the correct new schema - } - } catch { - // project_id doesn't exist - migrate from old schema - console.log('Migrating posts_fts table to add project_id...'); - await this.localClient.execute('DROP TABLE IF EXISTS posts_fts'); - await this.localClient.execute(` - CREATE VIRTUAL TABLE posts_fts USING fts5( - id UNINDEXED, - project_id UNINDEXED, - content, - content_rowid=rowid - ); - `); - console.log('FTS table migrated - rebuild index required'); - } - - // Create FTS5 virtual table for media full-text search - // Stores: id (unindexed, for lookups), project_id (unindexed, for filtering), - // content (stemmed text from original_name, alt, caption, tags) await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( id UNINDEXED, project_id UNINDEXED, content, content_rowid=rowid - ); + ) `); - // Migration: Ensure tags table exists (for databases created before tags feature) - const tagsTableExists = await this.localClient.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='tags'" - ); - if (tagsTableExists.rows.length === 0) { - console.log('Creating tags table...'); - await this.localClient.execute(` - CREATE TABLE tags ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - name TEXT NOT NULL, - color TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - await this.localClient.execute('CREATE INDEX idx_tags_project_id ON tags(project_id)'); - await this.localClient.execute('CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name)'); - console.log('Tags table created successfully'); - } - - // Migration: Add data_path column to projects table - const dataPathCol = await this.localClient.execute( - "SELECT name FROM pragma_table_info('projects') WHERE name = 'data_path'" - ); - if (dataPathCol.rows.length === 0) { - await this.localClient.execute("ALTER TABLE projects ADD COLUMN data_path TEXT"); - } - // Create default project if none exists - const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects'); - if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) { - const now = Date.now(); - await this.localClient.execute({ - sql: 'INSERT INTO projects (id, name, slug, description, created_at, updated_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)', - args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1], + const existingProjects = await this.localDb + .select({ count: sql`COUNT(*)` }) + .from(projects); + if (existingProjects[0] && existingProjects[0].count === 0) { + const now = new Date(); + await this.localDb.insert(projects).values({ + id: 'default', + name: 'Default Project', + slug: 'default', + description: 'Your first blog project', + createdAt: now, + updatedAt: now, + isActive: true, }); } - - // Create chat_conversations table for AI chat persistence - await this.localClient.execute(` - CREATE TABLE IF NOT EXISTS chat_conversations ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - model TEXT, - copilot_session_id TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_conversations_updated_at ON chat_conversations(updated_at)'); - - // Create chat_messages table for storing conversation messages - await this.localClient.execute(` - CREATE TABLE IF NOT EXISTS chat_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - conversation_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT, - tool_call_id TEXT, - tool_calls TEXT, - created_at INTEGER NOT NULL, - FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE - ) - `); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_conversation_id ON chat_messages(conversation_id)'); - - // Create import_definitions table for WXR import configurations - await this.localClient.execute(` - CREATE TABLE IF NOT EXISTS import_definitions ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - name TEXT NOT NULL, - wxr_file_path TEXT, - uploads_folder_path TEXT, - last_analysis_result TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ) - `); - await this.localClient.execute('CREATE INDEX IF NOT EXISTS idx_import_definitions_project_id ON import_definitions(project_id)'); } async close(): Promise { diff --git a/src/main/engine/ChatEngine.ts b/src/main/engine/ChatEngine.ts index cca0b47..9138e12 100644 --- a/src/main/engine/ChatEngine.ts +++ b/src/main/engine/ChatEngine.ts @@ -8,7 +8,9 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { eq, desc, asc } from 'drizzle-orm'; import { DatabaseConnection } from '../database/connection'; +import { chatConversations, chatMessages, settings } from '../database/schema'; export interface ChatConversationData { id: string; @@ -45,19 +47,18 @@ export class ChatEngine { * Create a new chat conversation */ async createConversation(input: CreateConversationInput = {}): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } - + const drizzle = this.db.getLocal(); const id = `chat_${uuidv4()}`; const title = input.title || 'New Chat'; const model = input.model || 'claude-sonnet-4'; - const now = Date.now(); + const now = new Date(); - await client.execute({ - sql: `INSERT INTO chat_conversations (id, title, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, - args: [id, title, model, now, now], + await drizzle.insert(chatConversations).values({ + id, + title, + model, + createdAt: now, + updatedAt: now, }); // Add system prompt as first message if provided @@ -66,7 +67,7 @@ export class ChatEngine { conversationId: id, role: 'system', content: input.systemPrompt, - createdAt: new Date(now), + createdAt: now, }); } @@ -74,8 +75,8 @@ export class ChatEngine { id, title, model, - createdAt: new Date(now), - updatedAt: new Date(now), + createdAt: now, + updatedAt: now, }; } @@ -83,42 +84,40 @@ export class ChatEngine { * Get a conversation by ID with all messages */ async getConversation(id: string): Promise<(ChatConversationData & { messages: ChatMessageData[] }) | null> { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const convResult = await client.execute({ - sql: `SELECT * FROM chat_conversations WHERE id = ?`, - args: [id], - }); + const rows = await drizzle + .select() + .from(chatConversations) + .where(eq(chatConversations.id, id)); - if (convResult.rows.length === 0) { + if (rows.length === 0) { return null; } - const row = convResult.rows[0]; + const row = rows[0]; const conversation: ChatConversationData = { - id: row.id as string, - title: row.title as string, - model: row.model as string | undefined, - createdAt: new Date(row.created_at as number), - updatedAt: new Date(row.updated_at as number), + id: row.id, + title: row.title, + model: row.model || undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, }; - const messagesResult = await client.execute({ - sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`, - args: [id], - }); + const messageRows = await drizzle + .select() + .from(chatMessages) + .where(eq(chatMessages.conversationId, id)) + .orderBy(asc(chatMessages.createdAt)); - const messages: ChatMessageData[] = messagesResult.rows.map(r => ({ - id: r.id as number, - conversationId: r.conversation_id as string, - role: r.role as 'system' | 'user' | 'assistant' | 'tool', - content: r.content as string | undefined, - toolCallId: r.tool_call_id as string | undefined, - toolCalls: r.tool_calls as string | undefined, - createdAt: new Date(r.created_at as number), + const messages: ChatMessageData[] = messageRows.map(r => ({ + id: r.id, + conversationId: r.conversationId, + role: r.role, + content: r.content || undefined, + toolCallId: r.toolCallId || undefined, + toolCalls: r.toolCalls || undefined, + createdAt: r.createdAt, })); return { ...conversation, messages }; @@ -128,22 +127,20 @@ export class ChatEngine { * Get all conversations, sorted by most recently updated */ async getRecentConversations(limit: number = 50): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const result = await client.execute({ - sql: `SELECT * FROM chat_conversations ORDER BY updated_at DESC LIMIT ?`, - args: [limit], - }); + const rows = await drizzle + .select() + .from(chatConversations) + .orderBy(desc(chatConversations.updatedAt)) + .limit(limit); - return result.rows.map(row => ({ - id: row.id as string, - title: row.title as string, - model: row.model as string | undefined, - createdAt: new Date(row.created_at as number), - updatedAt: new Date(row.updated_at as number), + return rows.map(row => ({ + id: row.id, + title: row.title, + model: row.model || undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, })); } @@ -151,90 +148,66 @@ export class ChatEngine { * Update a conversation's metadata */ async updateConversation(id: string, updates: Partial>): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const setClauses: string[] = ['updated_at = ?']; - const args: (string | number | null)[] = [Date.now()]; - - if (updates.title !== undefined) { - setClauses.push('title = ?'); - args.push(updates.title); - } - if (updates.model !== undefined) { - setClauses.push('model = ?'); - args.push(updates.model); - } - - args.push(id); - - await client.execute({ - sql: `UPDATE chat_conversations SET ${setClauses.join(', ')} WHERE id = ?`, - args, - }); + await drizzle + .update(chatConversations) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(chatConversations.id, id)); } /** * Delete a conversation and all its messages */ async deleteConversation(id: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); // Messages are deleted via CASCADE, but let's be explicit - await client.execute({ - sql: `DELETE FROM chat_messages WHERE conversation_id = ?`, - args: [id], - }); + await drizzle + .delete(chatMessages) + .where(eq(chatMessages.conversationId, id)); - await client.execute({ - sql: `DELETE FROM chat_conversations WHERE id = ?`, - args: [id], - }); + await drizzle + .delete(chatConversations) + .where(eq(chatConversations.id, id)); } /** * Add a message to a conversation */ async addMessage(message: Omit): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); + const createdAt = message.createdAt || new Date(); - const createdAt = message.createdAt?.getTime() || Date.now(); - - const result = await client.execute({ - sql: `INSERT INTO chat_messages (conversation_id, role, content, tool_call_id, tool_calls, created_at) - VALUES (?, ?, ?, ?, ?, ?)`, - args: [ - message.conversationId, - message.role, - message.content || null, - message.toolCallId || null, - message.toolCalls || null, + const result = await drizzle + .insert(chatMessages) + .values({ + conversationId: message.conversationId, + role: message.role, + content: message.content || null, + toolCallId: message.toolCallId || null, + toolCalls: message.toolCalls || null, createdAt, - ], - }); + }) + .returning({ id: chatMessages.id }); // Update conversation's updated_at timestamp - await client.execute({ - sql: `UPDATE chat_conversations SET updated_at = ? WHERE id = ?`, - args: [createdAt, message.conversationId], - }); + await drizzle + .update(chatConversations) + .set({ updatedAt: createdAt }) + .where(eq(chatConversations.id, message.conversationId)); return { - id: Number(result.lastInsertRowid), + id: result[0].id, conversationId: message.conversationId, role: message.role, content: message.content, toolCallId: message.toolCallId, toolCalls: message.toolCalls, - createdAt: new Date(createdAt), + createdAt, }; } @@ -242,24 +215,22 @@ export class ChatEngine { * Get messages for a conversation */ async getMessages(conversationId: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const result = await client.execute({ - sql: `SELECT * FROM chat_messages WHERE conversation_id = ? ORDER BY created_at ASC`, - args: [conversationId], - }); + const rows = await drizzle + .select() + .from(chatMessages) + .where(eq(chatMessages.conversationId, conversationId)) + .orderBy(asc(chatMessages.createdAt)); - return result.rows.map(r => ({ - id: r.id as number, - conversationId: r.conversation_id as string, - role: r.role as 'system' | 'user' | 'assistant' | 'tool', - content: r.content as string | undefined, - toolCallId: r.tool_call_id as string | undefined, - toolCalls: r.tool_calls as string | undefined, - createdAt: new Date(r.created_at as number), + return rows.map(r => ({ + id: r.id, + conversationId: r.conversationId, + role: r.role, + content: r.content || undefined, + toolCallId: r.toolCallId || undefined, + toolCalls: r.toolCalls || undefined, + createdAt: r.createdAt, })); } @@ -267,34 +238,27 @@ export class ChatEngine { * Clear all messages from a conversation (but keep the conversation) */ async clearMessages(conversationId: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - await client.execute({ - sql: `DELETE FROM chat_messages WHERE conversation_id = ?`, - args: [conversationId], - }); + await drizzle + .delete(chatMessages) + .where(eq(chatMessages.conversationId, conversationId)); } /** * Get default system prompt for new conversations */ async getDefaultSystemPrompt(): Promise { - const client = this.db.getLocalClient(); - if (!client) { - return this.getBuiltInSystemPrompt(); - } + const drizzle = this.db.getLocal(); - const result = await client.execute({ - sql: `SELECT value FROM settings WHERE key = 'chat_system_prompt'`, - args: [], - }); + const rows = await drizzle + .select() + .from(settings) + .where(eq(settings.key, 'chat_system_prompt')); // Return saved prompt if it exists and is non-empty - if (result.rows.length > 0 && result.rows[0].value) { - return result.rows[0].value as string; + if (rows.length > 0 && rows[0].value) { + return rows[0].value; } return this.getBuiltInSystemPrompt(); @@ -305,25 +269,30 @@ export class ChatEngine { * Pass empty string to reset to built-in default. */ async setDefaultSystemPrompt(prompt: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); // If empty string, delete the setting to use built-in default if (!prompt || prompt.trim() === '') { - await client.execute({ - sql: `DELETE FROM settings WHERE key = ?`, - args: ['chat_system_prompt'], - }); + await drizzle + .delete(settings) + .where(eq(settings.key, 'chat_system_prompt')); return; } - const now = Date.now(); - await client.execute({ - sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`, - args: ['chat_system_prompt', prompt, now], - }); + await drizzle + .insert(settings) + .values({ + key: 'chat_system_prompt', + value: prompt, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: settings.key, + set: { + value: prompt, + updatedAt: new Date(), + }, + }); } /** @@ -360,16 +329,15 @@ When answering questions: * Get a setting by key */ async getSetting(key: string): Promise { - const client = this.db.getLocalClient(); - if (!client) return null; + const drizzle = this.db.getLocal(); - const result = await client.execute({ - sql: `SELECT value FROM settings WHERE key = ?`, - args: [key], - }); + const rows = await drizzle + .select() + .from(settings) + .where(eq(settings.key, key)); - if (result.rows.length > 0) { - return result.rows[0].value as string; + if (rows.length > 0) { + return rows[0].value; } return null; } @@ -378,34 +346,37 @@ When answering questions: * Set a setting by key */ async setSetting(key: string, value: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const now = Date.now(); - await client.execute({ - sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`, - args: [key, value, now], - }); + await drizzle + .insert(settings) + .values({ + key, + value, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: settings.key, + set: { + value, + updatedAt: new Date(), + }, + }); } /** * Get selected model for new conversations */ async getSelectedModel(): Promise { - const client = this.db.getLocalClient(); - if (!client) { - return 'claude-sonnet-4'; - } + const drizzle = this.db.getLocal(); - const result = await client.execute({ - sql: `SELECT value FROM settings WHERE key = 'chat_model'`, - args: [], - }); + const rows = await drizzle + .select() + .from(settings) + .where(eq(settings.key, 'chat_model')); - if (result.rows.length > 0) { - return result.rows[0].value as string; + if (rows.length > 0) { + return rows[0].value; } return 'claude-sonnet-4'; @@ -415,15 +386,21 @@ When answering questions: * Set selected model for new conversations */ async setSelectedModel(model: string): Promise { - const client = this.db.getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } + const drizzle = this.db.getLocal(); - const now = Date.now(); - await client.execute({ - sql: `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`, - args: ['chat_model', model, now], - }); + await drizzle + .insert(settings) + .values({ + key: 'chat_model', + value: model, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: settings.key, + set: { + value: model, + updatedAt: new Date(), + }, + }); } } diff --git a/src/main/engine/ImportDefinitionEngine.ts b/src/main/engine/ImportDefinitionEngine.ts index f773218..031a0f7 100644 --- a/src/main/engine/ImportDefinitionEngine.ts +++ b/src/main/engine/ImportDefinitionEngine.ts @@ -6,7 +6,9 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { eq, and, desc } from 'drizzle-orm'; import { getDatabase } from '../database'; +import { importDefinitions } from '../database/schema'; export interface ImportDefinitionData { id: string; @@ -22,12 +24,8 @@ export interface ImportDefinitionData { export class ImportDefinitionEngine { private currentProjectId: string = 'default'; - private getClient() { - const client = getDatabase().getLocalClient(); - if (!client) { - throw new Error('Database not initialized'); - } - return client; + private getDb() { + return getDatabase().getLocal(); } setProjectContext(projectId: string): void { @@ -39,15 +37,20 @@ export class ImportDefinitionEngine { } async createDefinition(name?: string): Promise { - const client = this.getClient(); + const db = this.getDb(); const id = `import_${uuidv4()}`; - const now = Date.now(); + const now = new Date(); const defName = name || 'Untitled Import'; - await client.execute({ - sql: `INSERT INTO import_definitions (id, project_id, name, wxr_file_path, uploads_folder_path, last_analysis_result, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - args: [id, this.currentProjectId, defName, null, null, null, now, now], + await db.insert(importDefinitions).values({ + id, + projectId: this.currentProjectId, + name: defName, + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: now, + updatedAt: now, }); return { @@ -57,31 +60,37 @@ export class ImportDefinitionEngine { wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, - createdAt: new Date(now).toISOString(), - updatedAt: new Date(now).toISOString(), + createdAt: now.toISOString(), + updatedAt: now.toISOString(), }; } async getDefinition(id: string): Promise { - const client = this.getClient(); - const result = await client.execute({ - sql: `SELECT * FROM import_definitions WHERE id = ? AND project_id = ?`, - args: [id, this.currentProjectId], - }); + const db = this.getDb(); - if (result.rows.length === 0) return null; + const rows = await db + .select() + .from(importDefinitions) + .where(and( + eq(importDefinitions.id, id), + eq(importDefinitions.projectId, this.currentProjectId) + )); - return this.rowToData(result.rows[0] as any); + if (rows.length === 0) return null; + + return this.rowToData(rows[0]); } async getAllForProject(): Promise { - const client = this.getClient(); - const result = await client.execute({ - sql: `SELECT * FROM import_definitions WHERE project_id = ? ORDER BY updated_at DESC`, - args: [this.currentProjectId], - }); + const db = this.getDb(); - return result.rows.map((row: any) => this.rowToData(row)); + const rows = await db + .select() + .from(importDefinitions) + .where(eq(importDefinitions.projectId, this.currentProjectId)) + .orderBy(desc(importDefinitions.updatedAt)); + + return rows.map(row => this.rowToData(row)); } async updateDefinition( @@ -92,42 +101,35 @@ export class ImportDefinitionEngine { const existing = await this.getDefinition(id); if (!existing) return null; - const setClauses: string[] = []; - const args: any[] = []; + const db = this.getDb(); + + // Build update object + const updateData: Record = { + updatedAt: new Date(), + }; if (updates.name !== undefined) { - setClauses.push('name = ?'); - args.push(updates.name); + updateData.name = updates.name; } if (updates.wxrFilePath !== undefined) { - setClauses.push('wxr_file_path = ?'); - args.push(updates.wxrFilePath); + updateData.wxrFilePath = updates.wxrFilePath; } if (updates.uploadsFolderPath !== undefined) { - setClauses.push('uploads_folder_path = ?'); - args.push(updates.uploadsFolderPath); + updateData.uploadsFolderPath = updates.uploadsFolderPath; } if (updates.lastAnalysisResult !== undefined) { - setClauses.push('last_analysis_result = ?'); - args.push(typeof updates.lastAnalysisResult === 'string' + updateData.lastAnalysisResult = typeof updates.lastAnalysisResult === 'string' ? updates.lastAnalysisResult - : JSON.stringify(updates.lastAnalysisResult)); + : JSON.stringify(updates.lastAnalysisResult); } - if (setClauses.length === 0) return existing; - - const now = Date.now(); - setClauses.push('updated_at = ?'); - args.push(now); - - // WHERE clause args - args.push(id, this.currentProjectId); - - const client = this.getClient(); - await client.execute({ - sql: `UPDATE import_definitions SET ${setClauses.join(', ')} WHERE id = ? AND project_id = ?`, - args, - }); + await db + .update(importDefinitions) + .set(updateData) + .where(and( + eq(importDefinitions.id, id), + eq(importDefinitions.projectId, this.currentProjectId) + )); return this.getDefinition(id); } @@ -137,38 +139,41 @@ export class ImportDefinitionEngine { const existing = await this.getDefinition(id); if (!existing) return false; - const client = this.getClient(); - await client.execute({ - sql: `DELETE FROM import_definitions WHERE id = ? AND project_id = ?`, - args: [id, this.currentProjectId], - }); + const db = this.getDb(); + + await db + .delete(importDefinitions) + .where(and( + eq(importDefinitions.id, id), + eq(importDefinitions.projectId, this.currentProjectId) + )); return true; } - private rowToData(row: any): ImportDefinitionData { + private rowToData(row: typeof importDefinitions.$inferSelect): ImportDefinitionData { let parsedResult: unknown | null = null; - if (row.last_analysis_result) { + if (row.lastAnalysisResult) { try { - parsedResult = JSON.parse(row.last_analysis_result); + parsedResult = JSON.parse(row.lastAnalysisResult); } catch { - parsedResult = row.last_analysis_result; + parsedResult = row.lastAnalysisResult; } } return { id: row.id, - projectId: row.project_id, + projectId: row.projectId, name: row.name, - wxrFilePath: row.wxr_file_path ?? null, - uploadsFolderPath: row.uploads_folder_path ?? null, + wxrFilePath: row.wxrFilePath ?? null, + uploadsFolderPath: row.uploadsFolderPath ?? null, lastAnalysisResult: parsedResult, - createdAt: typeof row.created_at === 'number' - ? new Date(row.created_at).toISOString() - : row.created_at, - updatedAt: typeof row.updated_at === 'number' - ? new Date(row.updated_at).toISOString() - : row.updated_at, + createdAt: row.createdAt instanceof Date + ? row.createdAt.toISOString() + : new Date(row.createdAt as unknown as number).toISOString(), + updatedAt: row.updatedAt instanceof Date + ? row.updatedAt.toISOString() + : new Date(row.updatedAt as unknown as number).toISOString(), }; } } diff --git a/src/main/engine/TagEngine.ts b/src/main/engine/TagEngine.ts index b79efc9..678c339 100644 --- a/src/main/engine/TagEngine.ts +++ b/src/main/engine/TagEngine.ts @@ -3,7 +3,9 @@ import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import { app } from 'electron'; +import { eq, and, asc, sql, like } from 'drizzle-orm'; import { getDatabase } from '../database'; +import { tags, posts } from '../database/schema'; import { taskManager } from './TaskManager'; /** @@ -125,6 +127,15 @@ export class TagEngine extends EventEmitter { super(); } + private getDb() { + return getDatabase().getLocal(); + } + + // For JSON operations that Drizzle doesn't support natively + private getClient() { + return getDatabase().getLocalClient(); + } + /** * Returns the default internal project directory (in userData). */ @@ -167,11 +178,10 @@ export class TagEngine extends EventEmitter { * Get all tags with their post counts for the tag cloud */ async getTagsWithCounts(): Promise { - const client = getDatabase().getLocalClient(); + const client = this.getClient(); if (!client) return []; - // Query tags with counts from posts - // Use a subquery to count posts per tag name + // Query tags with counts from posts - requires raw SQL for JSON operations const result = await client.execute({ sql: ` SELECT @@ -202,8 +212,7 @@ export class TagEngine extends EventEmitter { * Create a new tag */ async createTag(input: CreateTagInput): Promise { - const client = getDatabase().getLocalClient(); - if (!client) throw new Error('Database not initialized'); + const db = this.getDb(); const name = input.name.trim().toLowerCase(); if (!name) { @@ -215,29 +224,36 @@ export class TagEngine extends EventEmitter { throw new Error('Invalid color format. Use hex format like #ff0000 or #f00'); } - // Check for duplicate - const existing = await client.execute({ - sql: 'SELECT id, name FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)', - args: [this.currentProjectId, name], - }); + // Check for duplicate using Drizzle + const existing = await db + .select({ id: tags.id }) + .from(tags) + .where(and( + eq(tags.projectId, this.currentProjectId), + sql`LOWER(${tags.name}) = LOWER(${name})` + )); - if (existing.rows.length > 0) { + if (existing.length > 0) { throw new Error(`Tag "${name}" already exists`); } - const now = Date.now(); + const now = new Date(); const tag: TagData = { id: uuidv4(), projectId: this.currentProjectId, name, color: input.color, - createdAt: new Date(now), - updatedAt: new Date(now), + createdAt: now, + updatedAt: now, }; - await client.execute({ - sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - args: [tag.id, tag.projectId, tag.name, tag.color || null, now, now], + await db.insert(tags).values({ + id: tag.id, + projectId: tag.projectId, + name: tag.name, + color: tag.color || null, + createdAt: now, + updatedAt: now, }); this.emit('tagCreated', tag); @@ -250,57 +266,53 @@ export class TagEngine extends EventEmitter { * Update a tag */ async updateTag(id: string, input: UpdateTagInput): Promise { - const client = getDatabase().getLocalClient(); - if (!client) return null; + const db = this.getDb(); // Get existing tag - const existing = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + const existing = await db + .select() + .from(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); - if (existing.rows.length === 0) { + if (existing.length === 0) { return null; } - const row = existing.rows[0] as any; + const row = existing[0]; // Validate color if provided if (input.color !== undefined && input.color !== null && !isValidHexColor(input.color)) { throw new Error('Invalid color format. Use hex format like #ff0000 or #f00'); } - const now = Date.now(); - const updates: string[] = []; - const args: any[] = []; - - if (input.color !== undefined) { - updates.push('color = ?'); - args.push(input.color); - } - - if (updates.length === 0) { + if (input.color === undefined) { // No updates return this.rowToTagData(row); } - updates.push('updated_at = ?'); - args.push(now); - args.push(id); - args.push(this.currentProjectId); + const now = new Date(); - await client.execute({ - sql: `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND project_id = ?`, - args, - }); + await db + .update(tags) + .set({ + color: input.color, + updatedAt: now, + }) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); const updatedTag: TagData = { id: row.id, - projectId: row.project_id, + projectId: row.projectId, name: row.name, - color: input.color !== undefined ? input.color || undefined : row.color, - createdAt: new Date(row.created_at), - updatedAt: new Date(now), + color: input.color !== undefined ? input.color || undefined : row.color || undefined, + createdAt: row.createdAt, + updatedAt: now, }; this.emit('tagUpdated', updatedTag); @@ -313,21 +325,25 @@ export class TagEngine extends EventEmitter { * Delete a tag and remove it from all posts (runs as background task) */ async deleteTag(id: string): Promise { - const client = getDatabase().getLocalClient(); + const db = this.getDb(); + const client = this.getClient(); if (!client) throw new Error('Database not initialized'); // Get tag - const tagResult = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + const tagRows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); - if (tagResult.rows.length === 0) { + if (tagRows.length === 0) { throw new Error('Tag not found'); } - const tag = tagResult.rows[0] as any; - const tagName = tag.name as string; + const tag = tagRows[0]; + const tagName = tag.name; // Run the deletion as a background task return taskManager.runTask({ @@ -336,15 +352,15 @@ export class TagEngine extends EventEmitter { execute: async (onProgress) => { onProgress(0, `Finding posts with tag "${tagName}"...`); - // Find all posts with this tag + // Find all posts with this tag - requires raw SQL for JSON const postsResult = await client.execute({ sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, args: [this.currentProjectId, `%"${tagName}"%`], }); const postsToUpdate = postsResult.rows.filter((row: any) => { - const tags: string[] = JSON.parse(row.tags || '[]'); - return tags.includes(tagName); + const postTags: string[] = JSON.parse(row.tags || '[]'); + return postTags.includes(tagName); }); const total = postsToUpdate.length; @@ -352,13 +368,16 @@ export class TagEngine extends EventEmitter { for (const row of postsToUpdate) { const postId = row.id as string; - const tags: string[] = JSON.parse((row as any).tags || '[]'); - const newTags = tags.filter(t => t !== tagName); + const postTags: string[] = JSON.parse((row as any).tags || '[]'); + const newTags = postTags.filter(t => t !== tagName); - await client.execute({ - sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?', - args: [JSON.stringify(newTags), Date.now(), postId], - }); + await db + .update(posts) + .set({ + tags: JSON.stringify(newTags), + updatedAt: new Date(), + }) + .where(eq(posts.id, postId)); updated++; onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); @@ -367,10 +386,12 @@ export class TagEngine extends EventEmitter { onProgress(90, 'Deleting tag...'); // Delete the tag - await client.execute({ - sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + await db + .delete(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); onProgress(100, 'Complete'); @@ -386,7 +407,8 @@ export class TagEngine extends EventEmitter { * Merge multiple source tags into a target tag (runs as background task) */ async mergeTags(sourceTagIds: string[], targetTagId: string): Promise { - const client = getDatabase().getLocalClient(); + const db = this.getDb(); + const client = this.getClient(); if (!client) throw new Error('Database not initialized'); if (sourceTagIds.length === 0) { @@ -394,30 +416,36 @@ export class TagEngine extends EventEmitter { } // Verify all source tags exist - const sourceTags: any[] = []; + const sourceTags: (typeof tags.$inferSelect)[] = []; for (const id of sourceTagIds) { - const result = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); - if (result.rows.length > 0) { - sourceTags.push(result.rows[0]); + const rows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); + if (rows.length > 0) { + sourceTags.push(rows[0]); } } // Verify target tag exists - const targetResult = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [targetTagId, this.currentProjectId], - }); + const targetRows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, targetTagId), + eq(tags.projectId, this.currentProjectId) + )); - if (targetResult.rows.length === 0) { + if (targetRows.length === 0) { throw new Error('Target tag not found'); } - const targetTag = targetResult.rows[0] as any; - const targetName = targetTag.name as string; - const sourceNames = sourceTags.map((t: any) => t.name as string); + const targetTag = targetRows[0]; + const targetName = targetTag.name; + const sourceNames = sourceTags.map(t => t.name); // Run as background task return taskManager.runTask({ @@ -441,19 +469,22 @@ export class TagEngine extends EventEmitter { for (const row of postsResult.rows) { const postId = row.id as string; - const tags: string[] = JSON.parse((row as any).tags || '[]'); + const postTags: string[] = JSON.parse((row as any).tags || '[]'); - if (tags.includes(sourceName)) { + if (postTags.includes(sourceName)) { // Remove source tag and add target if not already present - const newTags = tags.filter(t => t !== sourceName); + const newTags = postTags.filter(t => t !== sourceName); if (!newTags.includes(targetName)) { newTags.push(targetName); } - await client.execute({ - sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?', - args: [JSON.stringify(newTags), Date.now(), postId], - }); + await db + .update(posts) + .set({ + tags: JSON.stringify(newTags), + updatedAt: new Date(), + }) + .where(eq(posts.id, postId)); totalPostsUpdated++; } @@ -464,10 +495,12 @@ export class TagEngine extends EventEmitter { // Delete source tags for (const id of sourceTagIds) { - await client.execute({ - sql: 'DELETE FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + await db + .delete(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); } onProgress(100, 'Complete'); @@ -491,7 +524,8 @@ export class TagEngine extends EventEmitter { * Rename a tag (runs as background task to update posts) */ async renameTag(id: string, newName: string): Promise { - const client = getDatabase().getLocalClient(); + const db = this.getDb(); + const client = this.getClient(); if (!client) throw new Error('Database not initialized'); newName = newName.trim().toLowerCase(); @@ -500,29 +534,36 @@ export class TagEngine extends EventEmitter { } // Get existing tag - const tagResult = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + const tagRows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); - if (tagResult.rows.length === 0) { + if (tagRows.length === 0) { throw new Error('Tag not found'); } - const tag = tagResult.rows[0] as any; - const oldName = tag.name as string; + const tag = tagRows[0]; + const oldName = tag.name; if (oldName === newName) { return { success: true, postsUpdated: 0, oldName, newName }; } // Check for duplicate - const duplicateResult = await client.execute({ - sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?) AND id != ?', - args: [this.currentProjectId, newName, id], - }); + const duplicateRows = await db + .select({ id: tags.id }) + .from(tags) + .where(and( + eq(tags.projectId, this.currentProjectId), + sql`LOWER(${tags.name}) = LOWER(${newName})`, + sql`${tags.id} != ${id}` + )); - if (duplicateResult.rows.length > 0) { + if (duplicateRows.length > 0) { throw new Error(`Tag "${newName}" already exists`); } @@ -540,8 +581,8 @@ export class TagEngine extends EventEmitter { }); const postsToUpdate = postsResult.rows.filter((row: any) => { - const tags: string[] = JSON.parse(row.tags || '[]'); - return tags.includes(oldName); + const postTags: string[] = JSON.parse(row.tags || '[]'); + return postTags.includes(oldName); }); const total = postsToUpdate.length; @@ -549,13 +590,16 @@ export class TagEngine extends EventEmitter { for (const row of postsToUpdate) { const postId = row.id as string; - const tags: string[] = JSON.parse((row as any).tags || '[]'); - const newTags = tags.map(t => t === oldName ? newName : t); + const postTags: string[] = JSON.parse((row as any).tags || '[]'); + const updatedTags = postTags.map(t => t === oldName ? newName : t); - await client.execute({ - sql: 'UPDATE posts SET tags = ?, updated_at = ? WHERE id = ?', - args: [JSON.stringify(newTags), Date.now(), postId], - }); + await db + .update(posts) + .set({ + tags: JSON.stringify(updatedTags), + updatedAt: new Date(), + }) + .where(eq(posts.id, postId)); updated++; onProgress((updated / total) * 80, `Updated ${updated}/${total} posts...`); @@ -564,10 +608,16 @@ export class TagEngine extends EventEmitter { onProgress(90, 'Updating tag record...'); // Update the tag name - await client.execute({ - sql: 'UPDATE tags SET name = ?, updated_at = ? WHERE id = ? AND project_id = ?', - args: [newName, Date.now(), id, this.currentProjectId], - }); + await db + .update(tags) + .set({ + name: newName, + updatedAt: new Date(), + }) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); onProgress(100, 'Complete'); @@ -590,75 +640,84 @@ export class TagEngine extends EventEmitter { * Get a tag by ID */ async getTag(id: string): Promise { - const client = getDatabase().getLocalClient(); - if (!client) return null; + const db = this.getDb(); - const result = await client.execute({ - sql: 'SELECT * FROM tags WHERE id = ? AND project_id = ?', - args: [id, this.currentProjectId], - }); + const rows = await db + .select() + .from(tags) + .where(and( + eq(tags.id, id), + eq(tags.projectId, this.currentProjectId) + )); - if (result.rows.length === 0) { + if (rows.length === 0) { return null; } - return this.rowToTagData(result.rows[0] as any); + return this.rowToTagData(rows[0]); } /** * Get a tag by name (case-insensitive) */ async getTagByName(name: string): Promise { - const client = getDatabase().getLocalClient(); - if (!client) return null; + const db = this.getDb(); + const normalizedName = name.trim().toLowerCase(); - const result = await client.execute({ - sql: 'SELECT * FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)', - args: [this.currentProjectId, name.trim().toLowerCase()], - }); + const rows = await db + .select() + .from(tags) + .where(and( + eq(tags.projectId, this.currentProjectId), + sql`LOWER(${tags.name}) = LOWER(${normalizedName})` + )); - if (result.rows.length === 0) { + if (rows.length === 0) { return null; } - return this.rowToTagData(result.rows[0] as any); + return this.rowToTagData(rows[0]); } /** * Get all tags for the current project */ async getAllTags(): Promise { - const client = getDatabase().getLocalClient(); - if (!client) return []; + const db = this.getDb(); - const result = await client.execute({ - sql: 'SELECT * FROM tags WHERE project_id = ? ORDER BY name ASC', - args: [this.currentProjectId], - }); + const rows = await db + .select() + .from(tags) + .where(eq(tags.projectId, this.currentProjectId)) + .orderBy(asc(tags.name)); - return result.rows.map((row: any) => this.rowToTagData(row)); + return rows.map(row => this.rowToTagData(row)); } /** * Get post IDs that have a specific tag */ async getPostsWithTag(tagId: string): Promise { - const client = getDatabase().getLocalClient(); + const db = this.getDb(); + const client = this.getClient(); if (!client) return []; // First get the tag name - const tagResult = await client.execute({ - sql: 'SELECT name FROM tags WHERE id = ? AND project_id = ?', - args: [tagId, this.currentProjectId], - }); + const tagRows = await db + .select({ name: tags.name }) + .from(tags) + .where(and( + eq(tags.id, tagId), + eq(tags.projectId, this.currentProjectId) + )); - if (tagResult.rows.length === 0) { + if (tagRows.length === 0) { return []; } - const tagName = (tagResult.rows[0] as any).name as string; + const tagName = tagRows[0].name; - // Find posts with this tag + // Find posts with this tag - requires raw SQL for JSON const postsResult = await client.execute({ sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`, args: [this.currentProjectId, `%"${tagName}"%`], @@ -666,8 +725,8 @@ export class TagEngine extends EventEmitter { return postsResult.rows .filter((row: any) => { - const tags: string[] = JSON.parse(row.tags || '[]'); - return tags.includes(tagName); + const postTags: string[] = JSON.parse(row.tags || '[]'); + return postTags.includes(tagName); }) .map((row: any) => row.id as string); } @@ -676,19 +735,18 @@ export class TagEngine extends EventEmitter { * Sync tags from existing posts - discover tags that exist in posts but not in tags table */ async syncTagsFromPosts(): Promise { - const client = getDatabase().getLocalClient(); - if (!client) throw new Error('Database not initialized'); + const db = this.getDb(); // Get all tags from posts - const postsResult = await client.execute({ - sql: 'SELECT tags FROM posts WHERE project_id = ?', - args: [this.currentProjectId], - }); + const postRows = await db + .select({ tags: posts.tags }) + .from(posts) + .where(eq(posts.projectId, this.currentProjectId)); const discoveredTags = new Set(); - for (const row of postsResult.rows) { - const tags: string[] = JSON.parse((row as any).tags || '[]'); - for (const tag of tags) { + for (const row of postRows) { + const postTags: string[] = JSON.parse(row.tags || '[]'); + for (const tag of postTags) { if (tag.trim()) { discoveredTags.add(tag.trim().toLowerCase()); } @@ -696,23 +754,27 @@ export class TagEngine extends EventEmitter { } // Get existing tags - const existingResult = await client.execute({ - sql: 'SELECT name FROM tags WHERE project_id = ?', - args: [this.currentProjectId], - }); + const existingRows = await db + .select({ name: tags.name }) + .from(tags) + .where(eq(tags.projectId, this.currentProjectId)); - const existingNames = new Set(existingResult.rows.map((row: any) => (row.name as string).toLowerCase())); + const existingNames = new Set(existingRows.map(row => row.name.toLowerCase())); // Find missing tags const missingTags = Array.from(discoveredTags).filter(t => !existingNames.has(t)); const added: string[] = []; // Add missing tags - const now = Date.now(); + const now = new Date(); for (const tagName of missingTags) { - await client.execute({ - sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, NULL, ?, ?)', - args: [uuidv4(), this.currentProjectId, tagName, now, now], + await db.insert(tags).values({ + id: uuidv4(), + projectId: this.currentProjectId, + name: tagName, + color: null, + createdAt: now, + updatedAt: now, }); added.push(tagName); } @@ -731,14 +793,14 @@ export class TagEngine extends EventEmitter { /** * Convert database row to TagData */ - private rowToTagData(row: any): TagData { + private rowToTagData(row: typeof tags.$inferSelect): TagData { return { id: row.id, - projectId: row.project_id, + projectId: row.projectId, name: row.name, color: row.color || undefined, - createdAt: new Date(row.created_at), - updatedAt: new Date(row.updated_at), + createdAt: row.createdAt, + updatedAt: row.updatedAt, }; } @@ -748,12 +810,12 @@ export class TagEngine extends EventEmitter { */ private async saveTagsToFile(): Promise { try { - const tags = await this.getAllTags(); + const allTags = await this.getAllTags(); const filePath = this.getTagsFilePath(); const dir = path.dirname(filePath); // Serialize to portable format - only name and optional color - const serialized: SerializedTag[] = tags.map(tag => { + const serialized: SerializedTag[] = allTags.map(tag => { const entry: SerializedTag = { name: tag.name }; if (tag.color) { entry.color = tag.color; @@ -778,10 +840,8 @@ export class TagEngine extends EventEmitter { const content = await fs.readFile(filePath, 'utf-8'); const rawTags: any[] = JSON.parse(content); - const client = getDatabase().getLocalClient(); - if (!client) return; - - const now = Date.now(); + const db = this.getDb(); + const now = new Date(); for (const tag of rawTags) { // Support both portable format { name, color? } and legacy format with id @@ -791,23 +851,36 @@ export class TagEngine extends EventEmitter { const color = tag.color || null; // Check if tag with this name already exists - const existing = await client.execute({ - sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)', - args: [this.currentProjectId, name], - }); + const existing = await db + .select({ id: tags.id }) + .from(tags) + .where(and( + eq(tags.projectId, this.currentProjectId), + sql`LOWER(${tags.name}) = LOWER(${name})` + )); - if (existing.rows.length === 0) { + if (existing.length === 0) { // Create new tag with fresh ID - await client.execute({ - sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', - args: [uuidv4(), this.currentProjectId, name, color, now, now], + await db.insert(tags).values({ + id: uuidv4(), + projectId: this.currentProjectId, + name, + color, + createdAt: now, + updatedAt: now, }); } else if (color) { // Update color if provided and tag exists - await client.execute({ - sql: 'UPDATE tags SET color = ?, updated_at = ? WHERE project_id = ? AND LOWER(name) = LOWER(?)', - args: [color, now, this.currentProjectId, name], - }); + await db + .update(tags) + .set({ + color, + updatedAt: now, + }) + .where(and( + eq(tags.projectId, this.currentProjectId), + sql`LOWER(${tags.name}) = LOWER(${name})` + )); } } } catch (error: any) { diff --git a/tests/engine/ImportDefinitionEngine.test.ts b/tests/engine/ImportDefinitionEngine.test.ts index f56029c..8cdbf46 100644 --- a/tests/engine/ImportDefinitionEngine.test.ts +++ b/tests/engine/ImportDefinitionEngine.test.ts @@ -10,91 +10,58 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // Store for mock data const mockDefinitions = new Map(); -const mockLocalClient = { - execute: vi.fn(async (query: { sql: string; args: any[] }) => { - const sql = query.sql.trim(); +// Create chainable mock for Drizzle ORM that is thenable (can be awaited) +function createSelectChain(getData: () => any[]) { + const chain: any = { + from: vi.fn().mockImplementation(() => chain), + where: vi.fn().mockImplementation(() => chain), + orderBy: vi.fn().mockImplementation(() => chain), + limit: vi.fn().mockImplementation(() => chain), + // Make the chain thenable so it can be awaited directly + then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => { + return Promise.resolve(getData()).then(resolve, reject); + }, + }; + return chain; +} - // INSERT - if (sql.startsWith('INSERT')) { - const row = { - id: query.args[0], - project_id: query.args[1], - name: query.args[2], - wxr_file_path: query.args[3] ?? null, - uploads_folder_path: query.args[4] ?? null, - last_analysis_result: query.args[5] ?? null, - created_at: query.args[6], - updated_at: query.args[7], - }; - mockDefinitions.set(row.id, row); - return { rows: [] }; - } +// Track what data Drizzle queries should return +let mockDrizzleSelectResults: any[][] = []; - // SELECT by id - if (sql.startsWith('SELECT') && sql.includes('WHERE id = ?') && sql.includes('project_id = ?')) { - const id = query.args[0]; - const projectId = query.args[1]; - const def = mockDefinitions.get(id); - if (def && def.project_id === projectId) { - return { rows: [def] }; +const mockLocalDb = { + select: vi.fn(() => createSelectChain(() => mockDrizzleSelectResults.shift() || [])), + insert: vi.fn(() => ({ + values: vi.fn((data: any) => { + if (data && data.id) { + mockDefinitions.set(data.id, { + id: data.id, + projectId: data.projectId, + name: data.name, + wxrFilePath: data.wxrFilePath, + uploadsFolderPath: data.uploadsFolderPath, + lastAnalysisResult: data.lastAnalysisResult, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }); } - return { rows: [] }; - } - - // SELECT all for project - if (sql.startsWith('SELECT') && sql.includes('WHERE project_id = ?') && sql.includes('ORDER BY')) { - const projectId = query.args[0]; - const rows = Array.from(mockDefinitions.values()) - .filter(d => d.project_id === projectId) - .sort((a, b) => b.updated_at - a.updated_at); - return { rows }; - } - - // UPDATE - if (sql.startsWith('UPDATE')) { - // Find the id in args (last two args are id and project_id in WHERE) - const id = query.args[query.args.length - 2]; - const projectId = query.args[query.args.length - 1]; - const def = mockDefinitions.get(id); - if (def && def.project_id === projectId) { - // Apply updates based on the SET clause - // Parse set fields from the sql - const setMatch = sql.match(/SET (.+?) WHERE/); - if (setMatch) { - const setParts = setMatch[1].split(', '); - let argIdx = 0; - for (const part of setParts) { - const field = part.split(' = ')[0].trim(); - def[field] = query.args[argIdx]; - argIdx++; - } - } - return { rowsAffected: 1, rows: [] }; - } - return { rowsAffected: 0, rows: [] }; - } - - // DELETE - if (sql.startsWith('DELETE')) { - const id = query.args[0]; - const projectId = query.args[1]; - const def = mockDefinitions.get(id); - if (def && def.project_id === projectId) { - mockDefinitions.delete(id); - return { rowsAffected: 1, rows: [] }; - } - return { rowsAffected: 0, rows: [] }; - } - - return { rows: [] }; - }), + return Promise.resolve(); + }), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), }; // Mock the database module vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({ - getLocal: vi.fn(() => null), - getLocalClient: vi.fn(() => mockLocalClient), + getLocal: vi.fn(() => mockLocalDb), + getLocalClient: vi.fn(() => null), getRemote: vi.fn(() => null), getDataPaths: vi.fn(() => ({ database: '/mock/userData/bds.db', @@ -122,6 +89,7 @@ describe('ImportDefinitionEngine', () => { beforeEach(() => { vi.clearAllMocks(); mockDefinitions.clear(); + mockDrizzleSelectResults = []; engine = new ImportDefinitionEngine(); engine.setProjectContext('test-project'); }); @@ -168,17 +136,24 @@ describe('ImportDefinitionEngine', () => { it('should insert into the database', async () => { await engine.createDefinition('Test Import'); - expect(mockLocalClient.execute).toHaveBeenCalledTimes(1); - const call = mockLocalClient.execute.mock.calls[0][0]; - expect(call.sql).toContain('INSERT INTO import_definitions'); - expect(call.args[2]).toBe('Test Import'); + expect(mockLocalDb.insert).toHaveBeenCalledTimes(1); }); }); describe('getDefinition', () => { it('should return a definition by ID', async () => { const created = await engine.createDefinition('My Import'); - mockLocalClient.execute.mockClear(); + // Set up mock to return the created definition + mockDrizzleSelectResults = [[{ + id: created.id, + projectId: 'test-project', + name: 'My Import', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }]]; const def = await engine.getDefinition(created.id); @@ -188,6 +163,7 @@ describe('ImportDefinitionEngine', () => { }); it('should return null for non-existent ID', async () => { + mockDrizzleSelectResults = [[]]; const def = await engine.getDefinition('non-existent-id'); expect(def).toBeNull(); @@ -196,6 +172,7 @@ describe('ImportDefinitionEngine', () => { it('should not return definitions from other projects', async () => { const created = await engine.createDefinition('My Import'); engine.setProjectContext('other-project'); + mockDrizzleSelectResults = [[]]; // would be filtered by project const def = await engine.getDefinition(created.id); @@ -204,9 +181,17 @@ describe('ImportDefinitionEngine', () => { it('should parse lastAnalysisResult JSON', async () => { const created = await engine.createDefinition('My Import'); - // Manually set analysis result in mock store - const storedDef = mockDefinitions.get(created.id); - storedDef.last_analysis_result = JSON.stringify({ posts: { total: 5 } }); + // Set up mock to return the definition with analysis result + mockDrizzleSelectResults = [[{ + id: created.id, + projectId: 'test-project', + name: 'My Import', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: JSON.stringify({ posts: { total: 5 } }), + createdAt: new Date(), + updatedAt: new Date(), + }]]; const def = await engine.getDefinition(created.id); @@ -216,6 +201,7 @@ describe('ImportDefinitionEngine', () => { describe('getAllForProject', () => { it('should return empty array when no definitions exist', async () => { + mockDrizzleSelectResults = [[]]; const defs = await engine.getAllForProject(); expect(defs).toEqual([]); @@ -224,6 +210,11 @@ describe('ImportDefinitionEngine', () => { it('should return all definitions for the current project', async () => { await engine.createDefinition('Import 1'); await engine.createDefinition('Import 2'); + // Mock returning both definitions + mockDrizzleSelectResults = [[ + { id: 'id1', projectId: 'test-project', name: 'Import 1', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() }, + { id: 'id2', projectId: 'test-project', name: 'Import 2', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() }, + ]]; const defs = await engine.getAllForProject(); @@ -235,6 +226,10 @@ describe('ImportDefinitionEngine', () => { engine.setProjectContext('other-project'); await engine.createDefinition('Import B'); engine.setProjectContext('test-project'); + // Mock returning only the test-project definition + mockDrizzleSelectResults = [[ + { id: 'id1', projectId: 'test-project', name: 'Import A', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: new Date(), updatedAt: new Date() }, + ]]; const defs = await engine.getAllForProject(); @@ -243,10 +238,12 @@ describe('ImportDefinitionEngine', () => { }); it('should return definitions ordered by updatedAt DESC', async () => { - await engine.createDefinition('Older'); - // Small delay to ensure different timestamps - await new Promise(resolve => setTimeout(resolve, 10)); - await engine.createDefinition('Newer'); + const olderDate = new Date('2024-01-01'); + const newerDate = new Date('2024-02-01'); + mockDrizzleSelectResults = [[ + { id: 'id2', projectId: 'test-project', name: 'Newer', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: newerDate, updatedAt: newerDate }, + { id: 'id1', projectId: 'test-project', name: 'Older', wxrFilePath: null, uploadsFolderPath: null, lastAnalysisResult: null, createdAt: olderDate, updatedAt: olderDate }, + ]]; const defs = await engine.getAllForProject(); @@ -258,6 +255,29 @@ describe('ImportDefinitionEngine', () => { describe('updateDefinition', () => { it('should update the name', async () => { const created = await engine.createDefinition('Old Name'); + // First call for getDefinition check, second for returning updated data + mockDrizzleSelectResults = [ + [{ // getDefinition call inside updateDefinition + id: created.id, + projectId: 'test-project', + name: 'Old Name', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + [{ // return after update + id: created.id, + projectId: 'test-project', + name: 'New Name', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + ]; const updated = await engine.updateDefinition(created.id, { name: 'New Name' }); @@ -267,6 +287,29 @@ describe('ImportDefinitionEngine', () => { it('should update wxrFilePath', async () => { const created = await engine.createDefinition('Test'); + // First call for check, second for returning updated data + mockDrizzleSelectResults = [ + [{ // getDefinition check + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + [{ // return after update + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: '/path/to/export.xml', + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + ]; const updated = await engine.updateDefinition(created.id, { wxrFilePath: '/path/to/export.xml' }); @@ -275,6 +318,28 @@ describe('ImportDefinitionEngine', () => { it('should update uploadsFolderPath', async () => { const created = await engine.createDefinition('Test'); + mockDrizzleSelectResults = [ + [{ // getDefinition check + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + [{ // return after update + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: '/path/to/uploads', + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + ]; const updated = await engine.updateDefinition(created.id, { uploadsFolderPath: '/path/to/uploads' }); @@ -284,6 +349,28 @@ describe('ImportDefinitionEngine', () => { it('should update lastAnalysisResult as JSON', async () => { const created = await engine.createDefinition('Test'); const report = { posts: { total: 10, new: 5 } }; + mockDrizzleSelectResults = [ + [{ // getDefinition check + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }], + [{ // return after update + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: JSON.stringify(report), + createdAt: new Date(), + updatedAt: new Date(), + }], + ]; const updated = await engine.updateDefinition(created.id, { lastAnalysisResult: JSON.stringify(report) }); @@ -291,6 +378,7 @@ describe('ImportDefinitionEngine', () => { }); it('should return null for non-existent definition', async () => { + mockDrizzleSelectResults = [[]]; const updated = await engine.updateDefinition('non-existent', { name: 'Test' }); expect(updated).toBeNull(); @@ -299,6 +387,7 @@ describe('ImportDefinitionEngine', () => { it('should not update definitions from other projects', async () => { const created = await engine.createDefinition('Test'); engine.setProjectContext('other-project'); + mockDrizzleSelectResults = [[]]; // Would be filtered by project const updated = await engine.updateDefinition(created.id, { name: 'Hacked' }); @@ -309,6 +398,16 @@ describe('ImportDefinitionEngine', () => { describe('deleteDefinition', () => { it('should delete an existing definition', async () => { const created = await engine.createDefinition('To Delete'); + mockDrizzleSelectResults = [[{ + id: created.id, + projectId: 'test-project', + name: 'To Delete', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }]]; const result = await engine.deleteDefinition(created.id); @@ -316,6 +415,7 @@ describe('ImportDefinitionEngine', () => { }); it('should return false for non-existent definition', async () => { + mockDrizzleSelectResults = [[]]; const result = await engine.deleteDefinition('non-existent'); expect(result).toBe(false); @@ -324,6 +424,7 @@ describe('ImportDefinitionEngine', () => { it('should not delete definitions from other projects', async () => { const created = await engine.createDefinition('Test'); engine.setProjectContext('other-project'); + mockDrizzleSelectResults = [[]]; // Would be filtered by project const result = await engine.deleteDefinition(created.id); @@ -332,8 +433,21 @@ describe('ImportDefinitionEngine', () => { it('should remove the definition from the database', async () => { const created = await engine.createDefinition('Test'); + // First call returns the definition for delete + mockDrizzleSelectResults = [[{ + id: created.id, + projectId: 'test-project', + name: 'Test', + wxrFilePath: null, + uploadsFolderPath: null, + lastAnalysisResult: null, + createdAt: new Date(), + updatedAt: new Date(), + }]]; await engine.deleteDefinition(created.id); - + + // Second call returns empty for get + mockDrizzleSelectResults = [[]]; const def = await engine.getDefinition(created.id); expect(def).toBeNull(); }); diff --git a/tests/engine/TagEngine.test.ts b/tests/engine/TagEngine.test.ts index 22850ed..f15a292 100644 --- a/tests/engine/TagEngine.test.ts +++ b/tests/engine/TagEngine.test.ts @@ -14,19 +14,34 @@ const mockTags = new Map(); const mockPosts = new Map(); let mockExecuteArgs: any[] = []; -// Create chainable mock for Drizzle ORM +// Configure what data the Drizzle select chain returns - supports queue for multiple calls +let mockSelectDataQueue: any[][] = []; +let mockSelectDataDefault: any[] = []; + +function getNextMockSelectData(): any[] { + if (mockSelectDataQueue.length > 0) { + return mockSelectDataQueue.shift()!; + } + if (mockSelectDataDefault.length > 0) { + return mockSelectDataDefault; + } + return Array.from(mockTags.values()); +} + +// Create chainable mock for Drizzle ORM that is thenable (can be awaited) function createSelectChain() { - return { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockImplementation(function(this: any) { - return this; - }), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - offset: vi.fn().mockReturnThis(), - all: vi.fn().mockImplementation(() => Promise.resolve(Array.from(mockTags.values()))), - get: vi.fn().mockImplementation(() => Promise.resolve(undefined)), + const chain: any = { + from: vi.fn().mockImplementation(() => chain), + where: vi.fn().mockImplementation(() => chain), + orderBy: vi.fn().mockImplementation(() => chain), + limit: vi.fn().mockImplementation(() => chain), + offset: vi.fn().mockImplementation(() => chain), + // Make the chain thenable so it can be awaited directly + then: (resolve: (value: any[]) => void, reject?: (reason: any) => void) => { + return Promise.resolve(getNextMockSelectData()).then(resolve, reject); + }, }; + return chain; } function createDrizzleMock() { @@ -115,6 +130,8 @@ describe('TagEngine', () => { mockTags.clear(); mockPosts.clear(); mockExecuteArgs = []; + mockSelectDataQueue = []; + mockSelectDataDefault = []; resetMockCounters(); tagEngine = new TagEngine(); }); @@ -198,9 +215,8 @@ describe('TagEngine', () => { }); it('should throw error for duplicate tag name', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'existing', name: 'react' }], - }); + // Drizzle ORM: check for existing tag with same name + mockSelectDataQueue = [[{ id: 'existing', name: 'react' }]]; await expect(tagEngine.createTag({ name: 'react' })).rejects.toThrow('Tag "react" already exists'); }); @@ -208,9 +224,7 @@ describe('TagEngine', () => { describe('updateTag', () => { it('should update tag color', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'tag-1', name: 'react', color: null }], - }); + mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.updateTag('tag-1', { color: '#61dafb' }); @@ -219,9 +233,7 @@ describe('TagEngine', () => { }); it('should emit tagUpdated event', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'tag-1', name: 'react', color: null }], - }); + mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const handler = vi.fn(); tagEngine.on('tagUpdated', handler); @@ -232,7 +244,7 @@ describe('TagEngine', () => { }); it('should return null for non-existent tag', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + mockSelectDataDefault = []; const result = await tagEngine.updateTag('non-existent', { color: '#fff' }); @@ -242,15 +254,17 @@ describe('TagEngine', () => { describe('deleteTag', () => { it('should delete tag and remove from posts as a background task', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] }) // Find tag - .mockResolvedValueOnce({ rows: [ + // Drizzle ORM: get tag first + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + ]; + // Raw SQL: find posts with tag + mockLocalClient.execute.mockImplementationOnce(async () => ({ + rows: [ { id: 'post-1', tags: '["react", "typescript"]' }, { id: 'post-2', tags: '["react"]' }, - ] }) // Posts with tag - .mockResolvedValueOnce({ rows: [] }) // Update post-1 - .mockResolvedValueOnce({ rows: [] }) // Update post-2 - .mockResolvedValueOnce({ rows: [] }); // Delete tag + ], + })); const result = await tagEngine.deleteTag('tag-1'); @@ -259,10 +273,10 @@ describe('TagEngine', () => { }); it('should emit tagDeleted event', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'react', color: null }] }) - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [] }); + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + ]; + mockLocalClient.execute.mockImplementationOnce(async () => ({ rows: [] })); const handler = vi.fn(); tagEngine.on('tagDeleted', handler); @@ -273,7 +287,7 @@ describe('TagEngine', () => { }); it('should throw error for non-existent tag', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + mockSelectDataDefault = []; await expect(tagEngine.deleteTag('non-existent')).rejects.toThrow('Tag not found'); }); @@ -281,14 +295,16 @@ describe('TagEngine', () => { describe('mergeTags', () => { it('should merge multiple tags into one', async () => { + // Drizzle ORM selects: source tag 1, source tag 2, target tag + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [{ id: 'tag-2', name: 'javascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [{ id: 'tag-3', name: 'ecmascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + ]; + // Raw SQL for finding posts with tags mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) // Source tag 1 - .mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] }) // Source tag 2 - .mockResolvedValueOnce({ rows: [{ id: 'tag-3', name: 'ecmascript' }] }) // Target tag - .mockResolvedValueOnce({ rows: [{ id: 'post-1' }, { id: 'post-2' }] }) // Posts with source tags - .mockResolvedValueOnce({ rows: [] }) // Update posts - .mockResolvedValueOnce({ rows: [] }) // Delete source tag 1 - .mockResolvedValueOnce({ rows: [] }); // Delete source tag 2 + .mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["js"]' }] }) // Posts with source tag 1 + .mockResolvedValueOnce({ rows: [{ id: 'post-2', tags: '["javascript"]' }] }); // Posts with source tag 2 const result = await tagEngine.mergeTags(['tag-1', 'tag-2'], 'tag-3'); @@ -298,11 +314,11 @@ describe('TagEngine', () => { }); it('should emit tagsMerged event', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) - .mockResolvedValueOnce({ rows: [{ id: 'tag-2', name: 'javascript' }] }) - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [] }); + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [{ id: 'tag-2', name: 'javascript', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + ]; + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); // No posts with source tag const handler = vi.fn(); tagEngine.on('tagsMerged', handler); @@ -317,9 +333,10 @@ describe('TagEngine', () => { }); it('should throw error when target tag does not exist', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'js' }] }) - .mockResolvedValueOnce({ rows: [] }); // Target not found + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'js', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [], // Target not found + ]; await expect(tagEngine.mergeTags(['tag-1'], 'non-existent')).rejects.toThrow('Target tag not found'); }); @@ -327,12 +344,13 @@ describe('TagEngine', () => { describe('renameTags (batch rename)', () => { it('should rename multiple tags and update posts', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] }) - .mockResolvedValueOnce({ rows: [] }) // Check no duplicate - .mockResolvedValueOnce({ rows: [{ id: 'post-1' }] }) // Posts with tag - .mockResolvedValueOnce({ rows: [] }) // Update posts - .mockResolvedValueOnce({ rows: [] }); // Update tag name + // First call: get existing tag, Second call: check for duplicate + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'old-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [], // no duplicate + ]; + // Raw SQL for finding posts with the tag + mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1', tags: '["old-name"]' }] }); const result = await tagEngine.renameTag('tag-1', 'new-name'); @@ -341,11 +359,11 @@ describe('TagEngine', () => { }); it('should emit tagRenamed event', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ id: 'tag-1', name: 'old-name' }] }) - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [] }); + mockSelectDataQueue = [ + [{ id: 'tag-1', name: 'old-name', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }], + [], // no duplicate + ]; + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); // no posts to update const handler = vi.fn(); tagEngine.on('tagRenamed', handler); @@ -361,9 +379,8 @@ describe('TagEngine', () => { describe('getTag', () => { it('should return tag by ID', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }], - }); + // Set up mock data for Drizzle select (camelCase properties) + mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: '#61dafb', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTag('tag-1'); @@ -373,7 +390,7 @@ describe('TagEngine', () => { }); it('should return null for non-existent tag', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + mockSelectDataDefault = []; const result = await tagEngine.getTag('non-existent'); @@ -383,9 +400,7 @@ describe('TagEngine', () => { describe('getTagByName', () => { it('should return tag by name', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'tag-1', name: 'react', color: '#61dafb', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }], - }); + mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: '#61dafb', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTagByName('react'); @@ -394,9 +409,7 @@ describe('TagEngine', () => { }); it('should be case-insensitive', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ id: 'tag-1', name: 'react', color: null }], - }); + mockSelectDataDefault = [{ id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }]; const result = await tagEngine.getTagByName('REACT'); @@ -406,12 +419,10 @@ describe('TagEngine', () => { describe('getAllTags', () => { it('should return all tags for the current project', async () => { - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [ - { id: 'tag-1', name: 'react', color: null, project_id: 'default', created_at: Date.now(), updated_at: Date.now() }, - { id: 'tag-2', name: 'vue', color: '#42b883', project_id: 'default', created_at: Date.now(), updated_at: Date.now() }, - ], - }); + mockSelectDataDefault = [ + { id: 'tag-1', name: 'react', color: null, projectId: 'default', createdAt: new Date(), updatedAt: new Date() }, + { id: 'tag-2', name: 'vue', color: '#42b883', projectId: 'default', createdAt: new Date(), updatedAt: new Date() }, + ]; const result = await tagEngine.getAllTags(); @@ -423,17 +434,15 @@ describe('TagEngine', () => { describe('getPostsWithTag', () => { it('should return post IDs that have the specified tag', async () => { - // First call: get tag name from id - mockLocalClient.execute.mockResolvedValueOnce({ - rows: [{ name: 'react' }], - }); - // Second call: find posts with this tag - mockLocalClient.execute.mockResolvedValueOnce({ + // First call: Drizzle ORM to get tag name from id + mockSelectDataQueue = [[{ name: 'react' }]]; + // Second call: raw SQL to find posts with this tag + mockLocalClient.execute.mockImplementationOnce(async () => ({ rows: [ { id: 'post-1', tags: '["react", "typescript"]' }, { id: 'post-2', tags: '["react"]' }, ], - }); + })); const result = await tagEngine.getPostsWithTag('tag-1'); @@ -467,12 +476,11 @@ describe('TagEngine', () => { describe('syncTagsFromPosts', () => { it('should discover tags from existing posts and add missing ones', async () => { - mockLocalClient.execute - .mockResolvedValueOnce({ rows: [{ tags: '["react", "javascript"]' }, { tags: '["vue", "typescript"]' }] }) // Get posts - .mockResolvedValueOnce({ rows: [{ name: 'react' }] }) // Existing tags - .mockResolvedValueOnce({ rows: [] }) // Insert missing tags - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [] }); + // First call: get posts' tags, Second call: get existing tags + mockSelectDataQueue = [ + [{ tags: '["react", "javascript"]' }, { tags: '["vue", "typescript"]' }], + [{ name: 'react' }], // existing tags + ]; const result = await tagEngine.syncTagsFromPosts();