refactor(api): tidy the api and shared packages (#2733)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-13 17:37:48 +02:00
1 parent 580401d2dc
commit 5afbf67a70
333 files changed
+10180 -7910

No files matched your search

+26 -15
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ms} from 'itty-time';
import {requireIntegerInRange} from './IntegerOptions';
import {getValidTimestamp} from './TimestampUtils';
export interface AttachmentDecayRules {
minMb: number;
@@ -47,6 +49,11 @@ function toMb(sizeBytes: bigint | number): number {
return n / 1024 / 1024;
}
function logRatio(value: number, base: number): number {
const ratio = value / base;
return Number.isFinite(ratio) ? Math.log(ratio) : Math.log(value) - Math.log(base);
}
export function computeDecay({sizeBytes, uploadedAt, rules}: AttachmentDecayInput): AttachmentDecayResult | null {
const constants = {
minMb: rules?.minMb ?? DEFAULT_DECAY_CONSTANTS.MIN_MB,
@@ -66,18 +73,16 @@ export function computeDecay({sizeBytes, uploadedAt, rules}: AttachmentDecayInpu
lifetimeDays = constants.minDays;
} else {
const linearFrac = (sizeMB - constants.minMb) / (constants.maxMb - constants.minMb);
const logFrac = Math.log(sizeMB / constants.minMb) / Math.log(constants.maxMb / constants.minMb);
const logFrac = logRatio(sizeMB, constants.minMb) / logRatio(constants.maxMb, constants.minMb);
const blend = (1 - constants.curve) * linearFrac + constants.curve * logFrac;
lifetimeDays = constants.maxDays - blend * (constants.maxDays - constants.minDays);
}
const expiresAt = new Date(uploadedAt);
const expiresAt = new Date(getValidTimestamp(uploadedAt, 'Attachment upload date'));
expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays);
const sizeTB = (typeof sizeBytes === 'bigint' ? Number(sizeBytes) : sizeBytes) / 1024 / 1024 / 1024 / 1024;
const lifetimeMonths = lifetimeDays / 30;
const cost = sizeTB * constants.pricePerTBPerMonth * lifetimeMonths;
getValidTimestamp(expiresAt, 'Attachment decay expiry');
return {
expiresAt,
cost,
cost: computeCost({sizeBytes, lifetimeDays, pricePerTBPerMonth: constants.pricePerTBPerMonth}),
days: Math.round(lifetimeDays),
};
}
@@ -99,14 +104,17 @@ export function computeCost({
}
export function getExpiryBucket(expiresAt: Date): number {
return Number(
getValidTimestamp(expiresAt, 'Attachment expiry');
const bucket = Number(
`${expiresAt.getUTCFullYear()}${String(expiresAt.getUTCMonth() + 1).padStart(2, '0')}${String(expiresAt.getUTCDate()).padStart(2, '0')}`,
);
return requireIntegerInRange('Attachment expiry bucket', bucket, -2_147_483_648, 2_147_483_647);
}
export function extendExpiry(currentExpiry: Date | null, newlyComputed: Date): Date {
const newTimestamp = getValidTimestamp(newlyComputed, 'Computed attachment expiry');
if (!currentExpiry) return newlyComputed;
return currentExpiry > newlyComputed ? currentExpiry : newlyComputed;
return getValidTimestamp(currentExpiry, 'Current attachment expiry') > newTimestamp ? currentExpiry : newlyComputed;
}
export function maybeRenewExpiry({
@@ -124,16 +132,19 @@ export function maybeRenewExpiry({
}): Date | null {
if (!currentExpiry) return null;
if (windowDays <= 0) return null;
const remainingMs = currentExpiry.getTime() - now.getTime();
const currentTimestamp = getValidTimestamp(currentExpiry, 'Current attachment expiry');
const nowTimestamp = getValidTimestamp(now, 'Attachment renewal time');
const remainingMs = currentTimestamp - nowTimestamp;
if (remainingMs > thresholdDays * MS_PER_DAY) {
return null;
}
const targetMs = now.getTime() + windowDays * MS_PER_DAY;
const cappedTargetMs = maxExpiry ? Math.min(maxExpiry.getTime(), targetMs) : targetMs;
if (cappedTargetMs <= currentExpiry.getTime()) {
const targetMs = nowTimestamp + windowDays * MS_PER_DAY;
const cappedTargetMs = maxExpiry
? Math.min(getValidTimestamp(maxExpiry, 'Maximum attachment expiry'), targetMs)
: targetMs;
if (cappedTargetMs <= currentTimestamp) {
return null;
}
const target = new Date(now);
target.setTime(cappedTargetMs);
return target;
const target = new Date(cappedTargetMs);
return getValidTimestamp(target, 'Renewed attachment expiry') > currentTimestamp ? target : null;
}
+37 -3
View File
@@ -2,6 +2,35 @@
import assert from 'node:assert/strict';
export async function awaitAll(tasks: ReadonlyArray<Promise<unknown>>, failureMessage: string): Promise<void> {
const results = await Promise.allSettled(tasks);
const errors: Array<unknown> = [];
for (const result of results) {
if (result.status === 'rejected') errors.push(result.reason);
}
throwCompletionErrors(errors, failureMessage);
}
export async function runAllInOrder(
steps: ReadonlyArray<() => Promise<unknown>>,
failureMessage: string,
): Promise<void> {
const errors: Array<unknown> = [];
for (const step of steps) {
try {
await step();
} catch (error) {
errors.push(error);
}
}
throwCompletionErrors(errors, failureMessage);
}
function throwCompletionErrors(errors: ReadonlyArray<unknown>, failureMessage: string): void {
if (errors.length === 1) throw errors[0];
if (errors.length > 1) throw new AggregateError(errors, failureMessage);
}
export async function mapWithConcurrency<T, TResult>(
items: ReadonlyArray<T>,
concurrency: number,
@@ -10,13 +39,18 @@ export async function mapWithConcurrency<T, TResult>(
assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Concurrency must be a positive safe integer');
const results = new Array<TResult>(items.length);
let nextIndex = 0;
let failure: {error: unknown} | undefined;
async function worker(): Promise<void> {
for (;;) {
while (!failure && nextIndex < items.length) {
const index = nextIndex++;
if (index >= items.length) return;
results[index] = await mapper(items[index]!, index);
try {
results[index] = await mapper(items[index]!, index);
} catch (error) {
failure ??= {error};
}
}
}
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
if (failure) throw failure.error;
return results;
}
+21 -5
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import {DEFAULT_MAX_REDIRECTS, normalizeMaxRedirects} from '@pkgs/http_client/src/HttpClientRequestInternals';
import type {
HttpClient,
RequestOptions,
@@ -9,13 +11,14 @@ import type {
StreamResponse,
} from '@pkgs/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
import {Logger} from '../Logger';
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
const client: HttpClient = createHttpClient({
userAgent: 'fluxer-api',
requestUrlPolicy,
});
const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
const scopedClients = new WeakMap<RequestUrlPolicy, Map<number, HttpClient>>();
interface SendRequestOptions {
maxRedirects?: number;
@@ -24,8 +27,8 @@ interface SendRequestOptions {
function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
const policy = options?.requestUrlPolicy ?? requestUrlPolicy;
const maxRedirects = options?.maxRedirects ?? 0;
if (policy === requestUrlPolicy && maxRedirects === 0) {
const maxRedirects = normalizeMaxRedirects(options?.maxRedirects);
if (policy === requestUrlPolicy && maxRedirects === DEFAULT_MAX_REDIRECTS) {
return client;
}
let clientsForPolicy = scopedClients.get(policy);
@@ -39,7 +42,7 @@ function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
}
const scopedClient = createHttpClient({
userAgent: 'fluxer-api',
...(maxRedirects > 0 ? {maxRedirects} : {}),
maxRedirects,
requestUrlPolicy: policy,
});
clientsForPolicy.set(maxRedirects, scopedClient);
@@ -51,6 +54,12 @@ export async function sendRequest(opts: RequestOptions, options?: SendRequestOpt
return requestClient.sendRequest(opts);
}
export function discardResponseBody(stream: ResponseStream, status: number): void {
void stream?.cancel().catch(() => {
Logger.warn({status}, 'Failed to cancel discarded HTTP response body');
});
}
export class ResponseBodyTooLargeError extends Error {
constructor(
message: string,
@@ -87,7 +96,7 @@ function createResponseBodyTooLargeError(
actualBytes: number | null,
): ResponseBodyTooLargeError {
const description = options.description ?? 'Response body';
const source = options.url ? ` from ${options.url}` : '';
const source = options.url ? ` from ${formatUrlForDiagnostics(options.url)}` : '';
const actual = actualBytes == null ? 'unknown size' : `${actualBytes} bytes`;
return new ResponseBodyTooLargeError(
`${description}${source} exceeds the ${options.maxBytes}-byte limit (${actual})`,
@@ -100,6 +109,13 @@ export async function streamToBufferWithLimit(
stream: ResponseStream,
options: ResponseBodyReadOptions,
): Promise<Uint8Array> {
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
const error = new RangeError('maxBytes must be a nonnegative safe integer');
void stream?.cancel(error).catch(() => {
Logger.warn('Failed to cancel HTTP response body after invalid maxBytes');
});
throw error;
}
if (!stream) {
return new Uint8Array(0);
}
@@ -0,0 +1,16 @@
export function readOptionalIntegerEnv(name: string): number | undefined {
const raw = process.env[name]?.trim();
if (raw === undefined || raw === '') return undefined;
const value = Number(raw);
if (!/^-?\d+$/.test(raw) || !Number.isSafeInteger(value)) {
throw new Error(`${name} must be a safe integer`);
}
return value;
}
export function requireIntegerInRange(name: string, value: number, min: number, max: number): number {
if (!Number.isSafeInteger(value) || value < min || value > max) {
throw new RangeError(`${name} must be an integer between ${min} and ${max}`);
}
return value;
}
+4 -1
View File
@@ -44,13 +44,14 @@ export async function getIpAddressReverse(
if (cached !== null) return cached === '' ? null : cached;
}
let result: string | null = null;
let timeout: NodeJS.Timeout | undefined;
try {
const reversePromise: Promise<Array<string>> = dns.promises.reverse(ip);
let hostnames: Array<string>;
if (options.timeoutMs !== undefined) {
const timeoutMs = options.timeoutMs;
const timeoutPromise = new Promise<Array<string>>((_, reject) => {
setTimeout(() => reject(new Error(`reverse DNS timeout after ${timeoutMs}ms`)), timeoutMs);
timeout = setTimeout(() => reject(new Error(`reverse DNS timeout after ${timeoutMs}ms`)), timeoutMs);
});
hostnames = await Promise.race([reversePromise, timeoutPromise]);
} else {
@@ -59,6 +60,8 @@ export async function getIpAddressReverse(
result = hostnames[0] ?? null;
} catch {
result = null;
} finally {
clearTimeout(timeout);
}
if (cacheService) {
await cacheService.set(cacheKey, result ?? '', options.cacheTtlSeconds ?? REVERSE_DNS_CACHE_TTL_SECONDS);
+2 -12
View File
@@ -12,21 +12,11 @@ export function parseJsonUnknown(text: string): unknown {
}
export function parseJsonRecord(text: string): JsonRecord | null {
try {
const parsed = parseJsonUnknown(text);
return isJsonRecord(parsed) ? parsed : null;
} catch {
return null;
}
return parseJsonWithGuard(text, isJsonRecord);
}
export function parseJsonArray(text: string): Array<unknown> | null {
try {
const parsed = parseJsonUnknown(text);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
return parseJsonWithGuard(text, Array.isArray);
}
export function parseJsonWithGuard<T>(text: string, guard: (value: unknown) => value is T): T | null {
+9 -11
View File
@@ -7,19 +7,17 @@ function isDigit(char: string): boolean {
return char >= '0' && char <= '9';
}
function isValidJsonIntegerToken(token: string): boolean {
if (!/^-?\d+$/.test(token)) return false;
if (token === '0' || token === '-0') return true;
function isUnsafeIntegerToken(token: string): boolean {
if (!/^-?(?:0|[1-9]\d*)$/.test(token)) return false;
const digits = token[0] === '-' ? token.slice(1) : token;
return digits.length > 0 && digits[0] !== '0';
return digits.length === MAX_SAFE_INTEGER_DECIMAL.length
? digits > MAX_SAFE_INTEGER_DECIMAL
: digits.length > MAX_SAFE_INTEGER_DECIMAL.length;
}
function isUnsafeIntegerToken(token: string): boolean {
const digits = token[0] === '-' ? token.slice(1) : token;
if (digits === '0') return false;
if (digits.length < MAX_SAFE_INTEGER_DECIMAL.length) return false;
if (digits.length > MAX_SAFE_INTEGER_DECIMAL.length) return true;
return digits > MAX_SAFE_INTEGER_DECIMAL;
function isFollowedByColon(jsonText: string, index: number): boolean {
while (index < jsonText.length && ' \t\r\n'.includes(jsonText[index]!)) index++;
return jsonText[index] === ':';
}
export function coerceUnsafeIntegersToStrings(jsonText: string): string {
@@ -58,7 +56,7 @@ export function coerceUnsafeIntegersToStrings(jsonText: string): string {
break;
}
const token = jsonText.slice(start, i);
if (isValidJsonIntegerToken(token) && isUnsafeIntegerToken(token)) {
if (isUnsafeIntegerToken(token) && !isFollowedByColon(jsonText, i)) {
if (!outputParts) {
outputParts = [];
}
+11 -22
View File
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
const RANDOM_STRING_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const RANDOM_DIGIT_ALPHABET = '0123456789';
export function randomNumericCode(length: number): string {
const alphabetLength = RANDOM_DIGIT_ALPHABET.length;
function randomFromAlphabet(length: number, alphabet: string): string {
assert(Number.isSafeInteger(length) && length >= 0, 'Random string length must be a nonnegative safe integer');
const alphabetLength = alphabet.length;
const rangeSize = 256 - (256 % alphabetLength);
const randomBytes = new Uint8Array(length * 2);
crypto.getRandomValues(randomBytes);
@@ -19,28 +21,15 @@ export function randomNumericCode(length: number): string {
}
const randomByte = randomBytes[byteIndex++]!;
if (randomByte >= rangeSize) continue;
result += RANDOM_DIGIT_ALPHABET.charAt(randomByte % alphabetLength);
result += alphabet.charAt(randomByte % alphabetLength);
}
return result;
}
export function randomString(length: number) {
const alphabetLength = RANDOM_STRING_ALPHABET.length;
const rangeSize = 256 - (256 % alphabetLength);
const randomBytes = new Uint8Array(length * 2);
crypto.getRandomValues(randomBytes);
let result = '';
let byteIndex = 0;
while (result.length < length) {
if (byteIndex >= randomBytes.length) {
crypto.getRandomValues(randomBytes);
byteIndex = 0;
}
const randomByte = randomBytes[byteIndex++];
if (randomByte >= rangeSize) {
continue;
}
result += RANDOM_STRING_ALPHABET.charAt(randomByte % alphabetLength);
}
return result;
export function randomNumericCode(length: number): string {
return randomFromAlphabet(length, RANDOM_DIGIT_ALPHABET);
}
export function randomString(length: number): string {
return randomFromAlphabet(length, RANDOM_STRING_ALPHABET);
}
@@ -0,0 +1,14 @@
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {RateLimitResult} from '@pkgs/rate_limit/src/IRateLimitService';
export function getRetryAfterSeconds(result: RateLimitResult): number {
return result.retryAfter ?? Math.max(0, Math.ceil((result.resetTime.getTime() - Date.now()) / 1000));
}
export function createRateLimitError(result: RateLimitResult): RateLimitError {
return new RateLimitError({
retryAfter: getRetryAfterSeconds(result),
limit: result.limit,
resetTime: result.resetTime,
});
}
@@ -0,0 +1,196 @@
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
type RefreshTrigger = 'notification' | 'periodic';
interface RefreshSubscriptionOptions {
name: string;
channels: ReadonlyArray<string>;
refresh: () => Promise<void>;
onRefreshError: (error: unknown, trigger: RefreshTrigger) => void;
shouldRefresh?: (channel: string, message: string) => boolean;
drainInitialNotifications?: boolean;
periodicIntervalMs?: () => number | null;
closeMode?: 'disconnect' | 'quit';
}
export class RefreshSubscriptionUnavailableError extends Error {
constructor(name: string, cause: unknown) {
super(`Failed to subscribe ${name}`, {cause});
this.name = 'RefreshSubscriptionUnavailableError';
}
}
export class RefreshSubscription {
private session: RefreshSubscriptionSession | null = null;
constructor(private readonly options: RefreshSubscriptionOptions) {}
get isActive(): boolean {
return this.session?.isActive ?? false;
}
get isReady(): boolean {
return this.session?.isReady ?? false;
}
async start(provider: IKVProvider | null): Promise<void> {
const session = (this.session ??= new RefreshSubscriptionSession(this.options, provider));
try {
await session.start();
} catch (error) {
try {
await session.stop();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], `Failed to initialize and close ${this.options.name}`);
} finally {
if (this.session === session) this.session = null;
}
if (error instanceof RefreshSubscriptionUnavailableError) throw error;
throw new Error(`Failed to initialize ${this.options.name}`, {cause: error});
}
}
async stop(): Promise<void> {
const session = this.session;
if (!session) return;
try {
await session.stop();
} finally {
if (this.session === session) this.session = null;
}
}
}
class RefreshSubscriptionSession {
private readonly subscription: IKVSubscription | null;
private phase: 'starting' | 'running' | 'stopping' | 'stopped' = 'starting';
private startCompletion: Promise<void> | null = null;
private stopCompletion: Promise<void> | null = null;
private refreshTask: Promise<void> | null = null;
private pendingRefresh: RefreshTrigger | null = null;
private timer: NodeJS.Timeout | null = null;
private readonly messageHandler = (channel: string, message: string): void => {
if (!this.options.channels.includes(channel)) return;
if (this.options.shouldRefresh && !this.options.shouldRefresh(channel, message)) return;
this.requestRefresh('notification');
};
constructor(
private readonly options: RefreshSubscriptionOptions,
provider: IKVProvider | null,
) {
try {
this.subscription = provider?.duplicate() ?? null;
} catch (error) {
throw new RefreshSubscriptionUnavailableError(options.name, error);
}
}
get isReady(): boolean {
return this.phase === 'running';
}
get isActive(): boolean {
return this.phase === 'starting' || this.phase === 'running';
}
start(): Promise<void> {
this.assertActive();
this.startCompletion ??= Promise.resolve().then(() => this.initialize());
return this.startCompletion;
}
private async initialize(): Promise<void> {
this.assertActive();
if (this.subscription) {
this.subscription.on('message', this.messageHandler);
try {
await this.subscription.connect();
this.assertActive();
for (const channel of this.options.channels) {
await this.subscription.subscribe(channel);
this.assertActive();
}
} catch (error) {
this.assertActive();
throw new RefreshSubscriptionUnavailableError(this.options.name, error);
}
}
await this.options.refresh();
this.assertActive();
while (this.options.drainInitialNotifications && this.pendingRefresh !== null) {
this.pendingRefresh = null;
await this.options.refresh();
this.assertActive();
}
this.phase = 'running';
const intervalMs = this.options.periodicIntervalMs?.();
if (intervalMs !== undefined && intervalMs !== null) {
this.timer = setInterval(() => this.requestRefresh('periodic'), intervalMs);
this.timer.unref();
}
this.startPendingRefresh();
}
private assertActive(): void {
if (!this.isActive) {
throw new Error(`${this.options.name} is stopping or stopped`);
}
}
private requestRefresh(trigger: RefreshTrigger): void {
if (this.phase === 'stopping' || this.phase === 'stopped') return;
if (this.pendingRefresh !== 'notification') this.pendingRefresh = trigger;
this.startPendingRefresh();
}
private startPendingRefresh(): void {
if (!this.isReady || this.refreshTask || this.pendingRefresh === null) return;
this.refreshTask = Promise.resolve()
.then(() => this.refreshPending())
.finally(() => {
this.refreshTask = null;
this.startPendingRefresh();
});
}
private async refreshPending(): Promise<void> {
while (this.isReady && this.pendingRefresh !== null) {
const trigger = this.pendingRefresh;
this.pendingRefresh = null;
try {
await this.options.refresh();
} catch (error) {
this.options.onRefreshError(error, trigger);
}
}
}
stop(): Promise<void> {
if (this.stopCompletion) return this.stopCompletion;
const wasStarting = this.phase === 'starting';
this.phase = 'stopping';
this.pendingRefresh = null;
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.subscription?.off('message', this.messageHandler);
this.stopCompletion = this.close(wasStarting);
return this.stopCompletion;
}
private async close(wasStarting: boolean): Promise<void> {
try {
const [closed] = await Promise.allSettled([
Promise.resolve().then(() => {
if (wasStarting || this.options.closeMode !== 'quit') return this.subscription?.disconnect();
return this.subscription?.quit();
}),
this.startCompletion,
this.refreshTask,
]);
if (closed.status === 'rejected') throw closed.reason;
} finally {
this.phase = 'stopped';
}
}
}
+4 -20
View File
@@ -1,30 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const VERSION_PATTERN = /^\/v\d+/;
const VERSION_PATTERN = /^\/v\d+(?=\/|$)/;
export function stripApiPrefix(path: string): string {
if (path === '/api') {
return '/';
}
if (path.startsWith('/api/')) {
const afterApi = path.slice(4);
if (VERSION_PATTERN.test(afterApi)) {
const versionMatch = afterApi.match(VERSION_PATTERN);
if (versionMatch) {
const remaining = afterApi.slice(versionMatch[0].length);
return remaining === '' ? '/' : remaining;
}
}
return afterApi;
}
if (VERSION_PATTERN.test(path)) {
const versionMatch = path.match(VERSION_PATTERN);
if (versionMatch) {
const remaining = path.slice(versionMatch[0].length);
return remaining === '' ? '/' : remaining;
}
}
return path;
const unprefixedPath = path.startsWith('/api/') ? path.slice(4) : path;
const versionMatch = unprefixedPath.match(VERSION_PATTERN);
return versionMatch ? unprefixedPath.slice(versionMatch[0].length) || '/' : unprefixedPath;
}
export function normalizeRequestPath(path: string): string {
@@ -0,0 +1,25 @@
const MAX_DATE_TIMESTAMP = 8640000000000000;
export function isValidTimestamp(timestamp: unknown): timestamp is number {
return typeof timestamp === 'number' && Number.isInteger(timestamp) && Math.abs(timestamp) <= MAX_DATE_TIMESTAMP;
}
function validateTimestamp(timestamp: number, fieldName: string): number {
if (!isValidTimestamp(timestamp)) {
throw new RangeError(`${fieldName} must be an integer within the valid Date range`);
}
return timestamp;
}
export function getValidTimestamp(date: Date, fieldName: string): number {
return validateTimestamp(date.getTime(), fieldName);
}
export function parseStoredTimestamp(value: string | null, fieldName: string): number | null {
if (value === null) return null;
const digits = value.startsWith('-') ? value.slice(1) : value;
if (digits.length === 0 || /\D/.test(digits)) {
throw new TypeError(`${fieldName} must be a decimal millisecond timestamp`);
}
return validateTimestamp(Number(value), fieldName);
}
@@ -91,16 +91,20 @@ describe('mapWithConcurrency', () => {
expect(calls).toBe(0);
});
it('propagates a mapper rejection without waiting for another in-flight item', async () => {
it('waits for in-flight work before propagating a mapper rejection', async () => {
const first = Promise.withResolvers<number>();
const second = Promise.withResolvers<number>();
const failure = new Error('Mapper failed');
const completed: Array<unknown> = [];
const secondCompleted = second.promise.then((value) => completed.push(value));
const result = mapWithConcurrency([first.promise, second.promise], 2, (promise) => promise);
const rejected = expect(result).rejects.toBe(failure);
const rejected = result.catch((error) => completed.push(error));
first.reject(failure);
await rejected;
await new Promise<void>((resolve) => setImmediate(resolve));
second.resolve(2);
await second.promise;
await Promise.all([rejected, secondCompleted]);
expect(completed).toEqual([2, failure]);
expect(completed[1]).toBe(failure);
});
it('rejects synchronous mapper failures without starting queued serial work', async () => {