From c0fde0d9d09cb73ac5ded0db51d8c88e08a118d9 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 27 Jan 2025 22:47:40 -0800 Subject: [PATCH 1/6] [TypeScript] Make db.first generic --- packages/loot-core/src/mocks/budget.ts | 8 ++-- .../loot-core/src/server/accounts/link.ts | 2 +- .../loot-core/src/server/accounts/payees.ts | 9 ++--- .../loot-core/src/server/accounts/sync.ts | 6 +-- packages/loot-core/src/server/api.ts | 7 ++-- .../loot-core/src/server/budget/actions.ts | 4 +- packages/loot-core/src/server/budget/base.ts | 2 +- .../src/server/budget/cleanup-template.ts | 6 +-- .../src/server/budget/goalsSchedule.ts | 6 ++- .../loot-core/src/server/dashboard/app.ts | 4 +- packages/loot-core/src/server/db/index.ts | 40 ++++++++++--------- packages/loot-core/src/server/filters/app.ts | 2 +- packages/loot-core/src/server/main.test.ts | 4 +- packages/loot-core/src/server/main.ts | 16 ++++---- .../src/server/migrate/migrations.test.ts | 4 +- packages/loot-core/src/server/reports/app.ts | 2 +- .../loot-core/src/server/schedules/app.ts | 6 ++- .../src/server/schedules/find-schedules.ts | 2 +- .../transactions/transaction-rules.test.ts | 26 +++++++----- .../server/transactions/transaction-rules.ts | 22 +++++----- .../src/server/transactions/transfer.test.ts | 8 ++-- .../src/server/transactions/transfer.ts | 22 +++++----- packages/loot-core/src/server/update.ts | 7 ++-- 23 files changed, 117 insertions(+), 98 deletions(-) diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index 303a4584346..195ea939570 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -461,14 +461,14 @@ async function fillOther(handlers, account, payees, groups) { async function createBudget(accounts, payees, groups) { const primaryAccount = accounts.find(a => (a.name = 'Bank of America')); const earliestDate = ( - await db.first( - `SELECT * FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id + await db.first( + `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, ) ).date; const earliestPrimaryDate = ( - await db.first( - `SELECT * FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id + await db.first( + `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.id = ? AND a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, [primaryAccount.id], ) diff --git a/packages/loot-core/src/server/accounts/link.ts b/packages/loot-core/src/server/accounts/link.ts index db48e8c2530..655c974f963 100644 --- a/packages/loot-core/src/server/accounts/link.ts +++ b/packages/loot-core/src/server/accounts/link.ts @@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as db from '../db'; export async function findOrCreateBank(institution, requisitionId) { - const bank = await db.first( + const bank = await db.first>( 'SELECT id, bank_id, name FROM banks WHERE bank_id = ?', [requisitionId], ); diff --git a/packages/loot-core/src/server/accounts/payees.ts b/packages/loot-core/src/server/accounts/payees.ts index bb8183f4e96..695a7150c96 100644 --- a/packages/loot-core/src/server/accounts/payees.ts +++ b/packages/loot-core/src/server/accounts/payees.ts @@ -1,11 +1,10 @@ // @ts-strict-ignore -import { CategoryEntity, PayeeEntity } from '../../types/models'; import * as db from '../db'; export async function createPayee(description) { // Check to make sure no payee already exists with exactly the same // name - const row: Pick = await db.first( + const row = await db.first>( `SELECT id FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`, [description.toLowerCase()], ); @@ -13,19 +12,19 @@ export async function createPayee(description) { if (row) { return row.id; } else { - return (await db.insertPayee({ name: description })) as PayeeEntity['id']; + return (await db.insertPayee({ name: description })) as db.DbPayee['id']; } } export async function getStartingBalancePayee() { - let category: CategoryEntity = await db.first(` + let category = await db.first(` SELECT * FROM categories WHERE is_income = 1 AND LOWER(name) = 'starting balances' AND tombstone = 0 `); if (category === null) { - category = await db.first( + category = await db.first( 'SELECT * FROM categories WHERE is_income = 1 AND tombstone = 0', ); } diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index dfe4ab4f879..133292e0c80 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -584,7 +584,7 @@ export async function matchTransactions( ); // The first pass runs the rules, and preps data for fuzzy matching - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); const accountsMap = new Map(accounts.map(account => [account.id, account])); const transactionsStep1 = []; @@ -603,7 +603,7 @@ export async function matchTransactions( // is the highest fidelity match and should always be attempted // first. if (trans.imported_id) { - match = await db.first( + match = await db.first( 'SELECT * FROM v_transactions WHERE imported_id = ? AND account = ?', [trans.imported_id, acctId], ); @@ -737,7 +737,7 @@ export async function addTransactions( { rawPayeeName: true }, ); - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); const accountsMap = new Map(accounts.map(account => [account.id, account])); for (const { trans: originalTrans, subtransactions } of normalized) { diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 63e1ec7c14e..32b78ee7197 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -94,9 +94,10 @@ async function validateExpenseCategory(debug, id) { throw APIError(`${debug}: category id is required`); } - const row = await db.first('SELECT is_income FROM categories WHERE id = ?', [ - id, - ]); + const row = await db.first>( + 'SELECT is_income FROM categories WHERE id = ?', + [id], + ); if (!row) { throw APIError(`${debug}: category “${id}” does not exist`); diff --git a/packages/loot-core/src/server/budget/actions.ts b/packages/loot-core/src/server/budget/actions.ts index e09511ca33b..f3f12f277c1 100644 --- a/packages/loot-core/src/server/budget/actions.ts +++ b/packages/loot-core/src/server/budget/actions.ts @@ -329,7 +329,7 @@ export async function setNMonthAvg({ N: number; category: string; }): Promise { - const categoryFromDb = await db.first( + const categoryFromDb = await db.first>( 'SELECT is_income FROM v_categories WHERE id = ?', [category], ); @@ -361,7 +361,7 @@ export async function holdForNextMonth({ month: string; amount: number; }): Promise { - const row = await db.first( + const row = await db.first>( 'SELECT buffered FROM zero_budget_months WHERE id = ?', [month], ); diff --git a/packages/loot-core/src/server/budget/base.ts b/packages/loot-core/src/server/budget/base.ts index a3fcb075e17..975b00ac741 100644 --- a/packages/loot-core/src/server/budget/base.ts +++ b/packages/loot-core/src/server/budget/base.ts @@ -442,7 +442,7 @@ export async function createBudget(months) { } export async function createAllBudgets() { - const earliestTransaction = await db.first( + const earliestTransaction = await db.first( 'SELECT * FROM transactions WHERE isChild=0 AND date IS NOT NULL ORDER BY date ASC LIMIT 1', ); const earliestDate = diff --git a/packages/loot-core/src/server/budget/cleanup-template.ts b/packages/loot-core/src/server/budget/cleanup-template.ts index 8c1b109212c..85d387d5abb 100644 --- a/packages/loot-core/src/server/budget/cleanup-template.ts +++ b/packages/loot-core/src/server/budget/cleanup-template.ts @@ -64,7 +64,7 @@ async function applyGroupCleanups( ); const to_budget = budgeted + Math.abs(balance); const categoryId = generalGroup[ii].category; - let carryover = await db.first( + let carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, categoryId], ); @@ -220,7 +220,7 @@ async function processCleanup(month: string): Promise { } else { warnings.push(category.name + ' does not have available funds.'); } - const carryover = await db.first( + const carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, category.id], ); @@ -249,7 +249,7 @@ async function processCleanup(month: string): Promise { const budgeted = await getSheetValue(sheetName, `budget-${category.id}`); const to_budget = budgeted + Math.abs(balance); const categoryId = category.id; - let carryover = await db.first( + let carryover = await db.first>( `SELECT carryover FROM zero_budgets WHERE month = ? and category = ?`, [db_month, categoryId], ); diff --git a/packages/loot-core/src/server/budget/goalsSchedule.ts b/packages/loot-core/src/server/budget/goalsSchedule.ts index 9705a81afc2..57b2027bfca 100644 --- a/packages/loot-core/src/server/budget/goalsSchedule.ts +++ b/packages/loot-core/src/server/budget/goalsSchedule.ts @@ -21,8 +21,10 @@ async function createScheduleList( const errors = []; for (let ll = 0; ll < template.length; ll++) { - const { id: sid, completed: complete } = await db.first( - 'SELECT * FROM schedules WHERE TRIM(name) = ? AND tombstone = 0', + const { id: sid, completed: complete } = await db.first< + Pick + >( + 'SELECT id, completed FROM schedules WHERE TRIM(name) = ? AND tombstone = 0', [template[ll].name.trim()], ); const rule = await getRuleForSchedule(sid); diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts index 8b456f280c8..d1299069b51 100644 --- a/packages/loot-core/src/server/dashboard/app.ts +++ b/packages/loot-core/src/server/dashboard/app.ts @@ -144,7 +144,9 @@ async function addDashboardWidget( // If no x & y was provided - calculate it dynamically // The new widget should be the very last one in the list of all widgets if (!('x' in widget) && !('y' in widget)) { - const data = await db.first( + const data = await db.first< + Pick + >( 'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC', ); diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 40927d042e7..ad5262cebcd 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -37,6 +37,7 @@ import { DbAccount, DbCategory, DbCategoryGroup, + DbClockMessage, DbPayee, DbTransaction, DbViewTransaction, @@ -83,7 +84,7 @@ export function getDatabase() { } export async function loadClock() { - const row = await first('SELECT * FROM messages_clock'); + const row = await first('SELECT * FROM messages_clock'); if (row) { const clock = deserializeClock(row.clock); setClock(clock); @@ -166,12 +167,9 @@ export async function all(sql, params?: (string | number)[]) { return runQuery(sql, params, true) as any[]; } -export async function first(sql, params?: (string | number)[]) { - const arr = await runQuery(sql, params, true); - // TODO: In the next phase, we will make this function generic - // and pass the type of the return type to `runQuery`. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return arr.length === 0 ? null : (arr[0] as any); +export async function first(sql, params?: (string | number)[]) { + const arr = await runQuery(sql, params, true); + return arr.length === 0 ? null : arr[0]; } // The underlying sql system is now sync, but we can't update `first` yet @@ -353,7 +351,9 @@ export async function getCategoriesGrouped( export async function insertCategoryGroup(group) { // Don't allow duplicate group - const existingGroup = await first( + const existingGroup = await first< + Pick + >( `SELECT id, name, hidden FROM category_groups WHERE UPPER(name) = ? and tombstone = 0 LIMIT 1`, [group.name.toUpperCase()], ); @@ -363,7 +363,7 @@ export async function insertCategoryGroup(group) { ); } - const lastGroup = await first(` + const lastGroup = await first>(` SELECT sort_order FROM category_groups WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1 `); const sort_order = (lastGroup ? lastGroup.sort_order : 0) + SORT_INCREMENT; @@ -411,7 +411,7 @@ export async function insertCategory( let id_; await batchMessages(async () => { // Dont allow duplicated names in groups - const existingCatInGroup = await first( + const existingCatInGroup = await first>( `SELECT id FROM categories WHERE cat_group = ? and UPPER(name) = ? and tombstone = 0 LIMIT 1`, [category.cat_group, category.name.toUpperCase()], ); @@ -422,7 +422,7 @@ export async function insertCategory( } if (atEnd) { - const lastCat = await first(` + const lastCat = await first>(` SELECT sort_order FROM categories WHERE tombstone = 0 ORDER BY sort_order DESC, id DESC LIMIT 1 `); sort_order = (lastCat ? lastCat.sort_order : 0) + SORT_INCREMENT; @@ -507,11 +507,11 @@ export async function deleteCategory( } export async function getPayee(id: DbPayee['id']) { - return first(`SELECT * FROM payees WHERE id = ?`, [id]); + return first(`SELECT * FROM payees WHERE id = ?`, [id]); } export async function getAccount(id: DbAccount['id']) { - return first(`SELECT * FROM accounts WHERE id = ?`, [id]); + return first(`SELECT * FROM accounts WHERE id = ?`, [id]); } export async function insertPayee(payee) { @@ -525,9 +525,10 @@ export async function insertPayee(payee) { } export async function deletePayee(payee: Pick) { - const { transfer_acct } = await first('SELECT * FROM payees WHERE id = ?', [ - payee.id, - ]); + const { transfer_acct } = await first( + 'SELECT * FROM payees WHERE id = ?', + [payee.id], + ); if (transfer_acct) { // You should never be able to delete transfer payees return; @@ -654,7 +655,7 @@ export async function getOrphanedPayees() { } export async function getPayeeByName(name: DbPayee['name']) { - return first( + return first( `SELECT * FROM payees WHERE UNICODE_LOWER(name) = ? AND tombstone = 0`, [name.toLowerCase()], ); @@ -695,7 +696,10 @@ export async function moveAccount( id: DbAccount['id'], targetId: DbAccount['id'], ) { - const account = await first('SELECT * FROM accounts WHERE id = ?', [id]); + const account = await first( + 'SELECT * FROM accounts WHERE id = ?', + [id], + ); let accounts; if (account.closed) { accounts = await all( diff --git a/packages/loot-core/src/server/filters/app.ts b/packages/loot-core/src/server/filters/app.ts index 8fc22399b12..ac4059285b0 100644 --- a/packages/loot-core/src/server/filters/app.ts +++ b/packages/loot-core/src/server/filters/app.ts @@ -42,7 +42,7 @@ const filterModel = { }; async function filterNameExists(name, filterId, newItem) { - const idForName = await db.first( + const idForName = await db.first>( 'SELECT id from transaction_filters WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/main.test.ts b/packages/loot-core/src/server/main.test.ts index 39a16890d5f..0100c8d92fb 100644 --- a/packages/loot-core/src/server/main.test.ts +++ b/packages/loot-core/src/server/main.test.ts @@ -68,7 +68,9 @@ describe('Budgets', () => { // Grab the clock to compare later await db.openDatabase('test-budget'); - const row = await db.first('SELECT * FROM messages_clock'); + const row = await db.first( + 'SELECT * FROM messages_clock', + ); const { error } = await runHandler(handlers['load-budget'], { id: 'test-budget', diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 3e25b3723c0..d353a463e18 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -259,7 +259,7 @@ handlers['category-delete'] = mutator(async function ({ id, transferId }) { return withUndo(async () => { let result = {}; await batchMessages(async () => { - const row = await db.first( + const row = await db.first>( 'SELECT is_income FROM categories WHERE id = ?', [id], ); @@ -270,9 +270,10 @@ handlers['category-delete'] = mutator(async function ({ id, transferId }) { const transfer = transferId && - (await db.first('SELECT is_income FROM categories WHERE id = ?', [ - transferId, - ])); + (await db.first>( + 'SELECT is_income FROM categories WHERE id = ?', + [transferId], + )); if (!row || (transferId && !transfer)) { result = { error: 'no-categories' }; @@ -1504,9 +1505,10 @@ async function loadBudget(id: string) { // This is a bit leaky, but we need to set the initial budget type const { value: budgetType = 'rollover' } = - (await db.first('SELECT value from preferences WHERE id = ?', [ - 'budgetType', - ])) ?? {}; + (await db.first>( + 'SELECT value from preferences WHERE id = ?', + ['budgetType'], + )) ?? {}; sheet.get().meta().budgetType = budgetType; await budget.createAllBudgets(); diff --git a/packages/loot-core/src/server/migrate/migrations.test.ts b/packages/loot-core/src/server/migrate/migrations.test.ts index 23721fb8135..ba6f4fdd316 100644 --- a/packages/loot-core/src/server/migrate/migrations.test.ts +++ b/packages/loot-core/src/server/migrate/migrations.test.ts @@ -62,14 +62,14 @@ describe('Migrations', () => { return withMigrationsDir( __dirname + '/../../mocks/migrations', async () => { - let desc = await db.first( + let desc = await db.first<{ sql: string }>( "SELECT * FROM sqlite_master WHERE name = 'poop'", ); expect(desc).toBe(null); await migrate(db.getDatabase()); - desc = await db.first( + desc = await db.first<{ sql: string }>( "SELECT * FROM sqlite_master WHERE name = 'poop'", ); expect(desc).toBeDefined(); diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts index e1356696cad..f5d3beeb71a 100644 --- a/packages/loot-core/src/server/reports/app.ts +++ b/packages/loot-core/src/server/reports/app.ts @@ -85,7 +85,7 @@ async function reportNameExists( reportId: string, newItem: boolean, ) { - const idForName: { id: string } = await db.first( + const idForName = await db.first>( 'SELECT id from custom_reports WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/schedules/app.ts b/packages/loot-core/src/server/schedules/app.ts index 06c8e64999f..078b0e73767 100644 --- a/packages/loot-core/src/server/schedules/app.ts +++ b/packages/loot-core/src/server/schedules/app.ts @@ -140,7 +140,9 @@ export async function setNextDate({ if (newNextDate !== nextDate) { // Our `update` functon requires the id of the item and we don't // have it, so we need to query it - const nd = await db.first( + const nd = await db.first< + Pick + >( 'SELECT id, base_next_date_ts FROM schedules_next_date WHERE schedule_id = ?', [id], ); @@ -166,7 +168,7 @@ export async function setNextDate({ // Methods async function checkIfScheduleExists(name, scheduleId) { - const idForName = await db.first( + const idForName = await db.first>( 'SELECT id from schedules WHERE tombstone = 0 AND name = ?', [name], ); diff --git a/packages/loot-core/src/server/schedules/find-schedules.ts b/packages/loot-core/src/server/schedules/find-schedules.ts index 8d6df7354d0..a6ba12b8517 100644 --- a/packages/loot-core/src/server/schedules/find-schedules.ts +++ b/packages/loot-core/src/server/schedules/find-schedules.ts @@ -337,7 +337,7 @@ export async function findSchedules() { for (const account of accounts) { // Find latest transaction-ish to start with - const latestTrans = await db.first( + const latestTrans = await db.first( 'SELECT * FROM v_transactions WHERE account = ? AND parent_id IS NULL ORDER BY date DESC LIMIT 1', [account.id], ); diff --git a/packages/loot-core/src/server/transactions/transaction-rules.test.ts b/packages/loot-core/src/server/transactions/transaction-rules.test.ts index 5004ebb5c31..13a0abf04af 100644 --- a/packages/loot-core/src/server/transactions/transaction-rules.test.ts +++ b/packages/loot-core/src/server/transactions/transaction-rules.test.ts @@ -943,11 +943,14 @@ describe('Learning categories', () => { // Internally, it should still be stored with the internal names // so that it's backwards compatible - const rawRule = await db.first('SELECT * FROM rules'); - rawRule.conditions = JSON.parse(rawRule.conditions); - rawRule.actions = JSON.parse(rawRule.actions); - expect(rawRule.conditions[0].field).toBe('imported_description'); - expect(rawRule.actions[0].field).toBe('description'); + const rawRule = await db.first('SELECT * FROM rules'); + const parsedRule = { + ...rawRule, + conditions: JSON.parse(rawRule.conditions), + actions: JSON.parse(rawRule.actions), + }; + expect(parsedRule.conditions[0].field).toBe('imported_description'); + expect(parsedRule.actions[0].field).toBe('description'); await loadRules(); @@ -973,11 +976,14 @@ describe('Learning categories', () => { // This rule internally has been stored with the public names. // Making this work now allows us to switch to it by default in // the future - const rawRule = await db.first('SELECT * FROM rules'); - rawRule.conditions = JSON.parse(rawRule.conditions); - rawRule.actions = JSON.parse(rawRule.actions); - expect(rawRule.conditions[0].field).toBe('imported_payee'); - expect(rawRule.actions[0].field).toBe('payee'); + const rawRule = await db.first('SELECT * FROM rules'); + const parsedRule = { + ...rawRule, + conditions: JSON.parse(rawRule.conditions), + actions: JSON.parse(rawRule.actions), + }; + expect(parsedRule.conditions[0].field).toBe('imported_payee'); + expect(parsedRule.actions[0].field).toBe('payee'); const [rule] = getRules(); expect(rule.conditions[0].field).toBe('imported_payee'); diff --git a/packages/loot-core/src/server/transactions/transaction-rules.ts b/packages/loot-core/src/server/transactions/transaction-rules.ts index 40448d3ac40..b9cefd8a0aa 100644 --- a/packages/loot-core/src/server/transactions/transaction-rules.ts +++ b/packages/loot-core/src/server/transactions/transaction-rules.ts @@ -14,7 +14,6 @@ import { type TransactionEntity, type RuleActionEntity, type RuleEntity, - AccountEntity, } from '../../types/models'; import { schemaConfig } from '../aql'; import * as db from '../db'; @@ -220,9 +219,10 @@ export async function updateRule(rule) { } export async function deleteRule(id: string) { - const schedule = await db.first('SELECT id FROM schedules WHERE rule = ?', [ - id, - ]); + const schedule = await db.first>( + 'SELECT id FROM schedules WHERE rule = ?', + [id], + ); if (schedule) { return false; @@ -278,7 +278,7 @@ function onApplySync(oldValues, newValues) { // Runner export async function runRules( trans, - accounts: Map | null = null, + accounts: Map | null = null, ) { let accountsMap = null; if (accounts === null) { @@ -631,13 +631,11 @@ export async function applyActions( return null; } - const accounts: AccountEntity[] = await db.getAccounts(); + const accounts: db.DbAccount[] = await db.getAccounts(); + const accountsMap = new Map(accounts.map(account => [account.id, account])); const transactionsForRules = await Promise.all( transactions.map(transactions => - prepareTransactionForRules( - transactions, - new Map(accounts.map(account => [account.id, account])), - ), + prepareTransactionForRules(transactions, accountsMap), ), ); @@ -870,12 +868,12 @@ export async function updateCategoryRules(transactions) { export type TransactionForRules = TransactionEntity & { payee_name?: string; - _account?: AccountEntity; + _account?: db.DbAccount; }; export async function prepareTransactionForRules( trans: TransactionEntity, - accounts: Map | null = null, + accounts: Map | null = null, ): Promise { const r: TransactionForRules = { ...trans }; if (trans.payee) { diff --git a/packages/loot-core/src/server/transactions/transfer.test.ts b/packages/loot-core/src/server/transactions/transfer.test.ts index faf6d634684..5a28bb0433a 100644 --- a/packages/loot-core/src/server/transactions/transfer.test.ts +++ b/packages/loot-core/src/server/transactions/transfer.test.ts @@ -64,10 +64,10 @@ describe('Transfer', () => { const differ = expectSnapshotWithDiffer(await getAllTransactions()); - const transferTwo = await db.first( + const transferTwo = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'two'", ); - const transferThree = await db.first( + const transferThree = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'three'", ); @@ -134,10 +134,10 @@ describe('Transfer', () => { test('transfers are properly de-categorized', async () => { await prepareDatabase(); - const transferTwo = await db.first( + const transferTwo = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'two'", ); - const transferThree = await db.first( + const transferThree = await db.first( "SELECT * FROM payees WHERE transfer_acct = 'three'", ); diff --git a/packages/loot-core/src/server/transactions/transfer.ts b/packages/loot-core/src/server/transactions/transfer.ts index 4538e4c26c8..0158a73bc7c 100644 --- a/packages/loot-core/src/server/transactions/transfer.ts +++ b/packages/loot-core/src/server/transactions/transfer.ts @@ -2,12 +2,14 @@ import * as db from '../db'; async function getPayee(acct) { - return db.first('SELECT * FROM payees WHERE transfer_acct = ?', [acct]); + return db.first('SELECT * FROM payees WHERE transfer_acct = ?', [ + acct, + ]); } async function getTransferredAccount(transaction) { if (transaction.payee) { - const result = await db.first( + const result = await db.first>( 'SELECT transfer_acct FROM v_payees WHERE id = ?', [transaction.payee], ); @@ -18,14 +20,12 @@ async function getTransferredAccount(transaction) { } async function clearCategory(transaction, transferAcct) { - const { offbudget: fromOffBudget } = await db.first( - 'SELECT offbudget FROM accounts WHERE id = ?', - [transaction.account], - ); - const { offbudget: toOffBudget } = await db.first( - 'SELECT offbudget FROM accounts WHERE id = ?', - [transferAcct], - ); + const { offbudget: fromOffBudget } = await db.first< + Pick + >('SELECT offbudget FROM accounts WHERE id = ?', [transaction.account]); + const { offbudget: toOffBudget } = await db.first< + Pick + >('SELECT offbudget FROM accounts WHERE id = ?', [transferAcct]); // If the transfer is between two on budget or two off budget accounts, // we should clear the category, because the category is not relevant @@ -51,7 +51,7 @@ export async function addTransfer(transaction, transferredAccount) { return null; } - const { id: fromPayee } = await db.first( + const { id: fromPayee } = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [transaction.account], ); diff --git a/packages/loot-core/src/server/update.ts b/packages/loot-core/src/server/update.ts index 73f5a7fe62c..a8a1b1c89f5 100644 --- a/packages/loot-core/src/server/update.ts +++ b/packages/loot-core/src/server/update.ts @@ -13,9 +13,10 @@ async function runMigrations() { async function updateViews() { const hashKey = 'view-hash'; - const row = await db.first('SELECT value FROM __meta__ WHERE key = ?', [ - hashKey, - ]); + const row = await db.first<{ value: string }>( + 'SELECT value FROM __meta__ WHERE key = ?', + [hashKey], + ); const { value: hash } = row || {}; const views = makeViews(schema, schemaConfig); From 7425d0cbd3c771d50d5a3be6b25882989f9f69bb Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 27 Jan 2025 22:59:07 -0800 Subject: [PATCH 2/6] Release notes --- upcoming-release-notes/4248.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming-release-notes/4248.md diff --git a/upcoming-release-notes/4248.md b/upcoming-release-notes/4248.md new file mode 100644 index 00000000000..07f329fd72e --- /dev/null +++ b/upcoming-release-notes/4248.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +[TypeScript] Make `db.first` generic to make it easy to type DB query results. \ No newline at end of file From 9137c4f432687e6d889a9bcedf6391b5a94ced42 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 18 Feb 2025 12:27:58 -0800 Subject: [PATCH 3/6] Fix typecheck error --- packages/loot-core/src/server/sheet.ts | 12 ++++++++---- .../src/server/transactions/transfer.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/loot-core/src/server/sheet.ts b/packages/loot-core/src/server/sheet.ts index 106e59812fd..3259c991625 100644 --- a/packages/loot-core/src/server/sheet.ts +++ b/packages/loot-core/src/server/sheet.ts @@ -5,6 +5,7 @@ import { captureBreadcrumb } from '../platform/exceptions'; import * as sqlite from '../platform/server/sqlite'; import { sheetForMonth } from '../shared/months'; +import { DbPreference } from './db'; import * as Platform from './platform'; import { Spreadsheet } from './spreadsheet/spreadsheet'; import { resolveName } from './spreadsheet/util'; @@ -189,16 +190,19 @@ export async function reloadSpreadsheet(db): Promise { } } -export async function loadUserBudgets(db): Promise { +export async function loadUserBudgets( + db: typeof import('./db'), +): Promise { const sheet = globalSheet; // TODO: Clear out the cache here so make sure future loads of the app // don't load any extra values that aren't set here const { value: budgetType = 'rollover' } = - (await db.first('SELECT value from preferences WHERE id = ?', [ - 'budgetType', - ])) ?? {}; + (await db.first>( + 'SELECT value from preferences WHERE id = ?', + ['budgetType'], + )) ?? {}; const table = budgetType === 'report' ? 'reflect_budgets' : 'zero_budgets'; const budgets = await db.all(` diff --git a/packages/loot-core/src/server/transactions/transfer.test.ts b/packages/loot-core/src/server/transactions/transfer.test.ts index 5a28bb0433a..81701d66731 100644 --- a/packages/loot-core/src/server/transactions/transfer.test.ts +++ b/packages/loot-core/src/server/transactions/transfer.test.ts @@ -179,8 +179,8 @@ describe('Transfer', () => { await prepareDatabase(); const [transferOne, transferTwo] = await Promise.all([ - db.first("SELECT * FROM payees WHERE transfer_acct = 'one'"), - db.first("SELECT * FROM payees WHERE transfer_acct = 'two'"), + db.first("SELECT * FROM payees WHERE transfer_acct = 'one'"), + db.first("SELECT * FROM payees WHERE transfer_acct = 'two'"), ]); let parent: Transaction = { From 19fa6295856062f41aea9f80d2063ef8f4c7d875 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 20 Feb 2025 11:08:28 -0800 Subject: [PATCH 4/6] Cleanup type --- packages/loot-core/src/mocks/budget.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index 195ea939570..112cf41cf59 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -461,13 +461,13 @@ async function fillOther(handlers, account, payees, groups) { async function createBudget(accounts, payees, groups) { const primaryAccount = accounts.find(a => (a.name = 'Bank of America')); const earliestDate = ( - await db.first( + await db.first>( `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, ) ).date; const earliestPrimaryDate = ( - await db.first( + await db.first>( `SELECT t.date FROM v_transactions t LEFT JOIN accounts a ON t.account = a.id WHERE a.id = ? AND a.offbudget = 0 AND t.is_child = 0 ORDER BY date ASC LIMIT 1`, [primaryAccount.id], From e06d75a3974cfbc6ea3d6e37cfd3132573832471 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 21 Feb 2025 08:43:20 -0800 Subject: [PATCH 5/6] Update db.first calls --- packages/loot-core/src/server/accounts/app.ts | 29 +++++++++---------- .../loot-core/src/types/models/index.d.ts | 1 + 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index c9a8ca3c377..493a35cfd40 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -11,11 +11,9 @@ import { AccountEntity, CategoryEntity, SyncServerGoCardlessAccount, - PayeeEntity, TransactionEntity, SyncServerSimpleFinAccount, } from '../../types/models'; -import { BankEntity } from '../../types/models/bank'; import { createApp } from '../app'; import * as db from '../db'; import { @@ -77,7 +75,7 @@ async function getAccountBalance({ id: string; cutoff: string | Date; }) { - const { balance }: { balance: number } = await db.first( + const { balance } = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?', [id, db.toDateRepr(dayFromDate(cutoff))], ); @@ -85,11 +83,11 @@ async function getAccountBalance({ } async function getAccountProperties({ id }: { id: AccountEntity['id'] }) { - const { balance }: { balance: number } = await db.first( + const { balance } = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0', [id], ); - const { count }: { count: number } = await db.first( + const { count } = await db.first<{ count: number }>( 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0', [id], ); @@ -112,7 +110,7 @@ async function linkGoCardlessAccount({ const bank = await link.findOrCreateBank(account.institution, requisitionId); if (upgradingId) { - const accRow: AccountEntity = await db.first( + const accRow = await db.first( 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); @@ -178,7 +176,7 @@ async function linkSimpleFinAccount({ ); if (upgradingId) { - const accRow: AccountEntity = await db.first( + const accRow = await db.first( 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); @@ -278,7 +276,7 @@ async function closeAccount({ await unlinkAccount({ id }); return withUndo(async () => { - const account: AccountEntity = await db.first( + const account = await db.first( 'SELECT * FROM accounts WHERE id = ? AND tombstone = 0', [id], ); @@ -303,7 +301,7 @@ async function closeAccount({ true, ); - const { id: payeeId }: Pick = await db.first( + const { id: payeeId } = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [id], ); @@ -340,7 +338,7 @@ async function closeAccount({ // If there is a balance we need to transfer it to the specified // account (and possibly categorize it) if (balance !== 0 && transferAccountId) { - const { id: payeeId }: Pick = await db.first( + const { id: payeeId } = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [transferAccountId], ); @@ -942,7 +940,7 @@ async function importTransactions({ } async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { - const { bank: bankId }: Pick = await db.first( + const { bank: bankId } = await db.first>( 'SELECT bank FROM accounts WHERE id = ?', [id], ); @@ -951,7 +949,7 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return 'ok'; } - const accRow: AccountEntity = await db.first( + const accRow = await db.first( 'SELECT * FROM accounts WHERE id = ?', [id], ); @@ -972,7 +970,7 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return; } - const { count }: { count: number } = await db.first( + const { count } = await db.first<{ count: number }>( 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', [bankId], ); @@ -985,8 +983,9 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { } if (count === 0) { - const { bank_id: requisitionId }: Pick = - await db.first('SELECT bank_id FROM banks WHERE id = ?', [bankId]); + const { bank_id: requisitionId } = await db.first< + Pick + >('SELECT bank_id FROM banks WHERE id = ?', [bankId]); const serverConfig = getServer(); if (!serverConfig) { diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index 543ca5eca11..42b5287965e 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -13,3 +13,4 @@ export type * from './schedule'; export type * from './transaction'; export type * from './transaction-filter'; export type * from './user'; +export type * from './bank'; From 553e00b4dfebf7c0427dff0423f8e6dfb0359ad0 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Fri, 21 Feb 2025 09:24:37 -0800 Subject: [PATCH 6/6] Fix strict type --- packages/loot-core/src/server/accounts/app.ts | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index 493a35cfd40..164c91061ee 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -75,24 +75,27 @@ async function getAccountBalance({ id: string; cutoff: string | Date; }) { - const { balance } = await db.first<{ balance: number }>( + const result = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?', [id, db.toDateRepr(dayFromDate(cutoff))], ); - return balance ? balance : 0; + return result?.balance ? result.balance : 0; } async function getAccountProperties({ id }: { id: AccountEntity['id'] }) { - const { balance } = await db.first<{ balance: number }>( + const balanceResult = await db.first<{ balance: number }>( 'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0', [id], ); - const { count } = await db.first<{ count: number }>( + const countResult = await db.first<{ count: number }>( 'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0', [id], ); - return { balance: balance || 0, numTransactions: count }; + return { + balance: balanceResult?.balance || 0, + numTransactions: countResult?.count || 0, + }; } async function linkGoCardlessAccount({ @@ -114,6 +117,11 @@ async function linkGoCardlessAccount({ 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); + + if (!accRow) { + throw new Error(`Account with ID ${upgradingId} not found.`); + } + id = accRow.id; await db.update('accounts', { id, @@ -180,6 +188,11 @@ async function linkSimpleFinAccount({ 'SELECT * FROM accounts WHERE id = ?', [upgradingId], ); + + if (!accRow) { + throw new Error(`Account with ID ${upgradingId} not found.`); + } + id = accRow.id; await db.update('accounts', { id, @@ -301,11 +314,15 @@ async function closeAccount({ true, ); - const { id: payeeId } = await db.first>( + const transferPayee = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [id], ); + if (!transferPayee) { + throw new Error(`Transfer payee with account ID ${id} not found.`); + } + await batchMessages(async () => { // TODO: what this should really do is send a special message that // automatically marks the tombstone value for all transactions @@ -326,7 +343,7 @@ async function closeAccount({ }); db.deleteAccount({ id }); - db.deleteTransferPayee({ id: payeeId }); + db.deleteTransferPayee({ id: transferPayee.id }); }); } else { if (balance !== 0 && transferAccountId == null) { @@ -338,14 +355,20 @@ async function closeAccount({ // If there is a balance we need to transfer it to the specified // account (and possibly categorize it) if (balance !== 0 && transferAccountId) { - const { id: payeeId } = await db.first>( + const transferPayee = await db.first>( 'SELECT id FROM payees WHERE transfer_acct = ?', [transferAccountId], ); + if (!transferPayee) { + throw new Error( + `Transfer payee with account ID ${transferAccountId} not found.`, + ); + } + await mainApp.handlers['transaction-add']({ id: uuidv4(), - payee: payeeId, + payee: transferPayee.id, amount: -balance, account: id, date: monthUtils.currentDay(), @@ -940,20 +963,21 @@ async function importTransactions({ } async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { - const { bank: bankId } = await db.first>( - 'SELECT bank FROM accounts WHERE id = ?', + const accRow = await db.first( + 'SELECT * FROM accounts WHERE id = ?', [id], ); + if (!accRow) { + throw new Error(`Account with ID ${id} not found.`); + } + + const bankId = accRow.bank; + if (!bankId) { return 'ok'; } - const accRow = await db.first( - 'SELECT * FROM accounts WHERE id = ?', - [id], - ); - const isGoCardless = accRow.account_sync_source === 'goCardless'; await db.updateAccount({ @@ -970,7 +994,7 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return; } - const { count } = await db.first<{ count: number }>( + const accountWithBankResult = await db.first<{ count: number }>( 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', [bankId], ); @@ -982,16 +1006,23 @@ async function unlinkAccount({ id }: { id: AccountEntity['id'] }) { return 'ok'; } - if (count === 0) { - const { bank_id: requisitionId } = await db.first< - Pick - >('SELECT bank_id FROM banks WHERE id = ?', [bankId]); + if (!accountWithBankResult || accountWithBankResult.count === 0) { + const bank = await db.first>( + 'SELECT bank_id FROM banks WHERE id = ?', + [bankId], + ); + + if (!bank) { + throw new Error(`Bank with ID ${bankId} not found.`); + } const serverConfig = getServer(); if (!serverConfig) { throw new Error('Failed to get server config.'); } + const requisitionId = bank.bank_id; + try { await post( serverConfig.GOCARDLESS_SERVER + '/remove-account',