Remove preparation output dividers #52

Merged
eleith merged 1 commits from refactor/native-financial-stream into main 2026-11-12 20:46:25 +00:00
27 changed files with 776 additions and 615 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@hledger-reports/worker': patch
---
Prepare saved financial reports without injected output dividers, using shared transaction records for period counts.
@@ -55,6 +55,8 @@ const stage = {
insertOriginal: vi.fn<ReportStage['transactions']['insertOriginal']>(),
attachTransacted: vi.fn<ReportStage['transactions']['attachTransacted']>(),
assertComplete: vi.fn<ReportStage['transactions']['assertComplete']>(),
readSummary: vi.fn<ReportStage['transactions']['readSummary']>(),
countInPeriod: vi.fn<ReportStage['transactions']['countInPeriod']>(),
read: vi.fn<ReportStage['transactions']['read']>(),
accountHistory: vi.fn<ReportStage['transactions']['accountHistory']>()
},
+3 -1
View File
@@ -5,6 +5,7 @@ import * as activityIndexes from './migrations/002_account_activity_indexes.js';
import * as activitySummaries from './migrations/003_account_activity_summaries.js';
import * as nativeTransactions from './migrations/004_native_transactions.js';
import * as sharedAccountTransactions from './migrations/005_shared_account_transactions.js';
import * as transactionDates from './migrations/006_transaction_dates.js';
import type { ReportDatabase } from './schema.js';
export async function runMigrations(database: Kysely<ReportDatabase>): Promise<void> {
@@ -17,7 +18,8 @@ export async function runMigrations(database: Kysely<ReportDatabase>): Promise<v
'002_account_activity_indexes': activityIndexes,
'003_account_activity_summaries': activitySummaries,
'004_native_transactions': nativeTransactions,
'005_shared_account_transactions': sharedAccountTransactions
'005_shared_account_transactions': sharedAccountTransactions,
'006_transaction_dates': transactionDates
};
}
}
@@ -0,0 +1,9 @@
import type { Kysely } from 'kysely';
export async function up(database: Kysely<unknown>): Promise<void> {
await database.schema
.createIndex('transactions_date')
.on('transactions')
.column('transaction_date')
.execute();
}
+2
View File
@@ -2,6 +2,8 @@ export {
insertNativeTransaction,
attachTransactedTransaction,
readNativeTransaction,
readTransactionSummary,
countTransactionsInPeriod,
readAccountTransactionBatch,
assertTransactionImportComplete
} from './queries.js';
+19 -3
View File
@@ -12,9 +12,11 @@ import {
import {
assertTransactionImportComplete,
attachTransactedTransaction,
countTransactionsInPeriod,
insertNativeTransaction,
readAccountTransactionBatch,
readNativeTransaction,
readTransactionSummary,
type StoredNativeTransaction
} from './queries.js';
import { nativeTransactionFixture, richNativeExportsFixture } from './native.fixture.js';
@@ -51,13 +53,19 @@ afterEach(async () => {
it('retains exact native exports and original posting order through completion and read-only reopen', async () => {
const database = await createDatabase();
const exports = richNativeExportsFixture();
const nextJson = JSON.stringify(nativeTransactionFixture(8));
const nextJson = JSON.stringify({
...nativeTransactionFixture(8),
tdate: '2026-05-02',
tstatus: 'Unmarked'
});
const complete: StoredNativeTransaction = {
nativeIndex: 7,
date: '2026-05-01',
status: 'Pending',
...exports
};
expect(await readTransactionSummary(database)).toEqual({ transactionCount: 0, lastDate: null });
expect(await countTransactionsInPeriod(database, '2026-05-01', '2026-05-03')).toBe(0);
await expect(assertTransactionImportComplete(database)).resolves.toBeUndefined();
const writer = await database.startTransaction().execute();
try {
@@ -124,11 +132,19 @@ it('retains exact native exports and original posting order through completion a
expect(await readNativeTransaction(reader, 7)).toEqual(complete);
expect(await readNativeTransaction(reader, 8)).toEqual({
nativeIndex: 8,
date: '2026-05-01',
status: 'Pending',
date: '2026-05-02',
status: 'Unmarked',
originalJson: nextJson,
transactedJson: nextJson
});
expect(await readTransactionSummary(reader)).toEqual({
transactionCount: 2,
lastDate: '2026-05-02'
});
// Count each whole transaction once, by primary date rather than its posting dates.
expect(await countTransactionsInPeriod(reader, '2026-05-01', '2026-05-03')).toBe(2);
expect(await countTransactionsInPeriod(reader, '2026-05-01', '2026-05-02')).toBe(1);
expect(await countTransactionsInPeriod(reader, '2026-04-16', '2026-04-17')).toBe(0);
expect(await readNativeTransaction(reader, 9)).toBeNull();
expect(await readAccountTransactionBatch(reader, 'equity:conversion', 0)).toEqual([complete]);
await expect(assertTransactionImportComplete(reader)).resolves.toBeUndefined();
+45
View File
@@ -1,6 +1,7 @@
import {
assertTransactionExportsMatch,
decodeNativeTransaction,
type NativeTransactionSummary,
type NativeTransaction
} from '@hledger-reports/hledger/transactions';
import type { Kysely } from 'kysely';
@@ -105,6 +106,50 @@ export async function attachTransactedTransaction(
throw new Error('Transacted native transaction was not attached.');
}
function requireTransactionCount(count: number): number {
if (!Number.isSafeInteger(count) || count < 0)
throw new Error('Invalid native transaction count.');
return count;
}
function isPeriodDate(value: string): boolean {
return (
typeof value === 'string' &&
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(value) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
);
}
export async function readTransactionSummary(
database: Kysely<ReportDatabase>
): Promise<NativeTransactionSummary> {
const summary = await database
.selectFrom('transactions')
.select(({ fn }) => [
fn.countAll<number>().as('transactionCount'),
fn.max<string | null>('transaction_date').as('lastDate')
])
.executeTakeFirstOrThrow();
requireTransactionCount(summary.transactionCount);
return summary;
}
export async function countTransactionsInPeriod(
database: Kysely<ReportDatabase>,
begin: string,
endExclusive: string
): Promise<number> {
if (!isPeriodDate(begin) || !isPeriodDate(endExclusive) || begin >= endExclusive)
throw new Error('Invalid native transaction period.');
const result = await database
.selectFrom('transactions')
.select(({ fn }) => fn.countAll<number>().as('transactionCount'))
.where('transaction_date', '>=', begin)
.where('transaction_date', '<', endExclusive)
.executeTakeFirstOrThrow();
return requireTransactionCount(result.transactionCount);
}
export async function readNativeTransaction(
database: Kysely<ReportDatabase>,
nativeIndex: number
+54 -42
View File
@@ -1,17 +1,24 @@
import { describe, expect, it } from 'vitest';
import { decodeAccountBalances, organizeAccountBalance, planAccountBalances } from './balances.js';
import { describe, expect, it, vi } from 'vitest';
import { ReportStreamReader } from '../report-stream.js';
import { organizeAccountBalance, planAccountBalances, readAccountBalances } from './balances.js';
const asOf = '2026-04-02';
const frame = (raw: string, valued = raw, prefix = 'overview') =>
`${prefix}\n__HLEDGER_ACCOUNT_BALANCES__\n${raw}\n__HLEDGER_ACCOUNT_VALUATIONS__\n${valued}`;
const empty = '[[],[]]';
const amount = (commodity: string, quantity: string) => ({ commodity, quantity });
function reportReader(...documents: string[]): ReportStreamReader {
return new ReportStreamReader(
(async function* () {
for (const document of documents) yield Buffer.from(`${document}\n`);
})(),
() => {}
);
}
describe('finite account balance reports', () => {
it('appends only two all-account native tree commands with the exact cutoff and literal target', () => {
it('plans two all-account native tree commands with the exact cutoff and literal target', () => {
const commands = planAccountBalances({ asOf, baseCurrency: 'US D,quoted' });
const balance = [
'--',
'balance',
'--tree',
'--no-elide',
@@ -23,30 +30,26 @@ describe('finite account balance reports', () => {
'-Ojson',
'not:date:2026-04-03..'
];
expect(commands).toEqual([
'--',
'echo',
'__HLEDGER_ACCOUNT_BALANCES__',
...balance,
'--',
'echo',
'__HLEDGER_ACCOUNT_VALUATIONS__',
...balance,
'--value=2026-04-02,US D,quoted'
expect(commands).toEqual([balance, [...balance, '--value=2026-04-02,US D,quoted']]);
expect(planAccountBalances({ asOf, baseCurrency: '' })).toEqual([
balance,
[...balance, '--value=2026-04-02,']
]);
expect(planAccountBalances({ asOf: null, baseCurrency: '' })).toEqual([]);
});
it('decodes from the end losslessly, preserving cost-distinguished entries and exact full names', () => {
it('reads two native JSON documents losslessly, preserving bags and exact full names', async () => {
const raw = `[[["Assets:Parent","Parent",1,[
{"acommodity":"ABC","aquantity":{"decimalMantissa":900719925474099301,"decimalPlaces":2},"acost":{"tag":"UnitCost","contents":{"acommodity":"USD","aquantity":{"decimalMantissa":10,"decimalPlaces":0}}}},
{"acommodity":"ABC","aquantity":{"decimalMantissa":3,"decimalPlaces":0},"acost":{"tag":"UnitCost","contents":{"acommodity":"USD","aquantity":{"decimalMantissa":12,"decimalPlaces":0}}}}
]],["assets:parent","parent",1,[]],["Assets:Unused","Unused",1,[]]],[]]`;
const prefix =
'metadata path\n__HLEDGER_ACCOUNT_BALANCES__\nnot a report\n__HLEDGER_ACCOUNT_VALUATIONS__\noverview';
const text = frame(raw, empty, prefix);
const decoded = decodeAccountBalances(text, asOf);
expect(decoded.overviewBatch).toBe(prefix);
const valued = `[[["Assets:Parent","Parent",1,[
{"acommodity":"USD","aquantity":{"decimalMantissa":170,"decimalPlaces":0}}
]]],[]]`;
const reader = reportReader(raw, valued);
const json = vi.spyOn(reader, 'json');
const decoded = await readAccountBalances(reader, asOf);
expect(json).toHaveBeenCalledTimes(2);
expect(decoded.balances.get('Assets:Parent')).toEqual([
amount('ABC', '9007199254740993.01'),
amount('ABC', '3')
@@ -54,23 +57,33 @@ describe('finite account balance reports', () => {
expect(decoded.balances.get('assets:parent')).toEqual([]);
expect(decoded.balances.get('Assets:Unused')).toEqual([]);
expect(decoded.balances.has('Parent')).toBe(false);
// Retrying has no effect on input or earlier results.
expect(decodeAccountBalances(text, asOf)).toEqual(decoded);
expect(organizeAccountBalance('Assets:Parent', 'ready', asOf, 'USD', decoded)).toEqual({
status: 'ready',
balances: [amount('ABC', '9007199254740993.01'), amount('ABC', '3')],
valuation: { amounts: [amount('USD', '170')], unconverted: [] }
});
await reader.eof();
});
it('requires complete framing and native rows rather than accepting partial or duplicate output', () => {
expect(() => decodeAccountBalances(frame(empty).slice(0, -1), asOf)).toThrow();
expect(() => decodeAccountBalances('overview', asOf)).toThrow();
expect(() =>
decodeAccountBalances(frame('[[["a","a",0,[]],["a","a",0,[]]],[]]'), asOf)
).toThrow();
expect(() => decodeAccountBalances(frame('[[["a","a",0,null]],[]]'), asOf)).toThrow();
it('rejects duplicate or blank full names and invalid native amount bags', async () => {
for (const raw of [
'[[["a","a",0,[]],["a","a",0,[]]],[]]',
'[[["","a",0,[]]],[]]',
'[[["a","a",0,null]],[]]'
]) {
await expect(readAccountBalances(reportReader(raw, empty), asOf)).rejects.toThrow();
}
await expect(
readAccountBalances(reportReader(empty, '[[["a","a",0,[]]],null]'), asOf)
).rejects.toThrow();
});
it('retains zero and unnamed residual evidence only within the exact-name subtree', () => {
const balances = new Map([['Assets:Parent', []]]);
it('retains inclusive native bags and descendant residual evidence within the exact-name subtree', () => {
const balances = new Map([['Assets:Parent', [amount('ABC', '17')]]]);
const valuations = new Map([
['Assets:Parent', [amount('USD', '170')]],
['Assets:Parent:One', [amount('ABC', '1')]],
['Assets:Parent:Two', [amount('ABC', '-1')]],
['Assets:Parent:Zero', [amount('ZERO', '0'), amount('', '0')]],
['Assets:Parentage', [amount('SIBLING', '1')]],
['assets:parent:other', [amount('OTHERCASE', '1')]]
@@ -79,18 +92,17 @@ describe('finite account balance reports', () => {
organizeAccountBalance('Assets:Parent', 'ready', asOf, 'USD', { balances, valuations })
).toEqual({
status: 'ready',
balances: [],
valuation: { amounts: [amount('USD', '170')], unconverted: ['ZERO', ''] }
balances: [amount('ABC', '17')],
valuation: { amounts: [amount('USD', '170')], unconverted: ['ABC', 'ZERO', ''] }
});
});
it('keeps qualification and dates authoritative, but rejects missing dated ready rows', () => {
const reports = decodeAccountBalances('unchanged', null);
expect(reports).toEqual({
overviewBatch: 'unchanged',
balances: new Map(),
valuations: new Map()
});
it('keeps qualification and dates authoritative without reading undated reports', async () => {
const reader = reportReader();
const json = vi.spyOn(reader, 'json');
const reports = await readAccountBalances(reader, null);
expect(json).not.toHaveBeenCalled();
expect(reports).toEqual({ balances: new Map(), valuations: new Map() });
expect(organizeAccountBalance('a', 'ambiguous', null, 'USD', reports)).toEqual({
status: 'ambiguous'
});
+17 -30
View File
@@ -1,35 +1,33 @@
import { parseExactJson, reportedAmounts, type Amount, type ReportedAmount } from '../amounts.js';
import type { NativeValue } from '../overview/report.js';
import { isoDate } from '../report-date.js';
import type { ReportStreamReader } from '../report-stream.js';
export type PreparedBalance =
| { status: 'ambiguous' }
| { status: 'undated' }
| { status: 'ready'; balances: ReportedAmount[]; valuation: NativeValue };
interface AccountBalanceReports {
export interface AccountBalanceReports {
balances: Map<string, ReportedAmount[]>;
valuations: Map<string, ReportedAmount[]>;
}
const balanceMarker = '\n__HLEDGER_ACCOUNT_BALANCES__\n';
const valuationMarker = '\n__HLEDGER_ACCOUNT_VALUATIONS__\n';
const failureMessage = 'Invalid hledger account balance report.';
/** Append two finite reports to the existing cached-journal financial plan. */
/** Plan two finite native reports against the cached journal. */
export function planAccountBalances({
asOf,
baseCurrency
}: {
asOf: string | null;
baseCurrency: string;
}): string[] {
}): string[][] {
if (asOf === null) return [];
const end = new Date(`${asOf}T00:00:00Z`);
end.setUTCDate(end.getUTCDate() + 1);
const endExclusive = isoDate(end);
const balance = [
'--',
'balance',
'--tree',
'--no-elide',
@@ -42,16 +40,12 @@ export function planAccountBalances({
`not:date:${endExclusive}..`
];
return [
'--',
'echo',
balanceMarker.trim(),
...balance,
'--',
'echo',
valuationMarker.trim(),
...balance,
// The inferred commodity ID is literal, even when empty or containing spaces/commas.
`--value=${asOf},${baseCurrency}`
balance,
[
...balance,
// The inferred commodity ID is literal, even when empty or containing spaces/commas.
`--value=${asOf},${baseCurrency}`
]
];
}
@@ -82,21 +76,14 @@ function decodeBalanceReport(text: string): Map<string, ReportedAmount[]> {
return rows;
}
/** Pure prefix decoding: safe to retry while the existing report stream grows. */
export function decodeAccountBalances(
batch: string,
export async function readAccountBalances(
reader: ReportStreamReader,
asOf: string | null
): AccountBalanceReports & { overviewBatch: string } {
if (asOf === null) return { overviewBatch: batch, balances: new Map(), valuations: new Map() };
// Metadata can contain literal framing text; native JSON strings escape newlines.
const valuationSplit = batch.lastIndexOf(valuationMarker);
if (valuationSplit < 0) throw new Error(failureMessage);
const valuations = decodeBalanceReport(batch.slice(valuationSplit + valuationMarker.length));
batch = batch.slice(0, valuationSplit);
const balanceSplit = batch.lastIndexOf(balanceMarker);
if (balanceSplit < 0) throw new Error(failureMessage);
const balances = decodeBalanceReport(batch.slice(balanceSplit + balanceMarker.length));
return { overviewBatch: batch.slice(0, balanceSplit), balances, valuations };
): Promise<AccountBalanceReports> {
if (asOf === null) return { balances: new Map(), valuations: new Map() };
const balances = decodeBalanceReport(await reader.json());
const valuations = decodeBalanceReport(await reader.json());
return { balances, valuations };
}
/** Qualification belongs to the existing native account directory, not row presence. */
@@ -0,0 +1,14 @@
import { expect, it } from 'vitest';
import { journalMetadata } from './journal-stats.js';
it('rejects an authoritative field embedded in a multiline native journal path', () => {
const stats = `Main file : /fictional/path
Base currency : EUR
/part.journal
Base currency : USD
Last txn : 2026-04-16
Txns : 1
Txns span : 2026-04-16 to 2026-04-17
`;
expect(() => journalMetadata(stats)).toThrow('Invalid native journal stats.');
});
+12 -25
View File
@@ -16,9 +16,9 @@ function validDate(text: string): string {
}
// Read exact native fields, not delimiter markers that can occur in a Main file path.
function statsFields(stats: string, expected: number) {
function statsFields(stats: string) {
const lines = stats.split(/\r?\n/);
const field = (name: string, preserveWhitespace = false): string[] => {
const field = (name: string, preserveWhitespace = false): string => {
const pattern = new RegExp(
preserveWhitespace ? `^${name}[ \t]*: (.*)$` : `^${name}[ \t]*:[ \t]*(.*)$`
);
@@ -28,14 +28,14 @@ function statsFields(stats: string, expected: number) {
if (/\p{Cc}/u.test(match[1])) throw new Error(failureMessage);
return [preserveWhitespace ? match[1] : match[1].trim()];
});
if (matches.length !== expected) throw new Error(failureMessage);
return matches;
if (matches.length !== 1) throw new Error(failureMessage);
return matches[0];
};
return {
baseCurrency: field('Base currency', true),
last: field('Last txn'),
counts: field('Txns').map(transactionCount),
spans: field('Txns span')
count: transactionCount(field('Txns')),
span: field('Txns span')
};
}
@@ -52,23 +52,24 @@ function transactionCount(text: string): number {
return nonnegativeInteger(match[1]);
}
function wholeJournalMetadata(fields: ReturnType<typeof statsFields>): {
export function journalMetadata(stats: string): {
asOf: string | null;
journalCoverage: JournalCoverage;
baseCurrency: string;
} {
const fields = statsFields(stats);
// Native prints one separator space after the colon. The rest is the literal ID,
// including empty, 'none', commas, case and leading/trailing spaces.
const baseCurrency = fields.baseCurrency[0];
const last = fields.last[0];
const baseCurrency = fields.baseCurrency;
const last = fields.last;
let asOf: string | null = null;
if (last !== 'none') {
const lastMatch = /^(\d{4}-\d{2}-\d{2})(?:[ \t]+\([^\r\n]*\))?$/.exec(last);
if (!lastMatch) throw new Error(failureMessage);
asOf = validDate(lastMatch[1]);
}
const transactionCount = fields.counts[0];
const span = fields.spans[0];
const transactionCount = fields.count;
const span = fields.span;
if (transactionCount === 0) {
if (asOf !== null || !/^to(?:[ \t]+\([^\r\n]*\))?$/.test(span)) {
throw new Error(failureMessage);
@@ -95,17 +96,3 @@ function wholeJournalMetadata(fields: ReturnType<typeof statsFields>): {
journalCoverage: { transactionCount, firstDate, lastDate: isoDate(lastDate) }
};
}
export function journalMetadata(stats: string) {
return wholeJournalMetadata(statsFields(stats, 1));
}
// Whole stats followed by the finite period stats, before any JSON report frames.
// Filtered empty periods have explicit spans, unlike an empty whole journal.
export function journalMetadataWithCounts(stats: string, periodCount: number) {
const fields = statsFields(stats, periodCount + 1);
return {
metadata: wholeJournalMetadata(fields),
transactionCounts: fields.counts.slice(1)
};
}
+90 -95
View File
@@ -1,8 +1,9 @@
import { expect, it } from 'vitest';
import { decodeOverviewReports, planOverviewReports } from './batch.js';
import { planOverviewReports, readOverviewReports } from './batch.js';
import { planIncomeRanges } from './ranges.js';
import { journalMetadata, journalMetadataWithCounts } from '../journal-stats.js';
import { balanceFrames, incomeFrames, periodStats } from '../../test-fixtures.js';
import { journalMetadata } from '../journal-stats.js';
import { ReportStreamReader } from '../report-stream.js';
import { balanceReports, incomeReports } from '../../test-fixtures.js';
const stats = (asOf = '2026-04-16', currency = 'USD') =>
asOf === 'none'
@@ -13,66 +14,83 @@ function nextDay(asOf: string) {
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
const batch = (asOf = '2026-04-16') =>
stats(asOf) + periodStats(asOf) + incomeFrames(asOf) + balanceFrames(asOf);
function decode(text = batch(), discovery = stats()) {
const metadata = journalMetadata(discovery);
return decodeOverviewReports(text, metadata, planOverviewReports(metadata));
function readerFor(documents: string[]) {
return new ReportStreamReader(
(async function* () {
yield Buffer.from(documents.join('\n'));
})(),
() => {}
);
}
it('plans four statements and twelve exact-date sheets in one finite cached financial batch', () => {
it('plans four monthly statements followed by twelve historical exact-date valuations', () => {
const plan = planOverviewReports(journalMetadata(stats()));
expect(
plan.incomeRanges.map(({ months, begin, endExclusive }) => ({ months, begin, endExclusive }))
).toEqual([
{ months: 1, begin: '2026-04-01', endExclusive: '2026-04-17' },
{ months: 3, begin: '2026-02-01', endExclusive: '2026-04-17' },
{ months: 6, begin: '2025-11-01', endExclusive: '2026-04-17' },
{ months: 12, begin: '2025-05-01', endExclusive: '2026-04-17' }
expect(plan.incomeRanges.map(({ months }) => months)).toEqual([1, 3, 6, 12]);
expect(plan.commands.map(([command]) => command)).toEqual([
...Array(4).fill('incomestatement'),
...Array(12).fill('balancesheet')
]);
expect(plan.commands.slice(0, 25)).toEqual([
'stats',
...plan.incomeRanges.flatMap(({ begin, endExclusive }) => [
'--',
'stats',
'-b',
begin,
'-e',
endExclusive
const income = plan.commands[0];
expect(income).toEqual(
expect.arrayContaining([
'--change',
'--monthly',
'-Ojson',
'not:type:G',
'not:date:2026-04-17..'
])
);
expect(income.slice(income.indexOf('-b'), income.indexOf('-e') + 2)).toEqual([
'-b',
'2026-04-01',
'-e',
'2026-05-01'
]);
const commands = plan.commands.join(' ').split(' -- echo ').slice(1);
expect(commands).toHaveLength(16);
for (const [index, range] of plan.incomeRanges.entries()) {
expect(commands[index]).toBe(
`__HLEDGER_INCOME_${range.months}__ -- incomestatement --flat --change --monthly --empty --depth=2 -b ${range.begin} -e 2026-05-01 -Ojson not:type:G not:date:2026-04-17..`
);
}
expect(plan.snapshotPeriods.map(({ date }) => date)).toEqual([
'2025-05-31',
'2025-06-30',
'2025-07-31',
'2025-08-31',
'2025-09-30',
'2025-10-31',
'2025-11-30',
'2025-12-31',
'2026-01-31',
'2026-02-28',
'2026-03-31',
'2026-04-16'
const latest = plan.commands[15];
expect(latest).toEqual(
expect.arrayContaining([
'--historical',
'--value=2026-04-16,USD',
'-Ojson',
'not:date:2026-04-17..'
])
);
expect(latest.slice(latest.indexOf('-b'), latest.indexOf('-e') + 2)).toEqual([
'-b',
'2026-04-01',
'-e',
'2026-04-17'
]);
for (const [index, point] of plan.snapshotPeriods.entries()) {
expect(commands[index + 4]).toBe(
`__HLEDGER_NET_WORTH_${index + 1}__ -- balancesheet --historical --flat --empty --depth=1 -b ${point.begin} -e ${point.endExclusive} --value=${point.date},USD -Ojson not:date:${point.endExclusive}..`
);
}
const decoded = decode();
expect(decoded.incomeStatements.map(({ months }) => months)).toEqual([1, 3, 6, 12]);
expect(decoded.snapshots).toHaveLength(12);
expect(decoded.incomeStatements.map(({ transactionCount }) => transactionCount)).toEqual([
1, 3, 6, 12
expect(plan.snapshotPeriods[10].date).toBe('2026-03-31');
});
it('reads native JSON in plan order and attaches supplied counts and discovery coverage unchanged', async () => {
const metadata = journalMetadata(stats());
const plan = planOverviewReports(metadata);
const statements = incomeReports();
statements[0] = statements[0].replace(
'"prrTotal":[]',
'"prrTotal":[{"acommodity":"USD","aquantity":{"decimalMantissa":900719925474099306,"decimalPlaces":2}}]'
);
const reader = readerFor([...statements, ...balanceReports()]);
const reports = await readOverviewReports(reader, metadata, plan, [1, 3, 6, 12]);
expect(reports.journalCoverage).toEqual(metadata.journalCoverage);
expect(
reports.incomeStatements.map(({ months, transactionCount }) => [months, transactionCount])
).toEqual([
[1, 1],
[3, 3],
[6, 6],
[12, 12]
]);
expect(reports.incomeStatements[0].report.cbrTotals.prrTotal[0].aquantity.decimalMantissa).toBe(
900719925474099306n
);
expect(reports.snapshots.map(({ date }) => date)).toEqual(
plan.snapshotPeriods.map(({ date }) => date)
);
expect(reports.snapshots[11].report.cbrDates[0][1].contents).toBe('2026-04-17');
await reader.eof();
});
it.each([
@@ -87,7 +105,6 @@ it.each([
expect(plan.incomeRanges[3].endExclusive).toBe(cutoff);
expect(plan.snapshotPeriods[10].date).toBe(previous);
expect(plan.snapshotPeriods[11].date).toBe(asOf);
expect(decode(batch(asOf), stats(asOf)).asOf).toBe(asOf);
}
);
@@ -95,51 +112,29 @@ it('rejects a twelve-month window outside supported dates', () => {
expect(() => planOverviewReports(journalMetadata(stats('0001-06-30')))).toThrow();
});
it('omits all financial commands and dates for undated input', () => {
it('omits all financial commands and reads for undated input', async () => {
const metadata = journalMetadata(stats('none'));
const plan = planOverviewReports(metadata);
expect(plan).toEqual({ commands: ['stats'], incomeRanges: [], snapshotPeriods: [] });
expect(decodeOverviewReports(stats('none'), metadata, plan)).toEqual({
expect(plan).toEqual({ commands: [], incomeRanges: [], snapshotPeriods: [] });
const reader = readerFor([]);
expect(await readOverviewReports(reader, metadata, plan, [])).toEqual({
...metadata,
incomeStatements: [],
snapshots: []
});
expect(() => decodeOverviewReports(stats(), metadata, plan)).toThrow();
await reader.eof();
});
it.each(['INCOME_3', 'NET_WORTH_6'])('requires financial framing for %s', (name) => {
expect(() => decode(batch().replace(`__HLEDGER_${name}__`, 'missing'))).toThrow();
});
it('splits backwards despite literal markers in paths and escaped JSON strings', () => {
const markers = '\n__HLEDGER_INCOME_12__\n\n__HLEDGER_NET_WORTH_12__\n';
const text = batch().replace('"cbrTotals":', `"ignored":${JSON.stringify(markers)},"cbrTotals":`);
const withPaths = text.replaceAll(
'Base currency',
`Main file : /private/${markers}filename\nBase currency`
);
expect(decode(withPaths).snapshots).toHaveLength(12);
});
it.each([
batch().replace('__HLEDGER_INCOME_3__\n{', '__HLEDGER_INCOME_3__\nSECRET {'),
batch() + 'private tail',
batch().replace('Last txn : 2026-04-16', 'Last txn : 2026-04-15'),
batch().replace('Base currency : USD', 'Base currency : usd'),
batch().replace('Txns : 1 (0.0 per day)\n', ''),
batch().replace('Txns : 3 (0.0 per day)', 'Txns : 9007199254740992 (0.0 per day)'),
batch().replace('Base currency : USD', 'Base currency : US\u0000D'),
`Main file : /private/\nTxns : 999\nfilename\n${batch()}`
])(
'rejects malformed financial framing, counts or changed discovery metadata (case %#)',
(text) => {
expect(() => decode(text)).toThrow();
}
);
it('accepts bounded empty filtered stats without accepting them as an empty whole journal', () => {
const filtered =
'Base currency : USD\nLast txn : none\nTxns : 0\nTxns span : 2026-05-01 to 2026-06-01\n';
expect(journalMetadataWithCounts(stats() + filtered, 1).transactionCounts).toEqual([0]);
expect(() => journalMetadata(filtered)).toThrow();
it.each([1, 7])('rejects a malformed native financial document at index %i', async (index) => {
const metadata = journalMetadata(stats());
const documents = [...incomeReports(), ...balanceReports()];
documents[index] = '{"cbrDates":[],"cbrTotals":{},"cbrSubreports":null}';
await expect(
readOverviewReports(
readerFor(documents),
metadata,
planOverviewReports(metadata),
[1, 3, 6, 12]
)
).rejects.toThrow();
});
+24 -68
View File
@@ -1,6 +1,7 @@
import { parseBalanceSheet, type HledgerBalanceSheet } from './balance-sheet.js';
import { parseIncomeStatement, type HledgerIncomeStatement } from './income-statement.js';
import { journalMetadataWithCounts, type JournalCoverage } from '../journal-stats.js';
import type { journalMetadata, JournalCoverage } from '../journal-stats.js';
import type { ReportStreamReader } from '../report-stream.js';
import { isoDate } from '../report-date.js';
import { planIncomeRanges, type IncomeRangePlan, type Period } from './ranges.js';
@@ -15,39 +16,17 @@ export interface OverviewReports {
snapshots: (Period & { date: string; report: HledgerBalanceSheet })[];
}
// Static framing for four ranges and twelve valuations, not a report registry.
const incomeMarkers = [
'\n__HLEDGER_INCOME_1__\n',
'\n__HLEDGER_INCOME_3__\n',
'\n__HLEDGER_INCOME_6__\n',
'\n__HLEDGER_INCOME_12__\n'
];
const balanceMarkers = Array.from(
{ length: 12 },
(_, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n`
);
const failureMessage = 'Invalid hledger Overview batch.';
export function planOverviewReports({
asOf,
baseCurrency
}: Pick<OverviewReports, 'asOf' | 'baseCurrency'>) {
const incomeRanges = asOf === null ? [] : planIncomeRanges(asOf);
const snapshotPeriods: (Period & { date: string })[] = [];
const commands = ['stats'];
const commands: string[][] = [];
if (asOf === null) return { commands, incomeRanges, snapshotPeriods };
// Keep all text stats before JSON so backwards JSON framing remains unambiguous,
// even when Main file contains literal newlines and report markers.
for (const range of incomeRanges) {
commands.push('--', 'stats', '-b', range.begin, '-e', range.endExclusive);
}
const history = incomeRanges[3];
for (const [index, range] of incomeRanges.entries()) {
commands.push(
'--',
'echo',
incomeMarkers[index].trim(),
'--',
for (const range of incomeRanges) {
commands.push([
'incomestatement',
'--flat',
'--change',
@@ -62,7 +41,7 @@ export function planOverviewReports({
'not:type:G',
// Monthly expansion defeats positive date queries and a mid-month -e cutoff.
`not:date:${range.endExclusive}..`
);
]);
}
for (const [index, period] of history.periods.entries()) {
const monthEnd = new Date(`${period.endExclusive}T00:00:00Z`);
@@ -73,11 +52,7 @@ export function planOverviewReports({
date: index === 11 ? asOf : isoDate(monthEnd)
};
snapshotPeriods.push(point);
commands.push(
'--',
'echo',
balanceMarkers[index].trim(),
'--',
commands.push([
'balancesheet',
'--historical',
'--flat',
@@ -91,47 +66,28 @@ export function planOverviewReports({
`--value=${point.date},${baseCurrency}`,
'-Ojson',
`not:date:${point.endExclusive}..`
);
]);
}
return { commands, incomeRanges, snapshotPeriods };
}
export function decodeOverviewReports(
batch: string,
{ asOf, baseCurrency }: Pick<OverviewReports, 'asOf' | 'baseCurrency'>,
{ incomeRanges, snapshotPeriods }: ReturnType<typeof planOverviewReports>
): OverviewReports {
export async function readOverviewReports(
reader: ReportStreamReader,
metadata: ReturnType<typeof journalMetadata>,
{ incomeRanges, snapshotPeriods }: ReturnType<typeof planOverviewReports>,
transactionCounts: readonly number[]
): Promise<OverviewReports> {
const incomeStatements: OverviewReports['incomeStatements'] = [];
for (const [index, range] of incomeRanges.entries()) {
incomeStatements.push({
...range,
transactionCount: transactionCounts[index],
report: parseIncomeStatement(await reader.json())
});
}
const snapshots: OverviewReports['snapshots'] = [];
const statements: (IncomeRangePlan & { report: HledgerIncomeStatement })[] = [];
if (asOf !== null) {
// Decode backwards: paths may contain earlier literal markers; JSON strings escape them.
for (let index = 11; index >= 0; index--) {
const marker = balanceMarkers[index];
const split = batch.lastIndexOf(marker);
if (split < 0) throw new Error(failureMessage);
snapshots.unshift({
...snapshotPeriods[index],
report: parseBalanceSheet(batch.slice(split + marker.length))
});
batch = batch.slice(0, split);
}
for (let index = 3; index >= 0; index--) {
const marker = incomeMarkers[index];
const split = batch.lastIndexOf(marker);
if (split < 0) throw new Error(failureMessage);
statements.unshift({
...incomeRanges[index],
report: parseIncomeStatement(batch.slice(split + marker.length))
});
batch = batch.slice(0, split);
}
for (const period of snapshotPeriods) {
snapshots.push({ ...period, report: parseBalanceSheet(await reader.json()) });
}
const { metadata, transactionCounts } = journalMetadataWithCounts(batch, asOf === null ? 0 : 4);
const incomeStatements = statements.map((statement, index) => ({
...statement,
transactionCount: transactionCounts[index]
}));
if (metadata.asOf !== asOf || metadata.baseCurrency !== baseCurrency)
throw new Error(failureMessage);
return { ...metadata, incomeStatements, snapshots };
}
+50 -70
View File
@@ -4,12 +4,7 @@ import { join } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { prepareReports, getHledgerVersion } from '../index.js';
import { MemoryPreparationTransactions } from '../prepare-reports/transactions.fixture.js';
import {
balanceFrames,
decodeOverviewFixture,
incomeFrames,
periodStats
} from '../../test-fixtures.js';
import { balanceReports, decodeOverviewFixture, incomeReports } from '../../test-fixtures.js';
import { planOverviewReports } from './batch.js';
import { journalMetadata } from '../journal-stats.js';
@@ -43,28 +38,28 @@ const nativeAmount = (commodity: string, mantissa: number) => ({
});
const stats = (currency = 'USD') =>
`Base currency : ${currency}\nLast txn : 2026-04-16\nTxns : 12\nTxns span : 2025-05-01 to 2026-04-17\n`;
const frames = () =>
balanceFrames()
.split(/\n__HLEDGER_NET_WORTH_\d+__\n/)
.slice(1)
.map(
(text) =>
JSON.parse(text) as {
cbrDates: { tag: string; contents: string }[][];
cbrTotals: { prrAmounts: ReturnType<typeof nativeAmount>[][] };
cbrSubreports: [
string,
{ prTotals: { prrAmounts: ReturnType<typeof nativeAmount>[][] } },
boolean
][];
}
);
function batch(reports = frames(), currency = 'USD') {
return `${stats(currency)}${periodStats()}${incomeFrames()}${reports.map((report, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}`).join('')}`;
const sheets = () =>
balanceReports().map(
(text) =>
JSON.parse(text) as {
cbrDates: { tag: string; contents: string }[][];
cbrTotals: { prrAmounts: ReturnType<typeof nativeAmount>[][] };
cbrSubreports: [
string,
{ prTotals: { prrAmounts: ReturnType<typeof nativeAmount>[][] } },
boolean
][];
}
);
function documents(reports = sheets()) {
return {
incomeStatements: incomeReports(),
snapshots: reports.map((report) => JSON.stringify(report))
};
}
it('uses the exact native net bag, retaining mixed amounts, order and explicit zero entries', () => {
const reports = frames();
const reports = sheets();
const report = reports[11];
// Deliberately unrelated numbers: no JS accounting or net reconstruction.
report.cbrTotals.prrAmounts = [
@@ -76,7 +71,7 @@ it('uses the exact native net bag, retaining mixed amounts, order and explicit z
report.cbrSubreports[1][1].prTotals.prrAmounts = [
[nativeAmount('unpriced', 0), nativeAmount('only-debt', 100)]
];
const data = decodeOverviewFixture(stats(), batch(reports));
const data = decodeOverviewFixture(stats(), documents(reports));
expect(data.netWorth).toEqual({
currency: 'USD',
points: dates.map((date, index) => ({
@@ -99,11 +94,11 @@ it('uses the exact native net bag, retaining mixed amounts, order and explicit z
});
it.each(['', 'US,Dollar'])(
'preserves exact native base currency %j between both passes and in arguments',
'preserves exact native base currency %j in projections and arguments',
(currency) => {
const data = decodeOverviewFixture(stats(currency), batch(frames(), currency));
const data = decodeOverviewFixture(stats(currency), documents());
expect(data.netWorth?.currency).toBe(currency);
const args = planOverviewReports(journalMetadata(stats(currency))).commands;
const args = planOverviewReports(journalMetadata(stats(currency))).commands.flat();
expect(args.filter((arg) => arg.startsWith('--value='))).toEqual(
dates.map((date) => `--value=${date},${currency}`)
);
@@ -113,77 +108,62 @@ it.each(['', 'US,Dollar'])(
);
it.each([
['missing dates', (r: ReturnType<typeof frames>[number]) => r.cbrDates.pop()],
['extra dates', (r: ReturnType<typeof frames>[number]) => r.cbrDates.push(r.cbrDates[0])],
['missing dates', (r: ReturnType<typeof sheets>[number]) => r.cbrDates.pop()],
['extra dates', (r: ReturnType<typeof sheets>[number]) => r.cbrDates.push(r.cbrDates[0])],
[
'wrong start',
(r: ReturnType<typeof frames>[number]) => (r.cbrDates[0][0].contents = '2026-01-02')
(r: ReturnType<typeof sheets>[number]) => (r.cbrDates[0][0].contents = '2026-01-02')
],
[
'wrong final end',
(r: ReturnType<typeof frames>[number]) => (r.cbrDates[0][1].contents = '2026-05-01')
(r: ReturnType<typeof sheets>[number]) => (r.cbrDates[0][1].contents = '2026-05-01')
],
[
'non-exact date',
(r: ReturnType<typeof frames>[number]) => (r.cbrDates[0][0].tag = 'Unbounded')
(r: ReturnType<typeof sheets>[number]) => (r.cbrDates[0][0].tag = 'Unbounded')
],
['missing side', (r: ReturnType<typeof frames>[number]) => r.cbrSubreports.pop()],
['missing side', (r: ReturnType<typeof sheets>[number]) => r.cbrSubreports.pop()],
[
'wrong assets contribution',
(r: ReturnType<typeof frames>[number]) => (r.cbrSubreports[0][2] = false)
(r: ReturnType<typeof sheets>[number]) => (r.cbrSubreports[0][2] = false)
],
[
'wrong liabilities contribution',
(r: ReturnType<typeof frames>[number]) => (r.cbrSubreports[1][2] = true)
(r: ReturnType<typeof sheets>[number]) => (r.cbrSubreports[1][2] = true)
],
['wrong side', (r: ReturnType<typeof frames>[number]) => (r.cbrSubreports[1][0] = 'Equity')],
['extra total column', (r: ReturnType<typeof frames>[number]) => r.cbrTotals.prrAmounts.push([])],
['missing total column', (r: ReturnType<typeof frames>[number]) => r.cbrTotals.prrAmounts.pop()],
['wrong side', (r: ReturnType<typeof sheets>[number]) => (r.cbrSubreports[1][0] = 'Equity')],
['extra total column', (r: ReturnType<typeof sheets>[number]) => r.cbrTotals.prrAmounts.push([])],
['missing total column', (r: ReturnType<typeof sheets>[number]) => r.cbrTotals.prrAmounts.pop()],
[
'missing assets column',
(r: ReturnType<typeof frames>[number]) => r.cbrSubreports[0][1].prTotals.prrAmounts.pop()
(r: ReturnType<typeof sheets>[number]) => r.cbrSubreports[0][1].prTotals.prrAmounts.pop()
],
[
'extra liabilities column',
(r: ReturnType<typeof frames>[number]) => r.cbrSubreports[1][1].prTotals.prrAmounts.push([])
(r: ReturnType<typeof sheets>[number]) => r.cbrSubreports[1][1].prTotals.prrAmounts.push([])
]
])('rejects %s in a middle balance sheet without publishing partial income', (_name, mutate) => {
const reports = frames();
const reports = sheets();
mutate(reports[3]);
expect(() => decodeOverviewFixture(stats(), batch(reports))).toThrow();
expect(() => decodeOverviewFixture(stats(), documents(reports))).toThrow();
});
it('rejects a malformed middle balance report', () => {
const text = batch().replace(
/(__HLEDGER_NET_WORTH_3__\n)[\s\S]*?(\n__HLEDGER_NET_WORTH_4__)/,
'$1{"cbrDates":[],"cbrSubreports":null}$2'
);
expect(() => decodeOverviewFixture(stats(), text)).toThrow();
const reports = documents();
reports.snapshots[2] = '{"cbrDates":[],"cbrSubreports":null}';
expect(() => decodeOverviewFixture(stats(), reports)).toThrow();
});
describe.each(['net', 'assets', 'liabilities'] as const)('malformed consumed %s bags', (side) => {
it('rejects a null bag without publishing partial reports', () => {
const marker = '\n__HLEDGER_NET_WORTH_3__\n';
const next = '\n__HLEDGER_NET_WORTH_4__\n';
const text = batch();
const start = text.indexOf(marker) + marker.length;
const end = text.indexOf(next);
const report = text.slice(start, end);
const key = side === 'net' ? 'cbrTotals' : 'prTotals';
let seen = 0;
const malformed = report.replace(
new RegExp(`"${key}":\\{"prrAmounts":\\[\\[\\]\\]\\}`, 'g'),
(matched) => {
seen++;
return seen === (side === 'liabilities' ? 2 : 1)
? `"${key}":{"prrAmounts":[null]}`
: matched;
}
);
expect(malformed).not.toBe(report);
expect(() =>
decodeOverviewFixture(stats(), text.slice(0, start) + malformed + text.slice(end))
).toThrow();
const reports = sheets();
const report = reports[2];
let totals;
if (side === 'net') totals = report.cbrTotals;
else if (side === 'assets') totals = report.cbrSubreports[0][1].prTotals;
else totals = report.cbrSubreports[1][1].prTotals;
Reflect.set(totals, 'prrAmounts', [null]);
expect(() => decodeOverviewFixture(stats(), documents(reports))).toThrow();
});
});
+3 -13
View File
@@ -3,25 +3,15 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { planIncomeRanges, organizeIncomeRange } from './ranges.js';
import type { HledgerIncomeStatement } from './income-statement.js';
import { parseIncomeStatement, type HledgerIncomeStatement } from './income-statement.js';
import { incomeReports } from '../../test-fixtures.js';
import type { Amount } from '../amounts.js';
import { prepareReports, getHledgerVersion } from '../index.js';
import { MemoryPreparationTransactions } from '../prepare-reports/transactions.fixture.js';
const plan = planIncomeRanges('2026-04-16')[3];
function statement(range = plan): HledgerIncomeStatement {
const totals = () => ({ prrAmounts: range.periods.map(() => []), prrTotal: [] });
return {
cbrDates: range.periods.map(({ begin, endExclusive }) => [
{ tag: 'Exact', contents: begin },
{ tag: 'Exact', contents: endExclusive }
]),
cbrTotals: { prrTotal: [] },
cbrSubreports: [
['Revenues', { prTotals: totals(), prRows: [] }, true],
['Expenses', { prTotals: totals(), prRows: [] }, false]
]
};
return parseIncomeStatement(incomeReports()[([1, 3, 6, 12] as const).indexOf(range.months)]);
}
const amount = (commodity: string, mantissa: bigint): Amount => ({
acommodity: commodity,
+27 -24
View File
@@ -4,23 +4,18 @@ import { join } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { prepareReports, getHledgerVersion } from '../index.js';
import { MemoryPreparationTransactions } from '../prepare-reports/transactions.fixture.js';
import {
balanceFrames,
decodeOverviewFixture,
incomeFrames,
periodStats
} from '../../test-fixtures.js';
import { balanceReports, decodeOverviewFixture, incomeReports } from '../../test-fixtures.js';
const stats =
'Base currency : USD\nLast txn : 2026-04-16\nTxns : 12\nTxns span : 2025-05-01 to 2026-04-17\n';
const emptyStats = 'Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to \n';
const batch = (metadata = stats) => metadata + periodStats() + incomeFrames() + balanceFrames();
const reports = () => ({ incomeStatements: incomeReports(), snapshots: balanceReports() });
afterEach(() => {
vi.useRealTimers();
});
it('publishes all four ranges, a shared twelve-month history and latest native sides', () => {
const data = decodeOverviewFixture(stats, batch());
const data = decodeOverviewFixture(stats, reports());
expect(data).toMatchObject({
asOf: '2026-04-16',
journalCoverage: { transactionCount: 12, firstDate: '2025-05-01', lastDate: '2026-04-16' },
@@ -49,19 +44,23 @@ it('publishes monthly history only from the twelve-month statement', () => {
acommodity: 'USD',
aquantity: { decimalMantissa: mantissa, decimalPlaces: 2 }
});
const [shorter, history] = incomeFrames().split('\n__HLEDGER_INCOME_12__\n');
const statements =
shorter.replace('"prrAmounts":[[]]', `"prrAmounts":[[${amount(111)}]]`) +
'\n__HLEDGER_INCOME_12__\n' +
history.replace('"prrAmounts":[[]', `"prrAmounts":[[${amount(222)}]`);
const data = decodeOverviewFixture(stats, stats + periodStats() + statements + balanceFrames());
const native = reports();
native.incomeStatements[0] = native.incomeStatements[0].replace(
'"prrAmounts":[[]]',
`"prrAmounts":[[${amount(111)}]]`
);
native.incomeStatements[3] = native.incomeStatements[3].replace(
'"prrAmounts":[[]',
`"prrAmounts":[[${amount(222)}]`
);
const data = decodeOverviewFixture(stats, native);
expect(data.incomeExpenses.months[0].income).toEqual([{ commodity: 'USD', quantity: '2.22' }]);
expect(data.incomeExpenses.months[11].income).toEqual([]);
expect(data.ranges![12].income).toEqual([]);
});
it('projects undated input without invented ranges, sides, currency, dates or a timestamp', () => {
expect(decodeOverviewFixture(emptyStats, emptyStats)).toEqual({
expect(decodeOverviewFixture(emptyStats, { incomeStatements: [], snapshots: [] }, [])).toEqual({
asOf: null,
journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null },
ranges: null,
@@ -71,15 +70,19 @@ it('projects undated input without invented ranges, sides, currency, dates or a
});
});
it('uses second cached metadata and publishes no private paths or unused native fields', () => {
const metadata = `Main file : /private/SECRET.journal\n${stats
.replace('Txns : 12', 'Txns : 13')
.replace('2025-05-01 to', '2025-01-01 to')}`;
const text = batch(metadata).replace('"cbrTotals":', '"sourcePath":"SECRET","cbrTotals":');
const data = decodeOverviewFixture(stats, text);
it('publishes no private metadata paths or unused native fields', () => {
const metadata = `Main file : /private/SECRET.journal\n${stats}`;
const native = reports();
native.incomeStatements = native.incomeStatements.map((json) =>
json.replace('"cbrTotals":', '"sourcePath":"SECRET","cbrTotals":')
);
native.snapshots = native.snapshots.map((json) =>
json.replace('"cbrTotals":', '"sourcePath":"SECRET","cbrTotals":')
);
const data = decodeOverviewFixture(metadata, native);
expect(data.journalCoverage).toEqual({
transactionCount: 13,
firstDate: '2025-01-01',
transactionCount: 12,
firstDate: '2025-05-01',
lastDate: '2026-04-16'
});
expect(JSON.stringify(data)).not.toContain('SECRET');
@@ -88,7 +91,7 @@ it('uses second cached metadata and publishes no private paths or unused native
it('does not consult the clock or assign the producer-owned completion timestamp', () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2099-12-31T12:34:56.789Z'));
const data = decodeOverviewFixture(stats, batch());
const data = decodeOverviewFixture(stats, reports());
expect(data.asOf).toBe('2026-04-16');
expect(data).not.toHaveProperty('preparedAt');
expect(data).not.toHaveProperty('hledgerVersion');
@@ -4,7 +4,9 @@ import { planPreparedReportCommands } from './batch.js';
it('exports the whole journal once regardless of account count or ambiguous selections', () => {
const { args } = planPreparedReportCommands([], {
journal: journalMetadata('Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to\n'),
journal: journalMetadata(
'Base currency : USD\nLast txn : 2026-04-16\nTxns : 1\nTxns span : 2026-04-16 to 2026-04-17\n'
),
accounts: [
{ name: 'assets:cash', access: 'ready' },
{ name: 'assets:cash:daily', access: 'ready' },
@@ -21,7 +23,7 @@ it('exports the whole journal once regardless of account count or ambiguous sele
}
});
expect(args.filter((argument) => argument === 'print')).toHaveLength(2);
expect(args.slice(args.indexOf('print'), args.indexOf('aregister'))).toEqual([
expect(args.slice(args.indexOf('print'), args.indexOf('incomestatement'))).toEqual([
'print',
'--round=none',
'-Ojson',
@@ -34,4 +36,6 @@ it('exports the whole journal once regardless of account count or ambiguous sele
]);
expect(args.filter((argument) => argument === 'aregister')).toHaveLength(2);
expect(args).not.toContain('^assets:Case$');
expect(args).not.toContain('echo');
expect(args).not.toContain('stats');
});
+15 -31
View File
@@ -1,11 +1,12 @@
import { decodeOverviewReports, planOverviewReports } from '../overview/batch.js';
import { readOverviewReports, planOverviewReports } from '../overview/batch.js';
import { organizeOverviewData } from '../overview/report.js';
import {
planAccountBalances,
decodeAccountBalances,
readAccountBalances,
organizeAccountBalance,
type PreparedBalance
} from '../accounts/balances.js';
import type { ReportStreamReader } from '../report-stream.js';
import type { PreparationMetadata, QualifiedAccount } from './metadata.js';
export interface PreparedAccountSummary extends QualifiedAccount {
@@ -13,13 +14,6 @@ export interface PreparedAccountSummary extends QualifiedAccount {
summary: PreparedBalance;
}
// Keep the legacy delimiter for the finite financial prefix.
export const financialEndMarker = '\n__HLEDGER_PREPARED_REGISTERS__\n';
function echo(marker: string): string[] {
return ['--', 'echo', marker.trimEnd()];
}
function escapeAccountName(name: string): string {
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -29,41 +23,31 @@ export function planPreparedReportCommands(
metadata: PreparationMetadata
) {
const overviewPlan = planOverviewReports(metadata.journal);
const args = [
...inputFlags,
'run',
'--',
const commands = [
['print', '--round=none', '-Ojson'],
['print', '--round=none', '--value=transacted', '-Ojson'],
...overviewPlan.commands,
...planAccountBalances(metadata.journal),
...echo(financialEndMarker),
'--',
'print',
'--round=none',
'-Ojson',
'--',
'print',
'--round=none',
'--value=transacted',
'-Ojson'
...planAccountBalances(metadata.journal)
];
for (const { name, access } of metadata.accounts) {
if (access === 'ambiguous') continue;
const exactAccountPattern = `^${escapeAccountName(name)}$`;
// No separator before the literal pattern: run owns `--` separators.
args.push('--', 'aregister', '--historical', '--empty', '-Ojson', exactAccountPattern);
commands.push(['aregister', '--historical', '--empty', '-Ojson', exactAccountPattern]);
}
const args = [...inputFlags, 'run', ...commands.flatMap((command) => ['--', ...command])];
return { args, overviewPlan };
}
export function decodePreparedPrefix(
prefix: string,
export async function readPreparedReports(
reader: ReportStreamReader,
metadata: PreparationMetadata,
overviewPlan: ReturnType<typeof planOverviewReports>
overviewPlan: ReturnType<typeof planOverviewReports>,
transactionCounts: readonly number[]
) {
const accountReports = decodeAccountBalances(prefix, metadata.journal.asOf);
const overview = organizeOverviewData(
decodeOverviewReports(accountReports.overviewBatch, metadata.journal, overviewPlan)
await readOverviewReports(reader, metadata.journal, overviewPlan, transactionCounts)
);
const accountReports = await readAccountBalances(reader, metadata.journal.asOf);
const accounts: PreparedAccountSummary[] = metadata.accounts.map(({ name, access }) => {
const rowCount = access === 'ambiguous' ? null : 0;
const summary = organizeAccountBalance(
+112 -49
View File
@@ -1,5 +1,6 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { balanceReports, incomeReports } from '../../test-fixtures.js';
import { supportedHledgerVersion } from '../checks.js';
import { transactionFixture } from '../transactions/transaction.fixture.js';
import { prepareReports } from './run.js';
@@ -35,15 +36,33 @@ afterEach(async () => {
});
const versionOutput = `hledger ${supportedHledgerVersion}, SECRET banner\n`;
const journalStats = 'Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to\n';
const financialEndMarker = '\n__HLEDGER_PREPARED_REGISTERS__\n';
const financialPrefix = `${journalStats}${financialEndMarker}`;
const emptyStats = 'Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to\n';
const asOf = '2031-01-01';
function metadataStats(count: number): string {
return count === 0
? emptyStats
: `Base currency : USD\nLast txn : ${asOf}\nTxns : ${count}\nTxns span : 2031-01-01 to 2031-01-02\n`;
}
interface NativeFixture {
output: string;
count: number;
}
const nativeTransaction = {
...transactionFixture(),
tdate: '2031-01-01',
tdescription: 'λ, test'
};
const globalExports = `${financialPrefix}[${JSON.stringify(nativeTransaction)}]\n[${JSON.stringify(nativeTransaction)}]\n`;
const globalExports = `[${JSON.stringify(nativeTransaction)}]\n[${JSON.stringify(nativeTransaction)}]\n`;
const accountBalance = JSON.stringify([[['assets:cash', 'assets:cash', 0, []]], []]);
const financialReports = [
...incomeReports(asOf),
...balanceReports(asOf),
accountBalance,
accountBalance
].join('\n');
const datedExports = `${globalExports}${financialReports}\n`;
const registerRow = JSON.stringify([
nativeTransaction,
nativeTransaction,
@@ -53,7 +72,7 @@ const registerRow = JSON.stringify([
[]
]);
function accountProtocol(rowCount: number): string {
function accountFixture(rowCount: number): NativeFixture {
const transactions = Array.from({ length: rowCount }, (_, index) => ({
...nativeTransaction,
tindex: index + 1,
@@ -66,7 +85,10 @@ function accountProtocol(rowCount: number): string {
const rows = JSON.stringify(
transactions.map((transaction) => [transaction, transaction, false, ['other, 東京'], [], []])
);
return `${financialPrefix}${prints}\n${prints}\n${rows}`;
let output = `${prints}\n${prints}\n`;
if (rowCount > 0) output += `${financialReports}\n`;
output += rows;
return { output, count: rowCount };
}
function startFixtureChild(script: string): ChildProcessWithoutNullStreams {
@@ -86,8 +108,8 @@ function queueChildOutput(output: string) {
});
}
function queuePreparationMetadata() {
queueChildOutput(journalStats);
function queuePreparationMetadata(count: number) {
queueChildOutput(metadataStats(count));
queueChildOutput('assets:cash\n');
queueChildOutput('assets:cash ; type: C\n');
queueChildOutput('');
@@ -95,23 +117,23 @@ function queuePreparationMetadata() {
}
function mockPreparationProcesses(
output: string,
fixture: NativeFixture,
{ exitCode = 0, keepOpen = false }: { exitCode?: number; keepOpen?: boolean } = {}
): ChildProcessWithoutNullStreams {
queueChildOutput(versionOutput);
queuePreparationMetadata();
queuePreparationMetadata(fixture.count);
const child = startFixtureChild(
`process.stdin.pipe(process.stdout,{end:false});process.stdin.on('end',()=>process.stdout.write('',()=>{${keepOpen ? 'setInterval(()=>{},1000)' : `process.exit(${exitCode})`}}));`
);
processMock.spawn.mockImplementationOnce(() => {
child.stdin.end(output);
child.stdin.end(fixture.output);
return child;
});
return child;
}
async function expectRejected(output: string, exitCode = 0) {
const child = mockPreparationProcesses(output, { exitCode });
async function expectRejected(fixture: NativeFixture, exitCode = 0) {
const child = mockPreparationProcesses(fixture, { exitCode });
const writeRows = vi.fn(async () => {});
const error = await prepareReports('/private/SECRET', {
transactions: new MemoryPreparationTransactions(),
@@ -125,21 +147,28 @@ async function expectRejected(output: string, exitCode = 0) {
}
describe('preparation completion and failure', () => {
it('timestamps completion after the final row callback and stores only the checked version', async () => {
it('timestamps completion after native close and the final row callback, storing only the checked version', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockPreparationProcesses(`${globalExports}[${registerRow}] \t\r\n`);
const child = mockPreparationProcesses({
output: `${datedExports}[${registerRow}] \t\r\n`,
count: 1
});
child.once('close', () => {
vi.setSystemTime(new Date('2026-01-01T00:01:00Z'));
});
const result = await prepareReports('/private', {
transactions: new MemoryPreparationTransactions(),
async writeRows() {
await children.get(child);
vi.setSystemTime(new Date('2026-01-01T00:02:30Z'));
}
});
expect(result.overview.preparedAt).toBe('2026-01-01T00:02:30.000Z');
expect(result.overview.hledgerVersion).toBe(supportedHledgerVersion);
expect(result.accounts).toMatchObject([
{ name: 'assets:cash', access: 'ready', rowCount: 1, summary: { status: 'undated' } }
{ name: 'assets:cash', access: 'ready', rowCount: 1, summary: { status: 'ready' } }
]);
expect(processMock.spawn).toHaveBeenCalledTimes(7);
const inputFlags = ['--no-conf', '--lang=en', '--strict', '-f', '/private'];
@@ -152,24 +181,70 @@ describe('preparation completion and failure', () => {
}
});
it.each([
{ name: 'truncated account output', output: `${globalExports}[${registerRow}` },
{
name: 'malformed native JSON between global exports',
output: `${financialPrefix}[${JSON.stringify(nativeTransaction)}]invalid[]`
}
])('rejects $name without returning a partial report', async ({ output }) => {
await expectRejected(output);
it('prepares an undated journal without consuming overview or balance documents', async () => {
mockPreparationProcesses(accountFixture(0));
const writeRows = vi.fn(async () => {});
const result = await prepareReports('/private', {
transactions: new MemoryPreparationTransactions(),
writeRows
});
expect(result.accounts).toMatchObject([
{ name: 'assets:cash', access: 'ready', rowCount: 0, summary: { status: 'undated' } }
]);
expect(writeRows).toHaveBeenCalledExactlyOnceWith('assets:cash', []);
expect(processMock.spawn).toHaveBeenCalledTimes(7);
});
it.each([
{ name: 'truncated account output', output: `${datedExports}[${registerRow}` },
{ name: 'an object instead of a global transaction array', output: '{}\n[]\n' },
{
name: 'malformed native JSON between global exports',
output: `[${JSON.stringify(nativeTransaction)}]invalid[]`
}
])('rejects $name without returning a partial report', async ({ output }) => {
await expectRejected({ output, count: 1 });
});
it('rejects an unexpected native JSON document after the complete report plan', async () => {
const fixture = accountFixture(1);
await expectRejected({ ...fixture, output: `${fixture.output}\n{}` });
});
it.each([
{ name: 'transaction count', count: 2, transaction: nativeTransaction },
{
name: 'maximum primary date',
count: 1,
transaction: { ...nativeTransaction, tdate: '2030-12-31' }
}
])(
'rejects a metadata/global $name mismatch before financial reports or rows',
async ({ count, transaction }) => {
const print = JSON.stringify([transaction]);
mockPreparationProcesses({ output: `${print}\n${print}\n`, count });
const transactions = new MemoryPreparationTransactions();
const readSummary = vi.spyOn(transactions, 'readSummary');
const countInPeriod = vi.spyOn(transactions, 'countInPeriod');
const writeRows = vi.fn(async () => {});
await expect(prepareReports('/private', { transactions, writeRows })).rejects.toThrow();
expect(readSummary).toHaveBeenCalledOnce();
expect(countInPeriod).not.toHaveBeenCalled();
expect(writeRows).not.toHaveBeenCalled();
}
);
it('rejects a repeated register transaction even though shared storage permits rereads', async () => {
const writes = await expectRejected(`${globalExports}[${registerRow},${registerRow}]`);
const writes = await expectRejected({
output: `${datedExports}[${registerRow},${registerRow}]`,
count: 1
});
expect(writes).not.toHaveBeenCalled();
});
it('rejects incomplete global pairs even when every account is ambiguous', async () => {
queueChildOutput(versionOutput);
queueChildOutput(journalStats);
queueChildOutput(metadataStats(1));
queueChildOutput('assets:case\nassets:Case\n');
queueChildOutput('assets:case ; type: C\nassets:Case ; type: C\n');
queueChildOutput('');
@@ -181,7 +256,7 @@ describe('preparation completion and failure', () => {
paccount: index === 0 ? 'assets:case' : 'assets:Case'
}))
};
queueChildOutput(`${financialPrefix}[${JSON.stringify(original)}]\n[]\n`);
queueChildOutput(`[${JSON.stringify(original)}]\n[]\n`);
const transactions = new MemoryPreparationTransactions();
const assertComplete = vi.spyOn(transactions, 'assertComplete');
const writeRows = vi.fn(async () => {});
@@ -196,12 +271,12 @@ describe('preparation completion and failure', () => {
});
it('rejects a late native nonzero exit even after row writes', async () => {
const writes = await expectRejected(accountProtocol(51), 9);
const writes = await expectRejected(accountFixture(51), 9);
expect(writes).toHaveBeenCalled();
});
it('kills the child and withholds private diagnostics when a row callback fails', async () => {
const child = mockPreparationProcesses(accountProtocol(51), { keepOpen: true });
const child = mockPreparationProcesses(accountFixture(51), { keepOpen: true });
const error = await prepareReports('/private', {
transactions: new MemoryPreparationTransactions(),
async writeRows() {
@@ -217,7 +292,7 @@ describe('preparation completion and failure', () => {
it.each(['original import', 'account rows'] as const)(
'kills the child on abort but waits for the active %s write',
async (phase) => {
const child = mockPreparationProcesses(accountProtocol(50), { keepOpen: true });
const child = mockPreparationProcesses(accountFixture(50), { keepOpen: true });
const childClosed = new Promise<void>((resolve) => {
child.once('close', () => resolve());
});
@@ -276,8 +351,11 @@ describe('preparation completion and failure', () => {
}
);
it('kills a blocked financial read when aborted', async () => {
const child = mockPreparationProcesses(globalExports.slice(0, -5), { keepOpen: true });
it('kills a blocked global export read when aborted', async () => {
const child = mockPreparationProcesses(
{ output: globalExports.slice(0, -5), count: 1 },
{ keepOpen: true }
);
const controller = new AbortController();
const result = prepareReports('/private', {
transactions: new MemoryPreparationTransactions(),
@@ -313,25 +391,10 @@ describe('preparation completion and failure', () => {
});
});
describe('financial framing and metadata qualification', () => {
it('does not confuse financial stats-path markers with protocol boundaries', async () => {
mockPreparationProcesses(
`Main file : /private\n__HLEDGER_PREPARED_REGISTERS__\npart\n${globalExports}[]`
);
const result = await prepareReports('/private', {
transactions: new MemoryPreparationTransactions(),
async writeRows() {}
});
expect(result.accounts[0].rowCount).toBe(0);
});
it('rejects a forged complete protocol in a stats path unless physical EOF also validates', async () => {
await expectRejected(`${globalExports}[]rest-of-path\n${globalExports}[]`);
});
describe('metadata qualification', () => {
it('rejects unsafe account identities before starting financial execution', async () => {
queueChildOutput(versionOutput);
queueChildOutput(journalStats);
queueChildOutput(emptyStats);
queueChildOutput('assets:\tSECRET\n');
const error = await prepareReports('/private/SECRET', {
transactions: new MemoryPreparationTransactions(),
+22 -5
View File
@@ -6,9 +6,8 @@ import { readPreparationMetadata } from './metadata.js';
import type { PreparationTransactions } from '../transactions/store.js';
import { writePreparedAccountRows, type AccountRowWriter } from './account-stream.js';
import {
decodePreparedPrefix,
readPreparedReports,
planPreparedReportCommands,
financialEndMarker,
type PreparedAccountSummary
} from './batch.js';
@@ -33,9 +32,6 @@ export async function prepareReports(
{ signal: options.signal },
async (output, check) => {
const reader = new ReportStreamReader(output, check);
const { overview, accounts } = await reader.prefix(financialEndMarker, (prefix) =>
decodePreparedPrefix(prefix, metadata, overviewPlan)
);
await reader.array('object', async (json) => {
await options.transactions.insertOriginal(json);
});
@@ -45,6 +41,27 @@ export async function prepareReports(
check();
await options.transactions.assertComplete();
check();
const summary = await options.transactions.readSummary();
check();
// Native report spans can include posting dates; compare the latest primary date instead.
if (
summary.transactionCount !== metadata.journal.journalCoverage.transactionCount ||
summary.lastDate !== metadata.journal.asOf
)
throw new Error('Native transactions do not match preparation metadata.');
const transactionCounts: number[] = [];
for (const range of overviewPlan.incomeRanges) {
transactionCounts.push(
await options.transactions.countInPeriod(range.begin, range.endExclusive)
);
check();
}
const { overview, accounts } = await readPreparedReports(
reader,
metadata,
overviewPlan,
transactionCounts
);
for (const account of accounts) {
await writePreparedAccountRows(
reader,
@@ -1,5 +1,9 @@
import { decodeNativeTransaction } from '../transactions/export.js';
import type { NativeTransactionPair, PreparationTransactions } from '../transactions/store.js';
import type {
NativeTransactionPair,
NativeTransactionSummary,
PreparationTransactions
} from '../transactions/store.js';
import {
assertTransactionExportsMatch,
requireTransactionIndex
@@ -30,6 +34,24 @@ export class MemoryPreparationTransactions implements PreparationTransactions {
}
}
async readSummary(): Promise<NativeTransactionSummary> {
let lastDate: string | null = null;
for (const record of this.records.values()) {
const { tdate } = decodeNativeTransaction(record.originalJson);
if (lastDate === null || tdate > lastDate) lastDate = tdate;
}
return { transactionCount: this.records.size, lastDate };
}
async countInPeriod(begin: string, endExclusive: string): Promise<number> {
let count = 0;
for (const record of this.records.values()) {
const { tdate } = decodeNativeTransaction(record.originalJson);
if (tdate >= begin && tdate < endExclusive) count += 1;
}
return count;
}
async read(index: number): Promise<NativeTransactionPair> {
requireTransactionIndex(index);
const record = this.records.get(index);
+106 -48
View File
@@ -12,28 +12,37 @@ function reader(text: string | Buffer, split = 1) {
);
}
const transaction = String.raw`{
"tindex": 1,
"tdescription": "東京 λ \\\" ] }",
"tpostings": [{"paccount": "assets:bank", "pamount": [{
"acommodity": "USD",
"aquantity": {"decimalMantissa": 900719925474099312345, "decimalPlaces": 2, "floatingPoint": 9.007199254740993e+18}
}]}]
}`;
it.each(['array', 'object'] as const)(
'reads consecutive native %s arrays across UTF-8 and escaped strings',
'reads a root object then native %s rows across UTF-8 and escaped strings',
async (kind) => {
const value =
kind === 'object'
? { text: '東京 λ \\" ] }', nested: [{ key: [1, 2] }] }
: [{ text: '東京 λ \\" ] }' }];
const input = reader(
JSON.stringify([value, value]) + '\n' + JSON.stringify([value]) + ' \t\r\n'
);
const rows: unknown[] = [];
const value = kind === 'object' ? transaction : `[${transaction}]`;
const input = reader(` \t\r\n${transaction}\n[${value}, ${value}] \t\r\n`);
await expect(input.json()).resolves.toBe(transaction);
const rows: string[] = [];
await input.array(kind, async (json) => {
rows.push(JSON.parse(json));
rows.push(json);
});
await input.array(kind, async (json) => {
rows.push(JSON.parse(json));
});
expect(rows).toEqual([value, value, value]);
expect(rows).toEqual([value, value]);
await input.eof();
}
);
it('preserves a complete native JSON array without changing numeric tokens or internal whitespace', async () => {
const document = `[ ${transaction},\n${transaction} ]`;
const input = reader(`\n${document}\r\n`, 7);
await expect(input.json()).resolves.toBe(document);
await input.eof();
});
it.each([
['array', '[{}]'],
['object', '[[]]'],
@@ -50,26 +59,75 @@ it.each([
).rejects.toThrow();
});
it('awaits each object callback and propagates failure before consuming the next record', async () => {
const input = reader('[{},{}]', 100);
let release!: () => void;
let entered!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const held = new Promise<void>((resolve) => {
release = resolve;
});
it.each(['null', '{"nested":[{}]', '{"text":"unfinished\\'])(
'rejects a non-container root or truncated native JSON: %s',
async (text) => {
await expect(reader(text).json()).rejects.toThrow();
}
);
it.each(['[{}', '[{},{}]'])(
'awaits the callback and propagates failure before reading another row: %s',
async (first) => {
let pulls = 0;
const input = new ReportStreamReader(
(async function* () {
pulls++;
yield Buffer.from(first);
pulls++;
yield Buffer.from(',{}]');
})(),
() => {}
);
let release!: () => void;
let entered!: () => void;
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const held = new Promise<void>((resolve) => {
release = resolve;
});
const callback = vi.fn(async () => {
entered();
await held;
throw new Error('write failed');
});
const result = input.array('object', callback);
await started;
expect(callback).toHaveBeenCalledTimes(1);
expect(pulls).toBe(1);
release();
await expect(result).rejects.toThrow('write failed');
expect(callback).toHaveBeenCalledTimes(1);
expect(pulls).toBe(1);
}
);
it('checks cancellation after input and callback awaits', async () => {
let aborted = false;
const check = () => {
if (aborted) throw new Error('aborted');
};
const interrupted = new ReportStreamReader(
(async function* () {
aborted = true;
yield Buffer.from('{}');
})(),
check
);
await expect(interrupted.json()).rejects.toThrow('aborted');
aborted = false;
const input = new ReportStreamReader(
(async function* () {
yield Buffer.from('[{},{}]');
})(),
check
);
const callback = vi.fn(async () => {
entered();
await held;
throw new Error('write failed');
aborted = true;
});
const result = input.array('object', callback);
await started;
expect(callback).toHaveBeenCalledTimes(1);
release();
await expect(result).rejects.toThrow('write failed');
await expect(input.array('object', callback)).rejects.toThrow('aborted');
expect(callback).toHaveBeenCalledTimes(1);
});
@@ -84,21 +142,21 @@ it('accepts an object larger than the former 4 MiB threshold across chunks', asy
await input.eof();
});
it('accepts a prefix larger than the former 1 MiB threshold and preserves the next frame', async () => {
const prefix = 'x'.repeat(1024 * 1024 + 1);
const input = reader(`${prefix}\nEND\n[{}]`, 64 * 1024);
await expect(input.prefix('\nEND\n', (text) => text)).resolves.toBe(prefix);
const callback = vi.fn(async () => {});
await input.array('object', callback);
expect(callback).toHaveBeenCalledWith('{}');
await input.eof();
it('rejects invalid and incomplete UTF-8 in native JSON and object exports', async () => {
const invalid = Buffer.from([91, 123, 34, 0xff, 34, 58, 49, 125, 93]);
await expect(reader(invalid).json()).rejects.toThrow();
await expect(reader(invalid).array('object', async () => {})).rejects.toThrow();
await expect(reader(Buffer.from([123, 34, 0xe6, 0x9d])).json()).rejects.toThrow();
const input = reader(Buffer.from([123, 125, 0xe6, 0x9d]));
await expect(input.json()).resolves.toBe('{}');
await expect(input.eof()).rejects.toThrow();
});
it('retains fatal UTF-8 and exact EOF contracts for object exports', async () => {
await expect(
reader(Buffer.from([91, 123, 34, 0xff, 34, 58, 49, 125, 93])).array('object', async () => {})
).rejects.toThrow();
const input = reader('[{}]extra');
await input.array('object', async () => {});
await expect(input.eof()).rejects.toThrow('Extra native output');
});
it.each(['extra', '\u00a0'])(
'rejects non-JSON whitespace or extra output at EOF: %s',
async (suffix) => {
const input = reader(`[{}]${suffix}`);
await input.array('object', async () => {});
await expect(input.eof()).rejects.toThrow('Extra native output');
}
);
+54 -69
View File
@@ -1,4 +1,4 @@
// Private incremental stream reader. Only one native row is assembled at a time.
// Private incremental reader for native JSON documents and array rows.
export class ReportStreamReader {
private text = '';
private offset = 0;
@@ -15,76 +15,41 @@ export class ReportStreamReader {
private async more(): Promise<boolean> {
this.check();
if (this.offset < this.text.length) return true;
if (this.ended) return false;
const next = await this.input.next();
this.check();
this.offset = 0;
this.ended = !!next.done;
if (next.done) this.text = this.decoder.decode();
else {
this.text = this.decoder.decode(next.value, { stream: true });
while (this.offset >= this.text.length && !this.ended) {
const next = await this.input.next();
this.check();
this.offset = 0;
this.ended = !!next.done;
this.text = next.done
? this.decoder.decode()
: this.decoder.decode(next.value, { stream: true });
}
return this.text.length > 0 || (!this.ended && (await this.more()));
return this.offset < this.text.length;
}
// Candidates are validated as a whole prefix. A stats path containing a
// marker is not a boundary merely because its bytes match that marker.
async prefix<T>(marker: string, decode: (text: string) => T): Promise<T> {
let prefix = '';
let searched = 0;
private async skipWhitespace(): Promise<boolean> {
while (await this.more()) {
const start = prefix.length;
prefix += this.text.slice(this.offset);
this.offset = this.text.length;
let index = prefix.indexOf(marker, searched);
while (index >= 0) {
const end = index + marker.length;
let result: T;
try {
result = decode(prefix.slice(0, index));
} catch {
index = prefix.indexOf(marker, end);
continue;
}
// Return unconsumed bytes in the current chunk, without retaining prefix.
this.offset = end - start;
return result;
while (this.offset < this.text.length) {
if (!/^[ \t\r\n]$/.test(this.text[this.offset])) return true;
this.offset++;
}
searched = Math.max(0, prefix.length - marker.length + 1);
}
throw new Error('Missing native prefix.');
return false;
}
async eof(): Promise<void> {
while (await this.more()) {
if (!/^[ \t\r\n]$/.test(this.text[this.offset++])) throw new Error('Extra native output.');
}
}
async array(kind: 'array' | 'object', row: (json: string) => Promise<void>): Promise<void> {
const opening = kind === 'array' ? '[' : '{';
let state: 'open' | 'first' | 'next' | 'comma' | 'row' = 'open';
// Native parsers validate the contents; framing preserves their exact text.
async json(): Promise<string> {
if (!(await this.skipWhitespace())) throw new Error('Truncated native JSON.');
if (this.text[this.offset] !== '{' && this.text[this.offset] !== '[')
throw new Error('Invalid native JSON root.');
let depth = 0;
let quoted = false;
let escaped = false;
let parts: string[] = [];
while (await this.more()) {
let start = this.offset;
const parts: string[] = [];
do {
const start = this.offset;
while (this.offset < this.text.length) {
const char = this.text[this.offset++];
if (state !== 'row') {
if (/^[ \t\r\n]$/.test(char)) continue;
if (state === 'open' && char === '[') state = 'first';
else if ((state === 'first' || state === 'comma') && char === ']') return;
else if (state === 'comma' && char === ',') state = 'next';
else if ((state === 'first' || state === 'next') && char === opening) {
state = 'row';
depth = 1;
start = this.offset - 1;
} else throw new Error('Invalid native array.');
continue;
}
if (quoted) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
@@ -93,19 +58,39 @@ export class ReportStreamReader {
else if (char === '[' || char === '{') depth++;
else if (char === ']' || char === '}') depth--;
if (depth === 0) {
const part = this.text.slice(start, this.offset);
parts.push(part);
await row(parts.join(''));
this.check();
parts = [];
state = 'comma';
parts.push(this.text.slice(start, this.offset));
return parts.join('');
}
}
if (state === 'row') {
const part = this.text.slice(start);
parts.push(part);
}
parts.push(this.text.slice(start));
} while (await this.more());
throw new Error('Truncated native JSON.');
}
async eof(): Promise<void> {
if (await this.skipWhitespace()) throw new Error('Extra native output.');
}
async array(kind: 'array' | 'object', row: (json: string) => Promise<void>): Promise<void> {
if (!(await this.skipWhitespace())) throw new Error('Truncated native array.');
if (this.text[this.offset++] !== '[') throw new Error('Invalid native array.');
if (!(await this.skipWhitespace())) throw new Error('Truncated native array.');
if (this.text[this.offset] === ']') {
this.offset++;
return;
}
const opening = kind === 'array' ? '[' : '{';
while (true) {
if (this.text[this.offset] !== opening) throw new Error('Invalid native array row.');
const json = await this.json();
this.check();
await row(json);
this.check();
if (!(await this.skipWhitespace())) throw new Error('Truncated native array.');
const delimiter = this.text[this.offset++];
if (delimiter === ']') return;
if (delimiter !== ',') throw new Error('Invalid native array.');
if (!(await this.skipWhitespace())) throw new Error('Truncated native array.');
}
throw new Error('Truncated native array.');
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
export { decodeNativeTransaction } from './export.js';
export { assertTransactionExportsMatch, prepareTransaction } from './transaction.js';
export type { NativeTransaction } from './transaction.js';
export type { NativeTransactionPair, PreparationTransactions } from './store.js';
export type {
NativeTransactionPair,
NativeTransactionSummary,
PreparationTransactions
} from './store.js';
@@ -3,11 +3,18 @@ export interface NativeTransactionPair {
transactedJson: string;
}
export interface NativeTransactionSummary {
transactionCount: number;
lastDate: string | null;
}
/** Caller-owned storage for one explicit preparation; never a journal writer. */
export interface PreparationTransactions {
insertOriginal(json: string): Promise<void>;
attachTransacted(json: string): Promise<void>;
assertComplete(): Promise<void>;
readSummary(): Promise<NativeTransactionSummary>;
countInPeriod(begin: string, endExclusive: string): Promise<number>;
read(nativeIndex: number): Promise<NativeTransactionPair>;
accountHistory(accountName: string): AsyncIterable<NativeTransactionPair>;
}
+42 -38
View File
@@ -1,18 +1,35 @@
import { journalMetadata } from './src/journal-stats.js';
import { decodeOverviewReports, planOverviewReports } from './src/overview/batch.js';
import { planOverviewReports, type OverviewReports } from './src/overview/batch.js';
import { parseBalanceSheet } from './src/overview/balance-sheet.js';
import { parseIncomeStatement } from './src/overview/income-statement.js';
import { organizeOverviewData } from './src/overview/report.js';
import { planIncomeRanges } from './src/overview/ranges.js';
// Compose only pure production parsers/planners/organizers; never execute native jobs.
export function decodeOverviewFixture(discovery: string, batch: string) {
const metadata = journalMetadata(discovery);
return organizeOverviewData(
decodeOverviewReports(batch, metadata, planOverviewReports(metadata))
);
export function decodeOverviewFixture(
discoveryStats: string,
reports: { incomeStatements: string[]; snapshots: string[] },
transactionCounts: readonly number[] = [1, 3, 6, 12]
) {
const metadata = journalMetadata(discoveryStats);
const plan = planOverviewReports(metadata);
const overview: OverviewReports = {
...metadata,
incomeStatements: plan.incomeRanges.map((range, index) => ({
...range,
transactionCount: transactionCounts[index],
report: parseIncomeStatement(reports.incomeStatements[index])
})),
snapshots: plan.snapshotPeriods.map((period, index) => ({
...period,
report: parseBalanceSheet(reports.snapshots[index])
}))
};
return organizeOverviewData(overview);
}
// Native framing fixtures shared by the dashboard adapter tests only.
export function balanceFrames(asOf = '2026-04-16'): string {
// Owned native JSON documents shared by the dashboard adapter tests only.
export function balanceReports(asOf = '2026-04-16'): string[] {
const date = new Date(`${asOf}T00:00:00Z`);
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() - 11);
@@ -31,36 +48,23 @@ export function balanceFrames(asOf = '2026-04-16'): string {
['Liabilities', { prTotals: { prrAmounts: [[]] } }, false]
]
};
return `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}\n`;
}).join('');
return JSON.stringify(report);
});
}
// Empty finite statements; callers can replace a consumed field without rebuilding framing.
export function incomeFrames(asOf = '2026-04-16'): string {
return planIncomeRanges(asOf)
.map((range) => {
const totals = () => ({ prrAmounts: range.periods.map(() => []), prrTotal: [] });
const report = {
cbrDates: range.periods.map(({ begin, endExclusive }) =>
[begin, endExclusive].map((contents) => ({ tag: 'Exact', contents }))
),
cbrTotals: { prrTotal: [] },
cbrSubreports: [
['Revenues', { prTotals: totals(), prRows: [] }, true],
['Expenses', { prTotals: totals(), prRows: [] }, false]
]
};
return `\n__HLEDGER_INCOME_${range.months}__\n${JSON.stringify(report)}\n`;
})
.join('');
}
// Text stats stay together before every JSON frame. Empty filtered spans are bounded.
export function periodStats(asOf = '2026-04-16', counts = [1, 3, 6, 12]): string {
return planIncomeRanges(asOf)
.map(
(range, index) =>
`Base currency : USD\nLast txn : ${counts[index] === 0 ? 'none' : asOf}\nTxns : ${counts[index]} (0.0 per day)\nTxns span : ${range.begin} to ${range.endExclusive}\n`
)
.join('');
export function incomeReports(asOf = '2026-04-16'): string[] {
return planIncomeRanges(asOf).map((range) => {
const totals = () => ({ prrAmounts: range.periods.map(() => []), prrTotal: [] });
const report = {
cbrDates: range.periods.map(({ begin, endExclusive }) =>
[begin, endExclusive].map((contents) => ({ tag: 'Exact', contents }))
),
cbrTotals: { prrTotal: [] },
cbrSubreports: [
['Revenues', { prTotals: totals(), prRows: [] }, true],
['Expenses', { prTotals: totals(), prRows: [] }, false]
]
};
return JSON.stringify(report);
});
}
@@ -36,6 +36,8 @@ import {
attachTransactedTransaction,
assertTransactionImportComplete,
readNativeTransaction,
readTransactionSummary,
countTransactionsInPeriod,
readAccountTransactionBatch,
type StoredNativeTransaction
} from '@hledger-reports/db/transactions';
@@ -251,6 +253,12 @@ export async function createReportStage(
assertComplete() {
return operation(() => assertTransactionImportComplete(writer));
},
readSummary() {
return operation(() => readTransactionSummary(writer));
},
countInPeriod(begin, endExclusive) {
return operation(() => countTransactionsInPeriod(writer, begin, endExclusive));
},
read(nativeIndex) {
return operation(async () =>
nativePair(await readNativeTransaction(writer, nativeIndex))