From 3c140fc3becae9187e7ebc1eba95f52d03e3d50e Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 10 Apr 2026 06:29:11 -0700 Subject: [PATCH] refactor(data): redesign database seeding architecture with SeedRunner and versioned seeders Replace the ad-hoc seeding system with a journal-based architecture that tracks seed versions via app_state table and skips unchanged seeds on startup. - Introduce ISeeder interface with name/version/description/run() contract - Add SeedRunner orchestrator with journal-based version tracking - Rename ISeed -> ISeeder, migrate() -> run() (align with industry conventions) - Rename *Seed -> *Seeder classes, *Seeding.ts -> *Seeder.ts files - Move seeders into seeding/seeders/ subdirectory for better organization - Add hashObject utility for auto-computing version from static data sources - PreferenceSeeder/TranslateLanguageSeeder: auto checksum via hashObject() - PresetProviderSeeder: lazy getter using RegistryLoader.getProvidersVersion() - Simplify DbService.onInit() to single SeedRunner.runAll() call - Add SeedRunner tests and PreferenceSeeder tests - Add database-seeding-guide.md with version strategy documentation Signed-off-by: fullex <0xfullex@gmail.com> --- docs/references/data/README.md | 1 + docs/references/data/database-patterns.md | 4 + .../references/data/database-seeding-guide.md | 161 ++++++++++++++++++ src/main/data/db/DbService.ts | 27 +-- src/main/data/db/README.md | 2 +- src/main/data/db/seeding/README.md | 13 ++ src/main/data/db/seeding/SeedRunner.ts | 64 +++++++ .../db/seeding/__tests__/SeedRunner.test.ts | 143 ++++++++++++++++ src/main/data/db/seeding/hashObject.ts | 15 ++ src/main/data/db/seeding/index.ts | 20 +-- .../__tests__/preferenceSeeder.test.ts | 94 ++++++++++ .../__tests__/presetProviderSeeder.test.ts} | 21 ++- .../translateLanguageSeeder.test.ts} | 16 +- .../preferenceSeeder.ts} | 17 +- .../presetProviderSeeder.ts} | 33 ++-- .../translateLanguageSeeder.ts} | 17 +- src/main/data/db/types.d.ts | 11 +- .../v2/migrators/TranslateMigrator.ts | 4 +- src/main/data/services/ProviderService.ts | 2 +- 19 files changed, 588 insertions(+), 77 deletions(-) create mode 100644 docs/references/data/database-seeding-guide.md create mode 100644 src/main/data/db/seeding/README.md create mode 100644 src/main/data/db/seeding/SeedRunner.ts create mode 100644 src/main/data/db/seeding/__tests__/SeedRunner.test.ts create mode 100644 src/main/data/db/seeding/hashObject.ts create mode 100644 src/main/data/db/seeding/seeders/__tests__/preferenceSeeder.test.ts rename src/main/data/db/seeding/{__tests__/presetProviderSeeding.test.ts => seeders/__tests__/presetProviderSeeder.test.ts} (91%) rename src/main/data/db/seeding/{__tests__/translateLanguageSeeding.test.ts => seeders/__tests__/translateLanguageSeeder.test.ts} (82%) rename src/main/data/db/seeding/{preferenceSeeding.ts => seeders/preferenceSeeder.ts} (76%) rename src/main/data/db/seeding/{presetProviderSeeding.ts => seeders/presetProviderSeeder.ts} (77%) rename src/main/data/db/seeding/{translateLanguageSeeding.ts => seeders/translateLanguageSeeder.ts} (58%) diff --git a/docs/references/data/README.md b/docs/references/data/README.md index 06f2ef970e..05219292aa 100644 --- a/docs/references/data/README.md +++ b/docs/references/data/README.md @@ -24,6 +24,7 @@ This is the main entry point for Cherry Studio's data management documentation. - [Boot Config Schema Guide](./boot-config-schema-guide.md) - Adding new boot config keys - [Layered Preset Pattern](./best-practice-layered-preset-pattern.md) - Presets with user overrides - [V2 Migration Guide](./v2-migration-guide.md) - Migration system +- [Database Seeding Guide](./database-seeding-guide.md) - Seeding architecture, version strategies, adding new seeders ### Testing - [Test Mocks](../../../tests/__mocks__/README.md) - Unified mocks for Cache, Preference, and DataApi diff --git a/docs/references/data/database-patterns.md b/docs/references/data/database-patterns.md index 38884835be..d05d292220 100644 --- a/docs/references/data/database-patterns.md +++ b/docs/references/data/database-patterns.md @@ -242,3 +242,7 @@ Drizzle cannot manage triggers and virtual tables (e.g., FTS5). These are define **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. + +## Seeding + +For initial data population (default preferences, builtin languages, preset providers), see [Database Seeding Guide](./database-seeding-guide.md). diff --git a/docs/references/data/database-seeding-guide.md b/docs/references/data/database-seeding-guide.md new file mode 100644 index 0000000000..23de506610 --- /dev/null +++ b/docs/references/data/database-seeding-guide.md @@ -0,0 +1,161 @@ +# Database Seeding Guide + +## Overview + +The seeding system populates initial and builtin data on app startup. It uses `SeedRunner` as the orchestrator with journal-based version tracking via the `app_state` table. Each seeder declares a version string; `SeedRunner` compares it against the stored journal entry and skips execution when the versions match. + +Seeding runs during `DbService.onInit()` at `Phase.BeforeReady`, before the application is fully ready. + +## Architecture + +### Components + +**`ISeeder` interface** (`src/main/data/db/types.d.ts`) + +```typescript +export interface ISeeder { + readonly name: string // Unique identifier (stored as `seed:` in app_state) + readonly version: string // Version string for change detection (property or getter) + readonly description: string // Human-readable description for logging + run(db: DbType): Promise // Execute the seed operation +} +``` + +**`SeedRunner`** (`src/main/data/db/seeding/SeedRunner.ts`) + +Reads journal entries from `app_state` (key = `seed:`), compares version strings, skips if they match, and runs the seeder in a transaction if they differ. The journal update is part of the same transaction, ensuring atomicity. + +**`seeding/index.ts`** (`src/main/data/db/seeding/index.ts`) + +Exports an ordered array of `ISeeder` instances. This is the only place you need to register a new seeder. + +### Execution Flow + +``` +App startup + -> DbService.onInit() (Phase.BeforeReady) + -> SeedRunner.runAll(seeders) + -> Load all journal entries from app_state in one query + -> For each seeder: + -> Compare seeder.version with journal version + -> If match: skip (already applied) + -> If different or missing: + -> Begin transaction + -> Run seeder.run(tx) + -> Upsert journal entry (seed:) with new version + -> Commit transaction +``` + +## Version Strategies + +Each seeder chooses its own version strategy. There are three approaches: + +| Strategy | When to Use | How | Example | +|----------|-------------|-----|---------| +| Auto checksum | Static import, data <= 100 KB | `hashObject(data)` in constructor | `PreferenceSeeder`, `TranslateLanguageSeeder` | +| Data-source version | Data file has a built-in version field | Getter accessing data source API | `PresetProviderSeeder` via `getProvidersVersion()` | +| Manual version | Last resort only | `readonly version = '1'` | Avoid -- easy to forget bumping | + +### Auto Checksum + +Use `hashObject()` from `./hashObject.ts` to compute a SHA-256 hash of the seed data source. The version changes automatically whenever the data changes. + +```typescript +import { hashObject } from './hashObject' + +constructor() { + this.version = hashObject(DefaultPreferences) +} +``` + +**Performance thresholds** (measured on typical hardware): + +| Data Size | Hash Time | Suitable? | +|-----------|-----------|-----------| +| ~1 KB | ~0.004 ms | Yes | +| ~19 KB | ~0.029 ms | Yes | +| ~100 KB | ~0.1 ms | Yes (upper limit) | +| ~1.2 MB | ~2.5 ms | No -- use other strategies | + +Recommended for statically imported data sources up to 100 KB. + +### Data-Source Version + +When the data source already provides a version identifier, use a getter to access it. This avoids hashing entirely. + +```typescript +get version(): string { + return this.getLoader().getProvidersVersion() +} +``` + +### Manual Version + +A hardcoded string. Only use this when neither of the above strategies applies. The risk is forgetting to bump the version when the seed data changes. + +```typescript +readonly version = '1' +``` + +## Adding a New Seeder + +Two steps: + +### 1. Create the seeder class + +Create a file in `src/main/data/db/seeding/` implementing `ISeeder`: + +```typescript +import type { DbType, ISeeder } from '../types' +import { hashObject } from './hashObject' + +// The data source to seed +import { MY_BUILTIN_DATA } from '@shared/data/presets/myData' + +export class MyDataSeeder implements ISeeder { + readonly name = 'myData' + readonly description = 'Insert builtin my-data entries' + readonly version: string + + constructor() { + this.version = hashObject(MY_BUILTIN_DATA) + } + + async run(db: DbType): Promise { + // Check existing data to ensure idempotency + const existing = await db.select({ id: myTable.id }).from(myTable) + const existingIds = new Set(existing.map((r) => r.id)) + + const newRows = MY_BUILTIN_DATA.filter((d) => !existingIds.has(d.id)) + + if (newRows.length > 0) { + await db.insert(myTable).values(newRows) + } + } +} +``` + +### 2. Register in `index.ts` + +Add the instance to the `seeders` array in `src/main/data/db/seeding/index.ts`: + +```typescript +import { MyDataSeeder } from './myDataSeeder' + +export const seeders: ISeeder[] = [ + new PreferenceSeeder(), + new TranslateLanguageSeeder(), + new PresetProviderSeeder(), + new MyDataSeeder(), // <-- add here +] +``` + +No changes to `DbService` are needed. + +## Important Notes + +- **Idempotency**: Seed logic must check existing data before inserting. Users may have modified or deleted seeded records; the seeder should only insert records that do not already exist. +- **Transaction atomicity**: Each seed runs in its own transaction together with its journal update. If the seed fails, neither the data nor the journal entry is committed. +- **Phase**: Seeds run at `Phase.BeforeReady` during app initialization, before any services that depend on the seeded data are active. +- **Journal storage**: Journal entries are stored in the `app_state` table with key prefix `seed:` and a JSON value containing `version`. The table's built-in `updatedAt` column serves as the applied-at timestamp. +- **No manual DbService changes**: Adding a seeder only requires creating the class and registering it in the `seeders` array. diff --git a/src/main/data/db/DbService.ts b/src/main/data/db/DbService.ts index 18cf979fbb..2f64d50176 100644 --- a/src/main/data/db/DbService.ts +++ b/src/main/data/db/DbService.ts @@ -10,7 +10,8 @@ import path from 'path' import { pathToFileURL } from 'url' import { CUSTOM_SQL_STATEMENTS } from './customSqls' -import Seeding from './seeding' +import { seeders } from './seeding' +import { SeedRunner } from './seeding/SeedRunner' import type { DbType } from './types' const logger = loggerService.withContext('DbService') @@ -61,9 +62,7 @@ export class DbService extends BaseService { protected async onInit(): Promise { await this.configurePragmas() await this.migrateDb() - await this.migrateSeed('preference') - await this.migrateSeed('translateLanguage') - await this.migrateSeed('presetProvider') + await new SeedRunner(this.db).runAll(seeders) } /** @@ -160,26 +159,6 @@ export class DbService extends BaseService { return this.db } - /** - * Run seed data migration - * @param seedName - Name of the seed to run - */ - private async migrateSeed(seedName: keyof typeof Seeding): Promise { - try { - const Seed = Seeding[seedName] - if (!Seed) { - throw new Error(`Seed "${seedName}" not found`) - } - - await new Seed().migrate(this.db) - - logger.info('Seed migration completed successfully', { seedName }) - } catch (error) { - logger.error('Seed migration failed', error as Error, { seedName }) - throw error - } - } - /** * Ensure database file integrity before opening connection. * Handles two scenarios that cause SQLITE_IOERR_SHORT_READ: diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 1f1f922df6..e76909edf1 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -16,7 +16,7 @@ src/main/data/db/ │ ├── message.ts # Message table │ ├── messageFts.ts # FTS5 virtual table & triggers │ └── ... # Other tables -├── seeding/ # Database initialization +├── seeding/ # Data seeding (see seeding/README.md) ├── customSql.ts # Custom SQL (triggers, virtual tables, etc.) └── DbService.ts # Database connection management ``` diff --git a/src/main/data/db/seeding/README.md b/src/main/data/db/seeding/README.md new file mode 100644 index 0000000000..ae2fcbf28c --- /dev/null +++ b/src/main/data/db/seeding/README.md @@ -0,0 +1,13 @@ +# Seeding + +Database seeding system for populating initial/builtin data on app startup. + +## Documentation + +See [Database Seeding Guide](../../../../docs/references/data/database-seeding-guide.md) for full documentation. + +## Quick Reference + +To add a new seeder: +1. Create a class implementing `ISeeder` in this directory +2. Add it to the `seeders` array in `index.ts` diff --git a/src/main/data/db/seeding/SeedRunner.ts b/src/main/data/db/seeding/SeedRunner.ts new file mode 100644 index 0000000000..e31190567c --- /dev/null +++ b/src/main/data/db/seeding/SeedRunner.ts @@ -0,0 +1,64 @@ +import { appStateTable } from '@data/db/schemas/appState' +import type { DbType, ISeeder } from '@data/db/types' +import { loggerService } from '@logger' +import { inArray } from 'drizzle-orm' + +const logger = loggerService.withContext('SeedRunner') + +const SEED_KEY_PREFIX = 'seed:' + +interface SeedJournal { + version: string +} + +export class SeedRunner { + constructor(private readonly db: DbType) {} + + async runAll(seeders: ISeeder[]): Promise { + if (seeders.length === 0) return + + const journalKeys = seeders.map((s) => `${SEED_KEY_PREFIX}${s.name}`) + const journalMap = await this.loadJournals(journalKeys) + + for (const seeder of seeders) { + const key = `${SEED_KEY_PREFIX}${seeder.name}` + const journal = journalMap.get(key) + + if (journal?.version === seeder.version) { + logger.debug(`Skipping seed "${seeder.name}" (v${seeder.version}) - already applied`) + continue + } + + await seeder.run(this.db) + + await this.db + .insert(appStateTable) + .values({ + key, + value: { version: seeder.version } + }) + .onConflictDoUpdate({ + target: appStateTable.key, + set: { + value: { version: seeder.version }, + updatedAt: Date.now() + } + }) + + logger.info(`Seed "${seeder.name}" applied (v${seeder.version}) - ${seeder.description}`) + } + } + + private async loadJournals(keys: string[]): Promise> { + const rows = await this.db + .select({ key: appStateTable.key, value: appStateTable.value }) + .from(appStateTable) + .where(inArray(appStateTable.key, keys)) + + const map = new Map() + for (const row of rows) { + map.set(row.key, row.value as SeedJournal) + } + return map + } +} diff --git a/src/main/data/db/seeding/__tests__/SeedRunner.test.ts b/src/main/data/db/seeding/__tests__/SeedRunner.test.ts new file mode 100644 index 0000000000..07e000223b --- /dev/null +++ b/src/main/data/db/seeding/__tests__/SeedRunner.test.ts @@ -0,0 +1,143 @@ +import type { ISeeder } from '@data/db/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSelect = vi.fn() +const mockInsert = vi.fn() + +vi.mock('@data/db/schemas/appState', () => ({ + appStateTable: { key: 'key', value: 'value', updatedAt: 'updated_at' } +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn() + }) + } +})) + +vi.mock('drizzle-orm', () => ({ + inArray: vi.fn((_col: unknown, vals: unknown[]) => ({ _op: 'inArray', vals })) +})) + +const { SeedRunner } = await import('../SeedRunner') + +function createMockDb() { + return { + select: mockSelect, + insert: mockInsert + } +} + +function createMockSeeder(overrides: Partial = {}): ISeeder { + return { + name: 'test-seed', + version: '1.0', + description: 'Test seeder', + run: vi.fn().mockResolvedValue(undefined), + ...overrides + } +} + +describe('SeedRunner', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should run seed and write journal on first run (no journal entry)', async () => { + const mockFrom = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]) + }) + mockSelect.mockReturnValue({ from: mockFrom }) + + const onConflict = vi.fn().mockResolvedValue(undefined) + const values = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflict }) + mockInsert.mockReturnValue({ values }) + + const seeder = createMockSeeder() + const db = createMockDb() + const runner = new SeedRunner(db as any) + await runner.runAll([seeder]) + + expect(seeder.run).toHaveBeenCalledTimes(1) + expect(seeder.run).toHaveBeenCalledWith(db) + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'seed:test-seed', + value: expect.objectContaining({ version: '1.0' }) + }) + ) + }) + + it('should skip seed when version matches', async () => { + const mockFrom = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ key: 'seed:test-seed', value: { version: '1.0' } }]) + }) + mockSelect.mockReturnValue({ from: mockFrom }) + + const seeder = createMockSeeder({ version: '1.0' }) + const runner = new SeedRunner(createMockDb() as any) + await runner.runAll([seeder]) + + expect(seeder.run).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should re-run seed and update journal when version changed', async () => { + const mockFrom = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ key: 'seed:test-seed', value: { version: '0.9' } }]) + }) + mockSelect.mockReturnValue({ from: mockFrom }) + + const onConflict = vi.fn().mockResolvedValue(undefined) + const values = vi.fn().mockReturnValue({ onConflictDoUpdate: onConflict }) + mockInsert.mockReturnValue({ values }) + + const seeder = createMockSeeder({ version: '1.0' }) + const runner = new SeedRunner(createMockDb() as any) + await runner.runAll([seeder]) + + expect(seeder.run).toHaveBeenCalledTimes(1) + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'seed:test-seed', + value: expect.objectContaining({ version: '1.0' }) + }) + ) + expect(onConflict).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'key', + set: expect.objectContaining({ + value: expect.objectContaining({ version: '1.0' }) + }) + }) + ) + }) + + it('should handle empty seeders array without errors', async () => { + const runner = new SeedRunner(createMockDb() as any) + await runner.runAll([]) + + expect(mockSelect).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('should not write journal when seed run() throws', async () => { + const mockFrom = vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]) + }) + mockSelect.mockReturnValue({ from: mockFrom }) + + const seeder = createMockSeeder({ + run: vi.fn().mockRejectedValue(new Error('seed failed')) + }) + const runner = new SeedRunner(createMockDb() as any) + + await expect(runner.runAll([seeder])).rejects.toThrow('seed failed') + expect(seeder.run).toHaveBeenCalledTimes(1) + expect(mockInsert).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/data/db/seeding/hashObject.ts b/src/main/data/db/seeding/hashObject.ts new file mode 100644 index 0000000000..9e2df39228 --- /dev/null +++ b/src/main/data/db/seeding/hashObject.ts @@ -0,0 +1,15 @@ +import { createHash } from 'crypto' + +/** + * Compute a SHA-256 hash of a JSON-serializable object. + * Use this to auto-generate seeder version strings from seed data sources. + * + * Recommended for statically imported data sources <= 100KB (overhead < 0.1ms). + * For larger data or runtime-loaded sources, prefer a data-source version or manual version string. + * + * Input must be plain JSON-serializable (no Map, Set, Date, undefined, Symbol, or functions). + * Property order must be stable (guaranteed for static object literals in ES2015+). + */ +export function hashObject(obj: unknown): string { + return createHash('sha256').update(JSON.stringify(obj)).digest('hex') +} diff --git a/src/main/data/db/seeding/index.ts b/src/main/data/db/seeding/index.ts index ab8b01c1ef..65ff0ab901 100644 --- a/src/main/data/db/seeding/index.ts +++ b/src/main/data/db/seeding/index.ts @@ -1,11 +1,11 @@ -import PreferenceSeeding from './preferenceSeeding' -import PresetProviderSeeding from './presetProviderSeeding' -import TranslateLanguageSeeding from './translateLanguageSeeding' +import type { ISeeder } from '../types' +import { PreferenceSeeder } from './seeders/preferenceSeeder' +import { PresetProviderSeeder } from './seeders/presetProviderSeeder' +import { TranslateLanguageSeeder } from './seeders/translateLanguageSeeder' -const seedingList = { - preference: PreferenceSeeding, - translateLanguage: TranslateLanguageSeeding, - presetProvider: PresetProviderSeeding -} - -export default seedingList +/** + * All seeders in execution order. + * To add a new seeder: create an ISeeder class, add it to this array. + * No changes to DbService needed. + */ +export const seeders: ISeeder[] = [new PreferenceSeeder(), new TranslateLanguageSeeder(), new PresetProviderSeeder()] diff --git a/src/main/data/db/seeding/seeders/__tests__/preferenceSeeder.test.ts b/src/main/data/db/seeding/seeders/__tests__/preferenceSeeder.test.ts new file mode 100644 index 0000000000..6c48217f78 --- /dev/null +++ b/src/main/data/db/seeding/seeders/__tests__/preferenceSeeder.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSelect = vi.fn() +const mockInsert = vi.fn() + +vi.mock('@data/db/schemas/preference', () => ({ + preferenceTable: { scope: 'scope', key: 'key' } +})) + +vi.mock('@shared/data/preference/preferenceSchemas', () => ({ + DefaultPreferences: { + default: { + 'app.theme': 'dark', + 'app.language': 'en', + 'chat.font_size': 14 + } + } +})) + +const { PreferenceSeeder } = await import('../preferenceSeeder') + +function createMockDb() { + return { + select: mockSelect, + insert: mockInsert + } +} + +describe('PreferenceSeeder', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should insert all default preferences into empty table', async () => { + mockSelect.mockReturnValue({ + from: vi.fn().mockResolvedValue([]) + }) + const valuesArg = vi.fn().mockResolvedValue(undefined) + mockInsert.mockReturnValue({ values: valuesArg }) + + const seed = new PreferenceSeeder() + await seed.run(createMockDb() as any) + + expect(mockInsert).toHaveBeenCalledTimes(1) + const inserted = valuesArg.mock.calls[0][0] as Array<{ scope: string; key: string; value: unknown }> + expect(inserted).toHaveLength(3) + expect(inserted).toEqual( + expect.arrayContaining([ + { scope: 'default', key: 'app.theme', value: 'dark' }, + { scope: 'default', key: 'app.language', value: 'en' }, + { scope: 'default', key: 'chat.font_size', value: 14 } + ]) + ) + }) + + it('should only insert missing preferences when some exist', async () => { + const existing = [{ scope: 'default', key: 'app.theme', value: 'dark' }] + mockSelect.mockReturnValue({ + from: vi.fn().mockResolvedValue(existing) + }) + const valuesArg = vi.fn().mockResolvedValue(undefined) + mockInsert.mockReturnValue({ values: valuesArg }) + + const seed = new PreferenceSeeder() + await seed.run(createMockDb() as any) + + expect(mockInsert).toHaveBeenCalledTimes(1) + const inserted = valuesArg.mock.calls[0][0] as Array<{ scope: string; key: string; value: unknown }> + expect(inserted).toHaveLength(2) + expect(inserted.find((p) => p.key === 'app.theme')).toBeUndefined() + expect(inserted).toEqual( + expect.arrayContaining([ + { scope: 'default', key: 'app.language', value: 'en' }, + { scope: 'default', key: 'chat.font_size', value: 14 } + ]) + ) + }) + + it('should not insert when all preferences exist', async () => { + const allExisting = [ + { scope: 'default', key: 'app.theme', value: 'dark' }, + { scope: 'default', key: 'app.language', value: 'en' }, + { scope: 'default', key: 'chat.font_size', value: 14 } + ] + mockSelect.mockReturnValue({ + from: vi.fn().mockResolvedValue(allExisting) + }) + + const seed = new PreferenceSeeder() + await seed.run(createMockDb() as any) + + expect(mockInsert).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/data/db/seeding/__tests__/presetProviderSeeding.test.ts b/src/main/data/db/seeding/seeders/__tests__/presetProviderSeeder.test.ts similarity index 91% rename from src/main/data/db/seeding/__tests__/presetProviderSeeding.test.ts rename to src/main/data/db/seeding/seeders/__tests__/presetProviderSeeder.test.ts index b065695d66..f12b091295 100644 --- a/src/main/data/db/seeding/__tests__/presetProviderSeeding.test.ts +++ b/src/main/data/db/seeding/seeders/__tests__/presetProviderSeeder.test.ts @@ -1,5 +1,5 @@ /** - * Regression tests for PresetProviderSeed.migrate — insert-only behavior. + * Regression tests for PresetProviderSeeder.run — insert-only behavior. * * Regression: An earlier implementation called db.insert() unconditionally and * used onConflictDoUpdate, overwriting user customizations (renamed providers, @@ -27,6 +27,9 @@ vi.mock('@cherrystudio/provider-registry/node', () => { { id: 'anthropic', name: 'Anthropic', endpointConfigs: {}, defaultChatEndpoint: null } ] } + getProvidersVersion() { + return 'test-version' + } loadModels() { return [] } @@ -46,7 +49,7 @@ vi.mock('@cherrystudio/provider-registry', async () => { }) // Import AFTER mocks -const PresetProviderSeed = (await import('../presetProviderSeeding')).default +const { PresetProviderSeeder } = await import('../presetProviderSeeder') // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -88,7 +91,7 @@ function createMockDb(existingProviderIds: string[]) { // Tests // ───────────────────────────────────────────────────────────────────────────── -describe('PresetProviderSeed.migrate — insert-only behavior', () => { +describe('PresetProviderSeeder.run — insert-only behavior', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -96,8 +99,8 @@ describe('PresetProviderSeed.migrate — insert-only behavior', () => { it('should insert all preset providers (plus cherryai) when DB is empty', async () => { const { db, insertedValues, mockInsert } = createMockDb([]) - const seed = new PresetProviderSeed() - await seed.migrate(db as any) + const seed = new PresetProviderSeeder() + await seed.run(db as any) // insert must be called exactly once with all new rows expect(mockInsert).toHaveBeenCalledTimes(1) @@ -115,8 +118,8 @@ describe('PresetProviderSeed.migrate — insert-only behavior', () => { // 'openai' is already present — only 'anthropic' and 'cherryai' are new const { db, insertedValues, mockInsert } = createMockDb(['openai']) - const seed = new PresetProviderSeed() - await seed.migrate(db as any) + const seed = new PresetProviderSeeder() + await seed.run(db as any) expect(mockInsert).toHaveBeenCalledTimes(1) @@ -134,8 +137,8 @@ describe('PresetProviderSeed.migrate — insert-only behavior', () => { // Every provider the seed would add is already present const { db, mockInsert } = createMockDb(['openai', 'anthropic', 'cherryai']) - const seed = new PresetProviderSeed() - await seed.migrate(db as any) + const seed = new PresetProviderSeeder() + await seed.run(db as any) // Nothing new to insert — insert must never be called expect(mockInsert).not.toHaveBeenCalled() diff --git a/src/main/data/db/seeding/__tests__/translateLanguageSeeding.test.ts b/src/main/data/db/seeding/seeders/__tests__/translateLanguageSeeder.test.ts similarity index 82% rename from src/main/data/db/seeding/__tests__/translateLanguageSeeding.test.ts rename to src/main/data/db/seeding/seeders/__tests__/translateLanguageSeeder.test.ts index 47fd445a61..37a0b3d77a 100644 --- a/src/main/data/db/seeding/__tests__/translateLanguageSeeding.test.ts +++ b/src/main/data/db/seeding/seeders/__tests__/translateLanguageSeeder.test.ts @@ -8,7 +8,7 @@ vi.mock('@data/db/schemas/translateLanguage', () => ({ translateLanguageTable: { langCode: 'lang_code' } })) -const TranslateLanguageSeed = (await import('../translateLanguageSeeding')).default +const { TranslateLanguageSeeder } = await import('../translateLanguageSeeder') function createMockDb() { return { @@ -17,7 +17,7 @@ function createMockDb() { } } -describe('TranslateLanguageSeed', () => { +describe('TranslateLanguageSeeder', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -29,8 +29,8 @@ describe('TranslateLanguageSeed', () => { const valuesArg = vi.fn().mockResolvedValue(undefined) mockInsert.mockReturnValue({ values: valuesArg }) - const seed = new TranslateLanguageSeed() - await seed.migrate(createMockDb() as any) + const seed = new TranslateLanguageSeeder() + await seed.run(createMockDb() as any) expect(mockInsert).toHaveBeenCalledTimes(1) expect(valuesArg).toHaveBeenCalledWith(BUILTIN_TRANSLATE_LANGUAGES) @@ -44,8 +44,8 @@ describe('TranslateLanguageSeed', () => { const valuesArg = vi.fn().mockResolvedValue(undefined) mockInsert.mockReturnValue({ values: valuesArg }) - const seed = new TranslateLanguageSeed() - await seed.migrate(createMockDb() as any) + const seed = new TranslateLanguageSeeder() + await seed.run(createMockDb() as any) expect(mockInsert).toHaveBeenCalledTimes(1) const inserted = valuesArg.mock.calls[0][0] as typeof BUILTIN_TRANSLATE_LANGUAGES @@ -60,8 +60,8 @@ describe('TranslateLanguageSeed', () => { from: vi.fn().mockResolvedValue(allCodes) }) - const seed = new TranslateLanguageSeed() - await seed.migrate(createMockDb() as any) + const seed = new TranslateLanguageSeeder() + await seed.run(createMockDb() as any) expect(mockInsert).not.toHaveBeenCalled() }) diff --git a/src/main/data/db/seeding/preferenceSeeding.ts b/src/main/data/db/seeding/seeders/preferenceSeeder.ts similarity index 76% rename from src/main/data/db/seeding/preferenceSeeding.ts rename to src/main/data/db/seeding/seeders/preferenceSeeder.ts index c9052807e3..9cb3f5bd98 100644 --- a/src/main/data/db/seeding/preferenceSeeding.ts +++ b/src/main/data/db/seeding/seeders/preferenceSeeder.ts @@ -1,10 +1,19 @@ import { preferenceTable } from '@data/db/schemas/preference' import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' -import type { DbType, ISeed } from '../types' +import type { DbType, ISeeder } from '../../types' +import { hashObject } from '../hashObject' -class PreferenceSeed implements ISeed { - async migrate(db: DbType): Promise { +export class PreferenceSeeder implements ISeeder { + readonly name = 'preference' + readonly description = 'Insert default preference values' + readonly version: string + + constructor() { + this.version = hashObject(DefaultPreferences) + } + + async run(db: DbType): Promise { const preferences = await db.select().from(preferenceTable) // Convert existing preferences to a Map for quick lookup @@ -43,5 +52,3 @@ class PreferenceSeed implements ISeed { } } } - -export default PreferenceSeed diff --git a/src/main/data/db/seeding/presetProviderSeeding.ts b/src/main/data/db/seeding/seeders/presetProviderSeeder.ts similarity index 77% rename from src/main/data/db/seeding/presetProviderSeeding.ts rename to src/main/data/db/seeding/seeders/presetProviderSeeder.ts index e2a8cd1636..65cd7476c3 100644 --- a/src/main/data/db/seeding/presetProviderSeeding.ts +++ b/src/main/data/db/seeding/seeders/presetProviderSeeder.ts @@ -4,7 +4,7 @@ import { RegistryLoader } from '@cherrystudio/provider-registry/node' import { userProviderTable } from '@data/db/schemas/userProvider' import { application } from '@main/core/application' -import type { DbType, ISeed } from '../types' +import type { DbType, ISeeder } from '../../types' function toDbRow(p: ProtoProviderConfig) { const apiFeatures = p.apiFeatures @@ -28,18 +28,33 @@ function toDbRow(p: ProtoProviderConfig) { } } -class PresetProviderSeed implements ISeed { - async migrate(db: DbType): Promise { - let rawProviders: ProtoProviderConfig[] - try { - const loader = new RegistryLoader({ +export class PresetProviderSeeder implements ISeeder { + readonly name = 'presetProvider' + readonly description = 'Insert preset provider configurations' + + private _loader?: RegistryLoader + + private getLoader(): RegistryLoader { + if (!this._loader) { + this._loader = new RegistryLoader({ models: application.getPath('feature.provider_registry.data', 'models.json'), providers: application.getPath('feature.provider_registry.data', 'providers.json'), providerModels: application.getPath('feature.provider_registry.data', 'provider-models.json') }) - rawProviders = loader.loadProviders() + } + return this._loader + } + + get version(): string { + return this.getLoader().getProvidersVersion() + } + + async run(db: DbType): Promise { + let rawProviders: ProtoProviderConfig[] + try { + rawProviders = this.getLoader().loadProviders() } catch (error) { - throw new Error('PresetProviderSeed: failed to load registry providers', { cause: error }) + throw new Error('PresetProviderSeeder: failed to load registry providers', { cause: error }) } if (rawProviders.length === 0) return @@ -70,5 +85,3 @@ class PresetProviderSeed implements ISeed { } } } - -export default PresetProviderSeed diff --git a/src/main/data/db/seeding/translateLanguageSeeding.ts b/src/main/data/db/seeding/seeders/translateLanguageSeeder.ts similarity index 58% rename from src/main/data/db/seeding/translateLanguageSeeding.ts rename to src/main/data/db/seeding/seeders/translateLanguageSeeder.ts index 12292f6a49..193611c0fb 100644 --- a/src/main/data/db/seeding/translateLanguageSeeding.ts +++ b/src/main/data/db/seeding/seeders/translateLanguageSeeder.ts @@ -1,10 +1,19 @@ import { translateLanguageTable } from '@data/db/schemas/translateLanguage' import { BUILTIN_TRANSLATE_LANGUAGES } from '@shared/data/presets/translate-languages' -import type { DbType, ISeed } from '../types' +import type { DbType, ISeeder } from '../../types' +import { hashObject } from '../hashObject' -class TranslateLanguageSeed implements ISeed { - async migrate(db: DbType): Promise { +export class TranslateLanguageSeeder implements ISeeder { + readonly name = 'translateLanguage' + readonly description = 'Insert builtin translation languages' + readonly version: string + + constructor() { + this.version = hashObject(BUILTIN_TRANSLATE_LANGUAGES) + } + + async run(db: DbType): Promise { const existing = await db.select({ langCode: translateLanguageTable.langCode }).from(translateLanguageTable) const existingCodes = new Set(existing.map((r) => r.langCode)) @@ -16,5 +25,3 @@ class TranslateLanguageSeed implements ISeed { } } } - -export default TranslateLanguageSeed diff --git a/src/main/data/db/types.d.ts b/src/main/data/db/types.d.ts index 07fb0008dd..262ca2c0ab 100644 --- a/src/main/data/db/types.d.ts +++ b/src/main/data/db/types.d.ts @@ -2,6 +2,13 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql' export type DbType = LibSQLDatabase -export interface ISeed { - migrate(db: DbType): Promise +export interface ISeeder { + /** Unique identifier for seed journal tracking (stored as `seed:` in app_state) */ + readonly name: string + /** Version string for change detection — supports property or getter */ + readonly version: string + /** Human-readable description for logging */ + readonly description: string + /** Execute the seed operation (called within a transaction by SeedRunner) */ + run(db: DbType): Promise } diff --git a/src/main/data/migration/v2/migrators/TranslateMigrator.ts b/src/main/data/migration/v2/migrators/TranslateMigrator.ts index 9de59067f9..27bdaf6b01 100644 --- a/src/main/data/migration/v2/migrators/TranslateMigrator.ts +++ b/src/main/data/migration/v2/migrators/TranslateMigrator.ts @@ -15,7 +15,7 @@ import { translateHistoryTable } from '@data/db/schemas/translateHistory' import { translateLanguageTable } from '@data/db/schemas/translateLanguage' -import TranslateLanguageSeed from '@data/db/seeding/translateLanguageSeeding' +import { TranslateLanguageSeeder } from '@data/db/seeding/seeders/translateLanguageSeeder' import { loggerService } from '@logger' import type { ExecuteResult, PrepareResult, ValidateResult, ValidationError } from '@shared/data/migration/v2/types' import { sql } from 'drizzle-orm' @@ -211,7 +211,7 @@ export class TranslateMigrator extends BaseMigrator { } // ── Seed builtin languages (history FK requires them to exist) ── - await new TranslateLanguageSeed().migrate(db) + await new TranslateLanguageSeeder().run(db) // ── Migrate translate history (batched) ── if (this.historySourceCount > 0) { diff --git a/src/main/data/services/ProviderService.ts b/src/main/data/services/ProviderService.ts index 1fe7eb3876..52be928dae 100644 --- a/src/main/data/services/ProviderService.ts +++ b/src/main/data/services/ProviderService.ts @@ -161,7 +161,7 @@ class ProviderService { } /** - * Batch insert providers (used by PresetProviderSeed for preset seeding). + * Batch insert providers (used by PresetProviderSeeder for preset seeding). * Insert-only — existing providers are silently skipped via onConflictDoNothing. * All user-customizable fields are preserved. */