mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 00:08:23 +00:00
Merge main into the display language correction
This commit is contained in:
commit
06bba0daea
257 files changed
+15727
-3167
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { deleteDB, type IDBPDatabase, openDB } from "idb";
|
||||
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
import { Logger } from "octagonal-wheels/common/logger";
|
||||
const databaseCache: { [key: string]: IDBPDatabase<unknown> } = {};
|
||||
export { OpenKeyValueDatabase } from "./KeyValueDBv2.ts";
|
||||
@@ -13,7 +13,7 @@ export const _OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueData
|
||||
const storeKey = dbKey;
|
||||
let db: IDBPDatabase<unknown> | null = null;
|
||||
const _openDB = () => {
|
||||
return serialized("keyvaluedb-" + dbKey, async () => {
|
||||
return withLock("keyvaluedb-" + dbKey, async () => {
|
||||
const dbInstance = await openDB(dbKey, 1, {
|
||||
upgrade(db, _oldVersion, _newVersion, _transaction, _event) {
|
||||
return db.createObjectStore(storeKey);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { deleteDB, openDB, type IDBPDatabase } from "idb";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { withLock } from "octagonal-wheels/concurrency/lock";
|
||||
|
||||
const databaseCache = new Map<string, IDBKeyValueDatabase>();
|
||||
|
||||
export async function OpenKeyValueDatabase(dbKey: string): Promise<KeyValueDatabase> {
|
||||
return await serialized(`OpenKeyValueDatabase-${dbKey}`, async () => {
|
||||
return await withLock(`OpenKeyValueDatabase-${dbKey}`, async () => {
|
||||
const cachedDB = databaseCache.get(dbKey);
|
||||
if (cachedDB) {
|
||||
if (!cachedDB.isDestroyed) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { stepCyclicIndex } from "octagonal-wheels/collection";
|
||||
|
||||
export function navigateDiffNodes(
|
||||
root: ParentNode,
|
||||
selector: string,
|
||||
navigation: {
|
||||
currentDiffIndex: number;
|
||||
diffNavIndicator: { setText(text: string): void };
|
||||
},
|
||||
direction: "prev" | "next"
|
||||
): void {
|
||||
const diffElements = root.querySelectorAll<HTMLElement>(selector);
|
||||
if (diffElements.length === 0) return;
|
||||
|
||||
const previousFocused = root.querySelector(".diff-focused");
|
||||
if (previousFocused) {
|
||||
previousFocused.classList.remove("diff-focused");
|
||||
}
|
||||
|
||||
navigation.currentDiffIndex = stepCyclicIndex(navigation.currentDiffIndex, diffElements.length, direction);
|
||||
const target = diffElements[navigation.currentDiffIndex];
|
||||
target.classList.add("diff-focused");
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
navigation.diffNavIndicator.setText(`${navigation.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { withOwnedResource, type DisposableResource } from "octagonal-wheels/resource";
|
||||
|
||||
/**
|
||||
* Run a finite operation with a flow-owned remote resource and release it
|
||||
* after either success or failure.
|
||||
@@ -6,13 +8,9 @@
|
||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||
* a provider-owned resource when their operation rejects.
|
||||
*/
|
||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
||||
export function withOwnedRemoteResource<TResource extends DisposableResource & { dispose(): Promise<void> }, TResult>(
|
||||
resource: TResource,
|
||||
operation: (ownedResource: TResource) => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
try {
|
||||
return await operation(resource);
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
return withOwnedResource(resource, operation);
|
||||
}
|
||||
@@ -26,3 +26,47 @@ describe("flow-owned remote resources", () => {
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owned remote resource completion", () => {
|
||||
it("waits for cleanup and preserves its receiver", async () => {
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const disposing = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const cleanup = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const resource = {
|
||||
dispose: vi.fn(function (this: unknown) {
|
||||
expect(this).toBe(resource);
|
||||
started();
|
||||
return cleanup;
|
||||
}),
|
||||
};
|
||||
let settled = false;
|
||||
const result = withOwnedRemoteResource(resource, async () => "done");
|
||||
void result.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await disposing;
|
||||
expect(settled).toBe(false);
|
||||
finish();
|
||||
await expect(result).resolves.toBe("done");
|
||||
expect(resource.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps finally precedence when both the operation and cleanup fail", async () => {
|
||||
const operationError = new Error("operation");
|
||||
const disposalError = new Error("cleanup");
|
||||
const dispose = vi.fn(async () => {
|
||||
throw disposalError;
|
||||
});
|
||||
await expect(
|
||||
withOwnedRemoteResource({ dispose }, async () => {
|
||||
throw operationError;
|
||||
})
|
||||
).rejects.toBe(disposalError);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { findPathComponentsExceedingUtf8Limit as findKitPathComponentsExceedingUtf8Limit } from "octagonal-wheels/path";
|
||||
|
||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||
|
||||
export interface OversizedPathComponent {
|
||||
@@ -5,8 +7,6 @@ export interface OversizedPathComponent {
|
||||
utf8Bytes: number;
|
||||
}
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Return path components which exceed the conservative Android/Linux
|
||||
* compatibility boundary.
|
||||
@@ -18,9 +18,5 @@ export function findPathComponentsExceedingUtf8Limit(
|
||||
path: string,
|
||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
): OversizedPathComponent[] {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((component) => component.length > 0)
|
||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
||||
return findKitPathComponentsExceedingUtf8Limit(path, maxBytes);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ async function runOneShotWithOutcome(
|
||||
|
||||
// Manual and unattended wrappers share the provider transfer operation, but
|
||||
// keep interaction authority and result presentation explicit at this boundary.
|
||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
const userInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
@@ -81,20 +81,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
|
||||
);
|
||||
};
|
||||
|
||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
const unattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
@@ -136,8 +123,8 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||
continuous: supportedOpenReplicationContinuous(),
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
@@ -166,8 +153,8 @@ export function createCentralReplicatorProviderDefinitions(
|
||||
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||
userInitiatedOneShot: supportedCapability(userInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(unattendedOneShot),
|
||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
|
||||
@@ -209,6 +209,37 @@ describe("central Replicator provider definitions", () => {
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
||||
});
|
||||
|
||||
it.each([REMOTE_COUCHDB, REMOTE_MINIO] as const)(
|
||||
"keeps unattended %s work quiet and rejects interaction before opening replication",
|
||||
async (remoteType) => {
|
||||
const definition = createCentralReplicatorProviderDefinitions({} as never).get(remoteType)!;
|
||||
if (definition.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Unattended OneShot is unavailable");
|
||||
}
|
||||
const setting = Object.assign(createNewVaultSettings(), { remoteType });
|
||||
const instance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
};
|
||||
const blocked = await definition.unattendedOneShot.run(instance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
expect(blocked).toEqual({ status: "blocked", reason: "interaction-required" });
|
||||
expect(instance.openOneShotReplicationWithOutcome).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
definition.unattendedOneShot.run(instance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
expect(instance.openOneShotReplicationWithOutcome).toHaveBeenCalledExactlyOnceWith(setting, false);
|
||||
}
|
||||
);
|
||||
|
||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOwnedResource } from "octagonal-wheels/resource";
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
@@ -13,13 +14,8 @@ export interface ResourceReplicator {
|
||||
|
||||
/** Create one idempotent asynchronous disposer for a private Replicator. */
|
||||
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
|
||||
let disposal: Promise<void> | undefined;
|
||||
return () => {
|
||||
if (disposal === undefined) {
|
||||
disposal = Promise.resolve().then(() => replicator.closeReplication());
|
||||
}
|
||||
return disposal;
|
||||
};
|
||||
const resource = createOwnedResource(replicator, (owned) => Promise.resolve().then(() => owned.closeReplication()));
|
||||
return () => Promise.resolve(resource.dispose());
|
||||
}
|
||||
|
||||
/** Fence a finite resource from later edits to its source settings object. */
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createReplicatorDisposer } from "./shared";
|
||||
|
||||
describe("Replicator disposal", () => {
|
||||
it("defers closing, preserves the receiver, and retains one completion promise", async () => {
|
||||
let finish!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const replicator = {
|
||||
closeReplication: vi.fn(function (this: unknown) {
|
||||
expect(this).toBe(replicator);
|
||||
return closed;
|
||||
}),
|
||||
};
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
const pending = dispose();
|
||||
expect(replicator.closeReplication).not.toHaveBeenCalled();
|
||||
expect(dispose()).toBe(pending);
|
||||
await Promise.resolve();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
finish();
|
||||
await pending;
|
||||
expect(dispose()).toBe(pending);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains synchronous closing failures as one rejected promise", async () => {
|
||||
const error = new Error("close");
|
||||
const replicator = {
|
||||
closeReplication: vi.fn(() => {
|
||||
throw error;
|
||||
}),
|
||||
};
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
const pending = dispose();
|
||||
await expect(pending).rejects.toBe(error);
|
||||
expect(dispose()).toBe(pending);
|
||||
await expect(dispose()).rejects.toBe(error);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -389,13 +389,4 @@ export async function autosaveCache<K, V>(db: KeyValueDatabase, mapKey: string):
|
||||
};
|
||||
}
|
||||
|
||||
export function onlyInNTimes(n: number, proc: (progress: number) => unknown) {
|
||||
let counter = 0;
|
||||
return function () {
|
||||
if (counter++ % n == 0) {
|
||||
proc(counter);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
Reference in new issue
Block a user