Read saved account registers in bounded SQLite pages #47
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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+93
-23
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,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';
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user