Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions backend/src/ledgerSequenceDiff-boundary.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
30 changes: 30 additions & 0 deletions backend/src/ledgerSequenceDiff-constants.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 13 additions & 0 deletions backend/src/ledgerSequenceDiff-equal.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 13 additions & 0 deletions backend/src/ledgerSequenceDiff-first-newer.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
9 changes: 9 additions & 0 deletions backend/src/ledgerSequenceDiff-invalid-missing.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
10 changes: 10 additions & 0 deletions backend/src/ledgerSequenceDiff-invalid-negative.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
11 changes: 11 additions & 0 deletions backend/src/ledgerSequenceDiff-invalid-non-integer.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 13 additions & 0 deletions backend/src/ledgerSequenceDiff-options-object.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
13 changes: 13 additions & 0 deletions backend/src/ledgerSequenceDiff-second-newer.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
152 changes: 152 additions & 0 deletions backend/src/ledgerSequenceDiff.js
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions backend/src/ledgerSequenceDiffConstants.js
Original file line number Diff line number Diff line change
@@ -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",
});
66 changes: 66 additions & 0 deletions docs/features/ledgerSequenceDiff.md
Original file line number Diff line number Diff line change
@@ -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.