feat(onboarding): shared getting-started state, live re-read and data-change channel (ADR 0022) #402
18 changed files with 965 additions and 18 deletions
144
src/hooks/useImportWizard.executeImport.test.ts
Normal file
144
src/hooks/useImportWizard.executeImport.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
89
src/services/dataEvents.test.ts
Normal file
89
src/services/dataEvents.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/services/dataEvents.ts
Normal file
45
src/services/dataEvents.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
94
src/services/importSourceService.test.ts
Normal file
94
src/services/importSourceService.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
142
src/services/importedFileService.test.ts
Normal file
142
src/services/importedFileService.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
65
src/services/userPreferenceService.test.ts
Normal file
65
src/services/userPreferenceService.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue