feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402

Merged
maximus merged 4 commits from issue-396-onboarding-shared-state into main 2026-10-09 01:10:05 +00:00
18 changed files with 965 additions and 18 deletions
Showing only changes of commit 4febed9eab - Show all commits

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