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 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-10-08 07:33:47 -04:00
parent 4a82116614
commit 4febed9eab
18 changed files with 965 additions and 18 deletions

View file

@ -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<typeof import("react")>();
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();
});
});

View file

@ -33,6 +33,7 @@ import {
findDuplicates, findDuplicates,
} from "../services/transactionService"; } from "../services/transactionService";
import { categorizeBatch } from "../services/categorizationService"; import { categorizeBatch } from "../services/categorizationService";
import { notifyDataChanged } from "../services/dataEvents";
import { import {
getAllTemplates, getAllTemplates,
createTemplate, createTemplate,
@ -1156,6 +1157,11 @@ export function useImportWizard() {
payload: e instanceof Error ? e.message : String(e), payload: e instanceof Error ? e.message : String(e),
}); });
dispatch({ type: "SET_STEP", payload: "confirm" }); 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, state.duplicateResult,

View file

@ -1,5 +1,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getBudgetVsActualData, getActualTotalsForYear } from "./budgetService"; import {
getBudgetVsActualData,
getActualTotalsForYear,
upsertBudgetEntry,
deleteBudgetEntry,
upsertBudgetEntriesForYear,
applyTemplate,
saveAsTemplate,
} from "./budgetService";
import { subscribeDataChanged } from "./dataEvents";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); const getDb = vi.fn();
@ -9,11 +18,13 @@ vi.mock("./db", () => {
import { getDb } from "./db"; import { getDb } from "./db";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
const mockDb = { select: mockSelect }; const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute };
beforeEach(() => { beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never); vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset(); mockSelect.mockReset();
mockExecute.mockReset();
}); });
// getBudgetVsActualData fans out to 4 parallel selects (getAllActiveCategories, // 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]); 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([]);
});
});

View file

