Apply editorconfig
This commit is contained in:
parent
ad3191957a
commit
b05e415acf
131 changed files with 16404 additions and 13617 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import assert from "node:assert";
|
||||
|
||||
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
|
||||
assert.ok(
|
||||
set.size === values.length &&
|
||||
Array.from(set).every((value) => values.includes(value)),
|
||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||
set
|
||||
)
|
||||
.map((v) => '"' + v + '"')
|
||||
.join(", ")}`
|
||||
);
|
||||
assert.ok(
|
||||
set.size === values.length &&
|
||||
Array.from(set).every((value) => values.includes(value)),
|
||||
`Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from(
|
||||
set
|
||||
)
|
||||
.map((v) => '"' + v + '"')
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,54 +3,54 @@ import assert from "node:assert";
|
|||
import { awaitAll } from "./await-all";
|
||||
|
||||
void test("awaitAll resolves promises of the same type", async () => {
|
||||
const promises = [
|
||||
Promise.resolve(1),
|
||||
Promise.resolve(2),
|
||||
Promise.resolve(3)
|
||||
];
|
||||
const promises = [
|
||||
Promise.resolve(1),
|
||||
Promise.resolve(2),
|
||||
Promise.resolve(3)
|
||||
];
|
||||
|
||||
const results = await awaitAll(promises);
|
||||
assert.deepStrictEqual(results, [1, 2, 3]);
|
||||
const results = await awaitAll(promises);
|
||||
assert.deepStrictEqual(results, [1, 2, 3]);
|
||||
});
|
||||
|
||||
void test("awaitAll resolves promises of different types", async () => {
|
||||
const promises = [
|
||||
Promise.resolve("hello"),
|
||||
Promise.resolve(42),
|
||||
Promise.resolve(true)
|
||||
] as const;
|
||||
const promises = [
|
||||
Promise.resolve("hello"),
|
||||
Promise.resolve(42),
|
||||
Promise.resolve(true)
|
||||
] as const;
|
||||
|
||||
const results = await awaitAll(promises);
|
||||
const results = await awaitAll(promises);
|
||||
|
||||
// Type assertions to verify type inference
|
||||
const str: string = results[0];
|
||||
const num: number = results[1];
|
||||
const bool: boolean = results[2];
|
||||
// Type assertions to verify type inference
|
||||
const str: string = results[0];
|
||||
const num: number = results[1];
|
||||
const bool: boolean = results[2];
|
||||
|
||||
assert.strictEqual(str, "hello");
|
||||
assert.strictEqual(num, 42);
|
||||
assert.strictEqual(bool, true);
|
||||
assert.strictEqual(str, "hello");
|
||||
assert.strictEqual(num, 42);
|
||||
assert.strictEqual(bool, true);
|
||||
});
|
||||
|
||||
void test("awaitAll throws on first rejection", async () => {
|
||||
const error = new Error("Test error");
|
||||
const promises = [
|
||||
Promise.resolve(1),
|
||||
Promise.reject(error),
|
||||
Promise.resolve(3)
|
||||
];
|
||||
const error = new Error("Test error");
|
||||
const promises = [
|
||||
Promise.resolve(1),
|
||||
Promise.reject(error),
|
||||
Promise.resolve(3)
|
||||
];
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await awaitAll(promises);
|
||||
}, error);
|
||||
await assert.rejects(async () => {
|
||||
await awaitAll(promises);
|
||||
}, error);
|
||||
});
|
||||
|
||||
void test("awaitAll works with async functions", async () => {
|
||||
const asyncString = async (): Promise<string> => "async";
|
||||
const asyncNumber = async (): Promise<number> => 123;
|
||||
const asyncString = async (): Promise<string> => "async";
|
||||
const asyncNumber = async (): Promise<number> => 123;
|
||||
|
||||
const results = await awaitAll([asyncString(), asyncNumber()]);
|
||||
const results = await awaitAll([asyncString(), asyncNumber()]);
|
||||
|
||||
assert.strictEqual(results[0], "async");
|
||||
assert.strictEqual(results[1], 123);
|
||||
assert.strictEqual(results[0], "async");
|
||||
assert.strictEqual(results[1], 123);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
type PromiseTuple<T extends readonly unknown[]> = readonly [
|
||||
...{ [K in keyof T]: Promise<T[K]> }
|
||||
...{ [K in keyof T]: Promise<T[K]> }
|
||||
];
|
||||
|
||||
type ResolvedTuple<T extends readonly unknown[]> = {
|
||||
[K in keyof T]: T[K];
|
||||
[K in keyof T]: T[K];
|
||||
};
|
||||
|
||||
export const awaitAll = async <T extends readonly unknown[]>(
|
||||
promises: PromiseTuple<T>
|
||||
promises: PromiseTuple<T>
|
||||
): Promise<ResolvedTuple<T>> => {
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const result = await Promise.allSettled(promises);
|
||||
for (const res of result) {
|
||||
if (res.status === "rejected") {
|
||||
throw res.reason;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const result = await Promise.allSettled(promises);
|
||||
for (const res of result) {
|
||||
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>;
|
||||
// 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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function createClientId(): string {
|
||||
// @ts-expect-error, injected by webpack
|
||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||
// @ts-expect-error, injected by webpack
|
||||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
: typeof process !== "undefined"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
: typeof process !== "undefined"
|
||||
? process.platform
|
||||
: "unknown";
|
||||
|
||||
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
|
||||
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
type ResolveFunction<T> = undefined extends T
|
||||
? (value?: T) => unknown
|
||||
: (value: T) => unknown;
|
||||
? (value?: T) => unknown
|
||||
: (value: T) => unknown;
|
||||
|
||||
/**
|
||||
* A type-safe utility function to create a Promise with resolve and reject functions.
|
||||
* @returns A tuple containing a Promise, a resolve function, and a reject function.
|
||||
*/
|
||||
export function createPromise<T = unknown>(): [
|
||||
Promise<T>,
|
||||
ResolveFunction<T>,
|
||||
(error: unknown) => unknown
|
||||
Promise<T>,
|
||||
ResolveFunction<T>,
|
||||
(error: unknown) => unknown
|
||||
] {
|
||||
let resolve: undefined | ResolveFunction<T> = undefined;
|
||||
let reject: undefined | ((error: unknown) => unknown) = undefined;
|
||||
let resolve: undefined | ResolveFunction<T> = undefined;
|
||||
let reject: undefined | ((error: unknown) => unknown) = undefined;
|
||||
|
||||
const creationPromise = new Promise<T>(
|
||||
(resolve_, reject_) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
((resolve = resolve_ as ResolveFunction<T>), (reject = reject_))
|
||||
);
|
||||
const creationPromise = new Promise<T>(
|
||||
(resolve_, reject_) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
((resolve = resolve_ as ResolveFunction<T>), (reject = reject_))
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return [creationPromise, resolve!, reject!];
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return [creationPromise, resolve!, reject!];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,32 +8,32 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
|||
private readonly listeners: TListener[] = [];
|
||||
|
||||
/**
|
||||
* Adds a new listener to the collection.
|
||||
*
|
||||
* @param listener The listener callback to add
|
||||
* @returns An unsubscribe function that removes this listener when called
|
||||
*/
|
||||
* Adds a new listener to the collection.
|
||||
*
|
||||
* @param listener The listener callback to add
|
||||
* @returns An unsubscribe function that removes this listener when called
|
||||
*/
|
||||
public add(listener: TListener): () => void {
|
||||
this.listeners.push(listener);
|
||||
return () => this.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a listener from the collection.
|
||||
*
|
||||
* @param listener The listener callback to remove
|
||||
* @returns true if the listener was found and removed, false otherwise
|
||||
*/
|
||||
* Removes a listener from the collection.
|
||||
*
|
||||
* @param listener The listener callback to remove
|
||||
* @returns true if the listener was found and removed, false otherwise
|
||||
*/
|
||||
public remove(listener: TListener): boolean {
|
||||
return removeFromArray(this.listeners, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers all listeners synchronously with the provided arguments.
|
||||
* Any returned promises are ignored. Use triggerAsync() to await them.
|
||||
*
|
||||
* @param args The arguments to pass to each listener
|
||||
*/
|
||||
* Triggers all listeners synchronously with the provided arguments.
|
||||
* Any returned promises are ignored. Use triggerAsync() to await them.
|
||||
*
|
||||
* @param args The arguments to pass to each listener
|
||||
*/
|
||||
public trigger(...args: Parameters<TListener>): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(...args);
|
||||
|
|
@ -41,12 +41,12 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Triggers all listeners and awaits any promises they return.
|
||||
* Synchronous listeners are called immediately, and any async listeners
|
||||
* are awaited in parallel.
|
||||
*
|
||||
* @param args The arguments to pass to each listener
|
||||
*/
|
||||
* Triggers all listeners and awaits any promises they return.
|
||||
* Synchronous listeners are called immediately, and any async listeners
|
||||
* are awaited in parallel.
|
||||
*
|
||||
* @param args The arguments to pass to each listener
|
||||
*/
|
||||
public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
|
||||
await awaitAll(
|
||||
this.listeners
|
||||
|
|
|
|||
|
|
@ -3,273 +3,273 @@ import assert from "node:assert";
|
|||
import { FixedSizeDocumentCache } from "./fix-sized-cache";
|
||||
|
||||
describe("fixedSizeDocumentCache", () => {
|
||||
it("happyPath", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
it("happyPath", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
cache.put(3, doc3);
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
cache.put(3, doc3);
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("updateExistingEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([3, 4]);
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
it("updateExistingEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([3, 4]);
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
|
||||
cache.put(1, doc1_v1);
|
||||
assert.equal(cache.get(1), doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1_v1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
cache.put(1, doc1_v2); // Update doc1
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
cache.put(1, doc1_v1);
|
||||
assert.equal(cache.get(1), doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1_v1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
cache.put(1, doc1_v2); // Update doc1
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
|
||||
it("evictOldestEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
it("evictOldestEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(3, doc3);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(3, doc3);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("tooLargeEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([1, 2, 3]);
|
||||
it("tooLargeEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([1, 2, 3]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), undefined);
|
||||
});
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), undefined);
|
||||
});
|
||||
|
||||
it("multipleEvictionsInSinglePut", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes
|
||||
it("multipleEvictionsInSinglePut", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
// Cache now has 6 bytes total
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
// Cache now has 6 bytes total
|
||||
|
||||
cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10)
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3); // Still present
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10)
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3); // Still present
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
|
||||
it("clearCache", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
it("clearCache", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
|
||||
cache.reset();
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
cache.reset();
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
|
||||
// Should be able to add entries after clear
|
||||
cache.put(3, doc1);
|
||||
assert.equal(cache.get(3), doc1);
|
||||
});
|
||||
// Should be able to add entries after clear
|
||||
cache.put(3, doc1);
|
||||
assert.equal(cache.get(3), doc1);
|
||||
});
|
||||
|
||||
it("getNonExistentKey", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(999), undefined);
|
||||
});
|
||||
it("getNonExistentKey", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(999), undefined);
|
||||
});
|
||||
|
||||
it("updateEntryWithDifferentSizeTriggeringEviction", async () => {
|
||||
const cache = new FixedSizeDocumentCache(6);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
const doc3 = new Uint8Array([7, 8]);
|
||||
it("updateEntryWithDifferentSizeTriggeringEviction", async () => {
|
||||
const cache = new FixedSizeDocumentCache(6);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
const doc3 = new Uint8Array([7, 8]);
|
||||
|
||||
cache.put(1, doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
cache.put(1, doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
|
||||
// Update doc1 with larger version, should evict doc2
|
||||
cache.put(1, doc1_v2);
|
||||
// Update doc1 with larger version, should evict doc2
|
||||
cache.put(1, doc1_v2);
|
||||
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("singleItemCache", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
it("singleItemCache", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
cache.put(2, doc2);
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
|
||||
it("multipleGetsOnSameEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
it("multipleGetsOnSameEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
|
||||
// Multiple gets on doc1
|
||||
cache.get(1);
|
||||
cache.get(1);
|
||||
cache.get(1);
|
||||
// Multiple gets on doc1
|
||||
cache.get(1);
|
||||
cache.get(1);
|
||||
cache.get(1);
|
||||
|
||||
// Order should be: 2 (LRU), 1 (MRU)
|
||||
cache.put(3, doc3);
|
||||
// Order should be: 2 (LRU), 1 (MRU)
|
||||
cache.put(3, doc3);
|
||||
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("exactlySizedEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size
|
||||
it("exactlySizedEntry", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size
|
||||
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
cache.put(1, doc1);
|
||||
assert.equal(cache.get(1), doc1);
|
||||
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
cache.put(2, doc2);
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
cache.put(2, doc2);
|
||||
|
||||
// doc1 should be evicted to make room for doc2
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
// doc1 should be evicted to make room for doc2
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
|
||||
it("updateEntryMakesItMostRecent", async () => {
|
||||
const cache = new FixedSizeDocumentCache(6);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([3, 4]);
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
const doc3 = new Uint8Array([7, 8]);
|
||||
const doc4 = new Uint8Array([9, 10]);
|
||||
it("updateEntryMakesItMostRecent", async () => {
|
||||
const cache = new FixedSizeDocumentCache(6);
|
||||
const doc1_v1 = new Uint8Array([1, 2]);
|
||||
const doc1_v2 = new Uint8Array([3, 4]);
|
||||
const doc2 = new Uint8Array([5, 6]);
|
||||
const doc3 = new Uint8Array([7, 8]);
|
||||
const doc4 = new Uint8Array([9, 10]);
|
||||
|
||||
cache.put(1, doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
cache.put(1, doc1_v1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
|
||||
// Update doc1 (should move it to most recent)
|
||||
cache.put(1, doc1_v2);
|
||||
// Update doc1 (should move it to most recent)
|
||||
cache.put(1, doc1_v2);
|
||||
|
||||
// Order should be: 2 (LRU), 3, 1 (MRU)
|
||||
// Adding doc4 should evict doc2
|
||||
cache.put(4, doc4);
|
||||
// Order should be: 2 (LRU), 3, 1 (MRU)
|
||||
// Adding doc4 should evict doc2
|
||||
cache.put(4, doc4);
|
||||
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
assert.equal(cache.get(1), doc1_v2);
|
||||
assert.equal(cache.get(2), undefined); // Evicted
|
||||
assert.equal(cache.get(3), doc3);
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
|
||||
it("alternatingAccessPattern", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
it("alternatingAccessPattern", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
|
||||
// Alternate access between doc1 and doc2
|
||||
cache.get(1);
|
||||
cache.get(2);
|
||||
cache.get(1);
|
||||
cache.get(2);
|
||||
// Alternate access between doc1 and doc2
|
||||
cache.get(1);
|
||||
cache.get(2);
|
||||
cache.get(1);
|
||||
cache.get(2);
|
||||
|
||||
// Order should be: 1, 2 (MRU)
|
||||
cache.put(3, doc3);
|
||||
// Order should be: 1, 2 (MRU)
|
||||
cache.put(3, doc3);
|
||||
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
assert.equal(cache.get(1), undefined); // Evicted
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("zeroByteDocs", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([]);
|
||||
const doc2 = new Uint8Array([]);
|
||||
const doc3 = new Uint8Array([1, 2]);
|
||||
it("zeroByteDocs", async () => {
|
||||
const cache = new FixedSizeDocumentCache(2);
|
||||
const doc1 = new Uint8Array([]);
|
||||
const doc2 = new Uint8Array([]);
|
||||
const doc3 = new Uint8Array([1, 2]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
assert.equal(cache.get(3), doc3);
|
||||
});
|
||||
|
||||
it("resizeToLargerSizeNoEviction", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
it("resizeToLargerSizeNoEviction", async () => {
|
||||
const cache = new FixedSizeDocumentCache(4);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
|
||||
cache.resize(10);
|
||||
cache.resize(10);
|
||||
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
assert.equal(cache.get(1), doc1);
|
||||
assert.equal(cache.get(2), doc2);
|
||||
});
|
||||
|
||||
it("resizeCausesMultipleEvictions", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
const doc4 = new Uint8Array([7, 8]);
|
||||
it("resizeCausesMultipleEvictions", async () => {
|
||||
const cache = new FixedSizeDocumentCache(10);
|
||||
const doc1 = new Uint8Array([1, 2]);
|
||||
const doc2 = new Uint8Array([3, 4]);
|
||||
const doc3 = new Uint8Array([5, 6]);
|
||||
const doc4 = new Uint8Array([7, 8]);
|
||||
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
cache.put(4, doc4);
|
||||
// Cache has 8 bytes total
|
||||
cache.put(1, doc1);
|
||||
cache.put(2, doc2);
|
||||
cache.put(3, doc3);
|
||||
cache.put(4, doc4);
|
||||
// Cache has 8 bytes total
|
||||
|
||||
cache.resize(2);
|
||||
cache.resize(2);
|
||||
|
||||
// Should evict doc1, doc2, doc3 to get down to 2 bytes
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
assert.equal(cache.get(3), undefined);
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
// Should evict doc1, doc2, doc3 to get down to 2 bytes
|
||||
assert.equal(cache.get(1), undefined);
|
||||
assert.equal(cache.get(2), undefined);
|
||||
assert.equal(cache.get(3), undefined);
|
||||
assert.equal(cache.get(4), doc4);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,116 +4,116 @@ import type { VaultUpdateId } from "../../persistence/database";
|
|||
|
||||
// Doubly-linked list node for O(1) LRU operations
|
||||
class LRUNode {
|
||||
public constructor(
|
||||
public key: VaultUpdateId,
|
||||
public value: Uint8Array,
|
||||
public prev: LRUNode | null = null,
|
||||
public next: LRUNode | null = null
|
||||
) {}
|
||||
public constructor(
|
||||
public key: VaultUpdateId,
|
||||
public value: Uint8Array,
|
||||
public prev: LRUNode | null = null,
|
||||
public next: LRUNode | null = null
|
||||
) {}
|
||||
}
|
||||
|
||||
// evicting the least recently used documents when the size limit is exceeded.
|
||||
export class FixedSizeDocumentCache {
|
||||
private currentSizeInBytes: number;
|
||||
private readonly cache: Map<VaultUpdateId, LRUNode>;
|
||||
private head: LRUNode | null; // Least recently used
|
||||
private tail: LRUNode | null; // Most recently used
|
||||
private currentSizeInBytes: number;
|
||||
private readonly cache: Map<VaultUpdateId, LRUNode>;
|
||||
private head: LRUNode | null; // Least recently used
|
||||
private tail: LRUNode | null; // Most recently used
|
||||
|
||||
public constructor(private maxSizeInBytes: number) {
|
||||
this.currentSizeInBytes = 0;
|
||||
this.cache = new Map();
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
}
|
||||
public constructor(private maxSizeInBytes: number) {
|
||||
this.currentSizeInBytes = 0;
|
||||
this.cache = new Map();
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
}
|
||||
|
||||
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
||||
const node = this.cache.get(updateId);
|
||||
if (node) {
|
||||
this.moveToTail(node);
|
||||
return node.value;
|
||||
}
|
||||
public get(updateId: VaultUpdateId): Uint8Array | undefined {
|
||||
const node = this.cache.get(updateId);
|
||||
if (node) {
|
||||
this.moveToTail(node);
|
||||
return node.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public put(updateId: VaultUpdateId, content: Uint8Array): void {
|
||||
if (content.byteLength > this.maxSizeInBytes) {
|
||||
// Document is too large to fit in the cache
|
||||
return;
|
||||
}
|
||||
public put(updateId: VaultUpdateId, content: Uint8Array): void {
|
||||
if (content.byteLength > this.maxSizeInBytes) {
|
||||
// Document is too large to fit in the cache
|
||||
return;
|
||||
}
|
||||
|
||||
// If the document is already in the cache, update it
|
||||
const existingNode = this.cache.get(updateId);
|
||||
if (existingNode != null) {
|
||||
this.currentSizeInBytes -= existingNode.value.byteLength;
|
||||
this.removeNode(existingNode);
|
||||
this.cache.delete(updateId);
|
||||
}
|
||||
// If the document is already in the cache, update it
|
||||
const existingNode = this.cache.get(updateId);
|
||||
if (existingNode != null) {
|
||||
this.currentSizeInBytes -= existingNode.value.byteLength;
|
||||
this.removeNode(existingNode);
|
||||
this.cache.delete(updateId);
|
||||
}
|
||||
|
||||
const newNode = new LRUNode(updateId, content);
|
||||
this.cache.set(updateId, newNode);
|
||||
this.addToTail(newNode);
|
||||
this.currentSizeInBytes += content.byteLength;
|
||||
this.fitBelowMaxSize();
|
||||
}
|
||||
const newNode = new LRUNode(updateId, content);
|
||||
this.cache.set(updateId, newNode);
|
||||
this.addToTail(newNode);
|
||||
this.currentSizeInBytes += content.byteLength;
|
||||
this.fitBelowMaxSize();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.cache.clear();
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
this.currentSizeInBytes = 0;
|
||||
}
|
||||
public reset(): void {
|
||||
this.cache.clear();
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
this.currentSizeInBytes = 0;
|
||||
}
|
||||
|
||||
public resize(newMaxSizeInBytes: number): void {
|
||||
this.maxSizeInBytes = newMaxSizeInBytes;
|
||||
this.fitBelowMaxSize();
|
||||
}
|
||||
public resize(newMaxSizeInBytes: number): void {
|
||||
this.maxSizeInBytes = newMaxSizeInBytes;
|
||||
this.fitBelowMaxSize();
|
||||
}
|
||||
|
||||
private fitBelowMaxSize(): void {
|
||||
// Evict least recently used documents if over size limit
|
||||
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
|
||||
const lruNode = this.head;
|
||||
this.removeNode(lruNode);
|
||||
this.cache.delete(lruNode.key);
|
||||
this.currentSizeInBytes -= lruNode.value.byteLength;
|
||||
}
|
||||
}
|
||||
private fitBelowMaxSize(): void {
|
||||
// Evict least recently used documents if over size limit
|
||||
while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) {
|
||||
const lruNode = this.head;
|
||||
this.removeNode(lruNode);
|
||||
this.cache.delete(lruNode.key);
|
||||
this.currentSizeInBytes -= lruNode.value.byteLength;
|
||||
}
|
||||
}
|
||||
|
||||
private removeNode(node: LRUNode): void {
|
||||
if (node.prev) {
|
||||
node.prev.next = node.next;
|
||||
} else {
|
||||
this.head = node.next;
|
||||
}
|
||||
private removeNode(node: LRUNode): void {
|
||||
if (node.prev) {
|
||||
node.prev.next = node.next;
|
||||
} else {
|
||||
this.head = node.next;
|
||||
}
|
||||
|
||||
if (node.next) {
|
||||
node.next.prev = node.prev;
|
||||
} else {
|
||||
this.tail = node.prev;
|
||||
}
|
||||
if (node.next) {
|
||||
node.next.prev = node.prev;
|
||||
} else {
|
||||
this.tail = node.prev;
|
||||
}
|
||||
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
}
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
}
|
||||
|
||||
private addToTail(node: LRUNode): void {
|
||||
node.prev = this.tail;
|
||||
node.next = null;
|
||||
private addToTail(node: LRUNode): void {
|
||||
node.prev = this.tail;
|
||||
node.next = null;
|
||||
|
||||
if (this.tail) {
|
||||
this.tail.next = node;
|
||||
}
|
||||
if (this.tail) {
|
||||
this.tail.next = node;
|
||||
}
|
||||
|
||||
this.tail = node;
|
||||
this.tail = node;
|
||||
|
||||
this.head ??= node;
|
||||
}
|
||||
this.head ??= node;
|
||||
}
|
||||
|
||||
private moveToTail(node: LRUNode): void {
|
||||
if (node === this.tail) {
|
||||
return;
|
||||
}
|
||||
this.removeNode(node);
|
||||
this.addToTail(node);
|
||||
}
|
||||
private moveToTail(node: LRUNode): void {
|
||||
if (node === this.tail) {
|
||||
return;
|
||||
}
|
||||
this.removeNode(node);
|
||||
this.addToTail(node);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,226 +7,226 @@ import { awaitAll } from "../await-all";
|
|||
import { sleep } from "../sleep";
|
||||
|
||||
describe("withLock", () => {
|
||||
const testPath: RelativePath = "test/document/path";
|
||||
const testPath2: RelativePath = "test/document/path2";
|
||||
const logger = new Logger();
|
||||
const testPath: RelativePath = "test/document/path";
|
||||
const testPath2: RelativePath = "test/document/path2";
|
||||
const logger = new Logger();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let locks: Locks<RelativePath>;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let locks: Locks<RelativePath>;
|
||||
|
||||
beforeEach(() => {
|
||||
locks = new Locks<RelativePath>(logger);
|
||||
});
|
||||
beforeEach(() => {
|
||||
locks = new Locks<RelativePath>(logger);
|
||||
});
|
||||
|
||||
it("should execute function with single key lock", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock(testPath, () => {
|
||||
executionCount++;
|
||||
return "success";
|
||||
});
|
||||
it("should execute function with single key lock", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock(testPath, () => {
|
||||
executionCount++;
|
||||
return "success";
|
||||
});
|
||||
|
||||
assert.strictEqual(result, "success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
assert.strictEqual(result, "success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
|
||||
it("should execute async function with single key lock", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock(testPath, async () => {
|
||||
executionCount++;
|
||||
await sleep(10);
|
||||
return "async-success";
|
||||
});
|
||||
it("should execute async function with single key lock", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock(testPath, async () => {
|
||||
executionCount++;
|
||||
await sleep(10);
|
||||
return "async-success";
|
||||
});
|
||||
|
||||
assert.strictEqual(result, "async-success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
assert.strictEqual(result, "async-success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
|
||||
it("should execute function with multiple key locks", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock([testPath, testPath2], () => {
|
||||
executionCount++;
|
||||
return "multi-success";
|
||||
});
|
||||
it("should execute function with multiple key locks", async () => {
|
||||
let executionCount = 0;
|
||||
const result = await locks.withLock([testPath, testPath2], () => {
|
||||
executionCount++;
|
||||
return "multi-success";
|
||||
});
|
||||
|
||||
assert.strictEqual(result, "multi-success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
assert.strictEqual(result, "multi-success");
|
||||
assert.strictEqual(executionCount, 1);
|
||||
});
|
||||
|
||||
it("should sort multiple keys to prevent deadlocks", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
it("should sort multiple keys to prevent deadlocks", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
// Start two concurrent operations with keys in different orders
|
||||
const promise1 = locks.withLock([testPath2, testPath], async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
// Start two concurrent operations with keys in different orders
|
||||
const promise1 = locks.withLock([testPath2, testPath], async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
|
||||
const promise2 = locks.withLock([testPath, testPath2], async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
const promise2 = locks.withLock([testPath, testPath2], async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
// One operation should complete entirely before the other starts
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"operation1-start",
|
||||
"operation1-end",
|
||||
"operation2-start",
|
||||
"operation2-end"
|
||||
]);
|
||||
});
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
// One operation should complete entirely before the other starts
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"operation1-start",
|
||||
"operation1-end",
|
||||
"operation2-start",
|
||||
"operation2-end"
|
||||
]);
|
||||
});
|
||||
|
||||
it("should serialize access to same key", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
it("should serialize access to same key", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
const promise1 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
const promise1 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
|
||||
const promise2 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(30);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
const promise2 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(30);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"operation1-start",
|
||||
"operation1-end",
|
||||
"operation2-start",
|
||||
"operation2-end"
|
||||
]);
|
||||
});
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"operation1-start",
|
||||
"operation1-end",
|
||||
"operation2-start",
|
||||
"operation2-end"
|
||||
]);
|
||||
});
|
||||
|
||||
it("should allow concurrent access to different keys", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
it("should allow concurrent access to different keys", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
const promise1 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
const promise1 = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("operation1-start");
|
||||
await sleep(50);
|
||||
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
executionOrder.push("operation1-end");
|
||||
return "result1";
|
||||
});
|
||||
|
||||
const promise2 = locks.withLock(testPath2, async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(30);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
const promise2 = locks.withLock(testPath2, async () => {
|
||||
executionOrder.push("operation2-start");
|
||||
await sleep(30);
|
||||
executionOrder.push("operation2-end");
|
||||
return "result2";
|
||||
});
|
||||
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
const [result1, result2] = await awaitAll([promise1, promise2]);
|
||||
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
// Both operations should run concurrently
|
||||
assert.strictEqual(executionOrder[0], "operation1-start");
|
||||
assert.strictEqual(executionOrder[1], "operation2-start");
|
||||
});
|
||||
assert.strictEqual(result1, "result1");
|
||||
assert.strictEqual(result2, "result2");
|
||||
// Both operations should run concurrently
|
||||
assert.strictEqual(executionOrder[0], "operation1-start");
|
||||
assert.strictEqual(executionOrder[1], "operation2-start");
|
||||
});
|
||||
|
||||
it("should release locks even if function throws", async () => {
|
||||
const error = new Error("test error");
|
||||
it("should release locks even if function throws", async () => {
|
||||
const error = new Error("test error");
|
||||
|
||||
await assert.rejects(
|
||||
locks.withLock(testPath, () => {
|
||||
throw error;
|
||||
}),
|
||||
{ message: "test error" }
|
||||
);
|
||||
await assert.rejects(
|
||||
locks.withLock(testPath, () => {
|
||||
throw error;
|
||||
}),
|
||||
{ message: "test error" }
|
||||
);
|
||||
|
||||
// Lock should be released, allowing another operation
|
||||
const result = await locks.withLock(
|
||||
testPath,
|
||||
() => "success-after-error"
|
||||
);
|
||||
assert.strictEqual(result, "success-after-error");
|
||||
});
|
||||
// Lock should be released, allowing another operation
|
||||
const result = await locks.withLock(
|
||||
testPath,
|
||||
() => "success-after-error"
|
||||
);
|
||||
assert.strictEqual(result, "success-after-error");
|
||||
});
|
||||
|
||||
it("should release locks even if async function throws", async () => {
|
||||
const error = new Error("async test error");
|
||||
it("should release locks even if async function throws", async () => {
|
||||
const error = new Error("async test error");
|
||||
|
||||
await assert.rejects(
|
||||
locks.withLock(testPath, async () => {
|
||||
await sleep(10);
|
||||
await assert.rejects(
|
||||
locks.withLock(testPath, async () => {
|
||||
await sleep(10);
|
||||
|
||||
throw error;
|
||||
}),
|
||||
{ message: "async test error" }
|
||||
);
|
||||
throw error;
|
||||
}),
|
||||
{ message: "async test error" }
|
||||
);
|
||||
|
||||
// Lock should be released, allowing another operation
|
||||
const result = await locks.withLock(
|
||||
testPath,
|
||||
() => "success-after-async-error"
|
||||
);
|
||||
assert.strictEqual(result, "success-after-async-error");
|
||||
});
|
||||
// Lock should be released, allowing another operation
|
||||
const result = await locks.withLock(
|
||||
testPath,
|
||||
() => "success-after-async-error"
|
||||
);
|
||||
assert.strictEqual(result, "success-after-async-error");
|
||||
});
|
||||
|
||||
it("should handle empty array of keys", async () => {
|
||||
const result = await locks.withLock([], () => "empty-keys");
|
||||
assert.strictEqual(result, "empty-keys");
|
||||
});
|
||||
it("should handle empty array of keys", async () => {
|
||||
const result = await locks.withLock([], () => "empty-keys");
|
||||
assert.strictEqual(result, "empty-keys");
|
||||
});
|
||||
|
||||
it("should maintain FIFO order for multiple waiters", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
it("should maintain FIFO order for multiple waiters", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
// Start first operation that holds the lock
|
||||
const firstPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("first-start");
|
||||
await sleep(100);
|
||||
executionOrder.push("first-end");
|
||||
return "first";
|
||||
});
|
||||
// Start first operation that holds the lock
|
||||
const firstPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("first-start");
|
||||
await sleep(100);
|
||||
executionOrder.push("first-end");
|
||||
return "first";
|
||||
});
|
||||
|
||||
// Small delay to ensure first operation starts
|
||||
await sleep(10);
|
||||
// Small delay to ensure first operation starts
|
||||
await sleep(10);
|
||||
|
||||
// Queue second and third operations
|
||||
const secondPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("second-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("second-end");
|
||||
return "second";
|
||||
});
|
||||
// Queue second and third operations
|
||||
const secondPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("second-start");
|
||||
await sleep(50);
|
||||
executionOrder.push("second-end");
|
||||
return "second";
|
||||
});
|
||||
|
||||
const thirdPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("third-start");
|
||||
await sleep(20);
|
||||
executionOrder.push("third-end");
|
||||
return "third";
|
||||
});
|
||||
const thirdPromise = locks.withLock(testPath, async () => {
|
||||
executionOrder.push("third-start");
|
||||
await sleep(20);
|
||||
executionOrder.push("third-end");
|
||||
return "third";
|
||||
});
|
||||
|
||||
const [first, second, third] = await awaitAll([
|
||||
firstPromise,
|
||||
secondPromise,
|
||||
thirdPromise
|
||||
]);
|
||||
const [first, second, third] = await awaitAll([
|
||||
firstPromise,
|
||||
secondPromise,
|
||||
thirdPromise
|
||||
]);
|
||||
|
||||
assert.strictEqual(first, "first");
|
||||
assert.strictEqual(second, "second");
|
||||
assert.strictEqual(third, "third");
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"first-start",
|
||||
"first-end",
|
||||
"second-start",
|
||||
"second-end",
|
||||
"third-start",
|
||||
"third-end"
|
||||
]);
|
||||
});
|
||||
assert.strictEqual(first, "first");
|
||||
assert.strictEqual(second, "second");
|
||||
assert.strictEqual(third, "third");
|
||||
assert.deepStrictEqual(executionOrder, [
|
||||
"first-start",
|
||||
"first-end",
|
||||
"second-start",
|
||||
"second-end",
|
||||
"third-start",
|
||||
"third-end"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,148 +8,148 @@ import { awaitAll } from "../await-all";
|
|||
* @template T The type of the key used for locking
|
||||
*/
|
||||
export class Locks<T> {
|
||||
/** Currently locked keys */
|
||||
private readonly locked = new Set<T>();
|
||||
/** Currently locked keys */
|
||||
private readonly locked = new Set<T>();
|
||||
|
||||
/** Queue of resolve functions waiting for each key */
|
||||
private readonly waiters = new Map<T, (() => unknown)[]>();
|
||||
/** Queue of resolve functions waiting for each key */
|
||||
private readonly waiters = new Map<T, (() => unknown)[]>();
|
||||
|
||||
public constructor(private readonly logger?: Logger) {}
|
||||
public constructor(private readonly logger?: Logger) {}
|
||||
|
||||
/**
|
||||
* Executes a function while holding exclusive locks on one or more keys.
|
||||
*
|
||||
* This method ensures that the provided function runs with exclusive access to the
|
||||
* specified key(s). Multiple keys are sorted to prevent deadlocks when different
|
||||
* operations request the same keys in different orders.
|
||||
*
|
||||
* @template R The return type of the function to execute
|
||||
* @param keyOrKeys A single key or array of keys to lock during function execution
|
||||
* @param fn The function to execute while holding the lock(s). Can be sync or async.
|
||||
* @returns A Promise that resolves to the return value of the executed function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Lock a single key
|
||||
* const result = await locks.withLock('file1', () => {
|
||||
* // Critical section - only one operation can access 'file1' at a time
|
||||
* return processFile('file1');
|
||||
* });
|
||||
*
|
||||
* // Lock multiple keys (prevents deadlocks through consistent ordering)
|
||||
* await locks.withLock(['file1', 'file2'], async () => {
|
||||
* // Critical section - exclusive access to both files
|
||||
* await moveFile('file1', 'file2');
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @throws Any error thrown by the provided function will be propagated after locks are released
|
||||
*/
|
||||
public async withLock<R>(
|
||||
keyOrKeys: T | T[],
|
||||
fn: () => R | Promise<R>
|
||||
): Promise<R> {
|
||||
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
||||
/**
|
||||
* Executes a function while holding exclusive locks on one or more keys.
|
||||
*
|
||||
* This method ensures that the provided function runs with exclusive access to the
|
||||
* specified key(s). Multiple keys are sorted to prevent deadlocks when different
|
||||
* operations request the same keys in different orders.
|
||||
*
|
||||
* @template R The return type of the function to execute
|
||||
* @param keyOrKeys A single key or array of keys to lock during function execution
|
||||
* @param fn The function to execute while holding the lock(s). Can be sync or async.
|
||||
* @returns A Promise that resolves to the return value of the executed function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Lock a single key
|
||||
* const result = await locks.withLock('file1', () => {
|
||||
* // Critical section - only one operation can access 'file1' at a time
|
||||
* return processFile('file1');
|
||||
* });
|
||||
*
|
||||
* // Lock multiple keys (prevents deadlocks through consistent ordering)
|
||||
* await locks.withLock(['file1', 'file2'], async () => {
|
||||
* // Critical section - exclusive access to both files
|
||||
* await moveFile('file1', 'file2');
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @throws Any error thrown by the provided function will be propagated after locks are released
|
||||
*/
|
||||
public async withLock<R>(
|
||||
keyOrKeys: T | T[],
|
||||
fn: () => R | Promise<R>
|
||||
): Promise<R> {
|
||||
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
|
||||
|
||||
// Deduplicate keys to prevent deadlock from acquiring same lock twice
|
||||
const uniqueKeys = Array.from(new Set(keys));
|
||||
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
||||
// Deduplicate keys to prevent deadlock from acquiring same lock twice
|
||||
const uniqueKeys = Array.from(new Set(keys));
|
||||
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
|
||||
|
||||
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
|
||||
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
uniqueKeys.forEach((key) => {
|
||||
this.unlock(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
uniqueKeys.forEach((key) => {
|
||||
this.unlock(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.locked.clear();
|
||||
this.waiters.clear();
|
||||
}
|
||||
public reset(): void {
|
||||
this.locked.clear();
|
||||
this.waiters.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to acquire a lock immediately without waiting.
|
||||
* Must call `unlock()` if successful.
|
||||
*
|
||||
* @param key The key to lock
|
||||
* @returns `true` if lock acquired, `false` if already locked
|
||||
*/
|
||||
public tryLock(key: T): boolean {
|
||||
if (this.locked.has(key)) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Attempts to acquire a lock immediately without waiting.
|
||||
* Must call `unlock()` if successful.
|
||||
*
|
||||
* @param key The key to lock
|
||||
* @returns `true` if lock acquired, `false` if already locked
|
||||
*/
|
||||
public tryLock(key: T): boolean {
|
||||
if (this.locked.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.locked.add(key);
|
||||
this.locked.add(key);
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits to acquire a lock, blocking until available.
|
||||
* Operations are queued in FIFO order. Must call `unlock()` when done.
|
||||
*
|
||||
* @param key The key to wait for and lock
|
||||
* @returns Promise that resolves when lock is acquired
|
||||
*/
|
||||
public async waitForLock(key: T): Promise<void> {
|
||||
if (this.tryLock(key)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
/**
|
||||
* Waits to acquire a lock, blocking until available.
|
||||
* Operations are queued in FIFO order. Must call `unlock()` when done.
|
||||
*
|
||||
* @param key The key to wait for and lock
|
||||
* @returns Promise that resolves when lock is acquired
|
||||
*/
|
||||
public async waitForLock(key: T): Promise<void> {
|
||||
if (this.tryLock(key)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.logger?.debug(`Waiting for lock on ${key}`);
|
||||
this.logger?.debug(`Waiting for lock on ${key}`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// DefaultDict behavior
|
||||
let waiting = this.waiters.get(key);
|
||||
if (!waiting) {
|
||||
waiting = [];
|
||||
this.waiters.set(key, waiting);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
// DefaultDict behavior
|
||||
let waiting = this.waiters.get(key);
|
||||
if (!waiting) {
|
||||
waiting = [];
|
||||
this.waiters.set(key, waiting);
|
||||
}
|
||||
|
||||
waiting.push(resolve);
|
||||
});
|
||||
}
|
||||
waiting.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a lock and grants access to the next waiting operation in FIFO order.
|
||||
* Removes the key from locked set if no waiters.
|
||||
*
|
||||
* @param key The key to unlock
|
||||
* @throws {Error} If key is not currently locked
|
||||
*/
|
||||
public unlock(key: T): void {
|
||||
if (!this.locked.has(key)) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Releases a lock and grants access to the next waiting operation in FIFO order.
|
||||
* Removes the key from locked set if no waiters.
|
||||
*
|
||||
* @param key The key to unlock
|
||||
* @throws {Error} If key is not currently locked
|
||||
*/
|
||||
public unlock(key: T): void {
|
||||
if (!this.locked.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove first waiter to ensure FIFO order
|
||||
const nextWaiting = this.waiters.get(key)?.shift();
|
||||
// Remove first waiter to ensure FIFO order
|
||||
const nextWaiting = this.waiters.get(key)?.shift();
|
||||
|
||||
if (nextWaiting) {
|
||||
this.logger?.debug(`Granted lock on ${key}`);
|
||||
nextWaiting();
|
||||
} else {
|
||||
this.locked.delete(key);
|
||||
}
|
||||
}
|
||||
if (nextWaiting) {
|
||||
this.logger?.debug(`Granted lock on ${key}`);
|
||||
nextWaiting();
|
||||
} else {
|
||||
this.locked.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Lock {
|
||||
private readonly locks: Locks<boolean>;
|
||||
private readonly locks: Locks<boolean>;
|
||||
|
||||
public constructor(logger?: Logger) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
public constructor(logger?: Logger) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
|
||||
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
||||
return this.locks.withLock(true, fn);
|
||||
}
|
||||
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
|
||||
return this.locks.withLock(true, fn);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,74 +3,74 @@ import assert from "node:assert";
|
|||
import { CoveredValues } from "./min-covered";
|
||||
|
||||
describe("CoveredValues", () => {
|
||||
it("should initialize with the given min value", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
});
|
||||
it("should initialize with the given min value", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
});
|
||||
|
||||
it("should add values greater than min", () => {
|
||||
const covered = new CoveredValues(0);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 0);
|
||||
covered.add(1);
|
||||
assert.strictEqual(covered.min, 1);
|
||||
covered.add(4);
|
||||
assert.strictEqual(covered.min, 1);
|
||||
covered.add(2);
|
||||
assert.strictEqual(covered.min, 4);
|
||||
});
|
||||
it("should add values greater than min", () => {
|
||||
const covered = new CoveredValues(0);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 0);
|
||||
covered.add(1);
|
||||
assert.strictEqual(covered.min, 1);
|
||||
covered.add(4);
|
||||
assert.strictEqual(covered.min, 1);
|
||||
covered.add(2);
|
||||
assert.strictEqual(covered.min, 4);
|
||||
});
|
||||
|
||||
it("should ignore duplicate values", () => {
|
||||
const covered = new CoveredValues(0);
|
||||
covered.add(3);
|
||||
covered.add(3);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 0);
|
||||
covered.add(1);
|
||||
covered.add(2);
|
||||
assert.strictEqual(covered.min, 3);
|
||||
});
|
||||
it("should ignore duplicate values", () => {
|
||||
const covered = new CoveredValues(0);
|
||||
covered.add(3);
|
||||
covered.add(3);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 0);
|
||||
covered.add(1);
|
||||
covered.add(2);
|
||||
assert.strictEqual(covered.min, 3);
|
||||
});
|
||||
|
||||
it("should handle multiple consecutive values", () => {
|
||||
const covered = new CoveredValues(132);
|
||||
for (let i = 250; i > 132; i--) {
|
||||
assert.strictEqual(covered.min, 132);
|
||||
covered.add(i);
|
||||
}
|
||||
assert.strictEqual(covered.min, 250);
|
||||
});
|
||||
it("should handle multiple consecutive values", () => {
|
||||
const covered = new CoveredValues(132);
|
||||
for (let i = 250; i > 132; i--) {
|
||||
assert.strictEqual(covered.min, 132);
|
||||
covered.add(i);
|
||||
}
|
||||
assert.strictEqual(covered.min, 250);
|
||||
});
|
||||
|
||||
it("should handle adding values lower than current min", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
covered.add(6);
|
||||
assert.strictEqual(covered.min, 6);
|
||||
});
|
||||
it("should handle adding values lower than current min", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(3);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
covered.add(6);
|
||||
assert.strictEqual(covered.min, 6);
|
||||
});
|
||||
|
||||
it("should auto-advance when setting min value", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(7);
|
||||
covered.add(8);
|
||||
covered.add(9);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
// Setting min to 6 should auto-advance through 7, 8, 9
|
||||
covered.min = 6;
|
||||
assert.strictEqual(covered.min, 9);
|
||||
covered.add(10);
|
||||
assert.strictEqual(covered.min, 10);
|
||||
});
|
||||
it("should auto-advance when setting min value", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(7);
|
||||
covered.add(8);
|
||||
covered.add(9);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
// Setting min to 6 should auto-advance through 7, 8, 9
|
||||
covered.min = 6;
|
||||
assert.strictEqual(covered.min, 9);
|
||||
covered.add(10);
|
||||
assert.strictEqual(covered.min, 10);
|
||||
});
|
||||
|
||||
it("should handle setting min value with no consecutive values", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(10);
|
||||
covered.add(15);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
// Setting min to 8 should not auto-advance (no consecutive values)
|
||||
covered.min = 8;
|
||||
assert.strictEqual(covered.min, 8);
|
||||
// Add 9 to trigger auto-advance to 10
|
||||
covered.add(9);
|
||||
assert.strictEqual(covered.min, 10);
|
||||
});
|
||||
it("should handle setting min value with no consecutive values", () => {
|
||||
const covered = new CoveredValues(5);
|
||||
covered.add(10);
|
||||
covered.add(15);
|
||||
assert.strictEqual(covered.min, 5);
|
||||
// Setting min to 8 should not auto-advance (no consecutive values)
|
||||
covered.min = 8;
|
||||
assert.strictEqual(covered.min, 8);
|
||||
// Add 9 to trigger auto-advance to 10
|
||||
covered.add(9);
|
||||
assert.strictEqual(covered.min, 10);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,48 +14,48 @@
|
|||
* ```
|
||||
*/
|
||||
export class CoveredValues {
|
||||
private seenValues: number[] = [];
|
||||
private seenValues: number[] = [];
|
||||
|
||||
public constructor(private minValue: number) {}
|
||||
public constructor(private minValue: number) {}
|
||||
|
||||
public get min(): number {
|
||||
return this.minValue;
|
||||
}
|
||||
public get min(): number {
|
||||
return this.minValue;
|
||||
}
|
||||
|
||||
public set min(value: number) {
|
||||
this.minValue = Math.max(value, this.minValue);
|
||||
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
|
||||
this.advanceMinWhilePossible();
|
||||
}
|
||||
public set min(value: number) {
|
||||
this.minValue = Math.max(value, this.minValue);
|
||||
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
|
||||
this.advanceMinWhilePossible();
|
||||
}
|
||||
|
||||
public add(value: number | undefined): void {
|
||||
if (value === undefined || value < this.minValue) {
|
||||
return;
|
||||
}
|
||||
public add(value: number | undefined): void {
|
||||
if (value === undefined || value < this.minValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < this.seenValues.length && this.seenValues[i] < value) {
|
||||
i++;
|
||||
}
|
||||
let i = 0;
|
||||
while (i < this.seenValues.length && this.seenValues[i] < value) {
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i === this.seenValues.length) {
|
||||
this.seenValues.push(value);
|
||||
} else if (this.seenValues[i] === value) {
|
||||
return;
|
||||
} else {
|
||||
this.seenValues.splice(i, 0, value);
|
||||
}
|
||||
if (i === this.seenValues.length) {
|
||||
this.seenValues.push(value);
|
||||
} else if (this.seenValues[i] === value) {
|
||||
return;
|
||||
} else {
|
||||
this.seenValues.splice(i, 0, value);
|
||||
}
|
||||
|
||||
this.advanceMinWhilePossible();
|
||||
}
|
||||
this.advanceMinWhilePossible();
|
||||
}
|
||||
|
||||
private advanceMinWhilePossible(): void {
|
||||
while (
|
||||
this.seenValues.length > 0 &&
|
||||
this.seenValues[0] === this.minValue + 1
|
||||
) {
|
||||
this.seenValues.shift();
|
||||
this.minValue++;
|
||||
}
|
||||
}
|
||||
private advanceMinWhilePossible(): void {
|
||||
while (
|
||||
this.seenValues.length > 0 &&
|
||||
this.seenValues[0] === this.minValue + 1
|
||||
) {
|
||||
this.seenValues.shift();
|
||||
this.minValue++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import type { LogLine } from "../../tracing/logger";
|
|||
import { LogLevel } from "../../tracing/logger";
|
||||
|
||||
export function logToConsole(client: SyncClient): void {
|
||||
client.logger.onLogEmitted.add((logLine: LogLine) => {
|
||||
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
|
||||
client.logger.onLogEmitted.add((logLine: LogLine) => {
|
||||
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
|
||||
|
||||
switch (logLine.level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(formatted);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(formatted);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formatted);
|
||||
break;
|
||||
}
|
||||
});
|
||||
switch (logLine.level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(formatted);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
console.warn(formatted);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
console.info(formatted);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
console.debug(formatted);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { sleep } from "../sleep";
|
||||
|
||||
export const slowFetchFactory =
|
||||
(jitterScaleInSeconds: number) =>
|
||||
async (
|
||||
input: string | URL | globalThis.Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||
}
|
||||
(jitterScaleInSeconds: number) =>
|
||||
async (
|
||||
input: string | URL | globalThis.Request,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||
}
|
||||
|
||||
const response = await fetch(input, init);
|
||||
const response = await fetch(input, init);
|
||||
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||
}
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,79 +3,79 @@ import { Locks } from "../data-structures/locks";
|
|||
import type { Logger } from "../../tracing/logger";
|
||||
|
||||
export function slowWebSocketFactory(
|
||||
jitterScaleInSeconds: number,
|
||||
logger: Logger
|
||||
jitterScaleInSeconds: number,
|
||||
logger: Logger
|
||||
): typeof WebSocket {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return class FlakyWebSocket extends WebSocket {
|
||||
private static readonly RECEIVE_KEY = "websocket-receive";
|
||||
private static readonly SEND_KEY = "websocket-send";
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return class FlakyWebSocket extends WebSocket {
|
||||
private static readonly RECEIVE_KEY = "websocket-receive";
|
||||
private static readonly SEND_KEY = "websocket-send";
|
||||
|
||||
private readonly locks = new Locks(logger);
|
||||
private readonly locks = new Locks(logger);
|
||||
|
||||
public set onopen(callback: ((event: Event) => void) | null) {
|
||||
super.onopen = async (event: Event): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
public set onopen(callback: ((event: Event) => void) | null) {
|
||||
super.onopen = async (event: Event): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
public set onmessage(callback: ((event: MessageEvent) => void) | null) {
|
||||
super.onmessage = async (event: MessageEvent): Promise<void> => {
|
||||
await this.locks.withLock(
|
||||
FlakyWebSocket.RECEIVE_KEY,
|
||||
async () => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(
|
||||
Math.random() * jitterScaleInSeconds * 1000
|
||||
);
|
||||
}
|
||||
public set onmessage(callback: ((event: MessageEvent) => void) | null) {
|
||||
super.onmessage = async (event: MessageEvent): Promise<void> => {
|
||||
await this.locks.withLock(
|
||||
FlakyWebSocket.RECEIVE_KEY,
|
||||
async () => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(
|
||||
Math.random() * jitterScaleInSeconds * 1000
|
||||
);
|
||||
}
|
||||
|
||||
callback?.(event);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
callback?.(event);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
public set onclose(callback: ((event: CloseEvent) => void) | null) {
|
||||
super.onclose = async (event: CloseEvent): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
public set onclose(callback: ((event: CloseEvent) => void) | null) {
|
||||
super.onclose = async (event: CloseEvent): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
public set onerror(callback: ((event: Event) => void) | null) {
|
||||
super.onerror = async (event: Event): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
public set onerror(callback: ((event: Event) => void) | null) {
|
||||
super.onerror = async (event: Event): Promise<void> => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
callback?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
public send(
|
||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||
): void {
|
||||
this.waitingSend(data).catch((error: unknown) => {
|
||||
logger.error(`Error sending WebSocket message: ${error}`);
|
||||
});
|
||||
}
|
||||
public send(
|
||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||
): void {
|
||||
this.waitingSend(data).catch((error: unknown) => {
|
||||
logger.error(`Error sending WebSocket message: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async waitingSend(
|
||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||
): Promise<void> {
|
||||
// maintain message order
|
||||
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
super.send(data);
|
||||
});
|
||||
}
|
||||
} as unknown as typeof WebSocket;
|
||||
private async waitingSend(
|
||||
data: string | ArrayBufferLike | Blob | ArrayBufferView
|
||||
): Promise<void> {
|
||||
// maintain message order
|
||||
await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => {
|
||||
if (jitterScaleInSeconds > 0) {
|
||||
await sleep(Math.random() * jitterScaleInSeconds * 1000);
|
||||
}
|
||||
super.send(data);
|
||||
});
|
||||
}
|
||||
} as unknown as typeof WebSocket;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { EMPTY_HASH } from "./hash";
|
|||
|
||||
// TODO: make this smarter so that offline files can be renamed & edited at the same time
|
||||
export function findMatchingFile(
|
||||
contentHash: string,
|
||||
candidates: DocumentRecord[]
|
||||
contentHash: string,
|
||||
candidates: DocumentRecord[]
|
||||
): DocumentRecord | undefined {
|
||||
if (contentHash === EMPTY_HASH) {
|
||||
return undefined;
|
||||
}
|
||||
if (contentHash === EMPTY_HASH) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return candidates.find(({ metadata }) => metadata?.hash === contentHash);
|
||||
return candidates.find(({ metadata }) => metadata?.hash === contentHash);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export function getRandomColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
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))})`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
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))})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { Logger } from "../tracing/logger";
|
|||
import { globsToRegexes } from "./globs-to-regexes";
|
||||
|
||||
describe("globsToRegexes", () => {
|
||||
it("basicExample", async () => {
|
||||
const [regex] = globsToRegexes([".git/**"], new Logger());
|
||||
it("basicExample", async () => {
|
||||
const [regex] = globsToRegexes([".git/**"], new Logger());
|
||||
|
||||
assert.ok(regex.test(".git/objects/object"));
|
||||
assert.ok(regex.test(".git/objects/.object"));
|
||||
});
|
||||
assert.ok(regex.test(".git/objects/object"));
|
||||
assert.ok(regex.test(".git/objects/.object"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ import { makeRe } from "minimatch";
|
|||
import type { Logger } from "../tracing/logger";
|
||||
|
||||
export function globsToRegexes(globs: string[], logger: Logger): RegExp[] {
|
||||
return (
|
||||
globs
|
||||
.map((pattern) => {
|
||||
const result = makeRe(pattern, {
|
||||
dot: true
|
||||
});
|
||||
if (result === false) {
|
||||
logger.warn(
|
||||
`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
|
||||
.filter((pattern) => pattern !== false)
|
||||
);
|
||||
return (
|
||||
globs
|
||||
.map((pattern) => {
|
||||
const result = makeRe(pattern, {
|
||||
dot: true
|
||||
});
|
||||
if (result === false) {
|
||||
logger.warn(
|
||||
`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
|
||||
.filter((pattern) => pattern !== false)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
||||
export function hash(content: Uint8Array): string {
|
||||
let result = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
result = (result << 5) - result + content[i];
|
||||
result |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(result).toString(16).padStart(8, "0");
|
||||
let result = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
result = (result << 5) - result + content[i];
|
||||
result |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(result).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
export const EMPTY_HASH = hash(new Uint8Array(0));
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
for (const byte of content) {
|
||||
if (byte === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(content);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,70 +4,70 @@ import { isFileTypeMergable } from "./is-file-type-mergable";
|
|||
|
||||
const mergableExtensions = ["md", "txt"];
|
||||
describe("isFileTypeMergable", () => {
|
||||
it("should return true for .md files", () => {
|
||||
assert.strictEqual(isFileTypeMergable(".md", mergableExtensions), true);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.md", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/path/to/my/document.md", mergableExtensions),
|
||||
true
|
||||
);
|
||||
});
|
||||
it("should return true for .md files", () => {
|
||||
assert.strictEqual(isFileTypeMergable(".md", mergableExtensions), true);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.md", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/path/to/my/document.md", mergableExtensions),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return true for .txt files", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(".txt", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.txt", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(
|
||||
"my/path/to/my/document.txt",
|
||||
mergableExtensions
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
it("should return true for .txt files", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(".txt", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.txt", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(
|
||||
"my/path/to/my/document.txt",
|
||||
mergableExtensions
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should be case insensitive", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.MD", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/path/to/my/DOCUMENT.MD", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.TXT", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(
|
||||
"my/path/to/my/DOCUMENT.TXT",
|
||||
mergableExtensions
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
it("should be case insensitive", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.MD", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/path/to/my/DOCUMENT.MD", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("hi.TXT", mergableExtensions),
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(
|
||||
"my/path/to/my/DOCUMENT.TXT",
|
||||
mergableExtensions
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false for non-mergable file types", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(".json", mergableExtensions),
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("HELLO.JSON", mergableExtensions),
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/config.yml", mergableExtensions),
|
||||
false
|
||||
);
|
||||
});
|
||||
it("should return false for non-mergable file types", () => {
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable(".json", mergableExtensions),
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("HELLO.JSON", mergableExtensions),
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
isFileTypeMergable("my/config.yml", mergableExtensions),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export function isFileTypeMergable(
|
||||
pathOrFileName: string,
|
||||
mergeableExtensions: string[]
|
||||
pathOrFileName: string,
|
||||
mergeableExtensions: string[]
|
||||
): boolean {
|
||||
const parts = pathOrFileName.split(".");
|
||||
const fileExtension = parts.at(-1) ?? "";
|
||||
const parts = pathOrFileName.split(".");
|
||||
const fileExtension = parts.at(-1) ?? "";
|
||||
|
||||
return mergeableExtensions.includes(fileExtension.toLowerCase());
|
||||
return mergeableExtensions.includes(fileExtension.toLowerCase());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,42 +3,42 @@ import assert from "node:assert";
|
|||
import { lineAndColumnToPosition } from "./line-and-column-to-position";
|
||||
|
||||
describe("lineAndColumnToPosition", () => {
|
||||
it("should return the correct position for the first line", () => {
|
||||
const text = "Hello\nWorld";
|
||||
const position = lineAndColumnToPosition(text, 0, 3);
|
||||
assert.strictEqual(position, 3);
|
||||
});
|
||||
it("should return the correct position for the first line", () => {
|
||||
const text = "Hello\nWorld";
|
||||
const position = lineAndColumnToPosition(text, 0, 3);
|
||||
assert.strictEqual(position, 3);
|
||||
});
|
||||
|
||||
it("should return the correct position for the second line", () => {
|
||||
const text = "Hello\nWorld";
|
||||
const position = lineAndColumnToPosition(text, 1, 2);
|
||||
assert.strictEqual(position, 8);
|
||||
});
|
||||
it("should return the correct position for the second line", () => {
|
||||
const text = "Hello\nWorld";
|
||||
const position = lineAndColumnToPosition(text, 1, 2);
|
||||
assert.strictEqual(position, 8);
|
||||
});
|
||||
|
||||
it("should return the correct position for an empty string", () => {
|
||||
const text = "";
|
||||
const position = lineAndColumnToPosition(text, 0, 0);
|
||||
assert.strictEqual(position, 0);
|
||||
});
|
||||
it("should return the correct position for an empty string", () => {
|
||||
const text = "";
|
||||
const position = lineAndColumnToPosition(text, 0, 0);
|
||||
assert.strictEqual(position, 0);
|
||||
});
|
||||
|
||||
it("with carrige return", () => {
|
||||
assert.strictEqual(lineAndColumnToPosition("a\nb", 1, 1), 3);
|
||||
assert.strictEqual(lineAndColumnToPosition("a\r\nb", 1, 1), 3);
|
||||
});
|
||||
it("with carrige return", () => {
|
||||
assert.strictEqual(lineAndColumnToPosition("a\nb", 1, 1), 3);
|
||||
assert.strictEqual(lineAndColumnToPosition("a\r\nb", 1, 1), 3);
|
||||
});
|
||||
|
||||
it("should handle multi-line strings with varying lengths", () => {
|
||||
const text = "Line1\nLongerLine2\nShort3";
|
||||
const position = lineAndColumnToPosition(text, 2, 4);
|
||||
assert.strictEqual(position, 22);
|
||||
});
|
||||
it("should handle multi-line strings with varying lengths", () => {
|
||||
const text = "Line1\nLongerLine2\nShort3";
|
||||
const position = lineAndColumnToPosition(text, 2, 4);
|
||||
assert.strictEqual(position, 22);
|
||||
});
|
||||
|
||||
it("should throw an error if the line number is out of range", () => {
|
||||
const text = "Line1\nLine2";
|
||||
assert.throws(() => lineAndColumnToPosition(text, 3, 0));
|
||||
});
|
||||
it("should throw an error if the line number is out of range", () => {
|
||||
const text = "Line1\nLine2";
|
||||
assert.throws(() => lineAndColumnToPosition(text, 3, 0));
|
||||
});
|
||||
|
||||
it("should throw an error if the column number is out of range", () => {
|
||||
const text = "Line1\nLine2";
|
||||
assert.throws(() => lineAndColumnToPosition(text, 1, 10));
|
||||
});
|
||||
it("should throw an error if the column number is out of range", () => {
|
||||
const text = "Line1\nLine2";
|
||||
assert.throws(() => lineAndColumnToPosition(text, 1, 10));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,26 +9,26 @@
|
|||
* @throws Error if column number is out of range
|
||||
*/
|
||||
export function lineAndColumnToPosition(
|
||||
text: string,
|
||||
line: number,
|
||||
column: number
|
||||
text: string,
|
||||
line: number,
|
||||
column: number
|
||||
): number {
|
||||
const lines = text.replaceAll("\r", "").split("\n");
|
||||
const lines = text.replaceAll("\r", "").split("\n");
|
||||
|
||||
if (line >= lines.length) {
|
||||
throw new Error(`Line number ${line} is out of range.`);
|
||||
}
|
||||
if (line >= lines.length) {
|
||||
throw new Error(`Line number ${line} is out of range.`);
|
||||
}
|
||||
|
||||
if (column > lines[line].length) {
|
||||
throw new Error(`Column number ${column} is out of range.`);
|
||||
}
|
||||
if (column > lines[line].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;
|
||||
}
|
||||
let position = 0;
|
||||
for (let i = 0; i < line; i++) {
|
||||
position += lines[i].length + 1;
|
||||
}
|
||||
|
||||
position += column;
|
||||
position += column;
|
||||
|
||||
return position;
|
||||
return position;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,86 +3,86 @@ import assert from "node:assert";
|
|||
import { positionToLineAndColumn } from "./position-to-line-and-column";
|
||||
|
||||
describe("positionToLineAndColumn", () => {
|
||||
test("converts position to line and column in multi-line text", () => {
|
||||
const text = "ab\ncd\n";
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 1), {
|
||||
line: 0,
|
||||
column: 1
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 2), {
|
||||
line: 0,
|
||||
column: 2
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 3), {
|
||||
line: 1,
|
||||
column: 0
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 4), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 6), {
|
||||
line: 2,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
test("converts position to line and column in multi-line text", () => {
|
||||
const text = "ab\ncd\n";
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 1), {
|
||||
line: 0,
|
||||
column: 1
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 2), {
|
||||
line: 0,
|
||||
column: 2
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 3), {
|
||||
line: 1,
|
||||
column: 0
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 4), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 6), {
|
||||
line: 2,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
|
||||
test("with carrige returns", () => {
|
||||
assert.deepStrictEqual(positionToLineAndColumn("a\nb", 3), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
test("with carrige returns", () => {
|
||||
assert.deepStrictEqual(positionToLineAndColumn("a\nb", 3), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(positionToLineAndColumn("a\r\nb", 3), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn("a\r\nb", 3), {
|
||||
line: 1,
|
||||
column: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("with multiple carriage returns", () => {
|
||||
// Test that all \r characters are removed, not just the first one
|
||||
const text = "line1\r\nline2\r\nline3\r\n";
|
||||
test("with multiple carriage returns", () => {
|
||||
// Test that all \r characters are removed, not just the first one
|
||||
const text = "line1\r\nline2\r\nline3\r\n";
|
||||
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
|
||||
// Position 6 = start of 'line2' after all \r removed
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 6), {
|
||||
line: 1,
|
||||
column: 0
|
||||
});
|
||||
// Position 6 = start of 'line2' after all \r removed
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 6), {
|
||||
line: 1,
|
||||
column: 0
|
||||
});
|
||||
|
||||
// Position 12 = start of 'line3' after all \r removed
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 12), {
|
||||
line: 2,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
// Position 12 = start of 'line3' after all \r removed
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 12), {
|
||||
line: 2,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty input", () => {
|
||||
assert.deepStrictEqual(positionToLineAndColumn("", 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
test("handles empty input", () => {
|
||||
assert.deepStrictEqual(positionToLineAndColumn("", 0), {
|
||||
line: 0,
|
||||
column: 0
|
||||
});
|
||||
});
|
||||
|
||||
test("handles positions at the end of text", () => {
|
||||
const text = "End";
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 3), {
|
||||
line: 0,
|
||||
column: 3
|
||||
});
|
||||
});
|
||||
test("handles positions at the end of text", () => {
|
||||
const text = "End";
|
||||
assert.deepStrictEqual(positionToLineAndColumn(text, 3), {
|
||||
line: 0,
|
||||
column: 3
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error for position out of range", () => {
|
||||
const text = "Short text";
|
||||
assert.throws(() => positionToLineAndColumn(text, 15));
|
||||
assert.throws(() => positionToLineAndColumn(text, -1));
|
||||
});
|
||||
test("throws error for position out of range", () => {
|
||||
const text = "Short text";
|
||||
assert.throws(() => positionToLineAndColumn(text, 15));
|
||||
assert.throws(() => positionToLineAndColumn(text, -1));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,27 +7,27 @@
|
|||
* @throws Will throw an error if the position is negative or exceeds the text length
|
||||
*/
|
||||
export function positionToLineAndColumn(
|
||||
text: string,
|
||||
position: number
|
||||
text: string,
|
||||
position: number
|
||||
): { line: number; column: number } {
|
||||
if (position < 0) {
|
||||
throw new Error("Position cannot be negative");
|
||||
}
|
||||
if (position < 0) {
|
||||
throw new Error("Position cannot be negative");
|
||||
}
|
||||
|
||||
text = text.replaceAll("\r", "");
|
||||
text = text.replaceAll("\r", "");
|
||||
|
||||
if (position > text.length) {
|
||||
// position == text.length accounts for the cursor being after last character
|
||||
throw new Error(
|
||||
`Position ${position} exceeds text length ${text.length}`
|
||||
);
|
||||
}
|
||||
if (position > text.length) {
|
||||
// position == text.length accounts for the cursor being after last character
|
||||
throw new Error(
|
||||
`Position ${position} exceeds text length ${text.length}`
|
||||
);
|
||||
}
|
||||
|
||||
const textUpToPosition = text.substring(0, position);
|
||||
const lines = textUpToPosition.split("\n");
|
||||
const textUpToPosition = text.substring(0, position);
|
||||
const lines = textUpToPosition.split("\n");
|
||||
|
||||
const line = lines.length - 1;
|
||||
const column = lines[lines.length - 1].length;
|
||||
const line = lines.length - 1;
|
||||
const column = lines[lines.length - 1].length;
|
||||
|
||||
return { line, column };
|
||||
return { line, column };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,62 +3,62 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
|||
import assert from "node:assert";
|
||||
|
||||
describe("rateLimit", () => {
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
});
|
||||
beforeEach(() => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it("should call the function immediately on first invocation", async () => {
|
||||
const mockFn = mock.fn<() => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async () => "result");
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
it("should call the function immediately on first invocation", async () => {
|
||||
const mockFn = mock.fn<() => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async () => "result");
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
|
||||
const promise = rateLimited();
|
||||
assert.strictEqual(mockFn.mock.callCount(), 1);
|
||||
const promise = rateLimited();
|
||||
assert.strictEqual(mockFn.mock.callCount(), 1);
|
||||
|
||||
await promise;
|
||||
});
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("should call the function again after the interval has passed", async () => {
|
||||
const mockFn = mock.fn<(value: number) => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async () => "result");
|
||||
it("should call the function again after the interval has passed", async () => {
|
||||
const mockFn = mock.fn<(value: number) => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async () => "result");
|
||||
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
|
||||
const promise1 = rateLimited(1);
|
||||
await promise1;
|
||||
const promise1 = rateLimited(1);
|
||||
await promise1;
|
||||
|
||||
mock.timers.tick(200);
|
||||
mock.timers.tick(200);
|
||||
|
||||
const promise2 = rateLimited(2);
|
||||
await promise2;
|
||||
const promise2 = rateLimited(2);
|
||||
await promise2;
|
||||
|
||||
assert.strictEqual(mockFn.mock.callCount(), 2);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [2]);
|
||||
});
|
||||
assert.strictEqual(mockFn.mock.callCount(), 2);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [2]);
|
||||
});
|
||||
|
||||
it("should use the most recent arguments if multiple calls are made within interval", async () => {
|
||||
const mockFn = mock.fn<(value: string) => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async (val: string) => `${val}-result`);
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
it("should use the most recent arguments if multiple calls are made within interval", async () => {
|
||||
const mockFn = mock.fn<(value: string) => Promise<string>>();
|
||||
mockFn.mock.mockImplementation(async (val: string) => `${val}-result`);
|
||||
const rateLimited = rateLimit(mockFn, 100);
|
||||
|
||||
const promise1 = rateLimited("first");
|
||||
mock.timers.tick(10);
|
||||
const promise2 = rateLimited("second");
|
||||
mock.timers.tick(10);
|
||||
const promise3 = rateLimited("third");
|
||||
const promise1 = rateLimited("first");
|
||||
mock.timers.tick(10);
|
||||
const promise2 = rateLimited("second");
|
||||
mock.timers.tick(10);
|
||||
const promise3 = rateLimited("third");
|
||||
|
||||
mock.timers.tick(1000);
|
||||
mock.timers.tick(1000);
|
||||
|
||||
assert.strictEqual(await promise1, "first-result");
|
||||
assert.strictEqual(await promise2, "third-result");
|
||||
assert.strictEqual(await promise3, undefined);
|
||||
assert.strictEqual(await promise1, "first-result");
|
||||
assert.strictEqual(await promise2, "third-result");
|
||||
assert.strictEqual(await promise3, undefined);
|
||||
|
||||
assert.strictEqual(mockFn.mock.callCount(), 2);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ["first"]);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ["third"]);
|
||||
});
|
||||
assert.strictEqual(mockFn.mock.callCount(), 2);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ["first"]);
|
||||
assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ["third"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,48 +16,48 @@ import { sleep } from "./sleep";
|
|||
* 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>
|
||||
R,
|
||||
T extends (
|
||||
...args: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<R>
|
||||
>(
|
||||
fn: T,
|
||||
minIntervalMs: number | (() => number)
|
||||
fn: T,
|
||||
minIntervalMs: number | (() => number)
|
||||
): (...args: Parameters<T>) => Promise<R | undefined> {
|
||||
let newArgs: Parameters<T> | undefined = undefined;
|
||||
let running: Promise<unknown> | undefined = undefined;
|
||||
let newArgs: Parameters<T> | undefined = undefined;
|
||||
let running: Promise<unknown> | undefined = undefined;
|
||||
|
||||
const decoratedFn = async (
|
||||
...args: Parameters<T>
|
||||
): Promise<R | undefined> => {
|
||||
if (running !== undefined) {
|
||||
newArgs = args;
|
||||
await running;
|
||||
const decoratedFn = async (
|
||||
...args: Parameters<T>
|
||||
): Promise<R | undefined> => {
|
||||
if (running !== undefined) {
|
||||
newArgs = args;
|
||||
await running;
|
||||
|
||||
// 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
|
||||
return;
|
||||
}
|
||||
args = newArgs;
|
||||
newArgs = undefined;
|
||||
}
|
||||
// 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
|
||||
return;
|
||||
}
|
||||
args = newArgs;
|
||||
newArgs = undefined;
|
||||
}
|
||||
|
||||
const [promise, resolve] = createPromise();
|
||||
running = promise;
|
||||
sleep(
|
||||
typeof minIntervalMs === "function"
|
||||
? minIntervalMs()
|
||||
: minIntervalMs
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
// sleep cannot fail
|
||||
});
|
||||
return fn(...args);
|
||||
};
|
||||
const [promise, resolve] = createPromise();
|
||||
running = promise;
|
||||
sleep(
|
||||
typeof minIntervalMs === "function"
|
||||
? minIntervalMs()
|
||||
: minIntervalMs
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
// sleep cannot fail
|
||||
});
|
||||
return fn(...args);
|
||||
};
|
||||
|
||||
return decoratedFn;
|
||||
return decoratedFn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,38 +4,38 @@ import * as Sentry from "@sentry/browser";
|
|||
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
|
||||
|
||||
export const setUpTelemetry = (): (() => void) => {
|
||||
Sentry.init({
|
||||
dsn: "https://a9bb2b9151bb450ca86b936436e356c4@bugs.schmelczer.dev/1",
|
||||
release: `sync-client@${packageVersion}`,
|
||||
sendDefaultPii: true,
|
||||
integrations: [],
|
||||
tracesSampleRate: 0
|
||||
});
|
||||
Sentry.init({
|
||||
dsn: "https://a9bb2b9151bb450ca86b936436e356c4@bugs.schmelczer.dev/1",
|
||||
release: `sync-client@${packageVersion}`,
|
||||
sendDefaultPii: true,
|
||||
integrations: [],
|
||||
tracesSampleRate: 0
|
||||
});
|
||||
|
||||
Sentry.captureMessage("Initialised telemetry");
|
||||
Sentry.captureMessage("Initialised telemetry");
|
||||
|
||||
const onError = (event: ErrorEvent): void => {
|
||||
Sentry.captureException(event.error, {
|
||||
extra: {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("error", onError);
|
||||
const onError = (event: ErrorEvent): void => {
|
||||
Sentry.captureException(event.error, {
|
||||
extra: {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener("error", onError);
|
||||
|
||||
const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||
Sentry.captureException(event.reason);
|
||||
};
|
||||
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
||||
const onUnhandledRejection = (event: PromiseRejectionEvent): void => {
|
||||
Sentry.captureException(event.reason);
|
||||
};
|
||||
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
||||
Sentry.close(5000).catch(() => {
|
||||
// Ignore errors during shutdown
|
||||
});
|
||||
};
|
||||
return (): void => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
||||
Sentry.close(5000).catch(() => {
|
||||
// Ignore errors during shutdown
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue