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.
|
// 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()
|
||||||
|
|
@ -20,4 +21,4 @@ export async function abortable<T>(
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener("abort", abort);
|
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[]> = {
|
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>;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
||||||
private readonly onError: (error: unknown) => void = (error) => {
|
private readonly onError: (error: unknown) => void = (error) => {
|
||||||
console.error("Event observer failed", error);
|
console.error("Event observer failed", error);
|
||||||
}
|
}
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public get count(): number {
|
public get count(): number {
|
||||||
return this.listeners.length;
|
return this.listeners.length;
|
||||||
|
|
@ -69,14 +69,9 @@ 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;
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export class FixedSizeDocumentCache {
|
||||||
private currentSizeInBytes = 0;
|
private currentSizeInBytes = 0;
|
||||||
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
|
private readonly cache = new Map<VaultUpdateId, Uint8Array>();
|
||||||
|
|
||||||
public constructor(private maxSizeInBytes: number) { }
|
public constructor(private maxSizeInBytes: number) {}
|
||||||
|
|
||||||
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
||||||
const content = this.cache.get(updateId);
|
const content = this.cache.get(updateId);
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
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 = (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))})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue