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:
fullex
2026-04-10 06:29:11 -07:00
parent 798a15e919
commit 3c140fc3be
19 changed files with 588 additions and 77 deletions

View File

@@ -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

View File

@@ -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).

View 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.

View File

@@ -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:

View File

@@ -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
```

View 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`

View 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
}
}

View 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()
})
})

View 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')
}

View File

@@ -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()]

View File

@@ -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()
})
})

View File

@@ -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()

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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) {

View File

@@ -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.
*/