feat: add custom SQL handling for triggers and virtual tables

- Introduced a new method `runCustomMigrations` in `DbService` to execute custom SQL statements that Drizzle cannot manage, such as triggers and virtual tables.
- Updated `database-patterns.md` and `README.md` to document the handling of custom SQL and its importance in maintaining database integrity during migrations.
- Refactored `messageFts.ts` to define FTS5 virtual table and associated triggers as idempotent SQL statements for better migration management.
This commit is contained in:
fullex 2026-01-04 01:07:04 +08:00
parent b1de7283dc
commit 3dfd5c7c2b
7 changed files with 567 additions and 561 deletions

View File

@ -197,3 +197,11 @@ return this.getById(id)
The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`). The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`).
Business logic can choose to use soft delete or hard delete based on requirements. Business logic can choose to use soft delete or hard delete based on requirements.
## Custom SQL
Drizzle cannot manage triggers and virtual tables (e.g., FTS5). These are defined in `customSql.ts` and run automatically after every migration.
**Why**: SQLite's `DROP TABLE` removes associated triggers. When Drizzle modifies a table schema, it drops and recreates the table, losing triggers in the process.
**Adding new custom SQL**: Define statements as `string[]` in the relevant schema file, then spread into `CUSTOM_SQL_STATEMENTS` in `customSql.ts`. All statements must use `IF NOT EXISTS` to be idempotent.

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,11 @@
"when": 1767272575118 "when": 1767272575118
}, },
{ {
"breakpoints": true,
"idx": 1, "idx": 1,
"version": "6",
"when": 1767455592181,
"tag": "0001_futuristic_human_fly", "tag": "0001_futuristic_human_fly",
"breakpoints": true "version": "6",
"when": 1767455592181
} }
], ],
"version": "7" "version": "7"

View File

@ -6,6 +6,7 @@ import { app } from 'electron'
import path from 'path' import path from 'path'
import { pathToFileURL } from 'url' import { pathToFileURL } from 'url'
import { CUSTOM_SQL_STATEMENTS } from './customSql'
import Seeding from './seeding' import Seeding from './seeding'
import type { DbType } from './types' import type { DbType } from './types'
@ -120,6 +121,9 @@ class DbService {
const migrationsFolder = this.getMigrationsFolder() const migrationsFolder = this.getMigrationsFolder()
await migrate(this.db, { migrationsFolder }) await migrate(this.db, { migrationsFolder })
// Run custom SQL that Drizzle cannot manage (triggers, virtual tables, etc.)
await this.runCustomMigrations()
logger.info('Database migration completed successfully') logger.info('Database migration completed successfully')
} catch (error) { } catch (error) {
logger.error('Database migration failed', error as Error) logger.error('Database migration failed', error as Error)
@ -127,6 +131,27 @@ class DbService {
} }
} }
/**
* Run custom SQL statements that Drizzle cannot manage
*
* This includes triggers, virtual tables, and other SQL objects.
* Called after every migration because:
* 1. Drizzle doesn't track these in schema
* 2. DROP TABLE removes associated triggers
* 3. All statements use IF NOT EXISTS, so they're idempotent
*/
private async runCustomMigrations(): Promise<void> {
try {
for (const statement of CUSTOM_SQL_STATEMENTS) {
await this.db.run(sql.raw(statement))
}
logger.debug('Custom migrations completed', { count: CUSTOM_SQL_STATEMENTS.length })
} catch (error) {
logger.error('Custom migrations failed', error as Error)
throw error
}
}
/** /**
* Get the database instance * Get the database instance
* @throws {Error} If database is not initialized * @throws {Error} If database is not initialized

View File

@ -14,8 +14,10 @@ src/main/data/db/
│ ├── columnHelpers.ts # Reusable column definitions │ ├── columnHelpers.ts # Reusable column definitions
│ ├── topic.ts # Topic table │ ├── topic.ts # Topic table
│ ├── message.ts # Message table │ ├── message.ts # Message table
│ ├── messageFts.ts # FTS5 virtual table & triggers
│ └── ... # Other tables │ └── ... # Other tables
├── seeding/ # Database initialization ├── seeding/ # Database initialization
├── customSql.ts # Custom SQL (triggers, virtual tables, etc.)
└── DbService.ts # Database connection management └── DbService.ts # Database connection management
``` ```
@ -33,6 +35,10 @@ src/main/data/db/
yarn db:migrations:generate yarn db:migrations:generate
``` ```
### Custom SQL (Triggers, Virtual Tables)
Drizzle cannot manage triggers and virtual tables. See `customSql.ts` for how these are handled.
### Column Helpers ### Column Helpers
```typescript ```typescript

View File

@ -0,0 +1,25 @@
/**
* Custom SQL statements that Drizzle cannot manage
*
* Drizzle ORM doesn't track:
* - Virtual tables (FTS5)
* - Triggers
* - Custom indexes with expressions
*
* These are executed after every migration via DbService.runCustomMigrations()
* All statements must be idempotent (use IF NOT EXISTS, etc.)
*
* To add new custom SQL:
* 1. Create statements in the relevant schema file (e.g., messageFts.ts)
* 2. Import and spread them into CUSTOM_SQL_STATEMENTS below
*/
import { MESSAGE_FTS_STATEMENTS } from './schemas/messageFts'
/**
* All custom SQL statements to run after migrations
*/
export const CUSTOM_SQL_STATEMENTS: string[] = [
...MESSAGE_FTS_STATEMENTS
// Add more custom SQL arrays here as needed
]

