Improve typing, lint & format

This commit is contained in:
Andras Schmelczer 2026-09-27 19:37:41 +01:00
commit 82cd37b61d
15 changed files with 109 additions and 94 deletions

View file

@ -13,6 +13,7 @@ export async function abortable<T>(
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Only reset errors and timeout errors reach this helper. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Only reset errors and timeout errors reach this helper.
reject(signal.reason); reject(signal.reason);
}; };
signal.addEventListener("abort", abort, { once: true }); signal.addEventListener("abort", abort, { once: true });
}), }),
operation() operation()

View file

@ -1,25 +1,19 @@
type PromiseTuple<T extends readonly unknown[]> = readonly [
...{ [K in keyof T]: Promise<T[K]> }
];
type ResolvedTuple<T extends readonly unknown[]> = { type ResolvedTuple<T extends readonly unknown[]> = {
[K in keyof T]: T[K]; -readonly [K in keyof T]: Awaited<T[K]>;
}; };
export const awaitAll = async <T extends readonly unknown[]>( /** Await every operation before returning results or throwing the first error. */
promises: PromiseTuple<T> export async function awaitAll<T extends readonly unknown[] | []>(
): Promise<ResolvedTuple<T>> => { promises: T
): Promise<ResolvedTuple<T>> {
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
const result = await Promise.allSettled(promises); const result = await Promise.allSettled(promises);
for (const res of result) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Array.map preserves tuple order and length.
return result.map((res) => {
if (res.status === "rejected") { if (res.status === "rejected") {
throw res.reason; throw res.reason;
} }
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return res.value;
return result.map( }) as ResolvedTuple<T>;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion }
(res) => (res as PromiseFulfilledResult<unknown>).value
) as ResolvedTuple<T>;
};

View file

