The global inTransaction flag let every execute/select bypass the FIFO lock while a withTransaction callback ran, wherever it came from. The callback now receives its own handle, which runs directly, refuses close() and throws once the callback settled; any other call, getDb() from a callback included, queues behind the transaction. connectToProfile, initializeNewProfileDb and closeDb each run as one exclusive section (close the old pool, prepare, load), and every open or close bumps a connection generation: an operation queued for a closed connection throws "connection closed by profile switch". An operation waiting more than 15 s for the lock writes one Journal warning naming the holder and since when. findOrCreateSecurity now requires its executor: its getDb() fallback would deadlock if called from a transaction callback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
7.8 KiB
TypeScript
203 lines
7.8 KiB
TypeScript
import { getDb, withTransaction } from "./db";
|
|
import {
|
|
deletePreference,
|
|
getImportFolder,
|
|
getPreference,
|
|
setPreference,
|
|
} from "./userPreferenceService";
|
|
import { logInfo } from "./logService";
|
|
import {
|
|
CHECKLIST_ITEM_KEYS,
|
|
type ChecklistItemKey,
|
|
type OnboardingCounters,
|
|
} from "../shared/types";
|
|
|
|
/**
|
|
* Getting-started path data (#351, #396).
|
|
*
|
|
* `getOnboardingSnapshot` reads everything the path needs in one round of
|
|
* parallel reads, and writes nothing: the counters, the import folder, the
|
|
* "hidden" preference and the skipped steps. The writes are separate, and the
|
|
* onboarding provider is their only caller: `initializeGuidedPath` (rule D9),
|
|
* `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is
|
|
* announced on the data channel: the provider updates its own state. No UI
|
|
* lives here; `computeChecklist` turns the snapshot into rows and
|
|
* `useOnboardingChecklist` holds the state.
|
|
*/
|
|
|
|
/** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */
|
|
export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden";
|
|
|
|
/** `user_preferences` key set to "1" once rule D9 ran for the profile. */
|
|
export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized";
|
|
|
|
/** `user_preferences` key of a skipped step (D3): `onboarding_skipped_<key>`, value "1". */
|
|
export function skippedStepKey(key: ChecklistItemKey): string {
|
|
return `onboarding_skipped_${key}`;
|
|
}
|
|
|
|
export interface OnboardingSnapshot {
|
|
counters: OnboardingCounters;
|
|
/** True when an import folder has been chosen. */
|
|
importFolderSet: boolean;
|
|
/** True when the path is hidden (by the user, or by rule D9). */
|
|
hidden: boolean;
|
|
/** Skipped steps, in checklist order: only the six keys, only the value "1". */
|
|
skipped: ChecklistItemKey[];
|
|
}
|
|
|
|
interface CountersRow {
|
|
sources: number;
|
|
imports: number;
|
|
manual_categorized: number;
|
|
budget_entries: number;
|
|
transactions: number;
|
|
}
|
|
|
|
// `imports` counts a `completed` file only once it brought a transaction:
|
|
// `executeImport` writes the row `completed` before inserting, and a file whose
|
|
// rows were all in error (or that holds only its header) keeps that row with
|
|
// no transaction. The sub-query is covered by `idx_transactions_file`.
|
|
const COUNTERS_SQL = `
|
|
SELECT
|
|
(SELECT COUNT(*) FROM import_sources) AS sources,
|
|
(SELECT COUNT(*) FROM imported_files f
|
|
WHERE f.status = 'completed'
|
|
AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)) AS imports,
|
|
(SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized,
|
|
(SELECT COUNT(*) FROM budget_entries) AS budget_entries,
|
|
(SELECT COUNT(*) FROM transactions) AS transactions
|
|
`;
|
|
|
|
// `_` is a LIKE wildcard: the pattern may match more than the prefix, and the
|
|
// exact-key filter of `readSkippedSteps` is what decides.
|
|
const SKIPPED_SQL =
|
|
"SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'";
|
|
|
|
const UPSERT_PREFERENCE_SQL = `INSERT INTO user_preferences (key, value, updated_at)
|
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`;
|
|
|
|
async function readCounters(): Promise<OnboardingCounters> {
|
|
const db = await getDb();
|
|
const rows = await db.select<CountersRow[]>(COUNTERS_SQL);
|
|
const row = rows[0];
|
|
return {
|
|
sources: row?.sources ?? 0,
|
|
imports: row?.imports ?? 0,
|
|
manualCategorized: row?.manual_categorized ?? 0,
|
|
budgetEntries: row?.budget_entries ?? 0,
|
|
transactions: row?.transactions ?? 0,
|
|
};
|
|
}
|
|
|
|
/** Skipped steps, read in one query and kept only for a valid key holding "1". */
|
|
async function readSkippedSteps(): Promise<ChecklistItemKey[]> {
|
|
const db = await getDb();
|
|
const rows = await db.select<Array<{ key: string; value: string }>>(SKIPPED_SQL);
|
|
const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key));
|
|
return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key)));
|
|
}
|
|
|
|
/**
|
|
* Reads the counters, the import folder, the hidden flag and the skipped steps
|
|
* in parallel. A pure read: it never writes (rule D9 lives in
|
|
* `initializeGuidedPath`).
|
|
*/
|
|
export async function getOnboardingSnapshot(): Promise<OnboardingSnapshot> {
|
|
const [counters, importFolder, hiddenPref, skipped] = await Promise.all([
|
|
readCounters(),
|
|
getImportFolder(),
|
|
getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY),
|
|
readSkippedSteps(),
|
|
]);
|
|
return {
|
|
counters,
|
|
importFolderSet: importFolder !== null && importFolder !== "",
|
|
hidden: hiddenPref === "1",
|
|
skipped,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Rule D9, once per profile: at the first opening under this version, a
|
|
* profile that already has transactions gets the path hidden (same preference
|
|
* as "Hide", so Settings → Users shows it again); an empty or new profile sees
|
|
* it.
|
|
*
|
|
* The flag is read first, outside any transaction: once D9 ran, every later
|
|
* opening costs that one queued read and opens no transaction. Without the
|
|
* flag, D9 runs as one transaction under the DB lock, flag
|
|
* re-read inside it, so two concurrent first loads cannot apply it twice
|
|
* (CWE-362): the hidden preference is written first, then the flag, then
|
|
* COMMIT; any failure rolls both back and leaves the flag unset, so the next
|
|
* opening tries again. Only the value "1" counts as set, on both reads.
|
|
*
|
|
* Returns `{ hidden: true }` only when this call hid the path, which it also
|
|
* writes to the in-app log. Not announced on the data channel.
|
|
*/
|
|
export async function initializeGuidedPath(): Promise<{ hidden: boolean }> {
|
|
if ((await getPreference(ONBOARDING_GUIDED_INITIALIZED_KEY)) === "1") {
|
|
return { hidden: false };
|
|
}
|
|
const hidden = await withTransaction(async (db) => {
|
|
await db.execute("BEGIN");
|
|
try {
|
|
const flag = await db.select<Array<{ value: string }>>(
|
|
"SELECT value FROM user_preferences WHERE key = $1",
|
|
[ONBOARDING_GUIDED_INITIALIZED_KEY],
|
|
);
|
|
if (flag[0]?.value === "1") {
|
|
await db.execute("COMMIT");
|
|
return false;
|
|
}
|
|
const rows = await db.select<Array<{ transactions: number }>>(
|
|
"SELECT COUNT(*) AS transactions FROM transactions",
|
|
);
|
|
const hasTransactions = (rows[0]?.transactions ?? 0) > 0;
|
|
if (hasTransactions) {
|
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]);
|
|
}
|
|
await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_GUIDED_INITIALIZED_KEY, "1"]);
|
|
await db.execute("COMMIT");
|
|
return hasTransactions;
|
|
} catch (e) {
|
|
try {
|
|
await db.execute("ROLLBACK");
|
|
} catch {
|
|
// Keep the original error: it is the one that explains the failure.
|
|
}
|
|
throw e;
|
|
}
|
|
});
|
|
if (hidden) {
|
|
logInfo(
|
|
"Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again)",
|
|
);
|
|
}
|
|
return { hidden };
|
|
}
|
|
|
|
/** Hides the path until `showChecklist()` is called. */
|
|
export async function hideChecklist(): Promise<void> {
|
|
await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1");
|
|
logInfo("Onboarding: checklist hidden");
|
|
}
|
|
|
|
/** Shows the path again (removes the hidden preference). */
|
|
export async function showChecklist(): Promise<void> {
|
|
await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
|
logInfo("Onboarding: checklist shown");
|
|
}
|
|
|
|
/** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */
|
|
export async function skipStep(key: ChecklistItemKey): Promise<void> {
|
|
await setPreference(skippedStepKey(key), "1");
|
|
logInfo(`Onboarding: step ${key} skipped`);
|
|
}
|
|
|
|
/** Cancels a skip. Idempotent: deleting a missing preference changes nothing. */
|
|
export async function resumeStep(key: ChecklistItemKey): Promise<void> {
|
|
await deletePreference(skippedStepKey(key));
|
|
logInfo(`Onboarding: step ${key} resumed`);
|
|
}
|