@ -1,4 +1,6 @@
import type Database from "@tauri-apps/plugin-sql";
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { inPlaceholders } from "../utils/sqlFilters"; import { inPlaceholders } from "../utils/sqlFilters";
import type { import type {
Category, 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<void> {
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( export async function upsertBudgetEntry(
categoryId: number, categoryId: number,
year: number, year: number,
@ -48,12 +70,8 @@ export async function upsertBudgetEntry(
notes?: string notes?: string
): Promise<void> { ): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.execute( await writeBudgetEntry(db, categoryId, year, month, amount, notes);
`INSERT INTO budget_entries (category_id, year, month, amount, notes) notifyDataChanged();
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 deleteBudgetEntry( 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", "DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
[categoryId, year, month] [categoryId, year, month]
); );
notifyDataChanged();
} }
export async function getActualsByCategory( export async function getActualsByCategory(
@ -116,6 +135,7 @@ export async function upsertBudgetEntriesForYear(
); );
} }
} }
notifyDataChanged();
} }
// Templates // Templates
@ -165,9 +185,12 @@ export async function applyTemplate(
month: number month: number
): Promise<void> { ): Promise<void> {
const entries = await getTemplateEntries(templateId); const entries = await getTemplateEntries(templateId);
const db = await getDb();
for (const entry of entries) { 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<void> { export async function deleteTemplate(templateId: number): Promise<void> {

View file

@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { import {
validateKeyword, validateKeyword,
previewKeywordMatches, previewKeywordMatches,
applyKeywordWithReassignment, applyKeywordWithReassignment,
KEYWORD_MAX_LENGTH, KEYWORD_MAX_LENGTH,
} from "./categorizationService"; } from "./categorizationService";
import { subscribeDataChanged } from "./dataEvents";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); 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 mockSelect = vi.fn();
const mockExecute = vi.fn(); const mockExecute = vi.fn();
@ -180,3 +181,63 @@ describe("applyKeywordWithReassignment", () => {
expect(mockExecute).not.toHaveBeenCalled(); 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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb, withTransaction } from "./db"; import { getDb, withTransaction } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type Database from "@tauri-apps/plugin-sql"; import type Database from "@tauri-apps/plugin-sql";
import type { Keyword, RecentTransaction } from "../shared/types"; import type { Keyword, RecentTransaction } from "../shared/types";
@ -223,9 +224,14 @@ export async function applyKeywordWithReassignment(
} }
const keyword = validation.value; const keyword = validation.value;
return withTransaction((db) => const result = await withTransaction((db) =>
applyKeywordInTransaction(db, input, keyword), 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( async function applyKeywordInTransaction(

View file

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

View file

@ -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<DataChangedListener>();
/**
* 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);
};
}

View file

@ -21,7 +21,7 @@
* transaction exists to survive. * transaction exists to survive.
*/ */
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); const getDb = vi.fn();
@ -49,6 +49,7 @@ import {
type ExportImportSource, type ExportImportSource,
type ExportImportTemplate, type ExportImportTemplate,
} from "./dataExportService"; } from "./dataExportService";
import { subscribeDataChanged } from "./dataEvents";
import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat"; import { formatFromRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat";
import type { Category } from "../shared/types"; import type { Category } from "../shared/types";
import fr from "../i18n/locales/fr.json"; 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]); 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([]);
});
});

View file

@ -1,5 +1,6 @@
import type Database from "@tauri-apps/plugin-sql"; import type Database from "@tauri-apps/plugin-sql";
import { getDb, withTransaction } from "./db"; import { getDb, withTransaction } from "./db";
import { notifyDataChanged } from "./dataEvents";
import Papa from "papaparse"; import Papa from "papaparse";
import { toSqlFlag } from "../utils/sqlFlag"; import { toSqlFlag } from "../utils/sqlFlag";
import type { 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 * to go back to. `withTransaction` holds the single DB lock for the whole
* BEGIN..COMMIT so the statements cannot be split across pooled connections * BEGIN..COMMIT so the statements cannot be split across pooled connections
* (see `db.ts`); the BEGIN/COMMIT/ROLLBACK themselves are ours to issue. * (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( async function runRestore(
body: (db: Database) => Promise<void> body: (db: Database) => Promise<void>
): Promise<void> { ): Promise<void> {
return withTransaction(async (db) => { await withTransaction(async (db) => {
await db.execute("BEGIN"); await db.execute("BEGIN");
let open = true; let open = true;
try { try {
@ -436,6 +442,7 @@ async function runRestore(
throw e; throw e;
} }
}); });
notifyDataChanged();
} }
async function restoreCategories( async function restoreCategories(

View file

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

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type { ImportFormatRow, ImportSource } from "../shared/types"; import type { ImportFormatRow, ImportSource } from "../shared/types";
/** /**
@ -85,12 +86,19 @@ export async function createSource(
source.template_id ?? null, 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 // On conflict, lastInsertId may be 0 — look up the existing row
if (result.lastInsertId) return result.lastInsertId as number; if (result.lastInsertId) return result.lastInsertId as number;
const existing = await getSourceByName(source.name); const existing = await getSourceByName(source.name);
return existing!.id; 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( export async function updateSource(
id: number, id: number,
source: Partial<ImportSourceInput> source: Partial<ImportSourceInput>
@ -132,4 +140,5 @@ export async function updateSource(
export async function deleteSource(id: number): Promise<void> { export async function deleteSource(id: number): Promise<void> {
const db = await getDb(); const db = await getDb();
await db.execute("DELETE FROM import_sources WHERE id = $1", [id]); await db.execute("DELETE FROM import_sources WHERE id = $1", [id]);
notifyDataChanged();
} }

View file

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

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { isLinkedTransactionFkError } from "./balance.service"; import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService"; import { TransactionLinkedToBalanceError } from "./transactionService";
import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; 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", "SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
[file.source_id, file.filename] [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) { if (existing.length > 0) {
await db.execute( await db.execute(
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`, `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] [file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id]
); );
notifyDataChanged();
return existing[0].id; return existing[0].id;
} }
@ -58,6 +62,7 @@ export async function createImportedFile(file: {
file.notes || null, file.notes || null,
] ]
); );
notifyDataChanged();
return result.lastInsertId as number; 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`, `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] [status, rowCount ?? null, notes ?? null, id]
); );
notifyDataChanged();
} }
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> { export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
@ -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; return result.rowsAffected;
} }
@ -175,5 +184,6 @@ export async function deleteAllImportsWithTransactions(): Promise<number> {
} }
await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources"); await db.execute("DELETE FROM import_sources");
notifyDataChanged();
return result.rowsAffected; return result.rowsAffected;
} }

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("./db", () => { vi.mock("./db", () => {
const getDb = vi.fn(); const getDb = vi.fn();
@ -9,16 +9,32 @@ vi.mock("./db", () => {
}); });
import { getDb } from "./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 mockSelect = vi.fn();
const mockExecute = vi.fn(); const mockExecute = vi.fn();
const mockDb = { select: mockSelect, execute: mockExecute }; 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(() => { beforeEach(() => {
vi.mocked(getDb).mockResolvedValue(mockDb as never); vi.mocked(getDb).mockResolvedValue(mockDb as never);
mockSelect.mockReset(); mockSelect.mockReset();
mockExecute.mockReset(); mockExecute.mockReset();
notified.length = 0;
unsubscribe = subscribeDataChanged(() => notified.push(mockExecute.mock.calls.length));
});
afterEach(() => {
unsubscribe();
}); });
describe("getTransactionCount (#351)", () => { describe("getTransactionCount (#351)", () => {
@ -60,3 +76,48 @@ describe("updateTransactionCategory (#390)", () => {
expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]); 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([]);
});
});

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { categorizeBatch } from "./categorizationService"; import { categorizeBatch } from "./categorizationService";
import { import {
isLinkedTransactionFkError, isLinkedTransactionFkError,
@ -136,6 +137,9 @@ export async function insertBatch(
onProgress(insertedCount); 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; 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`, `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`,
[categoryId, toSqlFlag(isManual), txId] [categoryId, toSqlFlag(isManual), txId]
); );
// The manual flag is the "categorize by hand" step (ADR 0022).
notifyDataChanged();
} }
export async function updateTransactionNotes( export async function updateTransactionNotes(

View file

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

View file

@ -1,4 +1,5 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import type { UserPreference } from "../shared/types"; import type { UserPreference } from "../shared/types";
export async function getPreference(key: string): Promise<string | null> { export async function getPreference(key: string): Promise<string | null> {
@ -35,6 +36,11 @@ export async function getImportFolder(): Promise<string | null> {
return getPreference("import_folder"); 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<void> { export async function setImportFolder(path: string): Promise<void> {
return setPreference("import_folder", path); await setPreference("import_folder", path);
notifyDataChanged();
} }