@ -66,8 +66,10 @@ describe("EventListeners", () => {
const listeners = new EventListeners<() => void>(); const listeners = new EventListeners<() => void>();
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const listener1 = (): void => {}; const listener1 = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const listener2 = (): void => {}; const listener2 = (): void => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
const listener3 = (): void => {}; const listener3 = (): void => {};
@ -114,6 +116,7 @@ describe("EventListeners", () => {
const listener1 = (): void => { const listener1 = (): void => {
count1++; count1++;
}; };
const listener2 = (): void => { const listener2 = (): void => {
count2++; count2++;
}; };
@ -156,6 +159,37 @@ describe("EventListeners", () => {
assert.strictEqual(results.length, 3); assert.strictEqual(results.length, 3);
}); });
it("awaits every async listener even when another listener throws synchronously", async () => {
const listeners = new EventListeners<() => unknown>();
const release = Promise.withResolvers<undefined>();
const error = new Error("observer failed");
let completed = false;
let laterListenerCalled = false;
listeners.add(async () => {
await release.promise;
completed = true;
});
listeners.add(() => {
throw error;
});
listeners.add(() => {
laterListenerCalled = true;
});
let settled = false;
const result = assert
.rejects(listeners.triggerAsync(), error)
.then(() => {
settled = true;
});
await Promise.resolve();
assert(laterListenerCalled);
assert(!settled);
release.resolve(undefined);
await result;
assert(completed);
});
it("should not trigger cleared listeners", () => { it("should not trigger cleared listeners", () => {
const listeners = new EventListeners<() => void>(); const listeners = new EventListeners<() => void>();
let called = false; let called = false;

View file

@ -69,13 +69,8 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
*/ */
public async triggerAsync(...args: Parameters<TListener>): Promise<void> { public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
await awaitAll( await awaitAll(
this.listeners [...this.listeners].map(async (listener) => {
.map((listener) => { await listener(...args);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return listener(...args);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
}) })
); );
} }

View file

@ -19,7 +19,9 @@ export class FixedSizeDocumentCache {
} }
public put(updateId: VaultUpdateId, content: Uint8Array): void { public put(updateId: VaultUpdateId, content: Uint8Array): void {
if (content.byteLength > this.maxSizeInBytes) return; if (content.byteLength > this.maxSizeInBytes) {
return;
}
this.currentSizeInBytes -= this.cache.get(updateId)?.byteLength ?? 0; this.currentSizeInBytes -= this.cache.get(updateId)?.byteLength ?? 0;
this.cache.delete(updateId); this.cache.delete(updateId);
@ -41,7 +43,10 @@ export class FixedSizeDocumentCache {
private fitBelowMaxSize(): void { private fitBelowMaxSize(): void {
for (const [id, content] of this.cache) { for (const [id, content] of this.cache) {
if (this.currentSizeInBytes <= this.maxSizeInBytes) break; if (this.currentSizeInBytes <= this.maxSizeInBytes) {
break;
}
this.cache.delete(id); this.cache.delete(id);
this.currentSizeInBytes -= content.byteLength; this.currentSizeInBytes -= content.byteLength;
} }

View file

@ -4,12 +4,12 @@ import { Lock } from "./locks";
test("queued operations run in order and retain their results", async () => { test("queued operations run in order and retain their results", async () => {
const lock = new Lock(); const lock = new Lock();
const held = Promise.withResolvers<void>(); const held = Promise.withResolvers<undefined>();
const started = Promise.withResolvers<void>(); const started = Promise.withResolvers<undefined>();
const calls: number[] = []; const calls: number[] = [];
const first = lock.withLock(async () => { const first = lock.withLock(async () => {
calls.push(1); calls.push(1);
started.resolve(); started.resolve(undefined);
await held.promise; await held.promise;
calls.push(2); calls.push(2);
return "first"; return "first";
@ -24,7 +24,7 @@ test("queued operations run in order and retain their results", async () => {
}); });
await started.promise; await started.promise;
assert.deepEqual(calls, [1]); assert.deepEqual(calls, [1]);
held.resolve(); held.resolve(undefined);
assert.equal(await first, "first"); assert.equal(await first, "first");
assert.equal(await second, "second"); assert.equal(await second, "second");
assert.equal(await third, "third"); assert.equal(await third, "third");
@ -35,8 +35,11 @@ for (const asynchronous of [false, true]) {
test(`a ${asynchronous ? "rejected promise" : "thrown error"} releases the lock`, async () => { test(`a ${asynchronous ? "rejected promise" : "thrown error"} releases the lock`, async () => {
const lock = new Lock(); const lock = new Lock();
const error = new Error("failed operation"); const error = new Error("failed operation");
const failed = lock.withLock(() => { const failed = lock.withLock(async () => {
if (asynchronous) return Promise.reject(error); if (asynchronous) {
return Promise.reject(error);
}
throw error; throw error;
}); });
const next = lock.withLock(() => "continued"); const next = lock.withLock(() => "continued");

View file

@ -0,0 +1,5 @@
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
export function decodeText(content: Uint8Array): string {
return decoder.decode(content);
}

View file

@ -4,6 +4,7 @@ export function getRandomColor(name: string): string {
hash = (hash << 5) - hash + name.charCodeAt(i); hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // Convert to 32bit integer hash |= 0; // Convert to 32bit integer
} }
const normalised = hash / 0x7fffffff; const normalised = hash / 0x7fffffff;
return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`; return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`;
} }

View file

@ -13,6 +13,7 @@ export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
`Failed to parse ${pattern}' as a glob pattern, skipping it` `Failed to parse ${pattern}' as a glob pattern, skipping it`
); );
} }
return result; return result;
}) })
// eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item // eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item

View file

@ -1,13 +1,13 @@
import { decodeText } from "./decode-text";
// Text is unlikely to contain null bytes, so we can use that to distinguish binary files. // Text is unlikely to contain null bytes, so we can use that to distinguish binary files.
export function isBinary(content: Uint8Array): boolean { export function isBinary(content: Uint8Array): boolean {
for (const byte of content) { if (content.includes(0)) {
if (byte === 0) {
return true; return true;
} }
}
try { try {
new TextDecoder("utf-8", { fatal: true }).decode(content); decodeText(content);
} catch { } catch {
return true; return true;
} }

View file

@ -4,7 +4,11 @@ export function isFileTypeMergeable(
): boolean { ): boolean {
const name = pathOrFileName.split("/").at(-1) ?? ""; const name = pathOrFileName.split("/").at(-1) ?? "";
const separator = name.lastIndexOf("."); const separator = name.lastIndexOf(".");
if (separator < 0) return false;
if (separator < 0) {
return false;
}
const fileExtension = name.slice(separator + 1); const fileExtension = name.slice(separator + 1);
return mergeableExtensions.includes(fileExtension.toLowerCase()); return mergeableExtensions.includes(fileExtension.toLowerCase());

View file

@ -15,17 +15,18 @@ export function lineAndColumnToPosition(
): number { ): number {
const lines = text.replaceAll("\r", "").split("\n"); const lines = text.replaceAll("\r", "").split("\n");
if (line >= lines.length) { const selectedLine = lines[line];
if (selectedLine === undefined) {
throw new Error(`Line number ${line} is out of range.`); throw new Error(`Line number ${line} is out of range.`);
} }
if (column > lines[line].length) { if (column > selectedLine.length) {
throw new Error(`Column number ${column} is out of range.`); throw new Error(`Column number ${column} is out of range.`);
} }
let position = 0; let position = 0;
for (let i = 0; i < line; i++) { for (const precedingLine of lines.slice(0, line)) {
position += lines[i].length + 1; position += precedingLine.length + 1;
} }
position += column; position += column;

View file

@ -27,7 +27,7 @@ export function positionToLineAndColumn(
const lines = textUpToPosition.split("\n"); const lines = textUpToPosition.split("\n");
const line = lines.length - 1; const line = lines.length - 1;
const column = lines[lines.length - 1].length; const column = (lines.at(-1) ?? "").length;
return { line, column }; return { line, column };
} }

View file

@ -1,63 +1,33 @@
import { createPromise } from "./create-promise";
import { sleep } from "./sleep"; import { sleep } from "./sleep";
/** /** Coalesce calls during each interval; the first waiter runs the latest arguments. */
* Creates a rate-limited version of a given asynchronous function. export function rateLimit<Args extends unknown[], R>(
* Ensures that the function is not called more frequently than specified by `minIntervalMs`. fn: (...args: Args) => Promise<R>,
* If the function is called while a previous call is still within the rate limit window,
* it will queue up the most recent arguments and execute them after the rate limit expires.
* Only the most recent call is preserved in the queue.
*
* @template T - Type of the function to be rate limited
* @param {T} fn - The asynchronous function to rate limit
* @param {number | (() => number)} minIntervalMs - Minimum interval in milliseconds between calls,
* or a function that returns the minimum interval
* @returns {(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>} A decorated function that respects the rate limit.
* Returns the original function's return type when executed, or undefined if the call was superseded by a newer one.
*/
export function rateLimit<
R,
T extends (
...args: any // eslint-disable-line @typescript-eslint/no-explicit-any
) => Promise<R>
>(
fn: T,
minIntervalMs: number | (() => number) minIntervalMs: number | (() => number)
): (...args: Parameters<T>) => Promise<R | undefined> { ): (...args: Args) => Promise<R | undefined> {
let newArgs: Parameters<T> | undefined = undefined; let pending: Args | undefined = undefined;
let running: Promise<unknown> | undefined = undefined; let cooldown: Promise<void> | undefined = undefined;
const decoratedFn = async ( return async (...args: Args): Promise<R | undefined> => {
...args: Parameters<T> if (cooldown) {
): Promise<R | undefined> => { pending = args;
if (running !== undefined) { await cooldown;
newArgs = args;
await running;
// args might have changed while we were waiting // Another waiter may already have consumed the latest arguments.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Other calls mutate pending while awaiting the cooldown.
if (newArgs === undefined) { if (pending === undefined) {
// we weren't the first one to wake up, that means a newer
// invocation is running now, we can just bail
return; return;
} }
args = newArgs;
newArgs = undefined; args = pending;
pending = undefined;
} }
const [promise, resolve] = createPromise(); cooldown = sleep(
running = promise;
sleep(
typeof minIntervalMs === "function" typeof minIntervalMs === "function"
? minIntervalMs() ? minIntervalMs()
: minIntervalMs : minIntervalMs
) );
.then(resolve)
.catch(() => {
// sleep cannot fail
});
return fn(...args); return fn(...args);
}; };
return decoratedFn;
} }

View file

@ -13,5 +13,6 @@ export function removeFromArray<T>(array: T[], item: T): boolean {
array.splice(index, 1); array.splice(index, 1);
return true; return true;
} }
return false; return false;
} }