Read saved account registers in bounded SQLite pages #47

Merged
eleith merged 2 commits from perf/bounded-account-reads into main 2026-11-11 00:29:29 +00:00
29 changed files with 1308 additions and 563 deletions

No files matched your search

@@ -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.
@@ -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<AccountActivitySummary> = {}
): AccountActivityPage {
return {
rows,
summary: {
hasMultipleAmounts: false,
hasMultipleCommodities: false,
endingBalance: [],
endingCash: [],
unclassifiedEndingCash: [],
...summary
}
};
}
+50 -106
View File
@@ -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<number>(),
change: new Set<number>(),
balance: new Set<number>()
};
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/);
});
+10 -33
View File
@@ -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<CashRegisterData, 'rows' | 'endingBalance' | 'unsupported'> {
const commodities = new Set<string>();
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<CashRegisterData, 'rows' | 'endingBalance' | 'unsupported'> {
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
};
@@ -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();
+14 -29
View File
@@ -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<InvestmentRegisterData, 'transactions' | 'endingCash' | 'unclassifiedEndingCash'> {
// 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)
};
}
@@ -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<typeof loadLayout>[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<typeof loadCashAccount>[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<typeof loadInvestmentAccount>[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<typeof loadLayout>[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<typeof loadCashAccount>[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<typeof loadInvestmentAccount>[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<typeof loadCashAccount>[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 });
}
@@ -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<CashRegisterData> => {
// 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;
@@ -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<typeof mapCashRegisterPage> = {
const accountPage: AccountActivityPage = {
rows: preparedAccountRows(),
summary: {
hasMultipleAmounts: false,
hasMultipleCommodities: false,
endingBalance: [{ commodity: 'USD', quantity: '1.0001' }],
endingCash: [],
unclassifiedEndingCash: []
}
};
const projection: ReturnType<typeof projectCashRegisterPage> = {
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();
});
@@ -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();
});
@@ -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<InvestmentAccountPageData> => {
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;
@@ -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<typeof mapInvestmentRegisterPage> = {
const accountPage: AccountActivityPage = {
rows: preparedAccountRows(),
summary: {
hasMultipleAmounts: false,
hasMultipleCommodities: false,
endingBalance: [],
endingCash: [{ commodity: 'USD', quantity: '1.0001' }],
unclassifiedEndingCash: []
}
};
const projection: ReturnType<typeof projectInvestmentRegisterPage> = {
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();
});
@@ -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();
});
@@ -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 {
+7 -1
View File
@@ -1,6 +1,8 @@
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<ReportDatabase>): Promise<void> {
@@ -8,7 +10,11 @@ export async function runMigrations(database: Kysely<ReportDatabase>): Promise<v
db: database,
provider: {
async getMigrations() {
return { '001_reports': reports };
return {
'001_reports': reports,
'002_account_activity_indexes': activityIndexes,
'003_account_activity_summaries': activitySummaries
};
}
}
});
@@ -0,0 +1,16 @@
import type { Kysely } from 'kysely';
export async function up(database: Kysely<unknown>): Promise<void> {
// 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();
}
@@ -0,0 +1,51 @@
import { sql, type Kysely } from 'kysely';
export async function up(database: Kysely<unknown>): Promise<void> {
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);
}
@@ -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 });
});
@@ -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
};
}
}
+7
View File
@@ -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' },
+3
View File
@@ -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) ||
+10 -1
View File
@@ -10,4 +10,13 @@ export {
summaryMatchesDate
} from './data.js';
export type { StoredAccount, StoredReportData } from './data.js';
export { writeAccountRows, writeReportData, readReportData, readAccountRows } from './queries.js';
export {
writeAccountRows,
writeAccountActivitySummaries,
writeReportData,
readReportData,
readAccountActivityPage
} 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';
+146 -13
View File
@@ -10,7 +10,13 @@ import {
runMigrations,
type ReportDatabase
} from '../index.js';
import { readAccountRows, readReportData, writeAccountRows, writeReportData } from './queries.js';
import {
readAccountActivityPage,
readReportData,
writeAccountRows,
writeAccountActivitySummaries,
writeReportData
} from './queries.js';
import type { StoredReportData } from './data.js';
import {
amount,
@@ -18,7 +24,8 @@ import {
preparedOverview,
journal,
ready,
preparedAccountRows
preparedAccountRows,
preparedActivitySummary
} from './data.fixture.js';
let directory: string;
@@ -102,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 = {
@@ -114,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)
@@ -131,30 +152,114 @@ 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({
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))
).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]
});
await writeAccountActivitySummaries(
transaction,
new Map([
[account.name, preparedActivitySummary(rows)],
[other.name, preparedActivitySummary(otherRows)]
])
);
});
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 = 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));
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({ 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(
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 writeAccountActivitySummaries(
database,
new Map([[account.name, preparedActivitySummary(rows)]])
);
await writeAccountRows(database, account.name, 0, rows);
await closeDatabase(database);
// 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 +268,35 @@ 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: rows.slice(0, 1), summary: preparedActivitySummary(rows) });
await expect(
readAccountActivityPage(reader, account, { order: 'asc', offset: 1, limit: 1 })
).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 () => {
@@ -189,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();
+142 -22
View File
@@ -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,
@@ -9,6 +14,23 @@ import {
} from './data.js';
const insertBatchSize = 50;
const maxPageRows = 50;
export interface AccountActivityPage {
rows: PreparedAccountRow[];
summary: AccountActivitySummary;
}
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<ReportDatabase>,
@@ -48,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<ReportDatabase>,
summaries: ReadonlyMap<string, AccountActivitySummary>
): Promise<void> {
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);
@@ -68,7 +124,7 @@ export async function readReportData(database: Kysely<ReportDatabase>): 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 = {
@@ -79,48 +135,112 @@ export async function readReportData(database: Kysely<ReportDatabase>): 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;
}
export async function readAccountRows(
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;
}
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<ReportDatabase>,
account: StoredAccount
): Promise<PreparedAccountRow[] | null> {
account: StoredAccount,
{ order, offset, limit }: AccountActivityPageOptions
): Promise<AccountActivityPage | null> {
if (
(order !== 'asc' && order !== 'desc') ||
!Number.isSafeInteger(offset) ||
offset < 0 ||
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > maxPageRows
)
throw new Error('Invalid account page.');
const stored = await database
.selectFrom('accounts')
.select(['name', 'access', 'row_count'])
.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')
.where('account_name', '=', account.name)
.limit(1)
.executeTakeFirst();
if (activity) throw new Error('Invalid account data.');
return null;
}
const rowCount = account.rowCount;
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'])
.where('account_name', '=', account.name)
.orderBy('account_date', order)
.orderBy('ordinal', 'asc')
.limit(limit)
.offset(offset)
.execute();
if (account.access === 'ambiguous') {
if (records.length !== 0) throw new Error('Invalid account data.');
return null;
}
if (records.length !== account.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;
});
const expectedRows = Math.min(limit, Math.max(0, rowCount - offset));
if (records.length !== expectedRows) throw new Error('Invalid account data.');
return { rows: records.map((record) => decodeAccountRow(record, rowCount)), summary };
}
+13 -2
View File
@@ -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;
}
+3
View File
@@ -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).
+7 -2
View File
@@ -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';
@@ -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 () => {
+36 -21
View File
@@ -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<OverviewD
return (await readReportPublication(snapshotDirectory))?.overview ?? null;
}
export async function readAccountTransactions(
export async function readAccountPage(
publication: ReportPublication,
id: string
): Promise<{ id: string; name: string; transactions: PreparedAccountRow[] | null }> {
id: string,
options: AccountActivityPageOptions
): Promise<AccountActivityPage | null> {
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<string, { id: string; count: number }>();
const writtenAccounts = new Map<
string,
{ id: string; rowCount: number; summaryBuilder: AccountActivitySummaryBuilder }
>();
let currentName: string | undefined;
async function operation<T>(action: () => Promise<T>): Promise<T> {
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<string>();
const catalog: StoredAccount[] = [];
const activitySummaries = new Map<string, AccountActivitySummary>();
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));