View File

@ -24,58 +24,50 @@ export const SEARCHABLE_TEXT_EXPRESSION = `
` `
/** /**
* Migration SQL - Copy these statements to migration file * Custom SQL statements that Drizzle cannot manage
* These are executed after every migration via DbService.runCustomMigrations()
*
* All statements should use IF NOT EXISTS to be idempotent.
*/ */
export const MESSAGE_FTS_MIGRATION_SQL = ` export const MESSAGE_FTS_STATEMENTS: string[] = [
--> statement-breakpoint // FTS5 virtual table, Links to message table's searchable_text column
-- ============================================================ `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(
-- FTS5 Virtual Table and Triggers for Message Full-Text Search searchable_text,
-- ============================================================ content='message',
content_rowid='rowid',
tokenize='trigram'
)`,
-- 1. Create FTS5 virtual table with external content // Trigger: populate searchable_text and sync FTS on INSERT
-- Links to message table's searchable_text column `CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN
CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( UPDATE message SET searchable_text = (
searchable_text, SELECT group_concat(json_extract(value, '$.content'), ' ')
content='message', FROM json_each(json_extract(NEW.data, '$.blocks'))
content_rowid='rowid', WHERE json_extract(value, '$.type') = 'main_text'
tokenize='trigram' ) WHERE id = NEW.id;
);--> statement-breakpoint INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END`,
-- 2. Trigger: populate searchable_text and sync FTS on INSERT // Trigger: sync FTS on DELETE
CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN `CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN
-- Extract searchable text from data.blocks INSERT INTO message_fts(message_fts, rowid, searchable_text)
UPDATE message SET searchable_text = ( VALUES ('delete', OLD.rowid, OLD.searchable_text);
SELECT group_concat(json_extract(value, '$.content'), ' ') END`,
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
-- Sync to FTS5
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END;--> statement-breakpoint
-- 3. Trigger: sync FTS on DELETE // Trigger: update searchable_text and sync FTS on UPDATE OF data
CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN `CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN
INSERT INTO message_fts(message_fts, rowid, searchable_text) INSERT INTO message_fts(message_fts, rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text); VALUES ('delete', OLD.rowid, OLD.searchable_text);
END;--> statement-breakpoint UPDATE message SET searchable_text = (
SELECT group_concat(json_extract(value, '$.content'), ' ')
-- 4. Trigger: update searchable_text and sync FTS on UPDATE OF data FROM json_each(json_extract(NEW.data, '$.blocks'))
CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN WHERE json_extract(value, '$.type') = 'main_text'
-- Remove old FTS entry ) WHERE id = NEW.id;
INSERT INTO message_fts(message_fts, rowid, searchable_text) INSERT INTO message_fts(rowid, searchable_text)
VALUES ('delete', OLD.rowid, OLD.searchable_text); SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
-- Update searchable_text END`
UPDATE message SET searchable_text = ( ]
SELECT group_concat(json_extract(value, '$.content'), ' ')
FROM json_each(json_extract(NEW.data, '$.blocks'))
WHERE json_extract(value, '$.type') = 'main_text'
) WHERE id = NEW.id;
-- Add new FTS entry
INSERT INTO message_fts(rowid, searchable_text)
SELECT rowid, searchable_text FROM message WHERE id = NEW.id;
END;
`
/** /**
* Rebuild FTS index (run manually if needed) * Rebuild FTS index (run manually if needed)