mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
161
docs/references/data/database-seeding-guide.md
Normal file
161
docs/references/data/database-seeding-guide.md
Normal file
@@ -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:<name>` 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<void> // Execute the seed operation
|
||||
}
|
||||
```
|
||||
|
||||
**`SeedRunner`** (`src/main/data/db/seeding/SeedRunner.ts`)
|
||||
|
||||
Reads journal entries from `app_state` (key = `seed:<name>`), 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:<name>) 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<void> {
|
||||
// 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.
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
13
src/main/data/db/seeding/README.md
Normal file
13
src/main/data/db/seeding/README.md
Normal file
@@ -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`
|
||||
64
src/main/data/db/seeding/SeedRunner.ts
Normal file
64
src/main/data/db/seeding/SeedRunner.ts
Normal file
@@ -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<void> {
|
||||
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<Map<string, SeedJournal>> {
|
||||
const rows = await this.db
|
||||
.select({ key: appStateTable.key, value: appStateTable.value })
|
||||
.from(appStateTable)
|
||||
.where(inArray(appStateTable.key, keys))
|
||||
|
||||
const map = new Map<string, SeedJournal>()
|
||||
for (const row of rows) {
|
||||
map.set(row.key, row.value as SeedJournal)
|
||||
}
|
||||
return map
|
||||
}
|
||||
}
|
||||
143
src/main/data/db/seeding/__tests__/SeedRunner.test.ts
Normal file
143
src/main/data/db/seeding/__tests__/SeedRunner.test.ts
Normal file
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
15
src/main/data/db/seeding/hashObject.ts
Normal file
15
src/main/data/db/seeding/hashObject.ts
Normal file
@@ -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')
|
||||
}
|
||||
@@ -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()]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
11
src/main/data/db/types.d.ts
vendored
11
src/main/data/db/types.d.ts
vendored
@@ -2,6 +2,13 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
|
||||
export type DbType = LibSQLDatabase
|
||||
|
||||
export interface ISeed {
|
||||
migrate(db: DbType): Promise<void>
|
||||
export interface ISeeder {
|
||||
/** Unique identifier for seed journal tracking (stored as `seed:<name>` 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<void>
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user