@crewhaus/tool-ledger 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +243 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +888 -0
- package/dist/lib/amount.d.ts +73 -0
- package/dist/lib/amount.js +204 -0
- package/dist/lib/db.d.ts +171 -0
- package/dist/lib/db.js +326 -0
- package/dist/lib/invoice.d.ts +222 -0
- package/dist/lib/invoice.js +500 -0
- package/dist/lib/post.d.ts +129 -0
- package/dist/lib/post.js +440 -0
- package/dist/lib/query.d.ts +62 -0
- package/dist/lib/query.js +480 -0
- package/dist/lib/reconcile.d.ts +117 -0
- package/dist/lib/reconcile.js +335 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +48 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
import { decimalKernel } from "@crewhaus/tool-math";
|
|
3
|
+
/** Every refusal this package makes, so a caller can tell one from a crash. */
|
|
4
|
+
export declare class LedgerError extends CrewhausError {
|
|
5
|
+
readonly name = "LedgerError";
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare const ROUNDING_MODES: readonly ["halfUp", "halfEven", "halfDown", "ceiling", "floor", "up", "down"];
|
|
9
|
+
export type RoundingMode = decimalKernel.RoundingMode;
|
|
10
|
+
/**
|
|
11
|
+
* Half-even everywhere rounding is unavoidable. It is the mode that does not
|
|
12
|
+
* drift upward over a long series of conversions, which is the whole of what a
|
|
13
|
+
* ledger is.
|
|
14
|
+
*/
|
|
15
|
+
export declare const LEDGER_ROUNDING: RoundingMode;
|
|
16
|
+
/** The minor-unit exponent for a currency, refusing a code nobody knows. */
|
|
17
|
+
export declare function exponentFor(currency: string, override?: number): number;
|
|
18
|
+
/** Minor units rendered as a major-unit decimal string: 1050 @2 -> "10.50". */
|
|
19
|
+
export declare function formatMajor(minor: bigint, exponent: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* A major-unit decimal string ("100.00", "-3.5") as minor units.
|
|
22
|
+
*
|
|
23
|
+
* A fraction longer than the currency's exponent is REFUSED. Rounding it here
|
|
24
|
+
* would mean the entry posts an amount the source document does not contain,
|
|
25
|
+
* and the difference is invisible until a reconciliation a year later.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseMajor(text: string, exponent: number, label: string): bigint;
|
|
28
|
+
/** An integer count of minor units, from a string or a safe integer. */
|
|
29
|
+
export declare function parseMinorUnits(value: string | number, label: string): bigint;
|
|
30
|
+
/** A decimal factor — an fx rate, a quantity — parsed exactly. */
|
|
31
|
+
export declare function parseFactor(text: string, label: string): decimalKernel.Decimal;
|
|
32
|
+
/** Multiply minor units by an exact decimal and round once, at the end. */
|
|
33
|
+
export declare function multiplyMinor(minor: bigint, factor: decimalKernel.Decimal, mode?: RoundingMode): bigint;
|
|
34
|
+
/** Apply an fx rate, honouring both currencies' exponents, rounding once. */
|
|
35
|
+
export declare function convertMinor(minor: bigint, rate: decimalKernel.Decimal, fromExponent: number, toExponent: number, mode?: RoundingMode): bigint;
|
|
36
|
+
/**
|
|
37
|
+
* A calendar date, validated against the calendar.
|
|
38
|
+
*
|
|
39
|
+
* `Date.parse` accepts "2026-02-30" and rolls it to 2 March, which puts an
|
|
40
|
+
* entry in the wrong month without anybody typing a wrong month. The round
|
|
41
|
+
* trip through the constructed date is what catches it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertIsoDate(text: string, label: string): string;
|
|
44
|
+
/** Whole days since the epoch, in UTC. Dates are compared and differenced here. */
|
|
45
|
+
export declare function epochDay(isoDate: string): number;
|
|
46
|
+
/** Signed day difference, `a - b`. */
|
|
47
|
+
export declare function dayDiff(a: string, b: string): number;
|
|
48
|
+
/** The year an invoice sequence resets on comes from the DOCUMENT, not the clock. */
|
|
49
|
+
export declare function yearOf(isoDate: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* An ISO-8601 instant that carries a UTC offset.
|
|
52
|
+
*
|
|
53
|
+
* Matches `@crewhaus/tool-money`'s rule: an offset-less string means local
|
|
54
|
+
* time per ECMAScript, so the same posting would carry a different instant on
|
|
55
|
+
* two machines — and the instant is inside the hash chain.
|
|
56
|
+
*/
|
|
57
|
+
export declare function assertInstant(text: string, label: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* JSON with every object key in codepoint order and no incidental whitespace.
|
|
60
|
+
*
|
|
61
|
+
* This is what the hash chain and every idempotency fingerprint are taken
|
|
62
|
+
* over, so it has to be stable against the one thing JSON is not stable
|
|
63
|
+
* against: insertion order. A bigint serializes as its decimal string, and a
|
|
64
|
+
* non-integer number is refused outright — a float in the bytes that identify
|
|
65
|
+
* a payment is the defect this whole package exists to avoid.
|
|
66
|
+
*/
|
|
67
|
+
export declare function canonicalJson(value: unknown): string;
|
|
68
|
+
/** Locale-independent string order. */
|
|
69
|
+
export declare function byString(a: string, b: string): number;
|
|
70
|
+
/** SHA-256 hex of a string. The chain link and every fingerprint use this. */
|
|
71
|
+
export declare function sha256Hex(text: string): string;
|
|
72
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
73
|
+
export declare const json: (value: unknown) => string;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amounts, dates and the canonical bytes everything else is hashed over.
|
|
3
|
+
*
|
|
4
|
+
* Three rules hold everywhere below this line, and each of them is a bug that
|
|
5
|
+
* has shipped in somebody's books:
|
|
6
|
+
*
|
|
7
|
+
* 1. **No float ever touches an amount.** Every amount is a bigint count of
|
|
8
|
+
* minor units. The arithmetic and the ISO 4217 exponent table come from
|
|
9
|
+
* `@crewhaus/tool-math`'s money kernel rather than from a second copy here
|
|
10
|
+
* — a JPY exponent guessed at 2 is a 100x error, and there is exactly one
|
|
11
|
+
* table in this repo that knows it is 0.
|
|
12
|
+
* 2. **A scale the currency cannot hold is refused, not rounded.** "1.005" as
|
|
13
|
+
* USD is not $1.01; it is a caller who has not decided. A ledger that
|
|
14
|
+
* rounds an input silently is a ledger whose total disagrees with the
|
|
15
|
+
* document it was posted from.
|
|
16
|
+
* 3. **Totals accumulate in bigint, not in a JS number.** A single amount fits
|
|
17
|
+
* a safe integer — that is why `@crewhaus/tool-money` types one as
|
|
18
|
+
* `number` and this package matches it at the tool boundary — but a year of
|
|
19
|
+
* them summed does not, and SQLite's own `SUM()` silently promotes a
|
|
20
|
+
* 64-bit overflow to a float. So amounts are STORED as text and summed
|
|
21
|
+
* here.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
25
|
+
import { decimalKernel, moneyKernel } from "@crewhaus/tool-math";
|
|
26
|
+
/** Every refusal this package makes, so a caller can tell one from a crash. */
|
|
27
|
+
export class LedgerError extends CrewhausError {
|
|
28
|
+
name = "LedgerError";
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super("tool", message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const ROUNDING_MODES = decimalKernel.ROUNDING_MODES;
|
|
34
|
+
/**
|
|
35
|
+
* Half-even everywhere rounding is unavoidable. It is the mode that does not
|
|
36
|
+
* drift upward over a long series of conversions, which is the whole of what a
|
|
37
|
+
* ledger is.
|
|
38
|
+
*/
|
|
39
|
+
export const LEDGER_ROUNDING = "halfEven";
|
|
40
|
+
/** The minor-unit exponent for a currency, refusing a code nobody knows. */
|
|
41
|
+
export function exponentFor(currency, override) {
|
|
42
|
+
try {
|
|
43
|
+
return moneyKernel.minorUnitExponent(currency, override);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new LedgerError(err instanceof Error ? err.message : String(err));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Minor units rendered as a major-unit decimal string: 1050 @2 -> "10.50". */
|
|
50
|
+
export function formatMajor(minor, exponent) {
|
|
51
|
+
return moneyKernel.formatMinor(minor, exponent);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A major-unit decimal string ("100.00", "-3.5") as minor units.
|
|
55
|
+
*
|
|
56
|
+
* A fraction longer than the currency's exponent is REFUSED. Rounding it here
|
|
57
|
+
* would mean the entry posts an amount the source document does not contain,
|
|
58
|
+
* and the difference is invisible until a reconciliation a year later.
|
|
59
|
+
*/
|
|
60
|
+
export function parseMajor(text, exponent, label) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = decimalKernel.parseDecimal(text);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
throw new LedgerError(`${label} ("${text}") is not a decimal number: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
if (parsed.scale > exponent) {
|
|
69
|
+
throw new LedgerError(`${label} ("${text}") has ${parsed.scale} decimal places but this currency has ${exponent} — refusing to round an amount that was written down, decide the rounding before posting it`);
|
|
70
|
+
}
|
|
71
|
+
return parsed.unscaled * decimalKernel.pow10(exponent - parsed.scale);
|
|
72
|
+
}
|
|
73
|
+
/** An integer count of minor units, from a string or a safe integer. */
|
|
74
|
+
export function parseMinorUnits(value, label) {
|
|
75
|
+
try {
|
|
76
|
+
return moneyKernel.toMinor(value, label);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new LedgerError(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** A decimal factor — an fx rate, a quantity — parsed exactly. */
|
|
83
|
+
export function parseFactor(text, label) {
|
|
84
|
+
try {
|
|
85
|
+
return decimalKernel.parseDecimal(text);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
throw new LedgerError(`${label} ("${text}") is not a decimal number: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Multiply minor units by an exact decimal and round once, at the end. */
|
|
92
|
+
export function multiplyMinor(minor, factor, mode = LEDGER_ROUNDING) {
|
|
93
|
+
return moneyKernel.moneyMultiply(minor, factor, mode).rounded;
|
|
94
|
+
}
|
|
95
|
+
/** Apply an fx rate, honouring both currencies' exponents, rounding once. */
|
|
96
|
+
export function convertMinor(minor, rate, fromExponent, toExponent, mode = LEDGER_ROUNDING) {
|
|
97
|
+
return moneyKernel.convertMinor(minor, rate, fromExponent, toExponent, mode);
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// dates
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
103
|
+
/**
|
|
104
|
+
* A calendar date, validated against the calendar.
|
|
105
|
+
*
|
|
106
|
+
* `Date.parse` accepts "2026-02-30" and rolls it to 2 March, which puts an
|
|
107
|
+
* entry in the wrong month without anybody typing a wrong month. The round
|
|
108
|
+
* trip through the constructed date is what catches it.
|
|
109
|
+
*/
|
|
110
|
+
export function assertIsoDate(text, label) {
|
|
111
|
+
const m = ISO_DATE.exec(text);
|
|
112
|
+
if (!m) {
|
|
113
|
+
throw new LedgerError(`${label} ("${text}") must be an ISO calendar date, YYYY-MM-DD`);
|
|
114
|
+
}
|
|
115
|
+
const [, y, mo, d] = m;
|
|
116
|
+
const at = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
|
|
117
|
+
if (at.getUTCFullYear() !== Number(y) ||
|
|
118
|
+
at.getUTCMonth() + 1 !== Number(mo) ||
|
|
119
|
+
at.getUTCDate() !== Number(d)) {
|
|
120
|
+
throw new LedgerError(`${label} ("${text}") is not a date that exists`);
|
|
121
|
+
}
|
|
122
|
+
return text;
|
|
123
|
+
}
|
|
124
|
+
/** Whole days since the epoch, in UTC. Dates are compared and differenced here. */
|
|
125
|
+
export function epochDay(isoDate) {
|
|
126
|
+
const m = ISO_DATE.exec(isoDate);
|
|
127
|
+
if (!m)
|
|
128
|
+
throw new LedgerError(`"${isoDate}" is not an ISO calendar date`);
|
|
129
|
+
const [, y, mo, d] = m;
|
|
130
|
+
return Math.floor(Date.UTC(Number(y), Number(mo) - 1, Number(d)) / 86_400_000);
|
|
131
|
+
}
|
|
132
|
+
/** Signed day difference, `a - b`. */
|
|
133
|
+
export function dayDiff(a, b) {
|
|
134
|
+
return epochDay(a) - epochDay(b);
|
|
135
|
+
}
|
|
136
|
+
/** The year an invoice sequence resets on comes from the DOCUMENT, not the clock. */
|
|
137
|
+
export function yearOf(isoDate) {
|
|
138
|
+
return isoDate.slice(0, 4);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* An ISO-8601 instant that carries a UTC offset.
|
|
142
|
+
*
|
|
143
|
+
* Matches `@crewhaus/tool-money`'s rule: an offset-less string means local
|
|
144
|
+
* time per ECMAScript, so the same posting would carry a different instant on
|
|
145
|
+
* two machines — and the instant is inside the hash chain.
|
|
146
|
+
*/
|
|
147
|
+
export function assertInstant(text, label) {
|
|
148
|
+
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(text.trim())) {
|
|
149
|
+
throw new LedgerError(`${label} ("${text}") has no UTC offset — write it as e.g. 2026-01-01T00:00:00Z, because an offset-less string means local time and would hash differently on two machines`);
|
|
150
|
+
}
|
|
151
|
+
const parsed = Date.parse(text);
|
|
152
|
+
if (Number.isNaN(parsed))
|
|
153
|
+
throw new LedgerError(`${label} ("${text}") is not a valid instant`);
|
|
154
|
+
return new Date(parsed).toISOString();
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// canonical bytes
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* JSON with every object key in codepoint order and no incidental whitespace.
|
|
161
|
+
*
|
|
162
|
+
* This is what the hash chain and every idempotency fingerprint are taken
|
|
163
|
+
* over, so it has to be stable against the one thing JSON is not stable
|
|
164
|
+
* against: insertion order. A bigint serializes as its decimal string, and a
|
|
165
|
+
* non-integer number is refused outright — a float in the bytes that identify
|
|
166
|
+
* a payment is the defect this whole package exists to avoid.
|
|
167
|
+
*/
|
|
168
|
+
export function canonicalJson(value) {
|
|
169
|
+
return JSON.stringify(canonicalize(value));
|
|
170
|
+
}
|
|
171
|
+
function canonicalize(value) {
|
|
172
|
+
if (typeof value === "bigint")
|
|
173
|
+
return value.toString();
|
|
174
|
+
if (typeof value === "number") {
|
|
175
|
+
if (!Number.isFinite(value))
|
|
176
|
+
throw new LedgerError("a non-finite number cannot be canonicalized");
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
if (value === null || typeof value !== "object")
|
|
180
|
+
return value;
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return value.map(canonicalize);
|
|
183
|
+
const source = value;
|
|
184
|
+
const out = {};
|
|
185
|
+
// Codepoint order, never `localeCompare` — a locale-aware sort is not the
|
|
186
|
+
// same order on two machines, and the hash would move with it.
|
|
187
|
+
for (const key of Object.keys(source).sort(byString)) {
|
|
188
|
+
const entry = source[key];
|
|
189
|
+
if (entry === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
out[key] = canonicalize(entry);
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** Locale-independent string order. */
|
|
196
|
+
export function byString(a, b) {
|
|
197
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
198
|
+
}
|
|
199
|
+
/** SHA-256 hex of a string. The chain link and every fingerprint use this. */
|
|
200
|
+
export function sha256Hex(text) {
|
|
201
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
202
|
+
}
|
|
203
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
204
|
+
export const json = (value) => JSON.stringify(value);
|
package/dist/lib/db.d.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store: one `bun:sqlite` file, opened the way `@crewhaus/durable-state`
|
|
3
|
+
* opens one, with the whole of a posting inside a single `BEGIN IMMEDIATE`.
|
|
4
|
+
*
|
|
5
|
+
* THE INVARIANT THIS FILE EXISTS FOR
|
|
6
|
+
*
|
|
7
|
+
* A posting is three writes that are only correct together: the rows, the hash
|
|
8
|
+
* chain's new head, and the idempotency claim that says this batch has been
|
|
9
|
+
* seen. Commit them separately and there is a window in which the process can
|
|
10
|
+
* die leaving a ledger whose chain verifies perfectly and whose duplicate
|
|
11
|
+
* suppression has forgotten the entry — so the retry posts the same payment a
|
|
12
|
+
* second time, and every check anybody runs says the books are fine. Invoice
|
|
13
|
+
* numbering has the same shape from the other side: allocate the number, die
|
|
14
|
+
* before the document is recorded, and the sequence has a gap, which in most
|
|
15
|
+
* jurisdictions is not untidiness but a finding.
|
|
16
|
+
*
|
|
17
|
+
* So every mutation below happens inside ONE `tx.immediate(...)`. `IMMEDIATE`
|
|
18
|
+
* takes the write lock at BEGIN rather than at the first write, which is what
|
|
19
|
+
* makes the read half (is this key claimed? what is the chain head? what is the
|
|
20
|
+
* next invoice number?) serialize against another writer instead of racing it;
|
|
21
|
+
* `busy_timeout` is what makes the loser wait rather than fail instantly. Bun's
|
|
22
|
+
* `db.transaction()` rolls the whole thing back on a throw, so a failure
|
|
23
|
+
* anywhere in the batch leaves the file exactly as it was — no rows, no claim,
|
|
24
|
+
* no advanced head, no burnt invoice number.
|
|
25
|
+
*
|
|
26
|
+
* Amounts are stored as TEXT holding a bigint's decimal digits. SQLite would
|
|
27
|
+
* take them as INTEGER, but `bun:sqlite` hands an INTEGER column back as a JS
|
|
28
|
+
* number, and `SUM()` over one silently becomes a float once it overflows —
|
|
29
|
+
* both of which lose exactly the pennies this package is for.
|
|
30
|
+
*/
|
|
31
|
+
import { Database } from "bun:sqlite";
|
|
32
|
+
/** Bumped when the on-disk shape changes. A mismatch is refused, never migrated silently. */
|
|
33
|
+
export declare const SCHEMA_VERSION = 1;
|
|
34
|
+
/** The `prev_hash` of the first entry. Nothing precedes it. */
|
|
35
|
+
export declare const GENESIS_HASH: string;
|
|
36
|
+
/** How long a writer waits for another writer's lock before giving up. */
|
|
37
|
+
export declare const DEFAULT_BUSY_TIMEOUT_MS = 5000;
|
|
38
|
+
/** The five account types, and the side each one increases on. */
|
|
39
|
+
export declare const ACCOUNT_TYPES: readonly ["asset", "liability", "equity", "income", "expense"];
|
|
40
|
+
export type AccountType = (typeof ACCOUNT_TYPES)[number];
|
|
41
|
+
/**
|
|
42
|
+
* Which side an account's balance is normally on.
|
|
43
|
+
*
|
|
44
|
+
* This lives with the chart of accounts, never in a query. A P&L that decides
|
|
45
|
+
* the sign of "revenue" by looking at the account's name is a report that is
|
|
46
|
+
* wrong the first time somebody calls an account "Revenue refunds".
|
|
47
|
+
*/
|
|
48
|
+
export declare const NORMAL_BALANCE: Readonly<Record<AccountType, "debit" | "credit">>;
|
|
49
|
+
export type AccountRow = {
|
|
50
|
+
readonly code: string;
|
|
51
|
+
readonly name: string;
|
|
52
|
+
readonly type: AccountType;
|
|
53
|
+
};
|
|
54
|
+
export type EntryRow = {
|
|
55
|
+
readonly seq: number;
|
|
56
|
+
readonly entry_id: string;
|
|
57
|
+
readonly date: string;
|
|
58
|
+
readonly memo: string;
|
|
59
|
+
readonly reference: string;
|
|
60
|
+
readonly tags: string;
|
|
61
|
+
readonly source_system: string | null;
|
|
62
|
+
readonly source_id: string | null;
|
|
63
|
+
readonly base_currency: string;
|
|
64
|
+
readonly posted_at: string;
|
|
65
|
+
readonly prev_hash: string;
|
|
66
|
+
readonly hash: string;
|
|
67
|
+
};
|
|
68
|
+
export type LineRow = {
|
|
69
|
+
readonly entry_seq: number;
|
|
70
|
+
readonly idx: number;
|
|
71
|
+
readonly account: string;
|
|
72
|
+
readonly debit_minor: string;
|
|
73
|
+
readonly credit_minor: string;
|
|
74
|
+
readonly currency: string;
|
|
75
|
+
readonly exponent: number;
|
|
76
|
+
readonly fx_rate: string | null;
|
|
77
|
+
readonly base_debit_minor: string;
|
|
78
|
+
readonly base_credit_minor: string;
|
|
79
|
+
readonly counterparty: string | null;
|
|
80
|
+
readonly due_date: string | null;
|
|
81
|
+
readonly line_memo: string | null;
|
|
82
|
+
};
|
|
83
|
+
export type DocumentRow = {
|
|
84
|
+
readonly number: string;
|
|
85
|
+
readonly kind: string;
|
|
86
|
+
readonly sequence_name: string;
|
|
87
|
+
readonly ordinal: number;
|
|
88
|
+
readonly issue_date: string;
|
|
89
|
+
readonly idempotency_key: string;
|
|
90
|
+
readonly payload_hash: string;
|
|
91
|
+
readonly files: string;
|
|
92
|
+
readonly created_at: string;
|
|
93
|
+
};
|
|
94
|
+
export type OpenMode = "read" | "write";
|
|
95
|
+
/**
|
|
96
|
+
* Open the ledger file.
|
|
97
|
+
*
|
|
98
|
+
* `read` opens with SQLite's own read-only flag rather than a promise to
|
|
99
|
+
* behave: `LedgerQuery` and `LedgerReconcile` are declared read-only tools,
|
|
100
|
+
* and a declaration the database does not enforce is a comment. It also means
|
|
101
|
+
* a query against a ledger that does not exist is a refusal naming the path,
|
|
102
|
+
* not an empty ledger conjured into existence by the act of reading it.
|
|
103
|
+
*/
|
|
104
|
+
export declare function openDb(realPath: string, mode: OpenMode, busyTimeoutMs: number,
|
|
105
|
+
/**
|
|
106
|
+
* What to call the file in a message. Every other refusal in this package
|
|
107
|
+
* names the workspace-relative path, and this one used to name the absolute
|
|
108
|
+
* one — putting the machine's home directory into a result that goes
|
|
109
|
+
* straight into a model's context. Defaults to the real path for the
|
|
110
|
+
* in-memory and direct-library callers, which have no relative form.
|
|
111
|
+
*/
|
|
112
|
+
label?: string): Database;
|
|
113
|
+
export declare function readMeta(db: Database, key: string): string | null;
|
|
114
|
+
export declare function writeMeta(db: Database, key: string, value: string): void;
|
|
115
|
+
/** The chain's tip: the last entry's hash and how many entries it covers. */
|
|
116
|
+
export type ChainHead = {
|
|
117
|
+
readonly hash: string;
|
|
118
|
+
readonly length: number;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* The head is stored, not derived.
|
|
122
|
+
*
|
|
123
|
+
* A prefix of a valid hash chain is itself a valid hash chain, so recomputing
|
|
124
|
+
* the head from the rows cannot tell a complete ledger from one somebody
|
|
125
|
+
* truncated. The stored head can: `length` disagreeing with the row count is
|
|
126
|
+
* the only evidence that entries were removed.
|
|
127
|
+
*/
|
|
128
|
+
export declare function readHead(db: Database): ChainHead;
|
|
129
|
+
/** The bytes one entry's hash is taken over. Order is fixed by canonicalJson. */
|
|
130
|
+
export declare function entryHash(prevHash: string, payload: unknown): string;
|
|
131
|
+
export type ChainVerification = {
|
|
132
|
+
readonly ok: boolean;
|
|
133
|
+
readonly entries: number;
|
|
134
|
+
readonly head: string;
|
|
135
|
+
readonly storedHead: string;
|
|
136
|
+
readonly storedLength: number;
|
|
137
|
+
readonly problems: ReadonlyArray<{
|
|
138
|
+
readonly seq: number | null;
|
|
139
|
+
readonly reason: string;
|
|
140
|
+
}>;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Walk the chain from the genesis hash and report every break.
|
|
144
|
+
*
|
|
145
|
+
* Reports ALL of them rather than the first: "the chain broke at entry 4" and
|
|
146
|
+
* "the chain broke at entries 4, 900 and 901" are different findings, and the
|
|
147
|
+
* second one is the one that means somebody edited the file.
|
|
148
|
+
*/
|
|
149
|
+
export declare function verifyChain(db: Database, payloadOf: (entry: EntryRow, lines: ReadonlyArray<LineRow>) => unknown): ChainVerification;
|
|
150
|
+
/**
|
|
151
|
+
* A rollback asked for on purpose.
|
|
152
|
+
*
|
|
153
|
+
* `dryRun` runs the real posting — every validation, every insert, the chain
|
|
154
|
+
* advance, the claim — and then throws this to undo it. Running a separate
|
|
155
|
+
* "check only" path instead is how a dry run comes back clean and the real
|
|
156
|
+
* call fails: the two paths drift, and the one nobody exercises is the one
|
|
157
|
+
* that matters.
|
|
158
|
+
*/
|
|
159
|
+
export declare class DryRunRollback extends Error {
|
|
160
|
+
readonly payload: unknown;
|
|
161
|
+
constructor(payload: unknown);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run `work` inside one `BEGIN IMMEDIATE`, and turn SQLite's lock failure into
|
|
165
|
+
* a sentence that names the cause.
|
|
166
|
+
*
|
|
167
|
+
* The distinction matters for a caller deciding whether to retry: "another
|
|
168
|
+
* writer holds the ledger" is worth retrying and "debits do not equal credits"
|
|
169
|
+
* never is, and a bare `SQLITE_BUSY` stack trace tells a model neither.
|
|
170
|
+
*/
|
|
171
|
+
export declare function inImmediateTransaction<T>(db: Database, work: () => T): T;
|