Skip to content
Closed
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
120 changes: 1 addition & 119 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,41 @@ export class SimpleCache<T> {
return entry.value;
}

/**
* Check whether a key exists and has not expired.
*
* @param key - Cache key to check
* @returns `true` if the key exists and is unexpired, `false` otherwise
*/
has(key: string): boolean {
if (!this.enabled) return false;
const entry = this.store.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return false;
}
return true;
}

/**
* Remove all expired entries in a single sweep.
*
* @returns The number of entries removed
*/
purgeExpired(): number {
if (!this.enabled) return 0;
const now = Date.now();
let removed = 0;
for (const [key, entry] of this.store.entries()) {
if (now > entry.expiresAt) {
this.store.delete(key);
removed++;
}
}
return removed;
}

set(key: string, value: T): void {
if (!this.enabled) return;
const method = key.split(":")[0] || key;
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,7 @@ export type {
// Split ratio validator
// ---------------------------------------------------------------------------

export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients } from "./validators/splitRatioValidator.js";
export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients, validateSplitTotal, normalizeSplits } from "./validators/splitRatioValidator.js";
export type {
RecipientShare,
SplitConfig,
Expand Down Expand Up @@ -1366,3 +1366,10 @@ export type {
SubmitTransactionOptions,
SubmitServer,
} from "./transaction/submit.js";

// ---------------------------------------------------------------------------
// #614 — Memo-content search
// ---------------------------------------------------------------------------

export { searchByMemo } from "./search.js";
export type { SearchQuery, SearchResult } from "./search.js";
28 changes: 27 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Horizon } from "@stellar/stellar-sdk";
import type { InvoiceStatus } from "./types.js";
import type { Invoice, InvoiceStatus } from "./types.js";
import { SearchFailedError } from "./errors.js";

/** Query parameters for searching invoices. */
Expand Down Expand Up @@ -45,4 +45,30 @@ export async function searchInvoices(
} catch (error) {
throw new SearchFailedError(error instanceof Error ? error.message : String(error));
}
}

/**
* Search invoices by memo content substring.
*
* @param invoices - Array of invoices to search through
* @param query - Search query string
* @param opts - Optional search options
* @param opts.caseSensitive - If true, enables exact-case matching (default: false)
* @returns Filtered array of invoices whose memo contains the query
*/
export function searchByMemo(
invoices: Invoice[],
query: string,
opts?: { caseSensitive?: boolean },
): Invoice[] {
if (!query) return invoices;

const isCaseSensitive = opts?.caseSensitive ?? false;
const searchQuery = isCaseSensitive ? query : query.toLowerCase();

return invoices.filter((invoice) => {
if (invoice.memo === undefined || invoice.memo === null) return false;
const memo = isCaseSensitive ? invoice.memo : invoice.memo.toLowerCase();
return memo.includes(searchQuery);
});
}
52 changes: 51 additions & 1 deletion src/validators/splitRatioValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* actionable error objects.
*/

import { ValidationError } from "../errors.js";
import { StellarSplitError, ValidationError } from "../errors.js";
import type { Recipient } from "../types.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -178,3 +178,53 @@ export function ratiosToRecipients(
amount: amounts[i]!,
}));
}

/**
* Validate that the sum of an array of bigint splits equals the expected total.
*
* @param splits - Array of bigint split amounts
* @param totalBasisPoints - Expected total in basis points (default: 10000n = 100%)
* @throws StellarSplitError with code INVALID_RECIPIENT if sum !== total
*/
export function validateSplitTotal(
splits: bigint[],
totalBasisPoints?: bigint,
): void {
const total = totalBasisPoints ?? 10000n;

if (splits.length === 0) {
throw new StellarSplitError(
"splits must sum to 10000 basis points",
"INVALID_RECIPIENT",
);
}

const sum = splits.reduce((acc, s) => acc + s, 0n);
if (sum !== total) {
throw new StellarSplitError(
"splits must sum to 10000 basis points",
"INVALID_RECIPIENT",
);
}
}

/**
* Normalize an array of bigint amounts so they sum to exactly the given total.
* Distributes the rounding remainder to the last element.
*
* @param amounts - Array of bigint amounts to normalize
* @param total - The target total sum
* @returns A new array with the remainder distributed to the last element
*/
export function normalizeSplits(
amounts: bigint[],
total: bigint,
): bigint[] {
const sum = amounts.reduce((acc, s) => acc + s, 0n);
if (sum === total) return [...amounts];

const remainder = total - sum;
const result = [...amounts];
result[result.length - 1] = (result[result.length - 1] ?? 0n) + remainder;
return result;
}
58 changes: 58 additions & 0 deletions test/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,61 @@ describe("SimpleCache LRU", () => {
expect(stats.evictions).toBe(0);
});
});

describe("SimpleCache has()", () => {
it("returns true when key exists and is not expired", () => {
const cache = new SimpleCache<string>({ ttlMs: 10000 });
cache.set("key", "value");
expect(cache.has("key")).toBe(true);
});

it("returns false when key does not exist", () => {
const cache = new SimpleCache<string>({ ttlMs: 10000 });
expect(cache.has("nonexistent")).toBe(false);
});

it("returns false for expired key", () => {
const cache = new SimpleCache<string>({ ttlMs: 1 });
cache.set("key", "value");
// Wait for TTL to expire
const start = Date.now();
while (Date.now() - start < 5) {} // busy-wait ~5ms
expect(cache.has("key")).toBe(false);
});

it("returns false when cache is disabled", () => {
const cache = new SimpleCache<string>();
cache.set("key", "value");
expect(cache.has("key")).toBe(false);
});
});

describe("SimpleCache purgeExpired()", () => {
it("removes only expired entries", () => {
const cache = new SimpleCache<string>({ ttlMs: 10000 });
cache.set("fresh", "value");
cache.set("stale", "value");
// Manually set the stale entry to expired
const store = (cache as any).store as Map<string, any>;
const staleEntry = store.get("stale");
if (staleEntry) {
staleEntry.expiresAt = Date.now() - 1;
}
const removed = cache.purgeExpired();
expect(removed).toBe(1);
expect(cache.has("fresh")).toBe(true);
expect(cache.has("stale")).toBe(false);
});

it("returns 0 when nothing is expired", () => {
const cache = new SimpleCache<string>({ ttlMs: 10000 });
cache.set("a", "1");
cache.set("b", "2");
expect(cache.purgeExpired()).toBe(0);
});

it("returns 0 when cache is disabled", () => {
const cache = new SimpleCache<string>();
expect(cache.purgeExpired()).toBe(0);
});
});
Loading