From 4453703e5f2c53fc15331b99cb7034111d497021 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 15:10:15 -0800 Subject: [PATCH 1/2] perf(db): add indexed account activity pages --- packages/db/src/migrate.ts | 6 +- .../002_account_activity_indexes.ts | 16 ++++ packages/db/src/reports/index.ts | 9 +- packages/db/src/reports/queries.test.ts | 90 +++++++++++++++++-- packages/db/src/reports/queries.ts | 90 ++++++++++++++++--- 5 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 packages/db/src/migrations/002_account_activity_indexes.ts diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 2b02906..fa33851 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1,6 +1,7 @@ import type { Kysely } from 'kysely'; import { Migrator } from 'kysely/migration'; import * as reports from './migrations/001_reports.js'; +import * as activityIndexes from './migrations/002_account_activity_indexes.js'; import type { ReportDatabase } from './schema.js'; export async function runMigrations(database: Kysely): Promise { @@ -8,7 +9,10 @@ export async function runMigrations(database: Kysely): Promise): Promise { + // A reverse index scan would also reverse native same-date order. + await database.schema + .createIndex('account_activity_date_asc') + .on('account_activity') + .columns(['account_name', 'account_date asc', 'ordinal asc']) + .execute(); + + await database.schema + .createIndex('account_activity_date_desc') + .on('account_activity') + .columns(['account_name', 'account_date desc', 'ordinal asc']) + .execute(); +} diff --git a/packages/db/src/reports/index.ts b/packages/db/src/reports/index.ts index b5412e0..3185424 100644 --- a/packages/db/src/reports/index.ts +++ b/packages/db/src/reports/index.ts @@ -10,4 +10,11 @@ export { summaryMatchesDate } from './data.js'; export type { StoredAccount, StoredReportData } from './data.js'; -export { writeAccountRows, writeReportData, readReportData, readAccountRows } from './queries.js'; +export { + writeAccountRows, + writeReportData, + readReportData, + readAccountRows, + readAccountActivityPage +} from './queries.js'; +export type { AccountActivityPageOptions } from './queries.js'; diff --git a/packages/db/src/reports/queries.test.ts b/packages/db/src/reports/queries.test.ts index bed68d6..621f25a 100644 --- a/packages/db/src/reports/queries.test.ts +++ b/packages/db/src/reports/queries.test.ts @@ -10,7 +10,13 @@ import { runMigrations, type ReportDatabase } from '../index.js'; -import { readAccountRows, readReportData, writeAccountRows, writeReportData } from './queries.js'; +import { + readAccountRows, + readAccountActivityPage, + readReportData, + writeAccountRows, + writeReportData +} from './queries.js'; import type { StoredReportData } from './data.js'; import { amount, @@ -135,16 +141,80 @@ it('stores exact prepared payloads and native row order using the migrated schem expect(await readAccountRows(reader, data.accounts[1])).toEqual(rows); expect(await readAccountRows(reader, data.accounts[2])).toEqual(otherRows); expect(await readAccountRows(reader, data.accounts[3])).toBeNull(); + const firstPage = { order: 'asc', offset: 0, limit: 50 } as const; + expect(await readAccountActivityPage(reader, data.accounts[0], firstPage)).toEqual([]); + expect(await readAccountActivityPage(reader, data.accounts[3], firstPage)).toBeNull(); await expect( writeAccountRows(reader, 'assets:cash', rows.length, rows.slice(0, 1)) ).rejects.toThrow(); expect((await stat(path)).mode & 0o777).toBe(0o600); }); -it('rejects a persisted row with an out-of-range posting reference', async () => { +it('reads date-ordered pages with native ties and exact account scope', async () => { const database = await createDatabase(); - const account = { id: 'cash0001', ...ready() }; - const rows = preparedAccountRows(); + const rows = preparedAccountRows(53).reverse(); + for (const [ordinal, row] of rows.entries()) { + row.accountDate = ordinal < 26 ? '2026-04-30' : '2026-04-29'; + row.transaction.postings[0].date = row.accountDate; + } + const otherRows = preparedAccountRows(1, 'case-distinct account'); + otherRows[0].transaction.postings[0].account = 'assets:Cash'; + const account = { id: 'cash0001', ...ready('assets:cash', rows.length) }; + const other = { id: 'cash0002', ...ready('assets:Cash', otherRows.length) }; + await database.transaction().execute(async (transaction) => { + await writeAccountRows(transaction, account.name, 0, rows); + await writeAccountRows(transaction, other.name, 0, otherRows); + await writeReportData(transaction, { + journal, + overview: preparedOverview(), + accounts: [account, other] + }); + }); + const resultRowCounts: number[] = []; + const pageReader = database.withPlugin({ + transformQuery: ({ node }) => node, + async transformResult({ result }) { + resultRowCounts.push(result.rows.length); + return result; + } + }); + const laterDateRows = rows.slice(0, 26); + const earlierDateRows = rows.slice(26); + const ascending = [...earlierDateRows, ...laterDateRows]; + const readPage = (order: 'asc' | 'desc', offset: number) => + readAccountActivityPage(pageReader, account, { order, offset, limit: 50 }); + expect(await readPage('asc', 0)).toEqual(ascending.slice(0, 50)); + expect(await readPage('asc', 50)).toEqual(ascending.slice(50)); + expect(await readPage('desc', 0)).toEqual(rows.slice(0, 50)); + expect(await readPage('desc', 50)).toEqual(rows.slice(50)); + expect(await readPage('asc', 53)).toEqual([]); + expect(Math.max(...resultRowCounts)).toBe(50); + expect( + await readAccountActivityPage(database, other, { order: 'asc', offset: 0, limit: 50 }) + ).toEqual(otherRows); + const mismatchedAccount = { ...other, id: account.id }; + await expect( + readAccountActivityPage(database, mismatchedAccount, { order: 'asc', offset: 0, limit: 50 }) + ).rejects.toThrow(); + await expect(readAccountRows(database, mismatchedAccount)).rejects.toThrow(); + await expect( + readAccountActivityPage(database, account, { order: 'asc', offset: 0, limit: -1 }) + ).rejects.toThrow(); + await expect( + readAccountActivityPage(database, account, { order: 'asc', offset: 0, limit: 51 }) + ).rejects.toThrow(); + await database + .deleteFrom('account_activity') + .where('account_name', '=', account.name) + .where('ordinal', '=', 0) + .execute(); + await expect(readPage('desc', 50)).rejects.toThrow(); +}); + +it('decodes only the requested page and rejects invalid posting references', async () => { + const database = await createDatabase(); + const account = { id: 'cash0001', ...ready('assets:cash', 2) }; + const rows = preparedAccountRows(2); await writeReportData(database, { journal, overview: preparedOverview(), accounts: [account] }); await writeAccountRows(database, account.name, 0, rows); await closeDatabase(database); @@ -152,9 +222,9 @@ it('rejects a persisted row with an out-of-range posting reference', async () => // Valid SQLite/JSON is not sufficient: saved posting references must still be usable. const native = new DatabaseSync(path); try { - const badRow = { ...rows[0], accountPostingIndexes: [rows[0].transaction.postings.length] }; + const badRow = { ...rows[1], accountPostingIndexes: [rows[1].transaction.postings.length] }; native - .prepare('UPDATE account_activity SET row_json = ? WHERE account_name = ? AND ordinal = 0') + .prepare('UPDATE account_activity SET row_json = ? WHERE account_name = ? AND ordinal = 1') .run(JSON.stringify(badRow), account.name); } finally { native.close(); @@ -163,7 +233,13 @@ it('rejects a persisted row with an out-of-range posting reference', async () => if (!reader) throw new Error('Expected the current report schema.'); databases.add(reader); expect((await readReportData(reader)).accounts).toEqual([account]); - await expect(readAccountRows(reader, account)).rejects.toThrow('Invalid account data.'); + expect( + await readAccountActivityPage(reader, account, { order: 'asc', offset: 0, limit: 1 }) + ).toEqual(rows.slice(0, 1)); + await expect( + readAccountActivityPage(reader, account, { order: 'asc', offset: 1, limit: 1 }) + ).rejects.toThrow(); + await expect(readAccountRows(reader, account)).rejects.toThrow(); }); it('does not create missing storage or replace an existing report file', async () => { diff --git a/packages/db/src/reports/queries.ts b/packages/db/src/reports/queries.ts index 444d0fd..017bc6d 100644 --- a/packages/db/src/reports/queries.ts +++ b/packages/db/src/reports/queries.ts @@ -9,6 +9,18 @@ import { } from './data.js'; const insertBatchSize = 50; +const maxPageRows = 50; + +export interface AccountActivityPageOptions { + order: 'asc' | 'desc'; + offset: number; + limit: number; +} + +type StoredActivityRow = Pick< + ReportDatabase['account_activity'], + 'ordinal' | 'account_date' | 'row_json' +>; export async function writeAccountRows( database: Kysely, @@ -86,10 +98,10 @@ export async function readReportData(database: Kysely): Promise< return data; } -export async function readAccountRows( +async function assertStoredAccount( database: Kysely, account: StoredAccount -): Promise { +): Promise { const stored = await database .selectFrom('accounts') .select(['name', 'access', 'row_count']) @@ -102,6 +114,26 @@ export async function readAccountRows( stored.row_count !== account.rowCount ) throw new Error('Invalid account data.'); +} + +function decodeAccountRow(record: StoredActivityRow, rowCount: number): PreparedAccountRow { + const row = decodePayload(record.row_json); + if ( + !Number.isSafeInteger(record.ordinal) || + record.ordinal < 0 || + record.ordinal >= rowCount || + !hasAccountRowContainers(row) || + record.account_date !== row.accountDate + ) + throw new Error('Invalid account data.'); + return row; +} + +export async function readAccountRows( + database: Kysely, + account: StoredAccount +): Promise { + await assertStoredAccount(database, account); const records = await database .selectFrom('account_activity') .select(['ordinal', 'account_date', 'row_json']) @@ -112,15 +144,51 @@ export async function readAccountRows( if (records.length !== 0) throw new Error('Invalid account data.'); return null; } - if (records.length !== account.rowCount) throw new Error('Invalid account data.'); + const rowCount = account.rowCount; + if (rowCount === null || records.length !== rowCount) throw new Error('Invalid account data.'); return records.map((record, index) => { - const row = decodePayload(record.row_json); - if ( - record.ordinal !== index || - !hasAccountRowContainers(row) || - record.account_date !== row.accountDate - ) - throw new Error('Invalid account data.'); - return row; + if (record.ordinal !== index) throw new Error('Invalid account data.'); + return decodeAccountRow(record, rowCount); }); } + +export async function readAccountActivityPage( + database: Kysely, + account: StoredAccount, + { order, offset, limit }: AccountActivityPageOptions +): Promise { + if ( + (order !== 'asc' && order !== 'desc') || + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(limit) || + limit < 1 || + limit > maxPageRows + ) + throw new Error('Invalid account page.'); + await assertStoredAccount(database, account); + if (account.access === 'ambiguous') { + const activity = await database + .selectFrom('account_activity') + .select('ordinal') + .where('account_name', '=', account.name) + .limit(1) + .executeTakeFirst(); + if (activity) throw new Error('Invalid account data.'); + return null; + } + const rowCount = account.rowCount; + if (rowCount === null) throw new Error('Invalid account data.'); + const records = await database + .selectFrom('account_activity') + .select(['ordinal', 'account_date', 'row_json']) + .where('account_name', '=', account.name) + .orderBy('account_date', order) + .orderBy('ordinal', 'asc') + .limit(limit) + .offset(offset) + .execute(); + const expectedRows = Math.min(limit, Math.max(0, rowCount - offset)); + if (records.length !== expectedRows) throw new Error('Invalid account data.'); + return records.map((record) => decodeAccountRow(record, rowCount)); +} -- 2.54.0 From 6c5b1fe07849671864ec648ff3d93d79d8fb6463 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 16:24:46 -0800 Subject: [PATCH 2/2] perf(registers): read saved account pages with native summaries --- .changeset/page-saved-account-activity.md | 6 + .../server/registers/account-page.fixture.ts | 19 ++ .../web/src/lib/server/registers/cash.test.ts | 156 +++------ apps/web/src/lib/server/registers/cash.ts | 43 +-- .../lib/server/registers/investment.test.ts | 173 +++++----- .../src/lib/server/registers/investment.ts | 43 +-- .../lib/server/reports.integration.test.ts | 309 +++++++++++------- .../cash/[accountId]/+page.server.ts | 14 +- .../cash/[accountId]/page.server.test.ts | 115 +++++-- .../registers/cash/page.server.test.ts | 8 +- .../investments/[accountId]/+page.server.ts | 17 +- .../[accountId]/page.server.test.ts | 116 +++++-- .../registers/investments/page.server.test.ts | 8 +- .../prepare-reports/run.integration.test.ts | 23 +- packages/db/src/migrate.ts | 4 +- .../003_account_activity_summaries.ts | 51 +++ .../reports/account-activity-summary.test.ts | 65 ++++ .../src/reports/account-activity-summary.ts | 77 +++++ packages/db/src/reports/data.fixture.ts | 7 + packages/db/src/reports/data.ts | 3 + packages/db/src/reports/index.ts | 6 +- packages/db/src/reports/queries.test.ts | 85 ++++- packages/db/src/reports/queries.ts | 144 +++++--- packages/db/src/schema.ts | 15 +- packages/snapshots/README.md | 3 + packages/snapshots/src/index.ts | 9 +- .../snapshots/src/reports/publication.test.ts | 176 +++++++--- packages/snapshots/src/reports/publication.ts | 57 ++-- 28 files changed, 1163 insertions(+), 589 deletions(-) create mode 100644 .changeset/page-saved-account-activity.md create mode 100644 apps/web/src/lib/server/registers/account-page.fixture.ts create mode 100644 packages/db/src/migrations/003_account_activity_summaries.ts create mode 100644 packages/db/src/reports/account-activity-summary.test.ts create mode 100644 packages/db/src/reports/account-activity-summary.ts diff --git a/.changeset/page-saved-account-activity.md b/.changeset/page-saved-account-activity.md new file mode 100644 index 0000000..8778a65 --- /dev/null +++ b/.changeset/page-saved-account-activity.md @@ -0,0 +1,6 @@ +--- +'@hledger-reports/web': patch +'@hledger-reports/worker': patch +--- + +Read saved account registers fifty transactions at a time without changing their whole-account ending balances. After updating web and worker together, prepare reports again and reopen accounts from the sidebar. Existing generations are kept, not upgraded or deleted. diff --git a/apps/web/src/lib/server/registers/account-page.fixture.ts b/apps/web/src/lib/server/registers/account-page.fixture.ts new file mode 100644 index 0000000..d96adbd --- /dev/null +++ b/apps/web/src/lib/server/registers/account-page.fixture.ts @@ -0,0 +1,19 @@ +import type { PreparedAccountRow } from '@hledger-reports/hledger'; +import type { AccountActivitySummary, AccountActivityPage } from '@hledger-reports/snapshots'; + +export function accountPage( + rows: PreparedAccountRow[], + summary: Partial = {} +): AccountActivityPage { + return { + rows, + summary: { + hasMultipleAmounts: false, + hasMultipleCommodities: false, + endingBalance: [], + endingCash: [], + unclassifiedEndingCash: [], + ...summary + } + }; +} diff --git a/apps/web/src/lib/server/registers/cash.test.ts b/apps/web/src/lib/server/registers/cash.test.ts index 5cd9a43..8708b33 100644 --- a/apps/web/src/lib/server/registers/cash.test.ts +++ b/apps/web/src/lib/server/registers/cash.test.ts @@ -1,6 +1,7 @@ import { expect, it, vi } from 'vitest'; import type { DetailedAmount, PreparedPosting, PreparedAccountRow } from '@hledger-reports/hledger'; -import { mapCashRegisterPage } from './cash'; +import { projectCashRegisterPage } from './cash'; +import { accountPage } from './account-page.fixture'; vi.mock('@hledger-reports/hledger', () => { throw new Error('Projection must not import the native runtime.'); @@ -74,7 +75,8 @@ function row(): PreparedAccountRow { } it('projects native payee, account-relative date, exact raw amounts, full category and selected notes', () => { - const result = mapCashRegisterPage([row()], 'asc', 1); + const record = row(); + const result = projectCashRegisterPage(accountPage([record], { endingBalance: record.balance })); expect(result).toEqual({ unsupported: false, endingBalance: { commodity: 'USD', quantity: '9007199254740993.000000000000000001' }, @@ -105,7 +107,7 @@ it.each([ Object.assign(record.transaction, { description, payee: 'Employer', note, comment: '' }); for (const posting of record.transaction.postings) posting.comment = ''; const before = structuredClone(record); - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.payee).toBe('Employer'); expect(result.notes).toEqual(notes); expect(result.breakdown).toBeNull(); @@ -119,7 +121,7 @@ it('keeps repeated comments and their whitespace alongside the prepared note in record.transaction.comment = 'Native payee'; record.transaction.postings[0].comment = ' native note\n'; const before = structuredClone(record); - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.notes).toEqual([' native note\n', 'Native payee', ' native note\n']); expect(result.breakdown?.notes).toBe(result.notes); expect(result.breakdown?.lines[0].notes).toBe('counterpart comment'); @@ -137,7 +139,7 @@ it.each([ const record = row(); record.change = [amount(quantity)]; record.isSplit = true; - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.direction).toBe(direction); expect(result.amount?.quantity).toBe(quantity); expect(result.breakdown?.direction).toBe(direction); @@ -148,18 +150,18 @@ it('keeps empty bags unreported and blank commodity a literal supported unit', ( const record = row(); record.change = []; record.balance = []; - expect(mapCashRegisterPage([record], 'asc', 1)).toMatchObject({ + expect(projectCashRegisterPage(accountPage([record]))).toMatchObject({ endingBalance: null, unsupported: false, rows: [{ amount: null, balance: null, direction: 'unreported' }] }); record.change = [amount('1', '')]; - expect(mapCashRegisterPage([record], 'asc', 1)).toMatchObject({ + expect(projectCashRegisterPage(accountPage([record]))).toMatchObject({ endingBalance: null, unsupported: false, rows: [{ amount: { commodity: '', quantity: '1' } }] }); - expect(mapCashRegisterPage([], 'desc', 1)).toEqual({ + expect(projectCashRegisterPage(accountPage([]))).toEqual({ endingBalance: null, unsupported: false, rows: [] @@ -176,7 +178,7 @@ it('ignores counterpart units and preserves repeated-category and virtual postin tags: [['tag', 'value']] }) ); - const result = mapCashRegisterPage([record], 'asc', 1); + const result = projectCashRegisterPage(accountPage([record], { endingBalance: record.balance })); expect(result.unsupported).toBe(false); expect(result.rows[0].amount?.commodity).toBe('USD'); expect(result.endingBalance?.commodity).toBe('USD'); @@ -219,7 +221,7 @@ it('uses only native-selected indexes, keeping all actual statuses in native ind posting('assets:bank:child', { effectiveStatus: 'Cleared', comment: 'third' }) ]; record.accountPostingIndexes = [2, 0, 3, 4]; - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.statuses).toEqual(['Pending', 'Unmarked', 'Cleared']); expect(result.notes).toEqual(['native note', 'second', 'first', 'third']); expect(result.categories).toEqual(['arbitrary:selected']); @@ -243,7 +245,7 @@ it('shows internal-transfer account movements without summing selected postings' posting('assets:bank:checking', { amounts: [amount('-100')], comment: 'from checking' }), posting('assets:bank:savings', { amounts: [amount('100')], comment: 'to savings' }) ]; - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.categories).toEqual([]); expect(result.breakdown).toEqual({ date: result.date, @@ -273,105 +275,48 @@ it('shows internal-transfer account movements without summing selected postings' it('uses native split even with one counterpart, and full ordered categories for distinct counterparts', () => { const record = row(); record.isSplit = true; - expect(mapCashRegisterPage([record], 'asc', 1).rows[0].breakdown).not.toBeNull(); + expect(projectCashRegisterPage(accountPage([record])).rows[0].breakdown).not.toBeNull(); record.isSplit = false; record.transaction.postings.push( posting('another:category'), posting('expenses:Full category'), posting('another:category') ); - expect(mapCashRegisterPage([record], 'asc', 1).rows[0].categories).toEqual([ + expect(projectCashRegisterPage(accountPage([record])).rows[0].categories).toEqual([ 'expenses:Full category', 'another:category' ]); }); -it.each(['asc', 'desc'] as const)( - 'sorts date groups %s with native tie order and original final balance', - (order) => { - const records = ['2026-05-03', '2026-05-01', '2026-05-03', '2026-05-02'].map((date, index) => { - const record = row(); - record.accountDate = date; - record.transaction.index = [90, 70, 10, 30][index]; - record.balance = [amount(String(index))]; - return record; - }); - const before = structuredClone(records); - const result = mapCashRegisterPage(records, order, 1); - expect(result.rows.map((r) => r.index)).toEqual( - order === 'asc' ? [70, 30, 90, 10] : [90, 10, 30, 70] - ); - expect(result.rows.map((r) => r.balance?.quantity)).toEqual( - order === 'asc' ? ['1', '3', '0', '2'] : ['0', '2', '3', '1'] - ); - expect(result.endingBalance).toEqual({ commodity: 'USD', quantity: '3' }); - expect(records).toEqual(before); - } -); - -it('projects only page 2 transaction details while scanning whole-account metadata', () => { - const transactionReads: number[] = []; - const metadataReads = { - accountDate: new Set(), - change: new Set(), - balance: new Set() - }; - const records = Array.from({ length: 150 }, (_, index) => { +it('preserves supplied descending-date row order', () => { + const records = ['2026-05-03', '2026-05-02', '2026-05-01'].map((date, index) => { const record = row(); - record.accountDate = `2026-05-0${(index % 3) + 1}`; + record.accountDate = date; + record.transaction.index = [90, 10, 70][index]; record.balance = [amount(String(index))]; - record.isSplit = true; - record.transaction.index = index; - const transaction = record.transaction; - Object.defineProperty(record, 'transaction', { - get() { - if (index % 3 !== 1) throw new Error('Off-page transaction details read'); - transactionReads.push(index); - return transaction; - } - }); - for (const key of ['accountDate', 'change', 'balance'] as const) { - const value = record[key]; - Object.defineProperty(record, key, { - get() { - metadataReads[key].add(index); - return value; - } - }); - } return record; }); - const result = mapCashRegisterPage(records, 'asc', 2); - const visibleIndexes = Array.from({ length: 50 }, (_, index) => index * 3 + 1); - expect(transactionReads).toEqual(visibleIndexes); - expect(result.rows.map((record) => record.index)).toEqual(visibleIndexes); - expect(result.rows.every((record) => record.breakdown !== null)).toBe(true); - expect(result.endingBalance).toEqual({ commodity: 'USD', quantity: '149' }); - expect(result.unsupported).toBe(false); - for (const reads of Object.values(metadataReads)) expect(reads.size).toBe(150); + const page = accountPage(records); + const before = structuredClone(page); + const result = projectCashRegisterPage(page); + expect(result.rows.map((record) => record.index)).toEqual([90, 10, 70]); + expect(result.rows.map((record) => record.balance?.quantity)).toEqual(['0', '1', '2']); + expect(page).toEqual(before); }); -it.each([ - { field: 'change', mode: 'mixed-bag' }, - { field: 'balance', mode: 'mixed-bag' }, - { field: 'change', mode: 'duplicate-unit' }, - { field: 'balance', mode: 'duplicate-unit' }, - { field: 'change', mode: 'different-row-unit' }, - { field: 'balance', mode: 'different-row-unit' } -] as const)( - 'rejects off-page $mode in $field without reading any transaction details', - ({ field, mode }) => { - const records = Array.from({ length: 101 }, () => row()); - if (mode === 'different-row-unit') records[100][field] = [amount('1', 'EUR')]; - else records[100][field].push(amount('1', mode === 'mixed-bag' ? 'EUR' : 'USD')); - for (const record of records) { - Object.defineProperty(record, 'transaction', { - get() { - throw new Error('Unsupported accounts must not project transaction details'); - } - }); - } - expect(mapCashRegisterPage(records, 'asc', 1)).toEqual({ +it('uses the summary ending balance even when it is off-page', () => { + const result = projectCashRegisterPage(accountPage([row()], { endingBalance: [amount('7.00')] })); + expect(result).toMatchObject({ + unsupported: false, + endingBalance: { commodity: 'USD', quantity: '7.00' }, + rows: [{ balance: { commodity: 'USD', quantity: '9007199254740993.000000000000000001' } }] + }); +}); + +it.each([{ hasMultipleAmounts: true }, { hasMultipleCommodities: true }])( + 'rejects unsupported whole-account activity despite scalar visible rows: %j', + (summary) => { + expect(projectCashRegisterPage(accountPage([row()], summary))).toEqual({ unsupported: true, endingBalance: null, rows: [] @@ -436,7 +381,7 @@ it('deep-whitelists every DTO field and nested amount without exposing tags or p } inject(record); const before = structuredClone(record); - const result = mapCashRegisterPage([record], 'asc', 1); + const result = projectCashRegisterPage(accountPage([record], { endingBalance: [privateAmount] })); expect(JSON.stringify(result)).not.toMatch( /PRIVATE|sourcePosition|balanceAssertion|cost|style|extraNestedMetadata|transactionIndex|investment|transactedAmounts/ ); @@ -444,8 +389,9 @@ it('deep-whitelists every DTO field and nested amount without exposing tags or p expect(result.rows[0].breakdown).not.toHaveProperty('index'); expect(result.rows[0].breakdown).not.toHaveProperty('note'); expect(result.endingBalance).toEqual(result.rows[0].amount); - for (const p of result.rows[0].breakdown!.lines) - expect(p.amounts).toEqual([result.rows[0].amount]); + for (const line of result.rows[0].breakdown!.lines) { + expect(line.amounts).toEqual([result.rows[0].amount]); + } const keys = (value: object) => Object.keys(value).sort(); expect(keys(result)).toEqual(['endingBalance', 'rows', 'unsupported']); expect(keys(result.rows[0])).toEqual([ @@ -497,7 +443,7 @@ it('uses the register date and notes, leaving original dates and posting metadat status: 'Pending', effectiveStatus: 'Pending' }); - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.date).toBe('2026-05-03'); expect(result.statuses).toEqual(['Pending']); expect(result.breakdown).toMatchObject({ @@ -515,7 +461,7 @@ it('uses the register date and notes, leaving original dates and posting metadat it('does not invent selection or a transaction-level row status when native selection is empty', () => { const record = row(); record.accountPostingIndexes = []; - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.statuses).toEqual([]); expect(result.notes).toEqual(['native note', ' transaction note\n']); expect(result.categories).toEqual(['arbitrary:selected', 'expenses:Full category']); @@ -523,13 +469,11 @@ it('does not invent selection or a transaction-level row status when native sele expect(result.breakdown?.internalTransfer).toBe(false); }); -it('keeps an unreported final balance null rather than using an earlier recorded balance', () => { - const records = [row(), row()]; - records[1].balance = []; - expect(mapCashRegisterPage(records, 'desc', 1)).toMatchObject({ +it('keeps a missing summary ending balance rather than inferring it from visible balances', () => { + expect(projectCashRegisterPage(accountPage([row()], { endingBalance: [] }))).toMatchObject({ endingBalance: null, unsupported: false, - rows: [{ balance: { quantity: '9007199254740993.000000000000000001' } }, { balance: null }] + rows: [{ balance: { quantity: '9007199254740993.000000000000000001' } }] }); }); @@ -542,7 +486,7 @@ it('explains a 75/25 split with only two counterpart lines and full selected ban posting('expenses:second', { amounts: [amount('25')], comment: 'second allocation' }) ); const before = structuredClone(record); - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.breakdown).toEqual({ date: record.accountDate, payee: 'Native payee', @@ -580,7 +524,7 @@ it('does not infer a missing account change or line amount from reported counter amount('9007199254740993.123456789012345678', 'SHARES'), amount('7.00', 'EUR') ]; - const result = mapCashRegisterPage([record], 'asc', 1).rows[0]; + const result = projectCashRegisterPage(accountPage([record])).rows[0]; expect(result.breakdown).toMatchObject({ amount: null, direction: 'unreported', @@ -604,7 +548,7 @@ it('exposes no transaction or posting tags for an internal transfer', () => { record.transaction.tags = [['PRIVATE-transaction-tag', 'PRIVATE-value']]; for (const posting of record.transaction.postings) posting.tags = [['PRIVATE-posting-tag', 'PRIVATE-value']]; - const result = mapCashRegisterPage([record], 'asc', 1); + const result = projectCashRegisterPage(accountPage([record])); expect(result.rows[0].breakdown?.internalTransfer).toBe(true); expect(JSON.stringify(result)).not.toMatch(/tags|PRIVATE/); }); diff --git a/apps/web/src/lib/server/registers/cash.ts b/apps/web/src/lib/server/registers/cash.ts index 390a9ee..028249a 100644 --- a/apps/web/src/lib/server/registers/cash.ts +++ b/apps/web/src/lib/server/registers/cash.ts @@ -1,4 +1,5 @@ import type { PreparedAccountRow, PreparedPosting, ReportedAmount } from '@hledger-reports/hledger'; +import type { AccountActivityPage } from '@hledger-reports/snapshots'; import type { CashBreakdown, CashSplitLine, @@ -6,7 +7,6 @@ import type { CashRow } from '#lib/registers/cash/data.js'; -// Presentation pagination only: storage holds the whole native account report. export const cashRegisterPageSize = 50; // Whitelist amounts at every public depth; cost/basis/style and provenance stay private. @@ -29,7 +29,7 @@ function publicSplitLine(posting: PreparedPosting): CashSplitLine { }; } -function cashRow(row: PreparedAccountRow): CashRow { +function projectCashRow(row: PreparedAccountRow): CashRow { const transaction = row.transaction; // Native qualification owns account membership, not names or otherAccounts. const selectedIndexes = new Set(row.accountPostingIndexes); @@ -71,38 +71,15 @@ function cashRow(row: PreparedAccountRow): CashRow { }; } -/** Whole-account representability, native ending balance, then stable date ordering and page projection. - * No money arithmetic, FX conversion, sign inversion or account classification. - */ -export function mapCashRegisterPage( - records: PreparedAccountRow[], - order: CashRegisterData['order'], - page: number -): Pick { - const commodities = new Set(); - let unsupported = false; - for (const row of records) { - for (const amounts of [row.change, row.balance]) { - if (amounts.length > 1) unsupported = true; - for (const amount of amounts) commodities.add(amount.commodity); - } - } - unsupported ||= commodities.size > 1; - if (unsupported) return { rows: [], endingBalance: null, unsupported: true }; - const ending = records.at(-1)?.balance[0]; - const ordered = records.map((row, index) => ({ row, index })); - ordered.sort((left, right) => { - if (left.row.accountDate === right.row.accountDate) return left.index - right.index; - if (order === 'asc') { - if (left.row.accountDate < right.row.accountDate) return -1; - return 1; - } - if (left.row.accountDate < right.row.accountDate) return 1; - return -1; - }); - const offset = (page - 1) * cashRegisterPageSize; +export function projectCashRegisterPage({ + rows, + summary +}: AccountActivityPage): Pick { + if (summary.hasMultipleAmounts || summary.hasMultipleCommodities) + return { rows: [], endingBalance: null, unsupported: true }; + const ending = summary.endingBalance[0]; return { - rows: ordered.slice(offset, offset + cashRegisterPageSize).map(({ row }) => cashRow(row)), + rows: rows.map(projectCashRow), endingBalance: ending ? publicAmount(ending) : null, unsupported: false }; diff --git a/apps/web/src/lib/server/registers/investment.test.ts b/apps/web/src/lib/server/registers/investment.test.ts index 0485eda..6bd18e6 100644 --- a/apps/web/src/lib/server/registers/investment.test.ts +++ b/apps/web/src/lib/server/registers/investment.test.ts @@ -5,7 +5,8 @@ import type { PreparedPosting, PreparedSecurityActivity } from '@hledger-reports/hledger'; -import { investmentRegisterPageSize, mapInvestmentRegisterPage } from './investment'; +import { projectInvestmentRegisterPage } from './investment'; +import { accountPage } from './account-page.fixture'; vi.mock('@hledger-reports/hledger', () => { throw new Error('Projection must not import the native runtime.'); @@ -113,7 +114,7 @@ function row(): PreparedAccountRow { it('keeps two security lines and external funding, income, expense and gain once per transaction', () => { const record = row(); const before = structuredClone(record); - const result = mapInvestmentRegisterPage([record], 'asc', 1); + const result = projectInvestmentRegisterPage(accountPage([record])); expect(result).toEqual({ endingCash: [], unclassifiedEndingCash: [], @@ -186,7 +187,11 @@ it.each(['buy', 'sell'] as const)( }; record.transaction.postings.push(posting('another:funding', { amounts: [amount('7.00')] })); record.investment.cashPostingIndexes = [2, 6]; - const result = mapInvestmentRegisterPage([record], 'asc', 1); + const result = projectInvestmentRegisterPage( + accountPage([record], { + endingCash: record.investment.cash.balance + }) + ); const transaction = result.transactions[0]; expect(transaction.securityLines.map((line) => line.action)).toEqual([action, action]); expect(transaction.cash).toEqual({ @@ -217,7 +222,7 @@ it('preserves an unknown native action without fabricating a price, value or inc activity.action = 'unknown'; activity.prices = []; activity.transactedAmounts = null; - const result = mapInvestmentRegisterPage([record], 'asc', 1).transactions[0]; + const result = projectInvestmentRegisterPage(accountPage([record])).transactions[0]; expect(result.securityLines[0]).toEqual({ security: 'AAA', action: 'unknown', @@ -235,7 +240,7 @@ it('keeps a cash-only transaction as a group with no fabricated security line', record.accountPostingIndexes = [2]; record.investment.cash.direction = 'in'; record.investment.cash.change = [amount('+1.000')]; - const result = mapInvestmentRegisterPage([record], 'desc', 1); + const result = projectInvestmentRegisterPage(accountPage([record])); expect(result.transactions).toHaveLength(1); expect(result.transactions[0].securityLines).toEqual([]); expect(result.transactions[0].cash.change).toEqual([{ commodity: 'USD', quantity: '+1.000' }]); @@ -270,7 +275,14 @@ it('preserves multiple, repeated-unit, mixed-currency and unclassified bags lite record.investment.issues = ['unclassified-commodity', 'mixed-security-actions']; record.transaction.postings[2].amounts = bag; const before = structuredClone(record); - const result = mapInvestmentRegisterPage([record], 'asc', 1); + const result = projectInvestmentRegisterPage( + accountPage([record], { + hasMultipleAmounts: true, + hasMultipleCommodities: true, + endingCash: bag, + unclassifiedEndingCash: bag + }) + ); const transaction = result.transactions[0]; expect(transaction.securityLines[0]).toEqual({ security: 'AAA', @@ -305,7 +317,7 @@ it('deduplicates posting indexes only, with gain > income > expense > cash and o record.accountPostingIndexes = [firstSecurity, income, expense, gain]; record.transaction.postings[income] = posting('same:name', { amounts: [amount('8')] }); record.transaction.postings[expense] = posting('same:name', { amounts: [amount('8')] }); - const result = mapInvestmentRegisterPage([record], 'asc', 1).transactions[0]; + const result = projectInvestmentRegisterPage(accountPage([record])).transactions[0]; expect(result.related.map((line) => line.role)).toEqual(['cash', 'income', 'expense', 'gain']); expect(result.related.map((line) => line.account)).toEqual([ 'external:funding', @@ -326,7 +338,7 @@ it('does not infer roles from account names, otherAccounts, tags or unreferenced record.investment.expensePostingIndexes = []; record.investment.gainPostingIndexes = []; record.transaction.postings.push(posting('income:dividend', { tags: [['_ptype', 'gain']] })); - expect(mapInvestmentRegisterPage([record], 'asc', 1).transactions[0].related).toEqual([]); + expect(projectInvestmentRegisterPage(accountPage([record])).transactions[0].related).toEqual([]); }); it.each([ @@ -339,7 +351,9 @@ it.each([ record.transaction.note = note; record.transaction.comment = '\n'; for (const posting of record.transaction.postings) posting.comment = ' \t'; - expect(mapInvestmentRegisterPage([record], 'asc', 1).transactions[0].notes).toEqual(expected); + expect(projectInvestmentRegisterPage(accountPage([record])).transactions[0].notes).toEqual( + expected + ); }); it('keeps nonblank selected comments in producer index order, including repetitions, without unrelated notes', () => { @@ -347,98 +361,72 @@ it('keeps nonblank selected comments in producer index order, including repetiti record.accountPostingIndexes = [1, 0]; record.transaction.comment = 'native note'; record.transaction.postings[1].comment = 'native note'; - const result = mapInvestmentRegisterPage([record], 'asc', 1).transactions[0]; + const result = projectInvestmentRegisterPage(accountPage([record])).transactions[0]; expect(result.notes).toEqual(['native note', 'native note', 'native note', ' first leg\n']); expect(result.notes).not.toContain('funding note'); expect(result.related[0].notes).toBe('funding note'); }); -it.each(['asc', 'desc'] as const)( - 'sorts %s stably by actual account date, with native final cash rather than the sorted last row', - (order) => { - const records = ['2026-05-03', '2026-05-01', '2026-05-03', '2026-05-02'].map((date, index) => { - const record = row(); - record.accountDate = date; - record.transaction.index = [90, 70, 10, 30][index]; - record.investment.cash.balance = [amount(String(index))]; - record.investment.cash.unclassifiedBalance = [amount(String(index), 'UNKNOWN')]; - return record; - }); - const before = structuredClone(records); - const result = mapInvestmentRegisterPage(records, order, 1); - expect(result.transactions.map((transaction) => transaction.index)).toEqual( - order === 'asc' ? [70, 30, 90, 10] : [90, 10, 30, 70] - ); - expect(result.transactions.map((transaction) => transaction.cash.balance[0].quantity)).toEqual( - order === 'asc' ? ['1', '3', '0', '2'] : ['0', '2', '3', '1'] - ); - expect(result.endingCash).toEqual([{ commodity: 'USD', quantity: '3' }]); - expect(result.unclassifiedEndingCash).toEqual([{ commodity: 'UNKNOWN', quantity: '3' }]); - expect(records).toEqual(before); - } -); - -function forbidOffPageDetails(record: PreparedAccountRow) { - const unreadable = { - get() { - throw new Error('Off-page transaction details must not be projected'); - } - }; - Object.defineProperty(record, 'transaction', unreadable); - Object.defineProperty(record, 'accountPostingIndexes', unreadable); - for (const key of [ - 'securities', - 'issues', - 'cashPostingIndexes', - 'incomePostingIndexes', - 'expensePostingIndexes', - 'gainPostingIndexes' - ]) { - Object.defineProperty(record.investment, key, unreadable); - } -} - -it('projects only 50 selected transaction groups, never expanding security lines before pagination or touching off-page details', () => { - function dateGroup(date: string, firstIndex: number): PreparedAccountRow[] { - return Array.from({ length: 50 }, (_, offset) => { - const record = row(); - const index = firstIndex + offset; - record.accountDate = date; - record.transaction.index = index; - record.investment.cash.balance = [amount(String(index))]; - record.investment.cash.unclassifiedBalance = [amount(String(index), 'UNKNOWN')]; - return record; - }); - } - const earlier = dateGroup('2026-05-01', 0); - const visiblePage = dateGroup('2026-05-02', 50); - const later = dateGroup('2026-05-03', 100); - visiblePage[0].investment.securities = []; - const indexes = visiblePage.map((record) => record.transaction.index); - for (const record of [...earlier, ...later]) forbidOffPageDetails(record); - // Deliberately unsorted; native final cash still comes from the last earlier row. - const records = [...later, ...visiblePage, ...earlier]; - const result = mapInvestmentRegisterPage(records, 'asc', 2); - expect(investmentRegisterPageSize).toBe(50); - expect(result.transactions.map((transaction) => transaction.index)).toEqual(indexes); - expect(result.transactions).toHaveLength(50); +it('preserves supplied descending-date transaction groups', () => { + const records = ['2026-05-03', '2026-05-02', '2026-05-01'].map((date, index) => { + const record = row(); + record.accountDate = date; + record.transaction.index = [90, 10, 70][index]; + record.investment.cash.balance = [amount(String(index))]; + return record; + }); + records[0].investment.securities = []; + const page = accountPage(records); + const before = structuredClone(page); + const result = projectInvestmentRegisterPage(page); + expect(result.transactions.map((transaction) => transaction.index)).toEqual([90, 10, 70]); + expect(result.transactions.map((transaction) => transaction.cash.balance[0].quantity)).toEqual([ + '0', + '1', + '2' + ]); expect(result.transactions[0].securityLines).toEqual([]); - expect(result.transactions.flatMap((transaction) => transaction.securityLines)).toHaveLength(98); - expect(result.endingCash).toEqual([{ commodity: 'USD', quantity: '49' }]); - expect(result.unclassifiedEndingCash).toEqual([{ commodity: 'UNKNOWN', quantity: '49' }]); + expect( + result.transactions.slice(1).every((transaction) => transaction.securityLines.length === 2) + ).toBe(true); + expect(page).toEqual(before); }); -it('keeps empty accounts and empty native final bags empty rather than using an earlier balance', () => { - expect(mapInvestmentRegisterPage([], 'asc', 1)).toEqual({ +it('projects off-page cash endings from the summary without combining native amounts', () => { + const result = projectInvestmentRegisterPage( + accountPage([row()], { + endingCash: [amount('+7.00', 'EUR'), amount('1.0', 'EUR')], + unclassifiedEndingCash: [amount('-0.000', '')] + }) + ); + expect(result).toMatchObject({ + endingCash: [ + { commodity: 'EUR', quantity: '+7.00' }, + { commodity: 'EUR', quantity: '1.0' } + ], + unclassifiedEndingCash: [{ commodity: '', quantity: '-0.000' }] + }); +}); + +it('keeps empty summary endings rather than inferring them from visible cash balances', () => { + expect(projectInvestmentRegisterPage(accountPage([]))).toEqual({ transactions: [], endingCash: [], unclassifiedEndingCash: [] }); - const first = row(); - first.investment.cash.balance = [amount('999')]; - first.investment.cash.unclassifiedBalance = [amount('99', 'UNKNOWN')]; - const result = mapInvestmentRegisterPage([first, row()], 'desc', 1); - expect(result.transactions).toHaveLength(2); + const record = row(); + record.investment.cash.balance = [amount('999')]; + record.investment.cash.unclassifiedBalance = [amount('99', 'UNKNOWN')]; + const result = projectInvestmentRegisterPage( + accountPage([record], { + endingCash: [], + unclassifiedEndingCash: [] + }) + ); + expect(result.transactions[0].cash.balance).toEqual([{ commodity: 'USD', quantity: '999' }]); + expect(result.transactions[0].cash.unclassifiedBalance).toEqual([ + { commodity: 'UNKNOWN', quantity: '99' } + ]); expect(result.endingCash).toEqual([]); expect(result.unclassifiedEndingCash).toEqual([]); }); @@ -484,7 +472,12 @@ it('deep-whitelists public fields and every amount despite aliased private sourc } injectPrivateMetadata(record); const before = structuredClone(record); - const result = mapInvestmentRegisterPage([record], 'asc', 1); + const result = projectInvestmentRegisterPage( + accountPage([record], { + endingCash: bag, + unclassifiedEndingCash: bag + }) + ); const transaction = result.transactions[0]; expect(JSON.stringify(result)).not.toMatch(/PRIVATE|NOT-/); const keys = (object: object) => Object.keys(object).sort(); diff --git a/apps/web/src/lib/server/registers/investment.ts b/apps/web/src/lib/server/registers/investment.ts index fc22a6b..486822d 100644 --- a/apps/web/src/lib/server/registers/investment.ts +++ b/apps/web/src/lib/server/registers/investment.ts @@ -1,4 +1,5 @@ import type { PreparedAccountRow, ReportedAmount } from '@hledger-reports/hledger'; +import type { AccountActivityPage } from '@hledger-reports/snapshots'; import type { InvestmentRegisterData, InvestmentRelatedLine, @@ -41,8 +42,8 @@ function relatedActivity(row: PreparedAccountRow): InvestmentRelatedLine[] { ...gainIndexes ]); - // Visit referenced postings once in native order. Producer roles take priority: - // gain > income > expense > cash. Selected membership excludes only cash. + // Producer roles take priority: gain > income > expense > cash. + // Selected membership excludes only cash; posting order stays native. for (const index of [...relatedIndexes].sort((left, right) => left - right)) { let role: InvestmentRelatedLine['role']; if (gainIndexes.has(index)) role = 'gain'; @@ -62,7 +63,7 @@ function relatedActivity(row: PreparedAccountRow): InvestmentRelatedLine[] { return related; } -function investmentTransaction(row: PreparedAccountRow): InvestmentTransaction { +function projectInvestmentTransaction(row: PreparedAccountRow): InvestmentTransaction { const { transaction, investment } = row; const { cash } = investment; const securityLines = investment.securities.map((security) => { @@ -94,32 +95,16 @@ function investmentTransaction(row: PreparedAccountRow): InvestmentTransaction { }; } -/** Stable date ordering and whole-transaction pagination; no financial interpretation. */ -export function mapInvestmentRegisterPage( - records: PreparedAccountRow[], - order: InvestmentRegisterData['order'], - page: number -): Pick { - // Native final cash is independent of date order and the visible page. - const ending = records.at(-1)?.investment.cash; - let endingCash: ReportedAmount[] = []; - let unclassifiedEndingCash: ReportedAmount[] = []; - if (ending) { - endingCash = ending.balance.map(publicAmount); - unclassifiedEndingCash = ending.unclassifiedBalance.map(publicAmount); - } - const ordered = records.map((row, index) => ({ row, index })); - ordered.sort((left, right) => { - if (left.row.accountDate === right.row.accountDate) return left.index - right.index; - if (order === 'asc') return left.row.accountDate < right.row.accountDate ? -1 : 1; - return left.row.accountDate < right.row.accountDate ? 1 : -1; - }); - const offset = (page - 1) * investmentRegisterPageSize; +export function projectInvestmentRegisterPage({ + rows, + summary +}: AccountActivityPage): Pick< + InvestmentRegisterData, + 'transactions' | 'endingCash' | 'unclassifiedEndingCash' +> { return { - transactions: ordered - .slice(offset, offset + investmentRegisterPageSize) - .map(({ row }) => investmentTransaction(row)), - endingCash, - unclassifiedEndingCash + transactions: rows.map(projectInvestmentTransaction), + endingCash: summary.endingCash.map(publicAmount), + unclassifiedEndingCash: summary.unclassifiedEndingCash.map(publicAmount) }; } diff --git a/apps/web/src/lib/server/reports.integration.test.ts b/apps/web/src/lib/server/reports.integration.test.ts index fd914e9..8f9b473 100644 --- a/apps/web/src/lib/server/reports.integration.test.ts +++ b/apps/web/src/lib/server/reports.integration.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from 'vitest'; -import type { PreparedAccountRow, PreparedAccountSummary } from '@hledger-reports/hledger'; +import type { PreparedAccountSummary } from '@hledger-reports/hledger'; import { createReportStage, readReportPublication } from '@hledger-reports/snapshots'; import { amount, @@ -11,16 +11,41 @@ import { } from '../../../../../packages/db/src/reports/data.fixture.js'; import { load as loadLayout } from '../../routes/(protected)/+layout.server.js'; import { load as loadCashAccount } from '../../routes/(protected)/registers/cash/[accountId]/+page.server.js'; +import { load as loadInvestmentAccount } from '../../routes/(protected)/registers/investments/[accountId]/+page.server.js'; -it('keeps the layout and saved account leaf on one real publication until a fresh request', async () => { +function activity(account: string, payee: string, visibleBalance: string, endingBalance: string) { + const rows = preparedAccountRows(51); + for (const [ordinal, row] of rows.entries()) { + // The latest account date is not the native-final row, and lands alone on page two. + row.accountDate = ordinal === 0 ? '2026-04-30' : '2026-04-01'; + row.isSplit = false; + row.balance = [amount(visibleBalance)]; + row.investment.cash.balance = [amount(visibleBalance)]; + row.transaction.payee = payee; + row.transaction.postings = row.transaction.postings.slice(0, 2); + row.transaction.postings[0].account = account; + row.transaction.postings[0].date = row.accountDate; + row.transaction.postings[1].amounts = [amount('100.00')]; + row.transaction.postings[1].transactedAmounts = [amount('100.00')]; + row.investment.expensePostingIndexes = [1]; + } + rows[50].balance = [amount(endingBalance)]; + return rows; +} + +it('pins selected pages and account summaries until the next request', async () => { const root = await mkdtemp(join(tmpdir(), 'web-saved-reports-')); try { const snapshotDirectory = join(root, 'PRIVATE-snapshots'); - const account = 'assets:cash'; + const cashAccount = 'assets:cash'; + const investmentAccount = 'assets:broker'; const application = { config: { journal: '/PRIVATE/fictional.journal', - accounts: [{ account, label: 'Cash', register: 'cash' as const }] + accounts: [ + { account: cashAccount, register: 'cash' as const }, + { account: investmentAccount, register: 'investment' as const } + ] }, paths: { snapshotDirectory }, get jobsClient(): never { @@ -28,66 +53,89 @@ it('keeps the layout and saved account leaf on one real publication until a fres } }; const overview = { ...preparedOverview(), preparedAt: '2026-05-01T12:00:00Z' }; - const activity = (payee: string, balance: string) => { - const rows = preparedAccountRows(); - const row = rows[0]; - row.isSplit = false; - row.balance = [amount(balance)]; - row.transaction.payee = payee; - row.transaction.postings = row.transaction.postings.slice(0, 2); - row.transaction.postings[1].amounts = [amount('100.00')]; - row.transaction.postings[1].transactedAmounts = [amount('100.00')]; - row.investment.expensePostingIndexes = [1]; - return rows; - }; - const rowsA = activity('Market A', '9007199254740993.123456789'); - const rowsB = activity('Market B', '9007199254740994.123456789'); - const summary = (row: PreparedAccountRow): PreparedAccountSummary[] => [ + const summaries = (cashBalance: string, investmentValue: string): PreparedAccountSummary[] => [ { - name: account, + name: cashAccount, access: 'ready', - rowCount: 1, + rowCount: 51, summary: { status: 'ready', - balances: row.balance, - valuation: { amounts: row.balance, unconverted: [] } + balances: [amount(cashBalance)], + valuation: { amounts: [amount(cashBalance)], unconverted: [] } + } + }, + { + name: investmentAccount, + access: 'ready', + rowCount: 51, + summary: { + status: 'ready', + balances: [{ ...amount('3.000'), commodity: 'FUND' }], + valuation: { amounts: [amount(investmentValue)], unconverted: [] } } } ]; + const cashA = activity( + cashAccount, + 'Market A', + '9007199254740993.123456789', + '8007199254740993.000000001' + ); + const cashB = activity( + cashAccount, + 'Market B', + '9007199254740994.123456789', + '8007199254740994.000000002' + ); + cashB[50].change.push({ ...amount('0.00000002'), commodity: 'EUR' }); + const investmentA = activity( + investmentAccount, + 'Broker A', + '9007199254740993.123456789', + '8007199254740993.000000001' + ); + const investmentB = activity( + investmentAccount, + 'Broker B', + '9007199254740994.123456789', + '8007199254740994.000000002' + ); + investmentA[50].investment.cash.balance = [ + amount('10.00000001'), + { ...amount('-0.00'), commodity: 'EUR' } + ]; + investmentA[50].investment.cash.unclassifiedBalance = [ + { ...amount('2.000'), commodity: 'TOKEN' } + ]; + investmentB[50].investment.cash.balance = [amount('20.00000002')]; + investmentB[50].investment.cash.unclassifiedBalance = [ + { ...amount('3.000'), commodity: 'OTHER' } + ]; + const stageA = await createReportStage(snapshotDirectory, { id: 'A', createdAt: new Date(overview.preparedAt) }); - await stageA.writeRows(account, rowsA); - await stageA.publish(overview, summary(rowsA[0]), application.config.journal); - + await stageA.writeRows(cashAccount, cashA); + await stageA.writeRows(investmentAccount, investmentA); + await stageA.publish( + overview, + summaries('111.0000001', '222.0000002'), + application.config.journal + ); const locals = { application }; const layoutA = await loadLayout({ locals, url: new URL('http://localhost/registers/cash') } as unknown as Parameters[0]); - const idA = layoutA.accountSidebar.groups[0].accounts[0].id!; - const publicBalanceA = { commodity: '$', quantity: '9007199254740993.123456789' }; - const publicBalanceB = { commodity: '$', quantity: '9007199254740994.123456789' }; - const expectedSidebarAccount = { - id: idA, - name: account, - label: 'Cash', - access: 'ready', - value: { status: 'ready', amounts: [publicBalanceA], unconverted: [] } - }; - const expectedLayoutA = { - accountLabels: [{ account, label: 'Cash' }], - pathname: '/registers/cash', - accountSidebar: { - asOf: overview.asOf, - hasSavedReports: true, - netWorth: { amounts: [publicBalanceA], unconverted: [] }, - readFailed: false, - groups: [{ kind: 'cash', accounts: [expectedSidebarAccount] }] - } - }; - expect(layoutA).toEqual(expectedLayoutA); + const cashId = layoutA.accountSidebar.groups[0].accounts[0].id!; + const investmentId = layoutA.accountSidebar.groups[1].accounts[0].id!; + expect(layoutA.accountSidebar.groups[0].accounts[0].value).toMatchObject({ + amounts: [{ commodity: '$', quantity: '111.0000001' }] + }); + expect(layoutA.accountSidebar.groups[1].accounts[0].value).toMatchObject({ + amounts: [{ commodity: '$', quantity: '222.0000002' }] + }); const previous = await readReportPublication(snapshotDirectory); if (!previous) throw new Error('Expected publication A.'); @@ -97,97 +145,118 @@ it('keeps the layout and saved account leaf on one real publication until a fres { id: 'B', createdAt: new Date(preparedB) }, previous.accounts ); - await stageB.writeRows(account, rowsB); + await stageB.writeRows(cashAccount, cashB); + await stageB.writeRows(investmentAccount, investmentB); await stageB.publish( { ...overview, preparedAt: preparedB }, - summary(rowsB[0]), + summaries('333.0000003', '444.0000004'), application.config.journal ); - // This invokes the actual loaders, not Kit's router. Browser coverage owns client invalidation. - const leafA = await loadCashAccount({ + const cashPageA = await loadCashAccount({ locals, - url: new URL(`http://localhost/registers/cash/${idA}`), - params: { accountId: idA }, + params: { accountId: cashId }, parent: async () => layoutA, - setHeaders: () => {} + setHeaders: () => {}, + url: new URL(`http://localhost/registers/cash/${cashId}`) } as unknown as Parameters[0]); - const publicChange = { commodity: '$', quantity: '-100.00' }; - const expectedRowA = { - index: 1, - date: '2026-04-30', - payee: 'Market A', - categories: ['expenses:食料'], - notes: ['supplies', 'transaction note\nproject: home\n', 'bank note'], - amount: publicChange, - balance: publicBalanceA, - statuses: ['Cleared'], - direction: 'payment', - breakdown: null - }; - const expectedLeafA = { - configured: true, + expect(cashPageA).toMatchObject({ preparedAt: overview.preparedAt, - accountId: idA, - accountName: account, - access: 'ready', - rowCount: 1, - page: 1, - pageCount: 1, - order: 'asc', - rows: [expectedRowA], - endingBalance: publicBalanceA, - unsupported: false, - readFailed: false - }; - expect(leafA).toEqual(expectedLeafA); + readFailed: false, + page: 2, + pageCount: 2, + rowCount: 51, + rows: [ + { + index: 1, + payee: 'Market A', + balance: { commodity: '$', quantity: '9007199254740993.123456789' } + } + ], + endingBalance: { commodity: '$', quantity: '8007199254740993.000000001' }, + unsupported: false + }); + const investmentPageA = await loadInvestmentAccount({ + locals, + params: { accountId: investmentId }, + parent: async () => layoutA, + setHeaders: () => {}, + url: new URL(`http://localhost/registers/investments/${investmentId}`) + } as unknown as Parameters[0]); + expect(investmentPageA).toMatchObject({ + preparedAt: overview.preparedAt, + readFailed: false, + register: { + page: 2, + pageCount: 2, + transactionCount: 51, + transactions: [ + { + index: 1, + payee: 'Broker A', + cash: { balance: [{ commodity: '$', quantity: '9007199254740993.123456789' }] } + } + ], + endingCash: [ + { commodity: '$', quantity: '10.00000001' }, + { commodity: 'EUR', quantity: '-0.00' } + ], + unclassifiedEndingCash: [{ commodity: 'TOKEN', quantity: '2.000' }] + } + }); const freshLocals = { application }; const layoutB = await loadLayout({ locals: freshLocals, url: new URL('http://localhost/registers/cash') } as unknown as Parameters[0]); - const idB = layoutB.accountSidebar.groups[0].accounts[0].id!; - expect(idB).toBe(idA); - expect(layoutB).toEqual({ - ...expectedLayoutA, - accountSidebar: { - ...expectedLayoutA.accountSidebar, - groups: [ + expect(layoutB.accountSidebar.groups[0].accounts[0]).toMatchObject({ + id: cashId, + value: { amounts: [{ commodity: '$', quantity: '333.0000003' }] } + }); + expect(layoutB.accountSidebar.groups[1].accounts[0]).toMatchObject({ + id: investmentId, + value: { amounts: [{ commodity: '$', quantity: '444.0000004' }] } + }); + const cashPageB = await loadCashAccount({ + locals: freshLocals, + params: { accountId: cashId }, + parent: async () => layoutB, + setHeaders: () => {}, + url: new URL(`http://localhost/registers/cash/${cashId}`) + } as unknown as Parameters[0]); + expect(cashPageB).toMatchObject({ + preparedAt: preparedB, + readFailed: false, + rows: [], + endingBalance: null, + unsupported: true + }); + const investmentPageB = await loadInvestmentAccount({ + locals: freshLocals, + params: { accountId: investmentId }, + parent: async () => layoutB, + setHeaders: () => {}, + url: new URL(`http://localhost/registers/investments/${investmentId}`) + } as unknown as Parameters[0]); + expect(investmentPageB).toMatchObject({ + preparedAt: preparedB, + readFailed: false, + register: { + transactions: [ { - kind: 'cash', - accounts: [ - { - ...expectedSidebarAccount, - value: { status: 'ready', amounts: [publicBalanceB], unconverted: [] } - } - ] + index: 1, + payee: 'Broker B', + cash: { balance: [{ commodity: '$', quantity: '9007199254740994.123456789' }] } } - ] + ], + endingCash: [{ commodity: '$', quantity: '20.00000002' }], + unclassifiedEndingCash: [{ commodity: 'OTHER', quantity: '3.000' }] } }); - const leafB = await loadCashAccount({ - locals: freshLocals, - url: new URL(`http://localhost/registers/cash/${idB}`), - params: { accountId: idB }, - parent: async () => layoutB, - setHeaders: () => {} - } as unknown as Parameters[0]); - expect(leafB).toEqual({ - ...expectedLeafA, - preparedAt: preparedB, - rows: [ - { - ...expectedRowA, - payee: 'Market B', - balance: publicBalanceB - } - ], - endingBalance: publicBalanceB - }); - expect(JSON.stringify([layoutA, leafA, layoutB, leafB])).not.toMatch( - /PRIVATE|directory|journal|publication|savedReports|sourcePosition|costBasis|style/ - ); + expect( + JSON.stringify([layoutA, cashPageA, investmentPageA, layoutB, cashPageB, investmentPageB]) + ).not.toMatch(/PRIVATE|sourcePositions|costBasis|accountPostingIndexes|transactedAmounts/); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts index 0e3bac7..4a9b2c7 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts @@ -1,9 +1,9 @@ import { registerMembers } from '#lib/server/registers/accounts.js'; -import { readAccountTransactions } from '@hledger-reports/snapshots'; +import { readAccountPage } from '@hledger-reports/snapshots'; import { error } from '@sveltejs/kit'; import type { CashRegisterData } from '#lib/registers/cash/data.js'; import { readSavedReports } from '#lib/server/reports.js'; -import { cashRegisterPageSize, mapCashRegisterPage } from '#lib/server/registers/cash.js'; +import { cashRegisterPageSize, projectCashRegisterPage } from '#lib/server/registers/cash.js'; import type { PageServerLoad } from './$types'; function parseRequest( @@ -72,9 +72,13 @@ export const load = (async (event): Promise => { // A declared empty account needs no invented amounts or body read. if (rowCount === 0) return data; try { - const account = await readAccountTransactions(publication, accountId); - if (account.transactions === null) throw new Error('Prepared account rows unavailable.'); - Object.assign(data, mapCashRegisterPage(account.transactions, order, data.page)); + const accountPage = await readAccountPage(publication, accountId, { + order, + offset: (data.page - 1) * cashRegisterPageSize, + limit: cashRegisterPageSize + }); + if (accountPage === null) throw new Error('Prepared account rows unavailable.'); + Object.assign(data, projectCashRegisterPage(accountPage)); } catch { // Malformed/unavailable saved bodies are not a reason to run or repair reports. data.readFailed = true; diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts index 52f09f6..1970e97 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts @@ -1,24 +1,24 @@ import type { AccountConfig } from '@hledger-reports/config'; -import type { PreparedAccountRow } from '@hledger-reports/hledger'; +import { preparedAccountRows } from '../../../../../../../../packages/db/src/reports/data.fixture.js'; import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { readReportPublication, - readAccountTransactions, + readAccountPage, + type AccountActivityPage, type ReportPublication } from '@hledger-reports/snapshots'; import type { ApplicationContext } from '#lib/server/application.js'; -import { mapCashRegisterPage } from '#lib/server/registers/cash.js'; +import { projectCashRegisterPage } from '#lib/server/registers/cash.js'; import { load } from './+page.server.js'; vi.mock('@hledger-reports/snapshots', () => ({ readReportPublication: vi.fn(), - readAccountTransactions: vi.fn() + readAccountPage: vi.fn() })); -// Financial ordering, balances and deep DTO redaction belong to the mapper tests. vi.mock('#lib/server/registers/cash.js', () => ({ cashRegisterPageSize: 50, - mapCashRegisterPage: vi.fn() + projectCashRegisterPage: vi.fn() })); vi.mock('@hledger-reports/hledger', () => { throw new Error('Browsing must not load the native runtime.'); @@ -40,9 +40,17 @@ const publication: ReportPublication = { incomeExpenses: { months: [] } } }; -// The routing test passes the saved body through; it does not interpret native rows. -const rows: PreparedAccountRow[] = []; -const projection: ReturnType = { +const accountPage: AccountActivityPage = { + rows: preparedAccountRows(), + summary: { + hasMultipleAmounts: false, + hasMultipleCommodities: false, + endingBalance: [{ commodity: 'USD', quantity: '1.0001' }], + endingCash: [], + unclassifiedEndingCash: [] + } +}; +const projection: ReturnType = { rows: [], endingBalance: { commodity: 'USD', quantity: '1.0001' }, unsupported: false @@ -72,18 +80,18 @@ function event(query = '', accounts: AccountConfig[] = [{ account: name, registe beforeEach(() => { vi.resetAllMocks(); vi.mocked(readReportPublication).mockResolvedValue(publication); - vi.mocked(readAccountTransactions).mockResolvedValue({ id, name, transactions: rows }); - vi.mocked(mapCashRegisterPage).mockReturnValue(projection); + vi.mocked(readAccountPage).mockResolvedValue(accountPage); + vi.mocked(projectCashRegisterPage).mockReturnValue(projection); }); it('loads a member from one pinned publication and delegates its requested page', async () => { - vi.mocked(readAccountTransactions).mockImplementation(async (pinned) => { + vi.mocked(readAccountPage).mockImplementation(async (pinned) => { expect(pinned).toBe(publication); vi.mocked(readReportPublication).mockResolvedValue({ ...publication, directory: '/PRIVATE/newer', overview: { ...publication.overview, preparedAt: '2099-01-01T00:00:00Z' } }); - return { id, name, transactions: rows }; + return accountPage; }); const request = event('order=desc&page=2'); const result = await load(request); @@ -101,12 +109,16 @@ it('loads a member from one pinned publication and delegates its requested page' ...projection }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots'); - expect(readAccountTransactions).toHaveBeenCalledExactlyOnceWith(publication, id); - expect(mapCashRegisterPage).toHaveBeenCalledExactlyOnceWith(rows, 'desc', 2); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'desc', + offset: 50, + limit: 50 + }); + expect(projectCashRegisterPage).toHaveBeenCalledExactlyOnceWith(accountPage); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); }); it('returns a safe read failure when the saved body cannot be read', async () => { - vi.mocked(readAccountTransactions).mockRejectedValue( + vi.mocked(readAccountPage).mockRejectedValue( new Error('PRIVATE /books/included.journal:12 database diagnostic') ); const request = event(); @@ -127,18 +139,72 @@ it('returns a safe read failure when the saved body cannot be read', async () => endingBalance: null, unsupported: false }); - expect(readAccountTransactions).toHaveBeenCalledExactlyOnceWith(publication, id); - expect(mapCashRegisterPage).not.toHaveBeenCalled(); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'asc', + offset: 50, + limit: 50 + }); + expect(projectCashRegisterPage).not.toHaveBeenCalled(); expect(queue).not.toHaveBeenCalled(); }); +it('defaults descending navigation to the first page', async () => { + const firstPage = { ...accountPage, rows: preparedAccountRows(50) }; + vi.mocked(readAccountPage).mockResolvedValue(firstPage); + await load(event('order=desc')); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'desc', + offset: 0, + limit: 50 + }); + expect(projectCashRegisterPage).toHaveBeenCalledExactlyOnceWith(firstPage); +}); it('defaults ascending navigation to the last page', async () => { await load(event()); - expect(mapCashRegisterPage).toHaveBeenCalledExactlyOnceWith(rows, 'asc', 2); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'asc', + offset: 50, + limit: 50 + }); + expect(projectCashRegisterPage).toHaveBeenCalledExactlyOnceWith(accountPage); }); it('rejects a disabled register before IO', async () => { await expect(load(event('', []))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).not.toHaveBeenCalled(); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); +}); +it('does not read or project ambiguous or empty account activity', async () => { + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + accounts: [{ id, name, access: 'ambiguous', rowCount: null, summary: { status: 'ambiguous' } }] + }); + expect(await load(event())).toMatchObject({ + readFailed: false, + access: 'ambiguous', + rows: [], + rowCount: null, + endingBalance: null + }); + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + accounts: [{ id, name, access: 'ready', rowCount: 0, summary: { status: 'undated' } }] + }); + expect(await load(event())).toMatchObject({ + readFailed: false, + access: 'ready', + rows: [], + rowCount: 0, + page: 1, + pageCount: 1, + endingBalance: null + }); + expect(readAccountPage).not.toHaveBeenCalled(); + expect(projectCashRegisterPage).not.toHaveBeenCalled(); +}); +it('rejects an unknown member before reading activity', async () => { + const request = event(); + request.params.accountId = 'unknown1'; + await expect(load(request)).rejects.toMatchObject({ status: 404 }); + expect(readAccountPage).not.toHaveBeenCalled(); }); it('rejects a wrong-kind member before reading its body', async () => { await expect( @@ -150,13 +216,16 @@ it('rejects a wrong-kind member before reading its body', async () => { ) ).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).toHaveBeenCalledTimes(1); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); }); it('rejects invalid page syntax before IO and out-of-range pages before body reads', async () => { await expect(load(event('page=0'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('order=sideways'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('page=1&page=2'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('source=PRIVATE'))).rejects.toMatchObject({ status: 400 }); expect(readReportPublication).not.toHaveBeenCalled(); await expect(load(event('page=3'))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).toHaveBeenCalledTimes(1); - expect(readAccountTransactions).not.toHaveBeenCalled(); - expect(mapCashRegisterPage).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); + expect(projectCashRegisterPage).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts index 487a08d..b8af140 100644 --- a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts @@ -3,7 +3,7 @@ import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { readReportPublication, - readAccountTransactions, + readAccountPage, type ReportPublication } from '@hledger-reports/snapshots'; import type { ApplicationContext } from '#lib/server/application.js'; @@ -11,7 +11,7 @@ import { load } from './+page.server.js'; vi.mock('@hledger-reports/snapshots', () => ({ readReportPublication: vi.fn(), - readAccountTransactions: vi.fn() + readAccountPage: vi.fn() })); vi.mock('@hledger-reports/hledger', () => { throw new Error('Browsing must not load the native runtime.'); @@ -88,11 +88,11 @@ it('passively lists configured members in order, with stable IDs, missing member accounts: [{ id: null, name: 'custom:missing', access: 'missing', rowCount: null }, member] }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots'); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); }); it('rejects a disabled register before IO', async () => { await expect(load(event([]))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).not.toHaveBeenCalled(); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts index 54da62b..42f2a90 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts @@ -1,5 +1,5 @@ import { registerMembers } from '#lib/server/registers/accounts.js'; -import { readAccountTransactions } from '@hledger-reports/snapshots'; +import { readAccountPage } from '@hledger-reports/snapshots'; import { error } from '@sveltejs/kit'; import type { InvestmentAccountPageData, @@ -8,7 +8,7 @@ import type { import { readSavedReports } from '#lib/server/reports.js'; import { investmentRegisterPageSize, - mapInvestmentRegisterPage + projectInvestmentRegisterPage } from '#lib/server/registers/investment.js'; import type { PageServerLoad } from './$types'; @@ -84,12 +84,13 @@ export const load = (async (event): Promise => { return data; } try { - const account = await readAccountTransactions(publication, accountId); - if (account.transactions === null) throw new Error('Prepared account rows unavailable.'); - data.register = { - ...register, - ...mapInvestmentRegisterPage(account.transactions, order, currentPage) - }; + const accountPage = await readAccountPage(publication, accountId, { + order, + offset: (currentPage - 1) * investmentRegisterPageSize, + limit: investmentRegisterPageSize + }); + if (accountPage === null) throw new Error('Prepared account rows unavailable.'); + data.register = { ...register, ...projectInvestmentRegisterPage(accountPage) }; } catch { // Malformed/unavailable saved bodies are not a reason to run or repair reports. data.readFailed = true; diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts index 12e63ce..39c6457 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts @@ -1,24 +1,24 @@ import type { AccountConfig } from '@hledger-reports/config'; -import type { PreparedAccountRow } from '@hledger-reports/hledger'; +import { preparedAccountRows } from '../../../../../../../../packages/db/src/reports/data.fixture.js'; import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { readReportPublication, - readAccountTransactions, + readAccountPage, + type AccountActivityPage, type ReportPublication } from '@hledger-reports/snapshots'; import type { ApplicationContext } from '#lib/server/application.js'; -import { mapInvestmentRegisterPage } from '#lib/server/registers/investment.js'; +import { projectInvestmentRegisterPage } from '#lib/server/registers/investment.js'; import { load } from './+page.server.js'; vi.mock('@hledger-reports/snapshots', () => ({ readReportPublication: vi.fn(), - readAccountTransactions: vi.fn() + readAccountPage: vi.fn() })); -// Financial ordering, balances and deep DTO redaction belong to the mapper tests. vi.mock('#lib/server/registers/investment.js', () => ({ investmentRegisterPageSize: 50, - mapInvestmentRegisterPage: vi.fn() + projectInvestmentRegisterPage: vi.fn() })); vi.mock('@hledger-reports/hledger', () => { throw new Error('Browsing must not load the native runtime.'); @@ -40,9 +40,17 @@ const publication: ReportPublication = { incomeExpenses: { months: [] } } }; -// The routing test passes the saved body through; it does not interpret native rows. -const rows: PreparedAccountRow[] = []; -const projection: ReturnType = { +const accountPage: AccountActivityPage = { + rows: preparedAccountRows(), + summary: { + hasMultipleAmounts: false, + hasMultipleCommodities: false, + endingBalance: [], + endingCash: [{ commodity: 'USD', quantity: '1.0001' }], + unclassifiedEndingCash: [] + } +}; +const projection: ReturnType = { transactions: [], endingCash: [{ commodity: 'USD', quantity: '1.0001' }], unclassifiedEndingCash: [] @@ -75,18 +83,18 @@ function event( beforeEach(() => { vi.resetAllMocks(); vi.mocked(readReportPublication).mockResolvedValue(publication); - vi.mocked(readAccountTransactions).mockResolvedValue({ id, name, transactions: rows }); - vi.mocked(mapInvestmentRegisterPage).mockReturnValue(projection); + vi.mocked(readAccountPage).mockResolvedValue(accountPage); + vi.mocked(projectInvestmentRegisterPage).mockReturnValue(projection); }); it('loads a member from one pinned publication and delegates its requested page', async () => { - vi.mocked(readAccountTransactions).mockImplementation(async (pinned) => { + vi.mocked(readAccountPage).mockImplementation(async (pinned) => { expect(pinned).toBe(publication); vi.mocked(readReportPublication).mockResolvedValue({ ...publication, directory: '/PRIVATE/newer', overview: { ...publication.overview, preparedAt: '2099-01-01T00:00:00Z' } }); - return { id, name, transactions: rows }; + return accountPage; }); const request = event('order=desc&page=2'); const result = await load(request); @@ -107,12 +115,16 @@ it('loads a member from one pinned publication and delegates its requested page' } }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots'); - expect(readAccountTransactions).toHaveBeenCalledExactlyOnceWith(publication, id); - expect(mapInvestmentRegisterPage).toHaveBeenCalledExactlyOnceWith(rows, 'desc', 2); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'desc', + offset: 50, + limit: 50 + }); + expect(projectInvestmentRegisterPage).toHaveBeenCalledExactlyOnceWith(accountPage); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); }); it('returns a safe read failure when the saved body cannot be read', async () => { - vi.mocked(readAccountTransactions).mockRejectedValue( + vi.mocked(readAccountPage).mockRejectedValue( new Error('PRIVATE /books/included.journal:12 database diagnostic') ); const request = event(); @@ -126,18 +138,73 @@ it('returns a safe read failure when the saved body cannot be read', async () => preparedAt: publication.overview.preparedAt, register: null }); - expect(readAccountTransactions).toHaveBeenCalledExactlyOnceWith(publication, id); - expect(mapInvestmentRegisterPage).not.toHaveBeenCalled(); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'asc', + offset: 50, + limit: 50 + }); + expect(projectInvestmentRegisterPage).not.toHaveBeenCalled(); expect(queue).not.toHaveBeenCalled(); }); +it('defaults descending navigation to the first page', async () => { + const firstPage = { ...accountPage, rows: preparedAccountRows(50) }; + vi.mocked(readAccountPage).mockResolvedValue(firstPage); + await load(event('order=desc')); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'desc', + offset: 0, + limit: 50 + }); + expect(projectInvestmentRegisterPage).toHaveBeenCalledExactlyOnceWith(firstPage); +}); it('defaults ascending navigation to the last page', async () => { await load(event()); - expect(mapInvestmentRegisterPage).toHaveBeenCalledExactlyOnceWith(rows, 'asc', 2); + expect(readAccountPage).toHaveBeenCalledExactlyOnceWith(publication, id, { + order: 'asc', + offset: 50, + limit: 50 + }); + expect(projectInvestmentRegisterPage).toHaveBeenCalledExactlyOnceWith(accountPage); }); it('rejects a disabled register before IO', async () => { await expect(load(event('', []))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).not.toHaveBeenCalled(); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); +}); +it('does not read or project ambiguous or empty account activity', async () => { + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + accounts: [{ id, name, access: 'ambiguous', rowCount: null, summary: { status: 'ambiguous' } }] + }); + expect(await load(event())).toMatchObject({ + readFailed: false, + access: 'ambiguous', + register: null + }); + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + accounts: [{ id, name, access: 'ready', rowCount: 0, summary: { status: 'undated' } }] + }); + expect(await load(event())).toMatchObject({ + readFailed: false, + access: 'ready', + register: { + transactions: [], + transactionCount: 0, + page: 1, + pageCount: 1, + endingCash: [], + unclassifiedEndingCash: [] + } + }); + expect(readAccountPage).not.toHaveBeenCalled(); + expect(projectInvestmentRegisterPage).not.toHaveBeenCalled(); +}); +it('rejects an unknown member before reading activity', async () => { + const request = event(); + request.params.accountId = 'unknown1'; + await expect(load(request)).rejects.toMatchObject({ status: 404 }); + expect(readAccountPage).not.toHaveBeenCalled(); }); it('rejects a wrong-kind member before reading its body', async () => { await expect( @@ -149,13 +216,16 @@ it('rejects a wrong-kind member before reading its body', async () => { ) ).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).toHaveBeenCalledTimes(1); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); }); it('rejects invalid page syntax before IO and out-of-range pages before body reads', async () => { await expect(load(event('page=0'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('order=sideways'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('page=1&page=2'))).rejects.toMatchObject({ status: 400 }); + await expect(load(event('source=PRIVATE'))).rejects.toMatchObject({ status: 400 }); expect(readReportPublication).not.toHaveBeenCalled(); await expect(load(event('page=3'))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).toHaveBeenCalledTimes(1); - expect(readAccountTransactions).not.toHaveBeenCalled(); - expect(mapInvestmentRegisterPage).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); + expect(projectInvestmentRegisterPage).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts index ff50027..c6eeb6a 100644 --- a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts @@ -3,7 +3,7 @@ import { testConfig } from '#lib/server/application.fixture.js'; import { beforeEach, expect, it, vi } from 'vitest'; import { readReportPublication, - readAccountTransactions, + readAccountPage, type ReportPublication } from '@hledger-reports/snapshots'; import type { ApplicationContext } from '#lib/server/application.js'; @@ -11,7 +11,7 @@ import { load } from './+page.server.js'; vi.mock('@hledger-reports/snapshots', () => ({ readReportPublication: vi.fn(), - readAccountTransactions: vi.fn() + readAccountPage: vi.fn() })); vi.mock('@hledger-reports/hledger', () => { throw new Error('Browsing must not load the native runtime.'); @@ -88,11 +88,11 @@ it('passively lists configured members in order, with stable IDs, missing member accounts: [{ id: null, name: 'custom:missing', access: 'missing', rowCount: null }, member] }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots'); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); }); it('rejects a disabled register before IO', async () => { await expect(load(event([]))).rejects.toMatchObject({ status: 404 }); expect(readReportPublication).not.toHaveBeenCalled(); - expect(readAccountTransactions).not.toHaveBeenCalled(); + expect(readAccountPage).not.toHaveBeenCalled(); }); diff --git a/apps/worker/src/workflows/prepare-reports/run.integration.test.ts b/apps/worker/src/workflows/prepare-reports/run.integration.test.ts index 194bf68..01bb95f 100644 --- a/apps/worker/src/workflows/prepare-reports/run.integration.test.ts +++ b/apps/worker/src/workflows/prepare-reports/run.integration.test.ts @@ -6,7 +6,7 @@ import { afterEach, expect, it } from 'vitest'; import { openJobsClient, prepareReportsSpec, type JobsClient } from '@hledger-reports/jobs'; import { createReportStage, - readAccountTransactions, + readAccountPage, readOverview, readReportPublication, readValidation @@ -94,8 +94,16 @@ it('explicitly prepares a saved overview through the real SDK without a browser status: 'ready', balances: expect.arrayContaining([{ commodity: 'USD', quantity: '8520.00' }]) }); - const { transactions } = await readAccountTransactions(publication, account.id); - const salary = transactions!.find((row) => row.transaction.note === 'Salary')!; + const page = (await readAccountPage(publication, account.id, { + order: 'asc', + offset: 0, + limit: 50 + }))!; + const salary = page.rows.find((row) => row.transaction.note === 'Salary')!; + expect(salary.change).toEqual([ + expect.objectContaining({ commodity: 'USD', quantity: '1000.00' }) + ]); + expect(page.summary.endingBalance).toEqual([{ commodity: 'USD', quantity: '8520.00' }]); expect(salary.transaction.sourcePositions).toContainEqual( expect.objectContaining({ file: f.included, line: expect.any(Number) }) ); @@ -108,9 +116,12 @@ it('explicitly prepares a saved overview through the real SDK without a browser expect(await readOverview(f.snapshots)).toEqual(data); expect(await readValidation(f.snapshots)).toEqual(validation); const savedPublication = (await readReportPublication(f.snapshots))!; - expect((await readAccountTransactions(savedPublication, account.id)).transactions).toEqual( - transactions - ); + const savedPage = await readAccountPage(savedPublication, account.id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(savedPage).toEqual(page); expect(await readFile(f.journal, 'utf8')).toBe('include included.journal\n'); expect(await readFile(f.included, 'utf8')).toBe(source); } finally { diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index fa33851..12ba76a 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -2,6 +2,7 @@ import type { Kysely } from 'kysely'; import { Migrator } from 'kysely/migration'; import * as reports from './migrations/001_reports.js'; import * as activityIndexes from './migrations/002_account_activity_indexes.js'; +import * as activitySummaries from './migrations/003_account_activity_summaries.js'; import type { ReportDatabase } from './schema.js'; export async function runMigrations(database: Kysely): Promise { @@ -11,7 +12,8 @@ export async function runMigrations(database: Kysely): Promise): Promise { + await database.schema + .alterTable('accounts') + .renameColumn('summary_json', 'balance_json') + .execute(); + + await database.schema + .createTable('account_activity_summaries') + .addColumn('account_name', 'text', (column) => + column + .modifyFront(sql`COLLATE BINARY`) + .notNull() + .primaryKey() + ) + .addColumn('has_multiple_amounts', 'integer', (column) => column.notNull()) + .addColumn('has_multiple_commodities', 'integer', (column) => column.notNull()) + .addColumn('ending_balance_json', 'text', (column) => column.notNull()) + .addColumn('ending_cash_json', 'text', (column) => column.notNull()) + .addColumn('unclassified_ending_cash_json', 'text', (column) => column.notNull()) + .addCheckConstraint('activity_summary_multiple_amounts', sql`has_multiple_amounts IN (0, 1)`) + .addCheckConstraint( + 'activity_summary_multiple_commodities', + sql`has_multiple_commodities IN (0, 1)` + ) + .addCheckConstraint( + 'activity_summary_ending_balance', + sql`json_valid(ending_balance_json) AND json_type(ending_balance_json) = 'array'` + ) + .addCheckConstraint( + 'activity_summary_ending_cash', + sql`json_valid(ending_cash_json) AND json_type(ending_cash_json) = 'array'` + ) + .addCheckConstraint( + 'activity_summary_unclassified_ending_cash', + sql`json_valid(unclassified_ending_cash_json) + AND json_type(unclassified_ending_cash_json) = 'array'` + ) + .addForeignKeyConstraint( + 'activity_summary_account', + ['account_name'], + 'accounts', + ['name'], + (constraint) => constraint.deferrable().initiallyDeferred() + ) + .modifyEnd(sql`STRICT`) + .execute(); + + await sql`PRAGMA user_version = 2`.execute(database); +} diff --git a/packages/db/src/reports/account-activity-summary.test.ts b/packages/db/src/reports/account-activity-summary.test.ts new file mode 100644 index 0000000..e86a60f --- /dev/null +++ b/packages/db/src/reports/account-activity-summary.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from 'vitest'; +import { AccountActivitySummaryBuilder } from './account-activity-summary.js'; +import { amount, preparedAccountRows } from './data.fixture.js'; + +it('copies native-final bags and whole-account amount shape across chunks', () => { + const rows = preparedAccountRows(2); + rows[0].accountDate = '2026-04-30'; + rows[0].change = [{ ...amount('1'), commodity: '' }]; + rows[0].balance = [{ ...amount('1'), commodity: '' }]; + rows[1].accountDate = '2026-04-29'; + rows[1].change = [{ ...amount('-0.000'), commodity: '' }]; + rows[1].balance = [ + { ...amount('9007199254740993.123456789'), commodity: '' }, + { ...amount('0.000000000000000000012300'), commodity: '' } + ]; + rows[1].investment.cash.balance = [amount('2.00'), amount('-0.000'), amount('2.00')]; + rows[1].investment.cash.unclassifiedBalance = [{ ...amount('3.4000'), commodity: 'TEST' }]; + const builder = new AccountActivitySummaryBuilder(); + builder.append(rows.slice(0, 1)); + builder.append([]); + builder.append(rows.slice(1)); + rows[1].balance[0].quantity = 'changed after delivery'; + rows[1].investment.cash.balance.push(amount('999')); + expect(builder.build()).toEqual({ + hasMultipleAmounts: true, + hasMultipleCommodities: false, + endingBalance: [ + { commodity: '', quantity: '9007199254740993.123456789' }, + { commodity: '', quantity: '0.000000000000000000012300' } + ], + endingCash: [ + { commodity: '$', quantity: '2.00' }, + { commodity: '$', quantity: '-0.000' }, + { commodity: '$', quantity: '2.00' } + ], + unclassifiedEndingCash: [{ commodity: 'TEST', quantity: '3.4000' }] + }); +}); + +it('distinguishes blank units from other units and retains an empty native ending', () => { + const empty = { + hasMultipleAmounts: false, + hasMultipleCommodities: false, + endingBalance: [], + endingCash: [], + unclassifiedEndingCash: [] + }; + const builder = new AccountActivitySummaryBuilder(); + expect(builder.build()).toEqual(empty); + const rows = preparedAccountRows(3); + rows[0].change = [{ ...amount('0.000'), commodity: '' }]; + rows[0].balance = []; + rows[1].change = [amount('-0.000')]; + rows[1].balance = [amount('0.000')]; + rows[2].change = []; + rows[2].balance = []; + rows[2].investment.cash.balance = []; + rows[2].investment.cash.unclassifiedBalance = []; + builder.append(rows.slice(0, 1)); + expect(builder.build().hasMultipleCommodities).toBe(false); + builder.append(rows.slice(1, 2)); + expect(builder.build().hasMultipleCommodities).toBe(true); + builder.append(rows.slice(2)); + expect(builder.build()).toEqual({ ...empty, hasMultipleCommodities: true }); +}); diff --git a/packages/db/src/reports/account-activity-summary.ts b/packages/db/src/reports/account-activity-summary.ts new file mode 100644 index 0000000..8626630 --- /dev/null +++ b/packages/db/src/reports/account-activity-summary.ts @@ -0,0 +1,77 @@ +import type { PreparedAccountRow, ReportedAmount } from '@hledger-reports/hledger'; +import { isObject } from './data.js'; + +/** Ending bags come from the final native ordinal, not the latest date or visible page. */ +export interface AccountActivitySummary { + /** Across native change and balance bags, not the visible page. */ + hasMultipleAmounts: boolean; + hasMultipleCommodities: boolean; + endingBalance: ReportedAmount[]; + endingCash: ReportedAmount[]; + unclassifiedEndingCash: ReportedAmount[]; +} + +function isAmount(value: unknown): value is ReportedAmount { + return ( + isObject(value) && typeof value.commodity === 'string' && typeof value.quantity === 'string' + ); +} + +export function isAmountBag(value: unknown): value is ReportedAmount[] { + return Array.isArray(value) && value.every(isAmount); +} + +export function hasAccountActivitySummary(value: unknown): value is AccountActivitySummary { + return ( + isObject(value) && + typeof value.hasMultipleAmounts === 'boolean' && + typeof value.hasMultipleCommodities === 'boolean' && + isAmountBag(value.endingBalance) && + isAmountBag(value.endingCash) && + isAmountBag(value.unclassifiedEndingCash) + ); +} + +function copyAmounts(amounts: readonly ReportedAmount[]): ReportedAmount[] { + if (!isAmountBag(amounts)) throw new Error('Invalid account activity.'); + return amounts.map(({ commodity, quantity }) => ({ commodity, quantity })); +} + +export class AccountActivitySummaryBuilder { + private firstCommodity: string | undefined; + private hasMultipleAmounts = false; + private hasMultipleCommodities = false; + private endingBalance: ReportedAmount[] = []; + private endingCash: ReportedAmount[] = []; + private unclassifiedEndingCash: ReportedAmount[] = []; + + /** Append chunks in native ordinal order, not display-date order. */ + append(rows: readonly PreparedAccountRow[]): void { + for (const row of rows) { + for (const amounts of [row.change, row.balance]) { + if (!isAmountBag(amounts)) throw new Error('Invalid account activity.'); + this.hasMultipleAmounts ||= amounts.length > 1; + for (const amount of amounts) { + if (this.firstCommodity === undefined) this.firstCommodity = amount.commodity; + else if (this.firstCommodity !== amount.commodity) this.hasMultipleCommodities = true; + } + } + } + const ending = rows.at(-1); + if (ending) { + this.endingBalance = copyAmounts(ending.balance); + this.endingCash = copyAmounts(ending.investment.cash.balance); + this.unclassifiedEndingCash = copyAmounts(ending.investment.cash.unclassifiedBalance); + } + } + + build(): AccountActivitySummary { + return { + hasMultipleAmounts: this.hasMultipleAmounts, + hasMultipleCommodities: this.hasMultipleCommodities, + endingBalance: this.endingBalance, + endingCash: this.endingCash, + unclassifiedEndingCash: this.unclassifiedEndingCash + }; + } +} diff --git a/packages/db/src/reports/data.fixture.ts b/packages/db/src/reports/data.fixture.ts index d2d1203..bfa2437 100644 --- a/packages/db/src/reports/data.fixture.ts +++ b/packages/db/src/reports/data.fixture.ts @@ -4,6 +4,13 @@ import type { PreparedAccountSummary, PreparedAccountRow } from '@hledger-reports/hledger'; +import { AccountActivitySummaryBuilder } from './account-activity-summary.js'; + +export function preparedActivitySummary(rows: readonly PreparedAccountRow[] = []) { + const builder = new AccountActivitySummaryBuilder(); + builder.append(rows); + return builder.build(); +} const expenseAmounts = [ { commodity: 'EUR', quantity: '0.0000000000000000000000012300' }, diff --git a/packages/db/src/reports/data.ts b/packages/db/src/reports/data.ts index 77c948f..18734d4 100644 --- a/packages/db/src/reports/data.ts +++ b/packages/db/src/reports/data.ts @@ -12,6 +12,7 @@ export interface StoredAccount { name: string; access: 'ready' | 'ambiguous'; rowCount: number | null; + /** Native balance and valuation at the saved overview's as-of date. */ summary: PreparedBalance; } @@ -98,6 +99,8 @@ export function hasAccountRowContainers(value: unknown): value is PreparedAccoun if ( !isObject(value) || typeof value.accountDate !== 'string' || + !Array.isArray(value.change) || + !Array.isArray(value.balance) || !isObject(value.transaction) || !Array.isArray(value.transaction.postings) || !Array.isArray(value.accountPostingIndexes) || diff --git a/packages/db/src/reports/index.ts b/packages/db/src/reports/index.ts index 3185424..e22a9db 100644 --- a/packages/db/src/reports/index.ts +++ b/packages/db/src/reports/index.ts @@ -12,9 +12,11 @@ export { export type { StoredAccount, StoredReportData } from './data.js'; export { writeAccountRows, + writeAccountActivitySummaries, writeReportData, readReportData, - readAccountRows, readAccountActivityPage } from './queries.js'; -export type { AccountActivityPageOptions } from './queries.js'; +export type { AccountActivityPage, AccountActivityPageOptions } from './queries.js'; +export { AccountActivitySummaryBuilder } from './account-activity-summary.js'; +export type { AccountActivitySummary } from './account-activity-summary.js'; diff --git a/packages/db/src/reports/queries.test.ts b/packages/db/src/reports/queries.test.ts index 621f25a..0e3a47a 100644 --- a/packages/db/src/reports/queries.test.ts +++ b/packages/db/src/reports/queries.test.ts @@ -11,10 +11,10 @@ import { type ReportDatabase } from '../index.js'; import { - readAccountRows, readAccountActivityPage, readReportData, writeAccountRows, + writeAccountActivitySummaries, writeReportData } from './queries.js'; import type { StoredReportData } from './data.js'; @@ -24,7 +24,8 @@ import { preparedOverview, journal, ready, - preparedAccountRows + preparedAccountRows, + preparedActivitySummary } from './data.fixture.js'; let directory: string; @@ -108,6 +109,14 @@ it('stores exact prepared payloads and native row order using the migrated schem gainPostingIndexes: [], issues: ['unclassified-commodity', 'unavailable-transacted-values'] }; + const nativeEnding = rows[rows.length - 1]; + nativeEnding.balance = [ + { ...amount('9007199254740993.123456789'), commodity: '' }, + { ...amount('-0.000'), commodity: 'EUR' }, + { ...amount('9007199254740993.123456789'), commodity: '' } + ]; + nativeEnding.investment.cash.balance = []; + nativeEnding.investment.cash.unclassifiedBalance = [{ commodity: 'EUR', quantity: '0.000' }]; const otherRows = preparedAccountRows(1, 'case-distinct account'); otherRows[0].transaction.postings[0].account = 'assets:Cash'; const data: StoredReportData = { @@ -120,12 +129,18 @@ it('stores exact prepared payloads and native row order using the migrated schem { id: 'ambig001', ...ambiguous() } ] }; + const summaries = new Map([ + ['assets:empty', preparedActivitySummary()], + ['assets:cash', preparedActivitySummary(rows)], + ['assets:Cash', preparedActivitySummary(otherRows)] + ]); const transaction = await database.startTransaction().execute(); try { await writeAccountRows(transaction, 'assets:cash', 0, rows.slice(0, 51)); await writeAccountRows(transaction, 'assets:cash', 51, rows.slice(51)); await writeAccountRows(transaction, 'assets:Cash', 0, otherRows); await writeReportData(transaction, data); + await writeAccountActivitySummaries(transaction, summaries); await transaction.commit().execute(); } finally { if (!transaction.isCommitted && !transaction.isRolledBack) @@ -137,12 +152,22 @@ it('stores exact prepared payloads and native row order using the migrated schem if (!reader) throw new Error('Expected the current report schema.'); databases.add(reader); expect(await readReportData(reader)).toEqual(data); - expect(await readAccountRows(reader, data.accounts[0])).toEqual([]); - expect(await readAccountRows(reader, data.accounts[1])).toEqual(rows); - expect(await readAccountRows(reader, data.accounts[2])).toEqual(otherRows); - expect(await readAccountRows(reader, data.accounts[3])).toBeNull(); const firstPage = { order: 'asc', offset: 0, limit: 50 } as const; - expect(await readAccountActivityPage(reader, data.accounts[0], firstPage)).toEqual([]); + expect(await readAccountActivityPage(reader, data.accounts[0], firstPage)).toEqual({ + rows: [], + summary: preparedActivitySummary() + }); + const first = await readAccountActivityPage(reader, data.accounts[1], firstPage); + const last = await readAccountActivityPage(reader, data.accounts[1], { + ...firstPage, + offset: 50 + }); + expect(first).toEqual({ rows: rows.slice(0, 50), summary: summaries.get('assets:cash') }); + expect(last).toEqual({ rows: rows.slice(50), summary: first?.summary }); + expect(await readAccountActivityPage(reader, data.accounts[2], firstPage)).toEqual({ + rows: otherRows, + summary: summaries.get('assets:Cash') + }); expect(await readAccountActivityPage(reader, data.accounts[3], firstPage)).toBeNull(); await expect( writeAccountRows(reader, 'assets:cash', rows.length, rows.slice(0, 1)) @@ -169,6 +194,13 @@ it('reads date-ordered pages with native ties and exact account scope', async () overview: preparedOverview(), accounts: [account, other] }); + await writeAccountActivitySummaries( + transaction, + new Map([ + [account.name, preparedActivitySummary(rows)], + [other.name, preparedActivitySummary(otherRows)] + ]) + ); }); const resultRowCounts: number[] = []; const pageReader = database.withPlugin({ @@ -181,8 +213,8 @@ it('reads date-ordered pages with native ties and exact account scope', async () const laterDateRows = rows.slice(0, 26); const earlierDateRows = rows.slice(26); const ascending = [...earlierDateRows, ...laterDateRows]; - const readPage = (order: 'asc' | 'desc', offset: number) => - readAccountActivityPage(pageReader, account, { order, offset, limit: 50 }); + const readPage = async (order: 'asc' | 'desc', offset: number) => + (await readAccountActivityPage(pageReader, account, { order, offset, limit: 50 }))?.rows; expect(await readPage('asc', 0)).toEqual(ascending.slice(0, 50)); expect(await readPage('asc', 50)).toEqual(ascending.slice(50)); expect(await readPage('desc', 0)).toEqual(rows.slice(0, 50)); @@ -191,12 +223,11 @@ it('reads date-ordered pages with native ties and exact account scope', async () expect(Math.max(...resultRowCounts)).toBe(50); expect( await readAccountActivityPage(database, other, { order: 'asc', offset: 0, limit: 50 }) - ).toEqual(otherRows); + ).toEqual({ rows: otherRows, summary: preparedActivitySummary(otherRows) }); const mismatchedAccount = { ...other, id: account.id }; await expect( readAccountActivityPage(database, mismatchedAccount, { order: 'asc', offset: 0, limit: 50 }) ).rejects.toThrow(); - await expect(readAccountRows(database, mismatchedAccount)).rejects.toThrow(); await expect( readAccountActivityPage(database, account, { order: 'asc', offset: 0, limit: -1 }) ).rejects.toThrow(); @@ -216,6 +247,10 @@ it('decodes only the requested page and rejects invalid posting references', asy const account = { id: 'cash0001', ...ready('assets:cash', 2) }; const rows = preparedAccountRows(2); await writeReportData(database, { journal, overview: preparedOverview(), accounts: [account] }); + await writeAccountActivitySummaries( + database, + new Map([[account.name, preparedActivitySummary(rows)]]) + ); await writeAccountRows(database, account.name, 0, rows); await closeDatabase(database); @@ -235,11 +270,33 @@ it('decodes only the requested page and rejects invalid posting references', asy expect((await readReportData(reader)).accounts).toEqual([account]); expect( await readAccountActivityPage(reader, account, { order: 'asc', offset: 0, limit: 1 }) - ).toEqual(rows.slice(0, 1)); + ).toEqual({ rows: rows.slice(0, 1), summary: preparedActivitySummary(rows) }); await expect( readAccountActivityPage(reader, account, { order: 'asc', offset: 1, limit: 1 }) ).rejects.toThrow(); - await expect(readAccountRows(reader, account)).rejects.toThrow(); +}); + +it('rejects missing summaries and malformed native bags rather than inferring from a page', async () => { + const database = await createDatabase(); + const account = { id: 'cash0001', ...ready() }; + const rows = preparedAccountRows(); + await writeReportData(database, { journal, overview: preparedOverview(), accounts: [account] }); + await writeAccountRows(database, account.name, 0, rows); + const page = { order: 'asc', offset: 0, limit: 50 } as const; + await expect(readAccountActivityPage(database, account, page)).rejects.toThrow(); + await expect(writeAccountActivitySummaries(database, new Map())).rejects.toThrow(); + + await writeAccountActivitySummaries( + database, + new Map([[account.name, preparedActivitySummary(rows)]]) + ); + await database + .updateTable('account_activity_summaries') + .set({ ending_cash_json: '[{"commodity":"$","quantity":0}]' }) + .where('account_name', '=', account.name) + .execute(); + expect((await readReportData(database)).accounts).toEqual([account]); + await expect(readAccountActivityPage(database, account, page)).rejects.toThrow(); }); it('does not create missing storage or replace an existing report file', async () => { @@ -265,7 +322,7 @@ it('rejects a symlink rather than opening another report database', async () => it('withholds an unsupported schema without migrating or modifying the database', async () => { const database = await createDatabase(); - await sql`PRAGMA user_version = 2`.execute(database); + await sql`PRAGMA user_version = 1`.execute(database); await closeDatabase(database); const before = await readFile(path); expect(await openReadOnlyReportDatabase(path)).toBeNull(); diff --git a/packages/db/src/reports/queries.ts b/packages/db/src/reports/queries.ts index 017bc6d..afa052f 100644 --- a/packages/db/src/reports/queries.ts +++ b/packages/db/src/reports/queries.ts @@ -1,6 +1,11 @@ import type { PreparedAccountRow } from '@hledger-reports/hledger'; import type { Kysely } from 'kysely'; import type { ReportDatabase } from '../schema.js'; +import { + hasAccountActivitySummary, + isAmountBag, + type AccountActivitySummary +} from './account-activity-summary.js'; import { hasAccountRowContainers, hasStoredReportData, @@ -11,6 +16,11 @@ import { const insertBatchSize = 50; const maxPageRows = 50; +export interface AccountActivityPage { + rows: PreparedAccountRow[]; + summary: AccountActivitySummary; +} + export interface AccountActivityPageOptions { order: 'asc' | 'desc'; offset: number; @@ -60,12 +70,46 @@ export async function writeReportData( catalog_ordinal: start + index, access: account.access, row_count: account.rowCount, - summary_json: JSON.stringify(account.summary) + balance_json: JSON.stringify(account.summary) })); await database.insertInto('accounts').values(batch).execute(); } } +export async function writeAccountActivitySummaries( + database: Kysely, + summaries: ReadonlyMap +): Promise { + const readyAccounts = await database + .selectFrom('accounts') + .select('name') + .where('access', '=', 'ready') + .execute(); + if ( + summaries.size !== readyAccounts.length || + readyAccounts.some((account) => !summaries.has(account.name)) + ) + throw new Error('Invalid account activity summaries.'); + const entries = [...summaries]; + for (let start = 0; start < entries.length; start += insertBatchSize) { + const batch = entries + .slice(start, start + insertBatchSize) + .map(([name, summary]): ReportDatabase['account_activity_summaries'] => { + if (typeof name !== 'string' || !hasAccountActivitySummary(summary)) + throw new Error('Invalid account activity summary.'); + return { + account_name: name, + has_multiple_amounts: summary.hasMultipleAmounts ? 1 : 0, + has_multiple_commodities: summary.hasMultipleCommodities ? 1 : 0, + ending_balance_json: JSON.stringify(summary.endingBalance), + ending_cash_json: JSON.stringify(summary.endingCash), + unclassified_ending_cash_json: JSON.stringify(summary.unclassifiedEndingCash) + }; + }); + await database.insertInto('account_activity_summaries').values(batch).execute(); + } +} + function decodePayload(value: unknown): unknown { if (typeof value !== 'string') throw new Error('Invalid report payload.'); return JSON.parse(value); @@ -80,7 +124,7 @@ export async function readReportData(database: Kysely): Promise< if (!publication) throw new Error('Missing report data.'); const records = await database .selectFrom('accounts') - .select(['id', 'name', 'access', 'row_count', 'summary_json']) + .select(['id', 'name', 'access', 'row_count', 'balance_json']) .orderBy('catalog_ordinal', 'asc') .execute(); const data = { @@ -91,31 +135,13 @@ export async function readReportData(database: Kysely): Promise< name: account.name, access: account.access, rowCount: account.row_count, - summary: decodePayload(account.summary_json) + summary: decodePayload(account.balance_json) })) }; if (!hasStoredReportData(data)) throw new Error('Invalid report data.'); return data; } -async function assertStoredAccount( - database: Kysely, - account: StoredAccount -): Promise { - const stored = await database - .selectFrom('accounts') - .select(['name', 'access', 'row_count']) - .where('id', '=', account.id) - .executeTakeFirst(); - if ( - !stored || - stored.name !== account.name || - stored.access !== account.access || - stored.row_count !== account.rowCount - ) - throw new Error('Invalid account data.'); -} - function decodeAccountRow(record: StoredActivityRow, rowCount: number): PreparedAccountRow { const row = decodePayload(record.row_json); if ( @@ -129,34 +155,34 @@ function decodeAccountRow(record: StoredActivityRow, rowCount: number): Prepared return row; } -export async function readAccountRows( - database: Kysely, - account: StoredAccount -): Promise { - await assertStoredAccount(database, account); - const records = await database - .selectFrom('account_activity') - .select(['ordinal', 'account_date', 'row_json']) - .where('account_name', '=', account.name) - .orderBy('ordinal', 'asc') - .execute(); - if (account.access === 'ambiguous') { - if (records.length !== 0) throw new Error('Invalid account data.'); - return null; - } - const rowCount = account.rowCount; - if (rowCount === null || records.length !== rowCount) throw new Error('Invalid account data.'); - return records.map((record, index) => { - if (record.ordinal !== index) throw new Error('Invalid account data.'); - return decodeAccountRow(record, rowCount); - }); +function decodeActivitySummary( + record: ReportDatabase['account_activity_summaries'] +): AccountActivitySummary { + const endingBalance = decodePayload(record.ending_balance_json); + const endingCash = decodePayload(record.ending_cash_json); + const unclassifiedEndingCash = decodePayload(record.unclassified_ending_cash_json); + if ( + (record.has_multiple_amounts !== 0 && record.has_multiple_amounts !== 1) || + (record.has_multiple_commodities !== 0 && record.has_multiple_commodities !== 1) || + !isAmountBag(endingBalance) || + !isAmountBag(endingCash) || + !isAmountBag(unclassifiedEndingCash) + ) + throw new Error('Invalid account activity summary.'); + return { + hasMultipleAmounts: record.has_multiple_amounts === 1, + hasMultipleCommodities: record.has_multiple_commodities === 1, + endingBalance, + endingCash, + unclassifiedEndingCash + }; } export async function readAccountActivityPage( database: Kysely, account: StoredAccount, { order, offset, limit }: AccountActivityPageOptions -): Promise { +): Promise { if ( (order !== 'asc' && order !== 'desc') || !Number.isSafeInteger(offset) || @@ -166,8 +192,26 @@ export async function readAccountActivityPage( limit > maxPageRows ) throw new Error('Invalid account page.'); - await assertStoredAccount(database, account); + const stored = await database + .selectFrom('accounts') + .select(['id', 'name', 'access', 'row_count']) + .where('id', '=', account.id) + .executeTakeFirst(); + if ( + !stored || + stored.id !== account.id || + stored.name !== account.name || + stored.access !== account.access || + stored.row_count !== account.rowCount + ) + throw new Error('Invalid account data.'); + const storedSummary = await database + .selectFrom('account_activity_summaries') + .selectAll() + .where('account_name', '=', account.name) + .executeTakeFirst(); if (account.access === 'ambiguous') { + if (account.rowCount !== null || storedSummary) throw new Error('Invalid account activity.'); const activity = await database .selectFrom('account_activity') .select('ordinal') @@ -178,7 +222,15 @@ export async function readAccountActivityPage( return null; } const rowCount = account.rowCount; - if (rowCount === null) throw new Error('Invalid account data.'); + if ( + account.access !== 'ready' || + rowCount === null || + !Number.isSafeInteger(rowCount) || + rowCount < 0 + ) + throw new Error('Invalid account data.'); + if (!storedSummary) throw new Error('Missing account activity summary.'); + const summary = decodeActivitySummary(storedSummary); const records = await database .selectFrom('account_activity') .select(['ordinal', 'account_date', 'row_json']) @@ -190,5 +242,5 @@ export async function readAccountActivityPage( .execute(); const expectedRows = Math.min(limit, Math.max(0, rowCount - offset)); if (records.length !== expectedRows) throw new Error('Invalid account data.'); - return records.map((record) => decodeAccountRow(record, rowCount)); + return { rows: records.map((record) => decodeAccountRow(record, rowCount)), summary }; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b8af8dc..d072805 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,4 @@ -export const reportSchemaVersion = 1; +export const reportSchemaVersion = 2; interface PublicationTable { singleton: 1; @@ -12,7 +12,8 @@ interface AccountTable { catalog_ordinal: number; access: 'ready' | 'ambiguous'; row_count: number | null; - summary_json: string; + /** Prepared as-of balance; full-activity endings live in account_activity_summaries. */ + balance_json: string; } interface AccountActivityTable { @@ -22,8 +23,18 @@ interface AccountActivityTable { row_json: string; } +interface AccountActivitySummaryTable { + account_name: string; + has_multiple_amounts: 0 | 1; + has_multiple_commodities: 0 | 1; + ending_balance_json: string; + ending_cash_json: string; + unclassified_ending_cash_json: string; +} + export interface ReportDatabase { publication: PublicationTable; accounts: AccountTable; account_activity: AccountActivityTable; + account_activity_summaries: AccountActivitySummaryTable; } diff --git a/packages/snapshots/README.md b/packages/snapshots/README.md index 9c67374..42d4723 100644 --- a/packages/snapshots/README.md +++ b/packages/snapshots/README.md @@ -4,5 +4,8 @@ Stores complete private SQLite report sets. Writes are staged; readers keep one selected set for a consistent request. Recorded journal checks are separate from financial publication and do not prove freshness. +After a saved-report schema update, update web and worker together and explicitly +prepare reports again. Older generations are not upgraded or deleted. + Reports are derived data, not journal backups. See [data-preservation guidance](../../docs/development.md#private-data-and-upgrades). diff --git a/packages/snapshots/src/index.ts b/packages/snapshots/src/index.ts index 504e3eb..0cc5e27 100644 --- a/packages/snapshots/src/index.ts +++ b/packages/snapshots/src/index.ts @@ -2,9 +2,14 @@ export { createReportStage, readOverview, readReportPublication, - readAccountTransactions + readAccountPage } from './reports/publication.js'; export type { ReportPublication, ReportStage } from './reports/publication.js'; -export type { StoredAccount } from '@hledger-reports/db/reports'; +export type { + StoredAccount, + AccountActivitySummary, + AccountActivityPage, + AccountActivityPageOptions +} from '@hledger-reports/db/reports'; export { writeValidation, readValidation } from './validation.js'; export type { JournalValidationResult, ValidationRecord } from './validation.js'; diff --git a/packages/snapshots/src/reports/publication.test.ts b/packages/snapshots/src/reports/publication.test.ts index ddb3a60..3cec727 100644 --- a/packages/snapshots/src/reports/publication.test.ts +++ b/packages/snapshots/src/reports/publication.test.ts @@ -8,7 +8,7 @@ import { DatabaseSync } from 'node:sqlite'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { createReportStage, - readAccountTransactions, + readAccountPage, readOverview, readReportPublication } from '../index.js'; @@ -89,9 +89,19 @@ it('commits and closes a complete private database before making its directory v return database; }); const stage = await createReportStage(snapshots, newer, pinned.accounts); - const transactions = rows(2); - await stage.writeRows('assets:cash', transactions.slice(0, 1)); - await stage.writeRows('assets:cash', transactions.slice(1)); + const transactions = rows(52); + transactions[0].change.push({ + ...transactions[0].change[0], + commodity: 'EUR', + quantity: '0.0000000000000000000000012300' + }); + transactions[49].investment.cash.unclassifiedBalance = [transactions[49].balance[0]]; + transactions[51].accountDate = '2026-04-01'; + transactions[51].balance = [{ ...transactions[51].balance[0], quantity: '-0.000' }]; + transactions[51].investment.cash.balance = []; + await stage.writeRows('assets:cash', transactions.slice(0, 50)); + await stage.writeRows('assets:cash', transactions.slice(50)); + await stage.writeRows('assets:cash', []); const rename = fs.rename; const inspected = vi.fn(); vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { @@ -118,18 +128,33 @@ it('commits and closes a complete private database before making its directory v } await rename(source, destination); }); - await stage.publish(fixture(), [ready('assets:cash', 2)], journal); + await stage.publish(fixture(), [ready('assets:cash', 52)], journal); expect(inspected).toHaveBeenCalledOnce(); const publication = await selected(); expect(publication.overview).toEqual(fixture()); expect(publication.journal).toBe(journal); const account = publication.accounts[0]; - expect(account).toMatchObject(ready('assets:cash', 2)); - expect(await readAccountTransactions(publication, account.id)).toEqual({ - id: account.id, - name: account.name, - transactions + expect(account).toMatchObject(ready('assets:cash', 52)); + const firstPage = await readAccountPage(publication, account.id, { + order: 'desc', + offset: 0, + limit: 50 }); + const secondPage = await readAccountPage(publication, account.id, { + order: 'desc', + offset: 50, + limit: 50 + }); + expect(firstPage?.rows).toEqual(transactions.slice(0, 50)); + expect(secondPage?.rows).toEqual(transactions.slice(50)); + expect(firstPage?.summary).toEqual({ + hasMultipleAmounts: true, + hasMultipleCommodities: true, + endingBalance: [{ commodity: '$', quantity: '-0.000' }], + endingCash: [], + unclassifiedEndingCash: [] + }); + expect(secondPage?.summary).toEqual(firstPage?.summary); expect(await fs.readdir(publication.directory)).toEqual(['reports.sqlite']); expect(await fs.readlink(join(snapshots, 'current'))).toBe( `.sets/${basename(publication.directory)}` @@ -148,12 +173,27 @@ it('distinguishes empty-ready accounts from ambiguous access without requiring e journal ); const publication = await selected(); - for (const account of publication.accounts) - expect(await readAccountTransactions(publication, account.id)).toEqual({ - id: account.id, - name: account.name, - transactions: account.access === 'ready' ? [] : null + for (const account of publication.accounts) { + const page = await readAccountPage(publication, account.id, { + order: 'asc', + offset: 0, + limit: 50 }); + expect(page).toEqual( + account.access === 'ready' + ? { + rows: [], + summary: { + hasMultipleAmounts: false, + hasMultipleCommodities: false, + endingBalance: [], + endingCash: [], + unclassifiedEndingCash: [] + } + } + : null + ); + } }); it('reuses IDs by exact account identity and avoids collisions with removed and newly assigned IDs', async () => { @@ -178,8 +218,14 @@ it('reuses IDs by exact account identity and avoids collisions with removed and '00000002', '00000003' ]); - for (const account of publication.accounts) - expect((await readAccountTransactions(publication, account.id)).name).toBe(account.name); + for (const account of publication.accounts) { + const page = await readAccountPage(publication, account.id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(page?.rows).toEqual(rows()); + } }); it('keeps completed generations pinned through same-request recovery and discard', async () => { @@ -192,17 +238,23 @@ it('keeps completed generations pinned through same-request recovery and discard await stage.discard(); const sets = await fs.readdir(join(snapshots, '.sets')); expect(sets).toHaveLength(2); - expect((await readAccountTransactions(pinned, pinned.accounts[0].id)).transactions).toEqual( - rows() - ); + const pinnedPage = await readAccountPage(pinned, pinned.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(pinnedPage?.rows).toEqual(rows()); // Same-request selection may choose either UUID; both completed executions remain readable. const recovered = { ...pinned, directory: join(snapshots, '.sets', sets.find((set) => set !== basename(pinned.directory))!) }; - expect((await readAccountTransactions(recovered, pinned.accounts[0].id)).transactions).toEqual( - rows(1, 'recovered') - ); + const recoveredPage = await readAccountPage(recovered, pinned.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(recoveredPage?.rows).toEqual(rows(1, 'recovered')); }); it('selects request creation order, not report dates or the current alias', async () => { @@ -463,9 +515,12 @@ it('publishes without financial readback and keeps the new generation after alia vi.restoreAllMocks(); const publication = await selected(); expect(publication.overview.preparedAt).toBe('committed'); - expect( - (await readAccountTransactions(publication, publication.accounts[0].id)).transactions - ).toEqual(rows(1, 'committed')); + const page = await readAccountPage(publication, publication.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(page?.rows).toEqual(rows(1, 'committed')); expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(2); expect(await fs.readlink(join(snapshots, 'current'))).toBe(`.sets/${basename(pinned.directory)}`); }); @@ -490,15 +545,20 @@ it('closes read-only connections on success and on catalog or overview validatio const publication = await selected(); expect(connectionClosures.shift()).toHaveBeenCalledOnce(); const account = publication.accounts[0]; - expect((await readAccountTransactions(publication, account.id)).transactions).toEqual(rows()); + const page = await readAccountPage(publication, account.id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(page?.rows).toEqual(rows()); expect(connectionClosures.shift()).toHaveBeenCalledOnce(); const mismatched = { ...publication, accounts: [{ ...account, name: 'not the persisted account' }] }; - await expect(readAccountTransactions(mismatched, account.id)).rejects.toSatisfy( - safeError('Unable to read account transactions') - ); + await expect( + readAccountPage(mismatched, account.id, { order: 'asc', offset: 0, limit: 50 }) + ).rejects.toSatisfy(safeError('Unable to read account transactions')); expect(connectionClosures.shift()).toHaveBeenCalledOnce(); const overview = fixture(); const incomplete = { @@ -525,12 +585,12 @@ it('rejects unsafe and uncatalogued account IDs before opening financial data', directory: join(snapshots, '.sets', 'must-not-be-opened') }; const reader = vi.spyOn(databases, 'openReadOnlyReportDatabase'); - await expect(readAccountTransactions(publication, '../escape')).rejects.toSatisfy( - safeError('Unable to read account transactions') - ); - await expect(readAccountTransactions(publication, '00000000')).rejects.toSatisfy( - safeError('Unable to read account transactions') - ); + await expect( + readAccountPage(publication, '../escape', { order: 'asc', offset: 0, limit: 50 }) + ).rejects.toSatisfy(safeError('Unable to read account transactions')); + await expect( + readAccountPage(publication, '00000000', { order: 'asc', offset: 0, limit: 50 }) + ).rejects.toSatisfy(safeError('Unable to read account transactions')); expect(reader).not.toHaveBeenCalled(); }); @@ -543,19 +603,22 @@ it('reports a bad selected database without falling back or changing its bytes', await fs.writeFile(path, bytes); await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); expect(await fs.readFile(path)).toEqual(bytes); - expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( - rows() - ); + const previousPage = await readAccountPage(previous, previous.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(previousPage?.rows).toEqual(rows()); }); -it('withholds an unsupported selected SQLite version without migration or fallback', async () => { +it('withholds a selected v1 SQLite publication until explicit preparation without changing its bytes', async () => { await publish(); const previous = await selected(); await publish(newer); const path = join((await selected()).directory, 'reports.sqlite'); const database = new DatabaseSync(path); try { - database.exec('PRAGMA user_version = 999'); + database.exec('PRAGMA user_version = 1'); } finally { database.close(); } @@ -563,9 +626,15 @@ it('withholds an unsupported selected SQLite version without migration or fallba expect(await readReportPublication(snapshots)).toBeNull(); expect(await readOverview(snapshots)).toBeNull(); expect(await fs.readFile(path)).toEqual(bytes); - expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( - rows() - ); + const previousPage = await readAccountPage(previous, previous.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(previousPage?.rows).toEqual(rows()); + await publish({ id: 'explicit', createdAt: new Date(newer.createdAt.getTime() + 1) }); + expect(await readOverview(snapshots)).toEqual(fixture()); + expect(await fs.readFile(path)).toEqual(bytes); }); it('leaves a selected JSON-only generation unavailable and untouched until explicit preparation', async () => { @@ -587,9 +656,12 @@ it('leaves a selected JSON-only generation unavailable and untouched until expli await fs.writeFile(accountPath, accountBytes); expect(await readReportPublication(snapshots)).toBeNull(); expect(await readOverview(snapshots)).toBeNull(); - expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( - rows() - ); + const previousPage = await readAccountPage(previous, previous.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }); + expect(previousPage?.rows).toEqual(rows()); await publish({ id: 'explicit', createdAt: new Date(newer.createdAt.getTime() + 1) }); expect(await readOverview(snapshots)).toEqual(fixture()); expect(await fs.readFile(overviewPath, 'utf8')).toBe(overviewBytes); @@ -604,9 +676,13 @@ it('rejects a symlink in place of the selected completed directory', async () => await fs.rename(publication.directory, saved); await fs.symlink(saved, publication.directory); await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); - await expect(readAccountTransactions(publication, publication.accounts[0].id)).rejects.toSatisfy( - safeError('Unable to read account transactions') - ); + await expect( + readAccountPage(publication, publication.accounts[0].id, { + order: 'asc', + offset: 0, + limit: 50 + }) + ).rejects.toSatisfy(safeError('Unable to read account transactions')); }); it('requires a journal identity before replacing a publication', async () => { diff --git a/packages/snapshots/src/reports/publication.ts b/packages/snapshots/src/reports/publication.ts index 1c8ad2e..1b17456 100644 --- a/packages/snapshots/src/reports/publication.ts +++ b/packages/snapshots/src/reports/publication.ts @@ -11,16 +11,21 @@ import { type ReportTransaction } from '@hledger-reports/db'; import { + AccountActivitySummaryBuilder, hasOverviewContainers, isAccountCatalog, isAccountId, isAccountSummary, - readAccountRows, + readAccountActivityPage, readReportData, storedSummary, summaryMatchesDate, writeAccountRows, + writeAccountActivitySummaries, writeReportData, + type AccountActivitySummary, + type AccountActivityPage, + type AccountActivityPageOptions, type StoredAccount, type StoredReportData } from '@hledger-reports/db/reports'; @@ -101,10 +106,11 @@ export async function readOverview(snapshotDirectory: string): Promise { + id: string, + options: AccountActivityPageOptions +): Promise { try { if (!isAccountId(id)) throw new Error(); const account = publication.accounts.find((entry) => entry.id === id); @@ -117,11 +123,7 @@ export async function readAccountTransactions( const database = await openReadOnlyReportDatabase(join(directory, databaseName)); if (!database) throw new Error(); try { - return { - id, - name: account.name, - transactions: await readAccountRows(database, account) - }; + return await readAccountActivityPage(database, account, options); } finally { await database.destroy(); } @@ -217,7 +219,10 @@ export async function createReportStage( await runMigrations(database); transaction = await database.startTransaction().execute(); const writer = transaction; - const written = new Map(); + const writtenAccounts = new Map< + string, + { id: string; rowCount: number; summaryBuilder: AccountActivitySummaryBuilder } + >(); let currentName: string | undefined; async function operation(action: () => Promise): Promise { try { @@ -233,13 +238,18 @@ export async function createReportStage( return operation(async () => { if (typeof name !== 'string' || !Array.isArray(rows)) throw new Error(); if (currentName !== name) { - if (written.has(name)) throw new Error(); + if (writtenAccounts.has(name)) throw new Error(); currentName = name; - written.set(name, { id: accountId(name), count: 0 }); + writtenAccounts.set(name, { + id: accountId(name), + rowCount: 0, + summaryBuilder: new AccountActivitySummaryBuilder() + }); } - const metadata = written.get(name)!; - await writeAccountRows(writer, name, metadata.count, rows); - metadata.count += rows.length; + const writtenAccount = writtenAccounts.get(name)!; + await writeAccountRows(writer, name, writtenAccount.rowCount, rows); + writtenAccount.summaryBuilder.append(rows); + writtenAccount.rowCount += rows.length; }); }, publish(overview, accounts, journal) { @@ -251,9 +261,10 @@ export async function createReportStage( !Array.isArray(accounts) ) throw new Error(); - const remaining = new Set(written.keys()); + const remaining = new Set(writtenAccounts.keys()); const names = new Set(); const catalog: StoredAccount[] = []; + const activitySummaries = new Map(); for (const account of accounts) { if ( !isAccountSummary(account) || @@ -262,15 +273,18 @@ export async function createReportStage( ) throw new Error(); names.add(account.name); - const metadata = written.get(account.name); + const writtenAccount = writtenAccounts.get(account.name); remaining.delete(account.name); if (account.access === 'ambiguous') { - if (metadata !== undefined) throw new Error(); - } else if ((metadata?.count ?? 0) !== account.rowCount) { - throw new Error(); + if (writtenAccount !== undefined) throw new Error(); + } else { + if ((writtenAccount?.rowCount ?? 0) !== account.rowCount) throw new Error(); + const summaryBuilder = + writtenAccount?.summaryBuilder ?? new AccountActivitySummaryBuilder(); + activitySummaries.set(account.name, summaryBuilder.build()); } catalog.push({ - id: metadata?.id ?? accountId(account.name), + id: writtenAccount?.id ?? accountId(account.name), name: account.name, access: account.access, rowCount: account.rowCount, @@ -279,6 +293,7 @@ export async function createReportStage( } if (remaining.size) throw new Error(); await writeReportData(writer, { overview, accounts: catalog, journal }); + await writeAccountActivitySummaries(writer, activitySummaries); await writer.commit().execute(); await closeDatabase(); await rename(temporary, join(sets, token)); -- 2.54.0