From e6ccdc8ce2ff63537c561fa5d8eeb32e2faa1071 Mon Sep 17 00:00:00 2001 From: guoyunfeng Date: Thu, 22 Oct 2026 11:04:49 +0800 Subject: [PATCH] feat(backend): add ledger sequence difference calculator module - Implement backend/src/ledgerSequenceDiff.js to compute non-negative ledger count differences - Validate non-negative integer bounds and clarify which sequence argument is newer - Export constants LEDGER_SEQUENCE_ARGUMENT, LEDGER_SEQUENCE_SIGN, and LEDGER_SEQUENCE_ERROR - Add 9 single-behavior node:test test files covering directional checks, equality, boundary, BigInt safety, options objects, constants, and invalid inputs - Add feature documentation under docs/features/ledgerSequenceDiff.md Closes #373 --- .../src/ledgerSequenceDiff-boundary.test.js | 18 +++ .../src/ledgerSequenceDiff-constants.test.js | 30 ++++ backend/src/ledgerSequenceDiff-equal.test.js | 13 ++ .../ledgerSequenceDiff-first-newer.test.js | 13 ++ ...ledgerSequenceDiff-invalid-missing.test.js | 9 ++ ...edgerSequenceDiff-invalid-negative.test.js | 10 ++ ...erSequenceDiff-invalid-non-integer.test.js | 11 ++ .../ledgerSequenceDiff-options-object.test.js | 13 ++ .../ledgerSequenceDiff-second-newer.test.js | 13 ++ backend/src/ledgerSequenceDiff.js | 152 ++++++++++++++++++ backend/src/ledgerSequenceDiffConstants.js | 27 ++++ docs/features/ledgerSequenceDiff.md | 66 ++++++++ 12 files changed, 375 insertions(+) create mode 100644 backend/src/ledgerSequenceDiff-boundary.test.js create mode 100644 backend/src/ledgerSequenceDiff-constants.test.js create mode 100644 backend/src/ledgerSequenceDiff-equal.test.js create mode 100644 backend/src/ledgerSequenceDiff-first-newer.test.js create mode 100644 backend/src/ledgerSequenceDiff-invalid-missing.test.js create mode 100644 backend/src/ledgerSequenceDiff-invalid-negative.test.js create mode 100644 backend/src/ledgerSequenceDiff-invalid-non-integer.test.js create mode 100644 backend/src/ledgerSequenceDiff-options-object.test.js create mode 100644 backend/src/ledgerSequenceDiff-second-newer.test.js create mode 100644 backend/src/ledgerSequenceDiff.js create mode 100644 backend/src/ledgerSequenceDiffConstants.js create mode 100644 docs/features/ledgerSequenceDiff.md diff --git a/backend/src/ledgerSequenceDiff-boundary.test.js b/backend/src/ledgerSequenceDiff-boundary.test.js new file mode 100644 index 0000000..c5adb63 --- /dev/null +++ b/backend/src/ledgerSequenceDiff-boundary.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("handles boundary difference of exactly one ledger", () => { + const result = calculateLedgerDiff(1001, 1000); + assert.equal(result.difference, 1); + assert.equal(result.differenceBigInt, 1n); + assert.equal(result.newer, "first"); +}); + +test("handles very large ledger sequence BigInt inputs safely", () => { + const bigA = 9_007_199_254_740_991n; // Number.MAX_SAFE_INTEGER + const bigB = 9_007_199_254_740_900n; + const result = calculateLedgerDiff(bigA, bigB); + assert.equal(result.differenceBigInt, 91n); + assert.equal(result.difference, 91); +}); diff --git a/backend/src/ledgerSequenceDiff-constants.test.js b/backend/src/ledgerSequenceDiff-constants.test.js new file mode 100644 index 0000000..9d8b9db --- /dev/null +++ b/backend/src/ledgerSequenceDiff-constants.test.js @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + LEDGER_SEQUENCE_ARGUMENT, + LEDGER_SEQUENCE_ERROR, + LEDGER_SEQUENCE_SIGN, +} from "./ledgerSequenceDiffConstants.js"; + +test("LEDGER_SEQUENCE_ARGUMENT defines frozen position indicators", () => { + assert.equal(LEDGER_SEQUENCE_ARGUMENT.FIRST, "first"); + assert.equal(LEDGER_SEQUENCE_ARGUMENT.SECOND, "second"); + assert.equal(LEDGER_SEQUENCE_ARGUMENT.NEITHER, null); + assert.equal(Object.isFrozen(LEDGER_SEQUENCE_ARGUMENT), true); +}); + +test("LEDGER_SEQUENCE_SIGN defines frozen comparison signs", () => { + assert.equal(LEDGER_SEQUENCE_SIGN.FIRST_NEWER, 1); + assert.equal(LEDGER_SEQUENCE_SIGN.SECOND_NEWER, -1); + assert.equal(LEDGER_SEQUENCE_SIGN.EQUAL, 0); + assert.equal(Object.isFrozen(LEDGER_SEQUENCE_SIGN), true); +}); + +test("LEDGER_SEQUENCE_ERROR defines frozen error message fragments", () => { + assert.equal(LEDGER_SEQUENCE_ERROR.REQUIRED, "is required"); + assert.equal(LEDGER_SEQUENCE_ERROR.EMPTY, "cannot be empty"); + assert.equal(LEDGER_SEQUENCE_ERROR.NEGATIVE, "cannot be negative"); + assert.equal(LEDGER_SEQUENCE_ERROR.INTEGER, "must be an integer"); + assert.equal(Object.isFrozen(LEDGER_SEQUENCE_ERROR), true); +}); diff --git a/backend/src/ledgerSequenceDiff-equal.test.js b/backend/src/ledgerSequenceDiff-equal.test.js new file mode 100644 index 0000000..727697e --- /dev/null +++ b/backend/src/ledgerSequenceDiff-equal.test.js @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("handles identical sequence numbers with zero difference and null newer/older", () => { + const result = calculateLedgerDiff(5000, 5000); + assert.equal(result.difference, 0); + assert.equal(result.differenceBigInt, 0n); + assert.equal(result.newer, null); + assert.equal(result.older, null); + assert.equal(result.sign, 0); + assert.equal(result.isEqual, true); +}); diff --git a/backend/src/ledgerSequenceDiff-first-newer.test.js b/backend/src/ledgerSequenceDiff-first-newer.test.js new file mode 100644 index 0000000..c88076b --- /dev/null +++ b/backend/src/ledgerSequenceDiff-first-newer.test.js @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("identifies when first sequence is newer and calculates positive difference", () => { + const result = calculateLedgerDiff(1050, 1000); + assert.equal(result.difference, 50); + assert.equal(result.differenceBigInt, 50n); + assert.equal(result.newer, "first"); + assert.equal(result.older, "second"); + assert.equal(result.sign, 1); + assert.equal(result.isEqual, false); +}); diff --git a/backend/src/ledgerSequenceDiff-invalid-missing.test.js b/backend/src/ledgerSequenceDiff-invalid-missing.test.js new file mode 100644 index 0000000..c13a5c2 --- /dev/null +++ b/backend/src/ledgerSequenceDiff-invalid-missing.test.js @@ -0,0 +1,9 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("rejects missing, undefined, or null sequence inputs with TypeError", () => { + assert.throws(() => calculateLedgerDiff(undefined, 100), TypeError); + assert.throws(() => calculateLedgerDiff(100, null), TypeError); + assert.throws(() => calculateLedgerDiff(), TypeError); +}); diff --git a/backend/src/ledgerSequenceDiff-invalid-negative.test.js b/backend/src/ledgerSequenceDiff-invalid-negative.test.js new file mode 100644 index 0000000..770d14c --- /dev/null +++ b/backend/src/ledgerSequenceDiff-invalid-negative.test.js @@ -0,0 +1,10 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("rejects negative integer sequence numbers with RangeError", () => { + assert.throws(() => calculateLedgerDiff(-1, 100), RangeError); + assert.throws(() => calculateLedgerDiff(100, -5), RangeError); + assert.throws(() => calculateLedgerDiff("-50", "100"), RangeError); + assert.throws(() => calculateLedgerDiff(10n, -10n), RangeError); +}); diff --git a/backend/src/ledgerSequenceDiff-invalid-non-integer.test.js b/backend/src/ledgerSequenceDiff-invalid-non-integer.test.js new file mode 100644 index 0000000..881ec15 --- /dev/null +++ b/backend/src/ledgerSequenceDiff-invalid-non-integer.test.js @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("rejects fractional or non-numeric sequence values with TypeError", () => { + assert.throws(() => calculateLedgerDiff(12.34, 10), TypeError); + assert.throws(() => calculateLedgerDiff(10, 5.67), TypeError); + assert.throws(() => calculateLedgerDiff("invalid", 10), TypeError); + assert.throws(() => calculateLedgerDiff(NaN, 10), TypeError); + assert.throws(() => calculateLedgerDiff(Infinity, 10), TypeError); +}); diff --git a/backend/src/ledgerSequenceDiff-options-object.test.js b/backend/src/ledgerSequenceDiff-options-object.test.js new file mode 100644 index 0000000..b2f2ada --- /dev/null +++ b/backend/src/ledgerSequenceDiff-options-object.test.js @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff, ledgerSequenceDiff } from "./ledgerSequenceDiff.js"; + +test("accepts options object with first and second properties", () => { + const result = calculateLedgerDiff({ first: 200, second: 150 }); + assert.equal(result.difference, 50); + assert.equal(result.newer, "first"); + + const resultAlias = ledgerSequenceDiff({ current: "300", previous: "200" }); + assert.equal(resultAlias.difference, 100); + assert.equal(resultAlias.newer, "first"); +}); diff --git a/backend/src/ledgerSequenceDiff-second-newer.test.js b/backend/src/ledgerSequenceDiff-second-newer.test.js new file mode 100644 index 0000000..8ba0fe6 --- /dev/null +++ b/backend/src/ledgerSequenceDiff-second-newer.test.js @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateLedgerDiff } from "./ledgerSequenceDiff.js"; + +test("identifies when second sequence is newer and calculates non-negative difference", () => { + const result = calculateLedgerDiff(1000, 1050); + assert.equal(result.difference, 50); + assert.equal(result.differenceBigInt, 50n); + assert.equal(result.newer, "second"); + assert.equal(result.older, "first"); + assert.equal(result.sign, -1); + assert.equal(result.isEqual, false); +}); diff --git a/backend/src/ledgerSequenceDiff.js b/backend/src/ledgerSequenceDiff.js new file mode 100644 index 0000000..a9804e4 --- /dev/null +++ b/backend/src/ledgerSequenceDiff.js @@ -0,0 +1,152 @@ +/** + * Ledger sequence difference calculator. + * + * Computes the non-negative ledger count difference between two ledger sequence numbers, + * rejecting non-integer or negative inputs and clarifying which argument is newer. + */ + +import { + LEDGER_SEQUENCE_ARGUMENT, + LEDGER_SEQUENCE_ERROR, + LEDGER_SEQUENCE_SIGN, +} from "./ledgerSequenceDiffConstants.js"; + +/** + * Coerce an input sequence number into a non-negative BigInt. + * + * @param {unknown} value + * @param {string} label + * @returns {bigint} + * @throws {TypeError} When value is not an integer, is empty, or has an invalid format + * @throws {RangeError} When value is negative + */ +export function parseLedgerSequence(value, label = "ledger sequence") { + if (value === undefined || value === null) { + throw new TypeError(`${label} ${LEDGER_SEQUENCE_ERROR.REQUIRED}`); + } + + if (typeof value === "bigint") { + if (value < 0n) { + throw new RangeError(`${label} ${LEDGER_SEQUENCE_ERROR.NEGATIVE}`); + } + return value; + } + + if (typeof value === "number") { + if (!Number.isFinite(value) || Number.isNaN(value)) { + throw new TypeError(`${label} ${LEDGER_SEQUENCE_ERROR.VALID_NUMBER}`); + } + if (!Number.isInteger(value)) { + throw new TypeError(`${label} ${LEDGER_SEQUENCE_ERROR.INTEGER}`); + } + if (value < 0) { + throw new RangeError(`${label} ${LEDGER_SEQUENCE_ERROR.NEGATIVE}`); + } + return BigInt(value); + } + + const text = String(value).trim(); + if (!text) { + throw new TypeError(`${label} ${LEDGER_SEQUENCE_ERROR.EMPTY}`); + } + + if (text.startsWith("-")) { + throw new RangeError(`${label} ${LEDGER_SEQUENCE_ERROR.NEGATIVE}`); + } + + if (!/^\d+$/.test(text)) { + throw new TypeError(`Invalid ${label} integer format: "${text}"`); + } + + return BigInt(text); +} + +/** + * Calculate the non-negative ledger count difference between two ledger sequence numbers. + * + * Supports both positional parameters: + * `calculateLedgerDiff(first, second)` + * and options object: + * `calculateLedgerDiff({ first, second })` + * + * @param {number|bigint|string|object} firstInput + * @param {number|bigint|string} [secondInput] + * @returns {{ + * difference: number, + * differenceBigInt: bigint, + * newer: "first" | "second" | null, + * older: "first" | "second" | null, + * sign: 1 | -1 | 0, + * isEqual: boolean + * }} + * @throws {TypeError} When inputs are missing, non-integer, or invalid + * @throws {RangeError} When inputs are negative + */ +export function calculateLedgerDiff(firstInput, secondInput) { + let firstRaw; + let secondRaw; + + if ( + typeof firstInput === "object" && + firstInput !== null && + typeof firstInput !== "bigint" + ) { + firstRaw = firstInput.first ?? firstInput.seqA ?? firstInput.current ?? firstInput.a; + secondRaw = firstInput.second ?? firstInput.seqB ?? firstInput.previous ?? firstInput.b; + } else { + firstRaw = firstInput; + secondRaw = secondInput; + } + + const firstSeq = parseLedgerSequence(firstRaw, "first sequence"); + const secondSeq = parseLedgerSequence(secondRaw, "second sequence"); + + let diffBig; + let newer; + let older; + let sign; + + if (firstSeq > secondSeq) { + diffBig = firstSeq - secondSeq; + newer = LEDGER_SEQUENCE_ARGUMENT.FIRST; + older = LEDGER_SEQUENCE_ARGUMENT.SECOND; + sign = LEDGER_SEQUENCE_SIGN.FIRST_NEWER; + } else if (secondSeq > firstSeq) { + diffBig = secondSeq - firstSeq; + newer = LEDGER_SEQUENCE_ARGUMENT.SECOND; + older = LEDGER_SEQUENCE_ARGUMENT.FIRST; + sign = LEDGER_SEQUENCE_SIGN.SECOND_NEWER; + } else { + diffBig = 0n; + newer = LEDGER_SEQUENCE_ARGUMENT.NEITHER; + older = LEDGER_SEQUENCE_ARGUMENT.NEITHER; + sign = LEDGER_SEQUENCE_SIGN.EQUAL; + } + + const diffNumber = + diffBig <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(diffBig) + : Number.MAX_SAFE_INTEGER; + + return { + difference: diffNumber, + differenceBigInt: diffBig, + newer, + older, + sign, + isEqual: sign === 0, + }; +} + +/** + * Alias matching the module name. + */ +export const ledgerSequenceDiff = calculateLedgerDiff; + +export { + LEDGER_SEQUENCE_ARGUMENT, + LEDGER_SEQUENCE_ERROR, + LEDGER_SEQUENCE_SIGN, +}; + +export default calculateLedgerDiff; diff --git a/backend/src/ledgerSequenceDiffConstants.js b/backend/src/ledgerSequenceDiffConstants.js new file mode 100644 index 0000000..8bbba72 --- /dev/null +++ b/backend/src/ledgerSequenceDiffConstants.js @@ -0,0 +1,27 @@ +/** + * Constants and error messages for ledger sequence difference calculations. + */ + +/** Argument positional labels indicating which argument is newer/older. */ +export const LEDGER_SEQUENCE_ARGUMENT = Object.freeze({ + FIRST: "first", + SECOND: "second", + NEITHER: null, +}); + +/** Numeric sign indicator for relative sequence ordering. */ +export const LEDGER_SEQUENCE_SIGN = Object.freeze({ + FIRST_NEWER: 1, + SECOND_NEWER: -1, + EQUAL: 0, +}); + +/** Stable error message fragments for input validations. */ +export const LEDGER_SEQUENCE_ERROR = Object.freeze({ + REQUIRED: "is required", + EMPTY: "cannot be empty", + NEGATIVE: "cannot be negative", + VALID_NUMBER: "must be a valid number", + INTEGER: "must be an integer", + INVALID_FORMAT: "Invalid integer format", +}); diff --git a/docs/features/ledgerSequenceDiff.md b/docs/features/ledgerSequenceDiff.md new file mode 100644 index 0000000..23cad21 --- /dev/null +++ b/docs/features/ledgerSequenceDiff.md @@ -0,0 +1,66 @@ +# Ledger sequence difference calculator + +`calculateLedgerDiff` (also exported as `ledgerSequenceDiff`) computes the non-negative ledger count difference between two ledger sequence numbers, validating non-negative integer bounds and clarifying which argument is newer. + +## Overview + +In Stellar and Soroban node operations, indexers and monitoring daemons frequently need to compare ledger sequences to evaluate synchronization lag, checkpoint advancement, or replay spans. + +`calculateLedgerDiff` calculates the absolute difference between any two ledger sequences while maintaining clear directional context (`newer`, `older`, and `sign`). + +## Usage + +```js +import { + calculateLedgerDiff, + ledgerSequenceDiff, + LEDGER_SEQUENCE_ARGUMENT, + LEDGER_SEQUENCE_SIGN, +} from "./ledgerSequenceDiff.js"; + +// First argument is newer: +const res1 = calculateLedgerDiff(1050, 1000); +console.log(res1.difference); // 50 +console.log(res1.newer); // "first" +console.log(res1.older); // "second" +console.log(res1.sign); // 1 + +// Second argument is newer: +const res2 = calculateLedgerDiff(1000, 1050); +console.log(res2.difference); // 50 +console.log(res2.newer); // "second" +console.log(res2.older); // "first" +console.log(res2.sign); // -1 + +// Options object invocation: +const res3 = ledgerSequenceDiff({ current: 2000, previous: 1950 }); +console.log(res3.difference); // 50 +``` + +## API + +### `calculateLedgerDiff(firstInput, [secondInput])` + +- **`firstInput`**: First sequence (number, bigint, string) or options object `{ first, second }`. +- **`secondInput`**: Second sequence (number, bigint, string). +- **Returns**: + ```js + { + difference: number, // Non-negative integer difference + differenceBigInt: bigint, // BigInt difference + newer: "first" | "second" | null, + older: "first" | "second" | null, + sign: 1 | -1 | 0, + isEqual: boolean + } + ``` +- **Throws**: + - `TypeError` if either sequence is missing, not an integer, or non-numeric. + - `RangeError` if either sequence is negative. + +### Constants + +`backend/src/ledgerSequenceDiffConstants.js` exports: +- `LEDGER_SEQUENCE_ARGUMENT`: `{ FIRST: "first", SECOND: "second", NEITHER: null }` +- `LEDGER_SEQUENCE_SIGN`: `{ FIRST_NEWER: 1, SECOND_NEWER: -1, EQUAL: 0 }` +- `LEDGER_SEQUENCE_ERROR`: Standard error fragments.