From 4febed9eab575071cfb508876b9d754e6486f1e3 Mon Sep 17 00:00:00 2001 From: le king fu Date: Thu, 8 Oct 2026 07:33:47 -0400 Subject: [PATCH] feat(data): announce counter-moving writes on a data-change channel Add src/services/dataEvents.ts (ADR 0022): notifyDataChanged() and subscribeDataChanged(), without React, on the model of logService.subscribe. A throwing subscriber is logged and does not stop the others. Notify after every successful write of the tables the getting-started counters read, plus the import folder: setImportFolder, createSource, deleteSource, createImportedFile, updateFileStatus, both import deletions, insertBatch, updateTransactionCategory, applyKeywordWithReassignment (after COMMIT), the four budget writes and runRestore (after COMMIT, one point for the three restore modes). executeImport notifies once more when it ends, succeeded or not. A failed write notifies nothing; transactional writes notify once withTransaction has returned. applyTemplate notifies once for the whole template through a private writeBudgetEntry helper. Each service suite proves the notification after a success and its absence after a failure; executeImport is driven as a plain function with React's hooks stubbed. Co-Authored-By: Claude Opus 5.5 --- .../useImportWizard.executeImport.test.ts | 144 ++++++++++++++++++ src/hooks/useImportWizard.ts | 6 + src/services/budgetService.test.ts | 101 +++++++++++- src/services/budgetService.ts | 37 ++++- src/services/categorizationService.test.ts | 65 +++++++- src/services/categorizationService.ts | 8 +- src/services/dataEvents.test.ts | 89 +++++++++++ src/services/dataEvents.ts | 45 ++++++ src/services/dataExportService.test.ts | 80 +++++++++- src/services/dataExportService.ts | 9 +- src/services/importSourceService.test.ts | 94 ++++++++++++ src/services/importSourceService.ts | 9 ++ src/services/importedFileService.test.ts | 142 +++++++++++++++++ src/services/importedFileService.ts | 10 ++ src/services/transactionService.test.ts | 65 +++++++- src/services/transactionService.ts | 6 + src/services/userPreferenceService.test.ts | 65 ++++++++ src/services/userPreferenceService.ts | 8 +- 18 files changed, 965 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useImportWizard.executeImport.test.ts create mode 100644 src/services/dataEvents.test.ts create mode 100644 src/services/dataEvents.ts create mode 100644 src/services/importSourceService.test.ts create mode 100644 src/services/importedFileService.test.ts create mode 100644 src/services/userPreferenceService.test.ts diff --git a/src/hooks/useImportWizard.executeImport.test.ts b/src/hooks/useImportWizard.executeImport.test.ts new file mode 100644 index 0000000..abb04a8 --- /dev/null +++ b/src/hooks/useImportWizard.executeImport.test.ts @@ -0,0 +1,144 @@ +// useImportWizard — `executeImport` announces its end on the data channel (#396). +// +// No renderer in this project, and the reducer alone cannot reach the import: +// `executeImport` is a callback of the hook. React's three hooks used by +// `useImportWizard` are stubbed so the hook runs as a plain function on a +// prepared state, and the services it calls are mocked. What is pinned here is +// the ADR 0022 contract: one last notification once the import is over, +// succeeded or not, after its inserts and file statuses. + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const harness = vi.hoisted(() => ({ + state: null as unknown, + dispatch: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useReducer: () => [harness.state, harness.dispatch], + useCallback: (fn: unknown) => fn, + useEffect: () => undefined, + }; +}); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => "hash-of-file") })); +vi.mock("../services/db", () => ({ getDb: vi.fn(), withTransaction: vi.fn() })); +vi.mock("../services/dataEvents", () => ({ + notifyDataChanged: vi.fn(), + subscribeDataChanged: vi.fn(() => () => undefined), +})); +vi.mock("../services/userPreferenceService", () => ({ + getImportFolder: vi.fn(async () => null), + setImportFolder: vi.fn(), +})); +vi.mock("../services/importSourceService", () => ({ + getAllSources: vi.fn(async () => []), + getSourceByName: vi.fn(async () => null), + createSource: vi.fn(async () => 7), + updateSource: vi.fn(async () => undefined), +})); +vi.mock("../services/importedFileService", () => ({ + existsByHash: vi.fn(async () => null), + createImportedFile: vi.fn(async () => 11), + updateFileStatus: vi.fn(async () => undefined), + getFilesBySourceId: vi.fn(async () => []), +})); +vi.mock("../services/transactionService", () => ({ + insertBatch: vi.fn(async (rows: unknown[]) => rows.length), + findDuplicates: vi.fn(async () => []), +})); +vi.mock("../services/categorizationService", () => ({ + categorizeBatch: vi.fn(async (descriptions: string[]) => + descriptions.map(() => ({ category_id: null, supplier_id: null })), + ), +})); +vi.mock("../services/importConfigTemplateService", () => ({ + getAllTemplates: vi.fn(async () => []), + createTemplate: vi.fn(), + updateTemplate: vi.fn(), + deleteTemplate: vi.fn(), +})); + +import { useImportWizard, initialWizardState, type WizardState } from "./useImportWizard"; +import { notifyDataChanged } from "../services/dataEvents"; +import { createSource } from "../services/importSourceService"; +import { createImportedFile, updateFileStatus } from "../services/importedFileService"; +import { insertBatch } from "../services/transactionService"; +import type { ParsedRow } from "../shared/types"; + +const rows: ParsedRow[] = [1, 2].map((i) => ({ + rowIndex: i, + raw: [`2026-09-0${i}`, `Achat ${i}`, `-${i}`], + parsed: { date: `2026-09-0${i}`, description: `Achat ${i}`, amount: -i }, + sourceFilename: "releve.csv", +})); + +/** The wizard on its "confirm" step, ready to import two rows of one file. */ +const readyToImport = (): WizardState => ({ + ...initialWizardState, + step: "confirm", + sourceConfig: { ...initialWizardState.sourceConfig, name: "Desjardins" }, + selectedFiles: [ + { filename: "releve.csv", file_path: "/releves/Desjardins/releve.csv", size_bytes: 10, modified_at: "" }, + ], + parsedPreview: rows, + previewHeaders: ["date", "description", "amount"], + duplicateResult: { fileAlreadyImported: false, duplicateRows: [], newRows: rows }, + excludedDuplicateIndices: new Set(), +}); + +/** The order in which a mock was first called, to compare with the notification. */ +const order = (mock: unknown): number => vi.mocked(mock as () => void).mock.invocationCallOrder[0]; + +beforeEach(() => { + vi.mocked(notifyDataChanged).mockClear(); + vi.mocked(createSource).mockClear(); + vi.mocked(createImportedFile).mockClear(); + vi.mocked(updateFileStatus).mockClear(); + vi.mocked(insertBatch).mockClear(); + harness.dispatch.mockClear(); + harness.state = readyToImport(); +}); + +describe("executeImport — final data-change notification (#396)", () => { + it("notifies once at the end of a successful import, after the inserts", async () => { + await useImportWizard().executeImport(); + + expect(insertBatch).toHaveBeenCalledTimes(1); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + expect(order(notifyDataChanged)).toBeGreaterThan(order(insertBatch)); + expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "report" }); + }); + + it("notifies once at the end when the inserts fail, after the file statuses are settled", async () => { + vi.mocked(insertBatch).mockRejectedValueOnce(new Error("database is locked")); + + await useImportWizard().executeImport(); + + expect(updateFileStatus).toHaveBeenCalledWith(11, "error", 0, "Error: database is locked"); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + expect(order(notifyDataChanged)).toBeGreaterThan(order(updateFileStatus)); + }); + + it("notifies once at the end when the import fails before any insert", async () => { + vi.mocked(createSource).mockRejectedValueOnce(new Error("CHECK constraint failed")); + + await useImportWizard().executeImport(); + + expect(insertBatch).not.toHaveBeenCalled(); + expect(harness.dispatch).toHaveBeenCalledWith({ type: "SET_STEP", payload: "confirm" }); + expect(notifyDataChanged).toHaveBeenCalledTimes(1); + }); + + it("does not notify when there is nothing to import (no duplicate check yet)", async () => { + harness.state = { ...readyToImport(), duplicateResult: null }; + + await useImportWizard().executeImport(); + + expect(createSource).not.toHaveBeenCalled(); + expect(notifyDataChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 7f1a54b..65b8ace 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -33,6 +33,7 @@ import { findDuplicates, } from "../services/transactionService"; import { categorizeBatch } from "../services/categorizationService"; +import { notifyDataChanged } from "../services/dataEvents"; import { getAllTemplates, createTemplate, @@ -1156,6 +1157,11 @@ export function useImportWizard() { payload: e instanceof Error ? e.message : String(e), }); dispatch({ type: "SET_STEP", payload: "confirm" }); + } finally { + // One more announcement once the import is over, succeeded or not, so + // the last re-read of the getting-started path sees its final state: + // inserts done, file statuses settled (ADR 0022). + notifyDataChanged(); } }, [ state.duplicateResult, diff --git a/src/services/budgetService.test.ts b/src/services/budgetService.test.ts index 1fb8d64..d22e0f1 100644 --- a/src/services/budgetService.test.ts +++ b/src/services/budgetService.test.ts @@ -1,5 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getBudgetVsActualData, + getActualTotalsForYear, + upsertBudgetEntry, + deleteBudgetEntry, + upsertBudgetEntriesForYear, + applyTemplate, + saveAsTemplate, +} from "./budgetService"; +import { subscribeDataChanged } from "./dataEvents"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -9,11 +18,13 @@ vi.mock("./db", () => { import { getDb } from "./db"; const mockSelect = vi.fn(); -const mockDb = { select: mockSelect }; +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); + mockExecute.mockReset(); }); // getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories, @@ -118,3 +129,87 @@ describe("getActualTotalsForYear — accountIds filter (Issue #276)", () => { expect(params as unknown[]).toEqual(["2025-01-01", "2025-12-31", 4, 9]); }); }); + +describe("budget writes — data-change notification (#396)", () => { + /** Number of `execute` calls already made at each data-change notification. */ + const notified: number[] = []; + let unsubscribe: () => void = () => undefined; + + beforeEach(() => { + notified.length = 0; + unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); + }); + + afterEach(() => { + unsubscribe(); + }); + + it("upsertBudgetEntry notifies once after the write, and not when it fails", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await upsertBudgetEntry(4, 2026, 10, 250); + expect(notified).toEqual([1]); + + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(upsertBudgetEntry(4, 2026, 11, 250)).rejects.toThrow("database is locked"); + expect(notified).toEqual([1]); + }); + + it("deleteBudgetEntry notifies once after the delete, and not when it fails", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteBudgetEntry(4, 2026, 10); + expect(notified).toEqual([1]); + + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(deleteBudgetEntry(4, 2026, 10)).rejects.toThrow("database is locked"); + expect(notified).toEqual([1]); + }); + + it("upsertBudgetEntriesForYear notifies once, after the twelve months", async () => { + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + await upsertBudgetEntriesForYear(4, 2026, [100, 0, 100, 0, 100, 0, 100, 0, 100, 0, 100, 0]); + expect(mockExecute).toHaveBeenCalledTimes(12); + expect(notified).toEqual([12]); + }); + + it("upsertBudgetEntriesForYear does not notify when a month fails", async () => { + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1 }) + .mockRejectedValueOnce(new Error("database is locked")); + await expect(upsertBudgetEntriesForYear(4, 2026, Array(12).fill(100))).rejects.toThrow( + "database is locked", + ); + expect(notified).toEqual([]); + }); + + it("applyTemplate notifies once, after the template's last entry", async () => { + mockSelect.mockResolvedValueOnce([ + { id: 1, template_id: 7, category_id: 4, amount: 100 }, + { id: 2, template_id: 7, category_id: 5, amount: 200 }, + { id: 3, template_id: 7, category_id: 6, amount: 300 }, + ]); + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + await applyTemplate(7, 2026, 10); + expect(mockExecute).toHaveBeenCalledTimes(3); + expect(mockExecute.mock.calls.map((c) => (c[1] as unknown[])[0])).toEqual([4, 5, 6]); + expect(mockExecute.mock.calls[0][1]).toEqual([4, 2026, 10, 100, null]); + expect(notified).toEqual([3]); + }); + + it("applyTemplate does not notify when an entry fails", async () => { + mockSelect.mockResolvedValueOnce([ + { id: 1, template_id: 7, category_id: 4, amount: 100 }, + { id: 2, template_id: 7, category_id: 5, amount: 200 }, + ]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1 }) + .mockRejectedValueOnce(new Error("database is locked")); + await expect(applyTemplate(7, 2026, 10)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); + + it("saving a template changes no counter: saveAsTemplate stays silent", async () => { + mockExecute.mockResolvedValue({ rowsAffected: 1, lastInsertId: 9 }); + await saveAsTemplate("Mensuel", undefined, [{ category_id: 4, amount: 100 }]); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index 13d9f41..9bc2586 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -1,4 +1,6 @@ +import type Database from "@tauri-apps/plugin-sql"; import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { inPlaceholders } from "../utils/sqlFilters"; import type { Category, @@ -40,6 +42,26 @@ export async function getBudgetEntriesForMonth( ); } +// Every budget write below is announced on the data channel once it succeeded: +// the number of budget entries is a getting-started counter (ADR 0022). + +/** The UPSERT behind `upsertBudgetEntry` and `applyTemplate`, without the announcement. */ +async function writeBudgetEntry( + db: Database, + categoryId: number, + year: number, + month: number, + amount: number, + notes?: string +): Promise { + await db.execute( + `INSERT INTO budget_entries (category_id, year, month, amount, notes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`, + [categoryId, year, month, amount, notes || null] + ); +} + export async function upsertBudgetEntry( categoryId: number, year: number, @@ -48,12 +70,8 @@ export async function upsertBudgetEntry( notes?: string ): Promise { const db = await getDb(); - await db.execute( - `INSERT INTO budget_entries (category_id, year, month, amount, notes) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`, - [categoryId, year, month, amount, notes || null] - ); + await writeBudgetEntry(db, categoryId, year, month, amount, notes); + notifyDataChanged(); } export async function deleteBudgetEntry( @@ -66,6 +84,7 @@ export async function deleteBudgetEntry( "DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3", [categoryId, year, month] ); + notifyDataChanged(); } export async function getActualsByCategory( @@ -116,6 +135,7 @@ export async function upsertBudgetEntriesForYear( ); } } + notifyDataChanged(); } // Templates @@ -165,9 +185,12 @@ export async function applyTemplate( month: number ): Promise { const entries = await getTemplateEntries(templateId); + const db = await getDb(); for (const entry of entries) { - await upsertBudgetEntry(entry.category_id, year, month, entry.amount); + await writeBudgetEntry(db, entry.category_id, year, month, entry.amount); } + // One announcement for the whole template, after its last entry. + notifyDataChanged(); } export async function deleteTemplate(templateId: number): Promise { diff --git a/src/services/categorizationService.test.ts b/src/services/categorizationService.test.ts index 4fbdf9c..4c96bf4 100644 --- a/src/services/categorizationService.test.ts +++ b/src/services/categorizationService.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { validateKeyword, previewKeywordMatches, applyKeywordWithReassignment, KEYWORD_MAX_LENGTH, } from "./categorizationService"; +import { subscribeDataChanged } from "./dataEvents"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -18,7 +19,7 @@ vi.mock("./db", () => { }; }); -import { getDb } from "./db"; +import { getDb, withTransaction } from "./db"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); @@ -180,3 +181,63 @@ describe("applyKeywordWithReassignment", () => { expect(mockExecute).not.toHaveBeenCalled(); }); }); + +describe("applyKeywordWithReassignment — data-change notification (#396)", () => { + const input = { + keyword: "METRO", + categoryId: 3, + transactionIds: [1, 2], + allowReplaceExisting: false, + }; + + /** At each notification: the last statement issued, and whether the DB lock was held. */ + const seen: Array<{ last: string; inLock: boolean }> = []; + let inLock = false; + let unsubscribe: () => void = () => undefined; + + beforeEach(() => { + seen.length = 0; + inLock = false; + vi.mocked(withTransaction).mockImplementation(async (fn) => { + inLock = true; + try { + return await fn((await getDb()) as never); + } finally { + inLock = false; + } + }); + unsubscribe = subscribeDataChanged(() => { + const calls = mockExecute.mock.calls; + seen.push({ last: String(calls[calls.length - 1]?.[0]).trim(), inLock }); + }); + }); + + afterEach(() => { + unsubscribe(); + vi.mocked(withTransaction).mockImplementation(async (fn) => fn((await getDb()) as never)); + }); + + it("notifies once, after the COMMIT and once the DB lock is released", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValue({ lastInsertId: 77, rowsAffected: 1 }); + await applyKeywordWithReassignment(input); + expect(seen).toEqual([{ last: "COMMIT", inLock: false }]); + }); + + it("does not notify after a ROLLBACK", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute + .mockResolvedValueOnce(undefined) // BEGIN + .mockResolvedValueOnce({ lastInsertId: 77 }) // INSERT keywords + .mockRejectedValueOnce(new Error("disk full")); // UPDATE transactions fails + await expect(applyKeywordWithReassignment(input)).rejects.toThrow("disk full"); + expect(seen).toEqual([]); + }); + + it("does not notify for a refused keyword", async () => { + await expect(applyKeywordWithReassignment({ ...input, keyword: "a" })).rejects.toThrow( + "invalid_keyword:tooShort", + ); + expect(seen).toEqual([]); + }); +}); diff --git a/src/services/categorizationService.ts b/src/services/categorizationService.ts index e678a91..5e42dde 100644 --- a/src/services/categorizationService.ts +++ b/src/services/categorizationService.ts @@ -1,4 +1,5 @@ import { getDb, withTransaction } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type Database from "@tauri-apps/plugin-sql"; import type { Keyword, RecentTransaction } from "../shared/types"; @@ -223,9 +224,14 @@ export async function applyKeywordWithReassignment( } const keyword = validation.value; - return withTransaction((db) => + const result = await withTransaction((db) => applyKeywordInTransaction(db, input, keyword), ); + // Reached only after the COMMIT (a ROLLBACK rethrows), and once the DB lock + // is released: the recategorised transactions carry the manual flag that the + // "categorize by hand" step counts (ADR 0022). + notifyDataChanged(); + return result; } async function applyKeywordInTransaction( diff --git a/src/services/dataEvents.test.ts b/src/services/dataEvents.test.ts new file mode 100644 index 0000000..601c810 --- /dev/null +++ b/src/services/dataEvents.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("./logService", () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +import { logError } from "./logService"; +import { notifyDataChanged, subscribeDataChanged } from "./dataEvents"; + +// The channel is module state: every test unsubscribes what it subscribed. +const cleanups: Array<() => void> = []; +function subscribe(listener: () => void): () => void { + const unsubscribe = subscribeDataChanged(listener); + cleanups.push(unsubscribe); + return unsubscribe; +} + +beforeEach(() => { + vi.mocked(logError).mockReset(); +}); + +afterEach(() => { + cleanups.splice(0).forEach((unsubscribe) => unsubscribe()); +}); + +describe("dataEvents (#396)", () => { + it("a subscriber receives each notification", () => { + const listener = vi.fn(); + subscribe(listener); + notifyDataChanged(); + notifyDataChanged(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("an unsubscribed listener no longer receives", () => { + const listener = vi.fn(); + const unsubscribe = subscribe(listener); + notifyDataChanged(); + unsubscribe(); + notifyDataChanged(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("unsubscribing one listener leaves the others subscribed", () => { + const first = vi.fn(); + const second = vi.fn(); + const unsubscribeFirst = subscribe(first); + subscribe(second); + unsubscribeFirst(); + notifyDataChanged(); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("a subscriber that throws does not stop the others, and notify never throws", () => { + const before = vi.fn(); + const after = vi.fn(); + subscribe(before); + subscribe(() => { + throw new Error("listener bug"); + }); + subscribe(after); + + expect(() => notifyDataChanged()).not.toThrow(); + expect(before).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledTimes(1); + expect(vi.mocked(logError).mock.calls[0]).toEqual([ + "Data events: a subscriber failed", + "listener bug", + ]); + }); + + it("a listener that unsubscribes while being called does not skip the next one", () => { + const next = vi.fn(); + const unsubscribeSelf: { fn: () => void } = { fn: () => undefined }; + unsubscribeSelf.fn = subscribe(() => unsubscribeSelf.fn()); + subscribe(next); + notifyDataChanged(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("notifies nobody without subscribers", () => { + expect(() => notifyDataChanged()).not.toThrow(); + expect(logError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/dataEvents.ts b/src/services/dataEvents.ts new file mode 100644 index 0000000..9fbb443 --- /dev/null +++ b/src/services/dataEvents.ts @@ -0,0 +1,45 @@ +import { logError } from "./logService"; + +/** + * Data-change channel (#396, ADR 0022). + * + * The writes that may move a counter read elsewhere (the getting-started path + * reads `COUNTERS_SQL` and the import folder) call `notifyDataChanged()` once + * they have succeeded. The message only says "data that counters read may have + * changed": it carries no payload, and the data layer does not know who + * listens. Subscribers re-read on their own terms (the onboarding provider + * waits 150 ms to group a burst, then ignores any stale answer). + * + * Modeled on `logService.subscribe`, without React. + */ + +type DataChangedListener = () => void; + +const listeners = new Set(); + +/** + * Tells every subscriber that data read by counters may have changed. Never + * throws: a subscriber that fails is logged and the others still run, so a + * write that succeeded is never reported as failed because of a listener. + */ +export function notifyDataChanged(): void { + // Iterate over a copy: a listener may unsubscribe while being called. + for (const listener of [...listeners]) { + try { + listener(); + } catch (e) { + logError( + "Data events: a subscriber failed", + e instanceof Error ? e.message : String(e), + ); + } + } +} + +/** Subscribes to data changes. Returns the function that unsubscribes. */ +export function subscribeDataChanged(listener: DataChangedListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/services/dataExportService.test.ts b/src/services/dataExportService.test.ts index 3e101bf..1702c35 100644 --- a/src/services/dataExportService.test.ts +++ b/src/services/dataExportService.test.ts @@ -21,7 +21,7 @@ * transaction exists to survive. */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -49,6 +49,7 @@ import { type ExportImportSource, type ExportImportTemplate, } from "./dataExportService"; +import { subscribeDataChanged } from "./dataEvents"; import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat"; import type { Category } from "../shared/types"; import fr from "../i18n/locales/fr.json"; @@ -762,3 +763,80 @@ describe("a restore writes the manual flag as an integer (#390)", () => { expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]); }); }); + +// --------------------------------------------------------------------------- +// 8. A committed restore is announced on the data channel (#396, ADR 0022) +// --------------------------------------------------------------------------- + +describe("a committed restore is announced on the data channel (#396)", () => { + /** The statements already issued at each notification. */ + const seen: string[][] = []; + let unsubscribe: () => void = () => undefined; + + beforeEach(() => { + seen.length = 0; + unsubscribe = subscribeDataChanged(() => seen.push([...db.log])); + }); + + afterEach(() => { + unsubscribe(); + }); + + it("transactions with categories: once, after the COMMIT", async () => { + await importTransactionsWithCategories( + envelopeData({ transactions: [transactionFixture(1)] }), + "backup.json" + ); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("transactions only: once, after the COMMIT", async () => { + await importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json"); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("categories only: once, after the COMMIT", async () => { + await importCategoriesOnly({ categories: CATEGORY_FIXTURES, suppliers: [], keywords: [] }); + expect(seen).toHaveLength(1); + expect(seen[0][seen[0].length - 1]).toBe("COMMIT"); + }); + + it("a restore rolled back announces nothing", async () => { + let txInserts = 0; + wire( + makeFakeDb((sql) => { + if (!/INSERT INTO transactions/.test(sql)) return false; + return ++txInserts === 2; + }) + ); + await expect( + importTransactionsOnly( + envelopeData({ transactions: [transactionFixture(1), transactionFixture(2)] }), + "backup.json" + ) + ).rejects.toThrow("boom"); + expect(db.log).toContain("ROLLBACK"); + expect(seen).toEqual([]); + }); + + it("a failed COMMIT announces nothing", async () => { + wire(makeFakeDb((sql) => sql.trim() === "COMMIT")); + await expect( + importTransactionsOnly(envelopeData({ transactions: [transactionFixture(1)] }), "backup.json") + ).rejects.toThrow("boom"); + expect(seen).toEqual([]); + }); + + it("a backup refused at the boundary announces nothing", async () => { + await expect( + importTransactionsWithCategories( + envelopeData({ import_sources: [{ ...SOURCE, amount_mode: "nonsense" as never }] }), + "backup.json" + ) + ).rejects.toThrow(); + expect(db.log).toEqual([]); + expect(seen).toEqual([]); + }); +}); diff --git a/src/services/dataExportService.ts b/src/services/dataExportService.ts index bd3ef9a..5663f39 100644 --- a/src/services/dataExportService.ts +++ b/src/services/dataExportService.ts @@ -1,5 +1,6 @@ import type Database from "@tauri-apps/plugin-sql"; import { getDb, withTransaction } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import Papa from "papaparse"; import { toSqlFlag } from "../utils/sqlFlag"; import type { @@ -414,11 +415,16 @@ const HOST_SOURCE_MAPPING = JSON.stringify({ * to go back to. `withTransaction` holds the single DB lock for the whole * BEGIN..COMMIT so the statements cannot be split across pooled connections * (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue. + * + * A committed restore replaces the data every getting-started counter reads, + * so it is announced on the data channel: one point for the three restore + * modes, reached only after the COMMIT (a rollback rethrows) and once the DB + * lock is released (ADR 0022). */ async function runRestore( body: (db: Database) => Promise ): Promise { - return withTransaction(async (db) => { + await withTransaction(async (db) => { await db.execute("BEGIN"); let open = true; try { @@ -436,6 +442,7 @@ async function runRestore( throw e; } }); + notifyDataChanged(); } async function restoreCategories( diff --git a/src/services/importSourceService.test.ts b/src/services/importSourceService.test.ts new file mode 100644 index 0000000..801e170 --- /dev/null +++ b/src/services/importSourceService.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("./db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); + +import { getDb } from "./db"; +import { subscribeDataChanged } from "./dataEvents"; +import { + createSource, + deleteSource, + updateSource, + type ImportSourceInput, +} from "./importSourceService"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +/** Number of `execute` calls already made at each data-change notification. */ +const notified: number[] = []; +let unsubscribe: () => void = () => undefined; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); + notified.length = 0; + unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); +}); + +afterEach(() => { + unsubscribe(); +}); + +const input: ImportSourceInput = { + name: "Desjardins", + date_format: "YYYY-MM-DD", + delimiter: ",", + encoding: "utf-8", + column_mapping: '{"date":0,"description":1,"amount":2}', + skip_lines: 0, + has_header: 1, + amount_mode: "single", + sign_convention: "negative_expense", +}; + +describe("createSource — data-change notification (#396)", () => { + it("notifies once after the insert", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 4 }); + await expect(createSource(input)).resolves.toBe(4); + expect(notified).toEqual([1]); + }); + + it("notifies after an upsert on an existing name, before the lookup that only reads", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 0 }); + mockSelect.mockResolvedValueOnce([{ id: 9, name: "Desjardins" }]); + await expect(createSource(input)).resolves.toBe(9); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("CHECK constraint failed")); + await expect(createSource(input)).rejects.toThrow("CHECK constraint failed"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteSource — data-change notification (#396)", () => { + it("notifies once after the delete", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteSource(4); + expect(notified).toEqual([1]); + }); + + it("does not notify when the delete fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("FOREIGN KEY constraint failed")); + await expect(deleteSource(4)).rejects.toThrow("FOREIGN KEY constraint failed"); + expect(notified).toEqual([]); + }); +}); + +describe("updateSource — no notification (#396)", () => { + it("changes no counter, so it stays silent", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateSource(4, { name: "Desjardins chèques" }); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/importSourceService.ts b/src/services/importSourceService.ts index 4493249..8e5ba76 100644 --- a/src/services/importSourceService.ts +++ b/src/services/importSourceService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type { ImportFormatRow, ImportSource } from "../shared/types"; /** @@ -85,12 +86,19 @@ export async function createSource( source.template_id ?? null, ] ); + // The number of sources is a getting-started counter (ADR 0022). Announced + // right after the write: the lookup below only reads. + notifyDataChanged(); // On conflict, lastInsertId may be 0 — look up the existing row if (result.lastInsertId) return result.lastInsertId as number; const existing = await getSourceByName(source.name); return existing!.id; } +/** + * Edits a source in place. No data-change notification (ADR 0022): it changes + * no counter, the number of sources stays the same. + */ export async function updateSource( id: number, source: Partial @@ -132,4 +140,5 @@ export async function updateSource( export async function deleteSource(id: number): Promise { const db = await getDb(); await db.execute("DELETE FROM import_sources WHERE id = $1", [id]); + notifyDataChanged(); } diff --git a/src/services/importedFileService.test.ts b/src/services/importedFileService.test.ts new file mode 100644 index 0000000..110013d --- /dev/null +++ b/src/services/importedFileService.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("./db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); + +import { getDb } from "./db"; +import { subscribeDataChanged } from "./dataEvents"; +import { TransactionLinkedToBalanceError } from "./transactionService"; +import { + createImportedFile, + deleteAllImportsWithTransactions, + deleteImportWithTransactions, + updateFileStatus, +} from "./importedFileService"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +/** Number of `execute` calls already made at each data-change notification. */ +const notified: number[] = []; +let unsubscribe: () => void = () => undefined; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); + notified.length = 0; + unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); +}); + +afterEach(() => { + unsubscribe(); +}); + +const file = { + source_id: 3, + filename: "releve-2026-09.csv", + file_hash: "abc", + row_count: 12, + status: "completed", +}; + +const linkedRow = { transaction_id: 1, account_id: 2, account_name: "CELI", direction: "in" as const }; + +describe("createImportedFile — data-change notification (#396)", () => { + it("notifies once after inserting a new file record", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 21 }); + await expect(createImportedFile(file)).resolves.toBe(21); + expect(notified).toEqual([1]); + }); + + it("notifies once after updating the record of a file imported again", async () => { + mockSelect.mockResolvedValueOnce([{ id: 8 }]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await expect(createImportedFile(file)).resolves.toBe(8); + expect(String(mockExecute.mock.calls[0][0])).toContain("UPDATE imported_files"); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(createImportedFile(file)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("updateFileStatus — data-change notification (#396)", () => { + it("notifies once after the update", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateFileStatus(8, "error", 0, "insert failed"); + expect(notified).toEqual([1]); + }); + + it("does not notify when the update fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(updateFileStatus(8, "error")).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteImportWithTransactions — data-change notification (#396)", () => { + it("notifies once, after the last delete (orphaned source included)", async () => { + mockSelect + .mockResolvedValueOnce([{ source_id: 3 }]) // source of the file + .mockResolvedValueOnce([]) // no linked transfer + .mockResolvedValueOnce([{ cnt: 0 }]); // no file left for the source + mockExecute.mockResolvedValue({ rowsAffected: 12 }); + await expect(deleteImportWithTransactions(8)).resolves.toBe(12); + expect(mockExecute).toHaveBeenCalledTimes(3); + expect(notified).toEqual([3]); + }); + + it("does not notify when the pre-flight refuses (transactions linked to the Balance sheet)", async () => { + mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([linkedRow]); + await expect(deleteImportWithTransactions(8)).rejects.toBeInstanceOf( + TransactionLinkedToBalanceError, + ); + expect(mockExecute).not.toHaveBeenCalled(); + expect(notified).toEqual([]); + }); + + it("does not notify when a delete fails", async () => { + mockSelect.mockResolvedValueOnce([{ source_id: 3 }]).mockResolvedValueOnce([]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 12 }) + .mockRejectedValueOnce(new Error("database is locked")); + await expect(deleteImportWithTransactions(8)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("deleteAllImportsWithTransactions — data-change notification (#396)", () => { + it("notifies once, after the three deletes", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValue({ rowsAffected: 40 }); + await expect(deleteAllImportsWithTransactions()).resolves.toBe(40); + expect(notified).toEqual([3]); + }); + + it("does not notify when the pre-flight refuses", async () => { + mockSelect.mockResolvedValueOnce([linkedRow]); + await expect(deleteAllImportsWithTransactions()).rejects.toBeInstanceOf( + TransactionLinkedToBalanceError, + ); + expect(notified).toEqual([]); + }); + + it("does not notify when a delete fails", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(deleteAllImportsWithTransactions()).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/importedFileService.ts b/src/services/importedFileService.ts index 57f7a32..a559164 100644 --- a/src/services/importedFileService.ts +++ b/src/services/importedFileService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { isLinkedTransactionFkError } from "./balance.service"; import { TransactionLinkedToBalanceError } from "./transactionService"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; @@ -38,11 +39,14 @@ export async function createImportedFile(file: { "SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2", [file.source_id, file.filename] ); + // Both branches write the status that the "import a first file" step counts + // (`status = 'completed'`), so each one is announced (ADR 0022). if (existing.length > 0) { await db.execute( `UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`, [file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id] ); + notifyDataChanged(); return existing[0].id; } @@ -58,6 +62,7 @@ export async function createImportedFile(file: { file.notes || null, ] ); + notifyDataChanged(); return result.lastInsertId as number; } @@ -72,6 +77,7 @@ export async function updateFileStatus( `UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`, [status, rowCount ?? null, notes ?? null, id] ); + notifyDataChanged(); } export async function getAllImportedFiles(): Promise { @@ -142,6 +148,9 @@ export async function deleteImportWithTransactions( } } + // Announced once every delete went through; a refused or failed delete + // throws above and announces nothing (ADR 0022). + notifyDataChanged(); return result.rowsAffected; } @@ -175,5 +184,6 @@ export async function deleteAllImportsWithTransactions(): Promise { } await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM import_sources"); + notifyDataChanged(); return result.rowsAffected; } diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts index 317fb95..fbc6a1b 100644 --- a/src/services/transactionService.test.ts +++ b/src/services/transactionService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("./db", () => { const getDb = vi.fn(); @@ -9,16 +9,32 @@ vi.mock("./db", () => { }); import { getDb } from "./db"; -import { getTransactionCount, updateTransactionCategory } from "./transactionService"; +import { subscribeDataChanged } from "./dataEvents"; +import { + getTransactionCount, + insertBatch, + updateTransactionCategory, + updateTransactionNotes, +} from "./transactionService"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); const mockDb = { select: mockSelect, execute: mockExecute }; +/** Number of `execute` calls already made at each data-change notification. */ +const notified: number[] = []; +let unsubscribe: () => void = () => undefined; + beforeEach(() => { vi.mocked(getDb).mockResolvedValue(mockDb as never); mockSelect.mockReset(); mockExecute.mockReset(); + notified.length = 0; + unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); +}); + +afterEach(() => { + unsubscribe(); }); describe("getTransactionCount (#351)", () => { @@ -60,3 +76,48 @@ describe("updateTransactionCategory (#390)", () => { expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]); }); }); + +describe("updateTransactionCategory — data-change notification (#396)", () => { + it("notifies once after the update", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionCategory(5, 12, true); + expect(notified).toEqual([1]); + }); + + it("does not notify when the update fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(updateTransactionCategory(5, 12, true)).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); + + it("a note changes no counter: updateTransactionNotes stays silent", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionNotes(5, "loyer"); + expect(notified).toEqual([]); + }); +}); + +describe("insertBatch — data-change notification (#396)", () => { + const row = (i: number) => ({ + date: "2026-09-0" + i, + description: `Achat ${i}`, + amount: -i, + source_id: 1, + file_id: 2, + original_description: `Achat ${i}`, + }); + + it("notifies once, after the last insert", async () => { + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + await expect(insertBatch([row(1), row(2), row(3)])).resolves.toBe(3); + expect(notified).toEqual([3]); + }); + + it("does not notify when an insert fails", async () => { + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1 }) + .mockRejectedValueOnce(new Error("database is locked")); + await expect(insertBatch([row(1), row(2), row(3)])).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 0de93e0..5d98036 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import { categorizeBatch } from "./categorizationService"; import { isLinkedTransactionFkError, @@ -136,6 +137,9 @@ export async function insertBatch( onProgress(insertedCount); } + // Once, after the last insert: without it, the last re-read of an import + // could run before its transactions exist (ADR 0022). + notifyDataChanged(); return insertedCount; } @@ -306,6 +310,8 @@ export async function updateTransactionCategory( `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, [categoryId, toSqlFlag(isManual), txId] ); + // The manual flag is the "categorize by hand" step (ADR 0022). + notifyDataChanged(); } export async function updateTransactionNotes( diff --git a/src/services/userPreferenceService.test.ts b/src/services/userPreferenceService.test.ts new file mode 100644 index 0000000..d75e1c5 --- /dev/null +++ b/src/services/userPreferenceService.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("./db", () => { + const getDb = vi.fn(); + return { + getDb, + withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())), + }; +}); + +import { getDb } from "./db"; +import { subscribeDataChanged } from "./dataEvents"; +import { + deletePreference, + getImportFolder, + setImportFolder, + setPreference, +} from "./userPreferenceService"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +/** Number of `execute` calls already made at each data-change notification. */ +const notified: number[] = []; +let unsubscribe: () => void = () => undefined; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); + notified.length = 0; + unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length)); +}); + +afterEach(() => { + unsubscribe(); +}); + +describe("setImportFolder — data-change notification (#396)", () => { + it("writes the import_folder preference, then notifies once", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await setImportFolder("/home/max/releves"); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockExecute.mock.calls[0][1]).toEqual(["import_folder", "/home/max/releves"]); + expect(notified).toEqual([1]); + }); + + it("does not notify when the write fails", async () => { + mockExecute.mockRejectedValueOnce(new Error("database is locked")); + await expect(setImportFolder("/home/max/releves")).rejects.toThrow("database is locked"); + expect(notified).toEqual([]); + }); +}); + +describe("generic preferences do not notify (#396)", () => { + it("setPreference, deletePreference and reads stay silent", async () => { + mockExecute.mockResolvedValue({ rowsAffected: 1 }); + mockSelect.mockResolvedValue([]); + await setPreference("budget-grid-expanded", "[]"); + await deletePreference("budget-grid-expanded"); + await getImportFolder(); + expect(notified).toEqual([]); + }); +}); diff --git a/src/services/userPreferenceService.ts b/src/services/userPreferenceService.ts index f8bf7bf..0d2d876 100644 --- a/src/services/userPreferenceService.ts +++ b/src/services/userPreferenceService.ts @@ -1,4 +1,5 @@ import { getDb } from "./db"; +import { notifyDataChanged } from "./dataEvents"; import type { UserPreference } from "../shared/types"; export async function getPreference(key: string): Promise { @@ -35,6 +36,11 @@ export async function getImportFolder(): Promise { return getPreference("import_folder"); } +/** + * Stores the import folder. The getting-started path counts it as a step, so + * a successful write is announced on the data channel (ADR 0022). + */ export async function setImportFolder(path: string): Promise { - return setPreference("import_folder", path); + await setPreference("import_folder", path); + notifyDataChanged(); }