import { getDb } from "./db"; import { setPreference } from "./userPreferenceService"; import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService"; import type { BackupResult } from "./categoryBackupService"; import type { MigrationPlan, MappingRow } from "./categoryMappingService"; // ----------------------------------------------------------------------------- // Category migration service — orchestrates the atomic v2 → v1 SQL writeover // using a MigrationPlan computed upstream by categoryMappingService. // // The service is intentionally destructive: it is only meant to be called // *after* a verified pre-migration SREF backup (BackupResult) has been written // to disk and confirmed by the user. The caller (CategoriesMigrationPage / // useCategoryMigration hook) is responsible for sequencing backup → migrate → // surface success/error — this service only runs the DB part. // // Ordering (all inside a single BEGIN / COMMIT transaction): // 1. Guard: backup looks valid (path + checksum present). // 2. BEGIN. // 3. If plan.preserved.length > 0: INSERT a new parent category // "Catégories personnalisées (migration)" (with i18n_key so it renders // in both languages), keep its new id. // 4. INSERT all v1 taxonomy nodes with explicit ids from the bundled // `categoryTaxonomyV1.json`. We skip ids that already exist in the DB // (OR IGNORE) so a re-run is safe. // 5. UPDATE transactions.category_id per plan.rows mapping (v2 → v1). // 6. UPDATE budget_entries.category_id and budget_template_entries.category_id // per plan.rows mapping (v2 → v1). We DELETE conflicting rows first // because budget_entries has UNIQUE(category_id, year, month) and // budget_template_entries has UNIQUE(template_id, category_id). // 7. UPDATE keywords.category_id per plan.rows mapping (v2 → v1). We // DELETE conflicting rows first because of UNIQUE(keyword, category_id). // 8. UPDATE suppliers.category_id per plan.rows mapping (v2 → v1). // 9. Re-parent preserved v2 custom categories under the new "Catégories // personnalisées (migration)" parent. // 10. DELETE v2 seeded categories that are now empty (no transactions / // keywords / budgets / suppliers / child categories referencing them). // Deletion is soft (is_active=0) to preserve ON DELETE CASCADE from // historical budget_entries etc. in edge cases. // 11. Set `categories_schema_version='v1'` and record // `last_categories_migration` JSON in user_preferences. // 12. COMMIT. On any thrown error: ROLLBACK and report in MigrationOutcome. // ----------------------------------------------------------------------------- export interface MigrationOutcome { /** True when the transaction committed; false if we rolled back or aborted. */ succeeded: boolean; /** Human-readable error message on failure. Undefined on success. */ error?: string; /** Number of v1 taxonomy rows we inserted (may be 0 on re-run). */ insertedV1Count: number; /** Number of transactions whose category_id was rewritten. */ updatedTransactionsCount: number; /** Number of budget_entries + budget_template_entries rows rewritten. */ updatedBudgetsCount: number; /** Number of keywords rows rewritten. */ updatedKeywordsCount: number; /** Number of v2 categories we deactivated (soft-delete). */ deletedV2Count: number; /** Number of custom categories re-parented under the new parent. */ customPreservedCount: number; /** Path to the SREF backup that was created before this run. */ backupPath: string; } /** JSON journalled in user_preferences.last_categories_migration. */ export interface LastMigrationJournal { timestamp: string; backupPath: string; outcome: Pick< MigrationOutcome, | "insertedV1Count" | "updatedTransactionsCount" | "updatedBudgetsCount" | "updatedKeywordsCount" | "deletedV2Count" | "customPreservedCount" >; } // Preference keys we write at the end of a successful migration. const SCHEMA_VERSION_KEY = "categories_schema_version"; const LAST_MIGRATION_KEY = "last_categories_migration"; // Id reserved for the "Catégories personnalisées (migration)" parent we // create when the profile has custom v2 categories. We deliberately pick a // number that is outside the v1 taxonomy range (1000 – 1999) and outside the // v2 seed range (< 1000) to avoid collisions even on re-runs. const CUSTOM_PARENT_NEW_ID = 2000; const CUSTOM_PARENT_NAME = "Catégories personnalisées (migration)"; const CUSTOM_PARENT_I18N_KEY = "categoriesSeed.migration.customParent"; const CUSTOM_PARENT_COLOR = "#64748b"; const CUSTOM_PARENT_TYPE = "expense"; // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- function flattenTaxonomy(root: TaxonomyNode, parentId: number | null, out: Array): void { out.push({ id: root.id, name: root.name, i18n_key: root.i18n_key, parent_id: parentId, type: root.type, color: root.color, sort_order: root.sort_order, is_inputable: root.children.length === 0, }); for (const child of root.children) { flattenTaxonomy(child, root.id, out); } } interface TaxonomyFlat { id: number; name: string; i18n_key: string; parent_id: number | null; type: string; color: string; sort_order: number; is_inputable: boolean; } function listAllV1Rows(): TaxonomyFlat[] { const flat: TaxonomyFlat[] = []; for (const root of getTaxonomyV1().roots) { flattenTaxonomy(root, null, flat); } return flat; } /** Validate the backup looks usable — path + checksum present. */ function validateBackup(backup: BackupResult): void { if (!backup || typeof backup.path !== "string" || backup.path.length === 0) { throw new Error("invalid_backup: missing path"); } if (typeof backup.checksum !== "string" || backup.checksum.length === 0) { throw new Error("invalid_backup: missing checksum"); } } /** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */ function buildMappingFromRows(rows: MappingRow[]): Map { const map = new Map(); for (const row of rows) { if (row.v1TargetId !== null && row.v1TargetId !== undefined) { map.set(row.v2CategoryId, row.v1TargetId); } } return map; } // ----------------------------------------------------------------------------- // Public entry point // ----------------------------------------------------------------------------- export async function applyMigration( plan: MigrationPlan, backup: BackupResult, ): Promise { const outcome: MigrationOutcome = { succeeded: false, insertedV1Count: 0, updatedTransactionsCount: 0, updatedBudgetsCount: 0, updatedKeywordsCount: 0, deletedV2Count: 0, customPreservedCount: 0, backupPath: backup?.path ?? "", }; try { validateBackup(backup); } catch (e) { outcome.error = e instanceof Error ? e.message : String(e); return outcome; } const db = await getDb(); const mapping = buildMappingFromRows(plan.rows); await db.execute("BEGIN"); try { // 1. Optionally create the "custom categories (migration)" parent. let customParentId: number | null = null; if (plan.preserved.length > 0) { // Use INSERT OR IGNORE so a re-run never throws on the PK. await db.execute( `INSERT OR IGNORE INTO categories (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key) VALUES ($1, $2, NULL, $3, $4, 1, 0, 99, $5)`, [ CUSTOM_PARENT_NEW_ID, CUSTOM_PARENT_NAME, CUSTOM_PARENT_COLOR, CUSTOM_PARENT_TYPE, CUSTOM_PARENT_I18N_KEY, ], ); customParentId = CUSTOM_PARENT_NEW_ID; } // 2. INSERT v1 taxonomy. Roots first, then subcategories, then leaves, // thanks to flattenTaxonomy's depth-first walk. Use OR IGNORE so a // partial earlier run is recoverable. const v1Rows = listAllV1Rows(); for (const row of v1Rows) { const result = await db.execute( `INSERT OR IGNORE INTO categories (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, $8)`, [ row.id, row.name, row.parent_id, row.color, row.type, row.is_inputable ? 1 : 0, row.sort_order, row.i18n_key, ], ); // tauri-plugin-sql returns rowsAffected; on OR IGNORE conflicts it's 0. const affected = Number(result.rowsAffected ?? 0); outcome.insertedV1Count += affected; } // 3. Rewrite transactions.category_id v2 → v1. for (const [v2Id, v1Id] of mapping.entries()) { const r = await db.execute( `UPDATE transactions SET category_id = $1, updated_at = CURRENT_TIMESTAMP WHERE category_id = $2`, [v1Id, v2Id], ); outcome.updatedTransactionsCount += Number(r.rowsAffected ?? 0); } // 4. Rewrite budget_entries (handle UNIQUE(category_id, year, month) by // deleting rows we'd collide with first — in a preview-and-consent // flow, the collision means the user already has a budget on the v1 // target for the same period, so dropping the v2 duplicate is the // least-surprising choice). for (const [v2Id, v1Id] of mapping.entries()) { await db.execute( `DELETE FROM budget_entries WHERE category_id = $1 AND (year, month) IN ( SELECT year, month FROM budget_entries WHERE category_id = $2 )`, [v2Id, v1Id], ); const r = await db.execute( `UPDATE budget_entries SET category_id = $1, updated_at = CURRENT_TIMESTAMP WHERE category_id = $2`, [v1Id, v2Id], ); outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0); } // 5. Rewrite budget_template_entries (same collision rule via // UNIQUE(template_id, category_id)). for (const [v2Id, v1Id] of mapping.entries()) { await db.execute( `DELETE FROM budget_template_entries WHERE category_id = $1 AND template_id IN ( SELECT template_id FROM budget_template_entries WHERE category_id = $2 )`, [v2Id, v1Id], ); const r = await db.execute( `UPDATE budget_template_entries SET category_id = $1 WHERE category_id = $2`, [v1Id, v2Id], ); outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0); } // 6. Rewrite keywords (UNIQUE(keyword, category_id)). Drop v2 keywords // whose normalized spelling already points at the v1 target before the // UPDATE, to avoid constraint violations. for (const [v2Id, v1Id] of mapping.entries()) { await db.execute( `DELETE FROM keywords WHERE category_id = $1 AND keyword IN ( SELECT keyword FROM keywords WHERE category_id = $2 )`, [v2Id, v1Id], ); const r = await db.execute( `UPDATE keywords SET category_id = $1 WHERE category_id = $2`, [v1Id, v2Id], ); outcome.updatedKeywordsCount += Number(r.rowsAffected ?? 0); } // 7. Rewrite suppliers.category_id — no unique constraint, straightforward. for (const [v2Id, v1Id] of mapping.entries()) { await db.execute( `UPDATE suppliers SET category_id = $1, updated_at = CURRENT_TIMESTAMP WHERE category_id = $2`, [v1Id, v2Id], ); } // 8. Re-parent preserved custom categories under the new parent. We touch // only the top level of the custom tree (parent_id IS NULL or pointing // at a v2 structural parent in the 1..6 range): children follow naturally. if (customParentId !== null) { for (const preservedRow of plan.preserved) { const r = await db.execute( `UPDATE categories SET parent_id = $1 WHERE id = $2`, [customParentId, preservedRow.v2CategoryId], ); outcome.customPreservedCount += Number(r.rowsAffected ?? 0); } } // 9. Soft-delete v2 seeded categories that are now unreferenced. // We deactivate instead of hard-deleting so that any historical // reference we might have missed stays intact (is_active=0 hides them // from the UI lists). We explicitly only target the v2 seed id range // (< 1000) AND ids that map in our plan — this avoids touching user // custom categories that may also have parent_id < 1000 structural. for (const row of plan.rows) { // Only deactivate rows that were part of the v2 seed AND we successfully // mapped to a v1 target. Rows with no v1 target (unresolved review) are // left alone — in the UX, the consent step is blocked until all rows // are resolved, so this should be dead code, but it is a safety net. if (row.v1TargetId === null) continue; const r = await db.execute( `UPDATE categories SET is_active = 0 WHERE id = $1`, [row.v2CategoryId], ); outcome.deletedV2Count += Number(r.rowsAffected ?? 0); } // 10. Also deactivate the v2 structural parents (1..6) — they have no v1 // equivalent and become obsolete after the migration. { const r = await db.execute( `UPDATE categories SET is_active = 0 WHERE id IN (1, 2, 3, 4, 5, 6)`, ); outcome.deletedV2Count += Number(r.rowsAffected ?? 0); } // 11. Bump the schema version and journal the run. await db.execute( `INSERT INTO user_preferences (key, value, updated_at) VALUES ($1, 'v1', CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = 'v1', updated_at = CURRENT_TIMESTAMP`, [SCHEMA_VERSION_KEY], ); const journal: LastMigrationJournal = { timestamp: new Date().toISOString(), backupPath: backup.path, outcome: { insertedV1Count: outcome.insertedV1Count, updatedTransactionsCount: outcome.updatedTransactionsCount, updatedBudgetsCount: outcome.updatedBudgetsCount, updatedKeywordsCount: outcome.updatedKeywordsCount, deletedV2Count: outcome.deletedV2Count, customPreservedCount: outcome.customPreservedCount, }, }; await db.execute( `INSERT INTO user_preferences (key, value, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`, [LAST_MIGRATION_KEY, JSON.stringify(journal)], ); await db.execute("COMMIT"); outcome.succeeded = true; return outcome; } catch (e) { try { await db.execute("ROLLBACK"); } catch { // Swallow: if the rollback itself fails there is nothing we can do here // besides returning the original error to the caller. } outcome.error = e instanceof Error ? e.message : String(e); outcome.succeeded = false; return outcome; } } /** * Convenience: mark the schema as v1 without running the full migration. * Exported for tests and tooling — the happy-path user flow goes through * `applyMigration` which handles this transactionally. */ export async function markSchemaVersionV1(): Promise { await setPreference(SCHEMA_VERSION_KEY, "v1"); }