Improve typing, lint & format
This commit is contained in:
parent
4721392281
commit
82cd37b61d
15 changed files with 109 additions and 94 deletions
|
|
@ -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.
|
||||
reject(signal.reason);
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
}),
|
||||
operation()
|
||||
|
|
@ -20,4 +21,4 @@ export async function abortable<T>(
|
|||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
type PromiseTuple<T extends readonly unknown[]> = readonly [
|
||||
...{ [K in keyof T]: Promise<T[K]> }
|
||||
];
|
||||
|
||||
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[]>(
|
||||
promises: PromiseTuple<T>
|
||||
): Promise<ResolvedTuple<T>> => {
|
||||
/** Await every operation before returning results or throwing the first error. */
|
||||
export async function awaitAll<T extends readonly unknown[] | []>(
|
||||
promises: T
|
||||
): Promise<ResolvedTuple<T>> {
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
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") {
|
||||
throw res.reason;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(res) => (res as PromiseFulfilledResult<unknown>).value
|
||||
) as ResolvedTuple<T>;
|
||||
};
|
||||
return res.value;
|
||||
}) as ResolvedTuple<T>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,10 @@ describe("EventListeners", () => {
|
|||
const listeners = new EventListeners<() => void>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const listener1 = (): void => {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const listener2 = (): void => {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const listener3 = (): void => {};
|
||||
|
||||
|
|
@ -114,6 +116,7 @@ describe("EventListeners", () => {
|
|||
const listener1 = (): void => {
|
||||
count1++;
|
||||
};
|
||||
|
||||
const listener2 = (): void => {
|
||||
count2++;
|
||||
};
|
||||
|
|
@ -156,6 +159,37 @@ describe("EventListeners", () => {
|
|||
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", () => {
|
||||
const listeners = new EventListeners<() => void>();
|
||||
let called = false;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
|||
private readonly onError: (error: unknown) => void = (error) => {
|
||||
console.error("Event observer failed", error);
|
||||
}
|
||||
) { }
|
||||
) {}
|
||||
|
||||
public get count(): number {
|
||||
return this.listeners.length;
|
||||
|
|
@ -69,14 +69,9 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
|||
*/
|
||||
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
|
||||
await awaitAll(
|
||||
this.listeners
|
||||
.map((listener) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return listener(...args);
|
||||
})
|
||||
.filter((result): result is Promise<unknown> => {
|
||||
return result instanceof Promise;
|
||||
})
|
||||
[...this.listeners].map(async (listener) => {
|
||||
await listener(...args);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export class FixedSizeDocumentCache {
|
|||
private currentSizeInBytes = 0;
|
||||
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
|
||||
|
||||
public constructor(private maxSizeInBytes: number) { }
|
||||
public constructor(private maxSizeInBytes: number) {}
|
||||
|
||||
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
||||
const content = this.cache.get(updateId);
|
||||
|
|
@ -19,7 +19,9 @@ export class FixedSizeDocumentCache {
|
|||
}
|
||||
|
||||
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.cache.delete(updateId);
|
||||
|
|
@ -41,7 +43,10 @@ export class FixedSizeDocumentCache {
|
|||
|
||||
private fitBelowMaxSize(): void {
|
||||
for (const [id, content] of this.cache) {
|
||||
if (this.currentSizeInBytes <= this.maxSizeInBytes) break;
|
||||
if (this.currentSizeInBytes <= this.maxSizeInBytes) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.cache.delete(id);
|
||||
this.currentSizeInBytes -= content.byteLength;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { Lock } from "./locks";
|
|||
|
||||
test("queued operations run in order and retain their results", async () => {
|
||||
const lock = new Lock();
|
||||
const held = Promise.withResolvers<void>();
|
||||
const started = Promise.withResolvers<void>();
|
||||
const held = Promise.withResolvers<undefined>();
|
||||
const started = Promise.withResolvers<undefined>();
|
||||
const calls: number[] = [];
|
||||
const first = lock.withLock(async () => {
|
||||
calls.push(1);
|
||||
started.resolve();
|
||||
started.resolve(undefined);
|
||||
await held.promise;
|
||||
calls.push(2);
|
||||
return "first";
|
||||
|
|
@ -24,7 +24,7 @@ test("queued operations run in order and retain their results", async () => {
|
|||
});
|
||||
await started.promise;
|
||||
assert.deepEqual(calls, [1]);
|
||||
held.resolve();
|
||||
held.resolve(undefined);
|
||||
assert.equal(await first, "first");
|
||||
assert.equal(await second, "second");
|
||||
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 () => {
|
||||
const lock = new Lock();
|
||||
const error = new Error("failed operation");
|
||||
const failed = lock.withLock(() => {
|
||||
if (asynchronous) return Promise.reject(error);
|
||||
const failed = lock.withLock(async () => {
|
||||
if (asynchronous) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
const next = lock.withLock(() => "continued");
|
||||
|
|
|
|||
5
frontend/sync-client/src/utils/decode-text.ts
Normal file
5
frontend/sync-client/src/utils/decode-text.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export function getRandomColor(name: string): string {
|
|||
hash = (hash << 5) - hash + name.charCodeAt(i);
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
|
||||
const normalised = hash / 0x7fffffff;
|
||||
return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
|
|||
`Failed to parse ${pattern}' as a glob pattern, skipping it`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
// eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
export function isBinary(content: Uint8Array): boolean {
|
||||
for (const byte of content) {
|
||||
if (byte === 0) {
|
||||
return true;
|
||||
}
|
||||
if (content.includes(0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||
decodeText(content);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ export function isFileTypeMergeable(
|
|||
): boolean {
|
||||
const name = pathOrFileName.split("/").at(-1) ?? "";
|
||||
const separator = name.lastIndexOf(".");
|
||||
if (separator < 0) return false;
|
||||
|
||||
if (separator < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileExtension = name.slice(separator + 1);
|
||||
|
||||
return mergeableExtensions.includes(fileExtension.toLowerCase());
|
||||
|
|
|
|||
|
|
@ -15,17 +15,18 @@ export function lineAndColumnToPosition(
|
|||
): number {
|
||||
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.`);
|
||||
}
|
||||
|
||||
if (column > lines[line].length) {
|
||||
if (column > selectedLine.length) {
|
||||
throw new Error(`Column number ${column} is out of range.`);
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
for (let i = 0; i < line; i++) {
|
||||
position += lines[i].length + 1;
|
||||
for (const precedingLine of lines.slice(0, line)) {
|
||||
position += precedingLine.length + 1;
|
||||
}
|
||||
|
||||
position += column;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function positionToLineAndColumn(
|
|||
const lines = textUpToPosition.split("\n");
|
||||
|
||||
const line = lines.length - 1;
|
||||
const column = lines[lines.length - 1].length;
|
||||
const column = (lines.at(-1) ?? "").length;
|
||||
|
||||
return { line, column };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,33 @@
|
|||
import { createPromise } from "./create-promise";
|
||||
import { sleep } from "./sleep";
|
||||
|
||||
/**
|
||||
* Creates a rate-limited version of a given asynchronous function.
|
||||
* Ensures that the function is not called more frequently than specified by `minIntervalMs`.
|
||||
* 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,
|
||||
/** Coalesce calls during each interval; the first waiter runs the latest arguments. */
|
||||
export function rateLimit<Args extends unknown[], R>(
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
minIntervalMs: number | (() => number)
|
||||
): (...args: Parameters<T>) => Promise<R | undefined> {
|
||||
let newArgs: Parameters<T> | undefined = undefined;
|
||||
let running: Promise<unknown> | undefined = undefined;
|
||||
): (...args: Args) => Promise<R | undefined> {
|
||||
let pending: Args | undefined = undefined;
|
||||
let cooldown: Promise<void> | undefined = undefined;
|
||||
|
||||
const decoratedFn = async (
|
||||
...args: Parameters<T>
|
||||
): Promise<R | undefined> => {
|
||||
if (running !== undefined) {
|
||||
newArgs = args;
|
||||
await running;
|
||||
return async (...args: Args): Promise<R | undefined> => {
|
||||
if (cooldown) {
|
||||
pending = args;
|
||||
await cooldown;
|
||||
|
||||
// args might have changed while we were waiting
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (newArgs === undefined) {
|
||||
// we weren't the first one to wake up, that means a newer
|
||||
// invocation is running now, we can just bail
|
||||
// Another waiter may already have consumed the latest arguments.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Other calls mutate pending while awaiting the cooldown.
|
||||
if (pending === undefined) {
|
||||
return;
|
||||
}
|
||||
args = newArgs;
|
||||
newArgs = undefined;
|
||||
|
||||
args = pending;
|
||||
pending = undefined;
|
||||
}
|
||||
|
||||
const [promise, resolve] = createPromise();
|
||||
running = promise;
|
||||
sleep(
|
||||
cooldown = sleep(
|
||||
typeof minIntervalMs === "function"
|
||||
? minIntervalMs()
|
||||
: minIntervalMs
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
// sleep cannot fail
|
||||
});
|
||||
);
|
||||
return fn(...args);
|
||||
};
|
||||
|
||||
return decoratedFn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ export function removeFromArray<T>(array: T[], item: T): boolean {
|
|||
array.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue