mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Reuse scoped content and progress operations
This commit is contained in:
1 parent
28bc6e87a1
commit
cfdc698775
16 files changed
+1595
-131
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
import { findPathComponentsExceedingUtf8Limit as findKitPathComponentsExceedingUtf8Limit } from "octagonal-wheels/path";
|
||||||
|
|
||||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||||
|
|
||||||
export interface OversizedPathComponent {
|
export interface OversizedPathComponent {
|
||||||
@@ -5,8 +7,6 @@ export interface OversizedPathComponent {
|
|||||||
utf8Bytes: number;
|
utf8Bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const utf8Encoder = new TextEncoder();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return path components which exceed the conservative Android/Linux
|
* Return path components which exceed the conservative Android/Linux
|
||||||
* compatibility boundary.
|
* compatibility boundary.
|
||||||
@@ -18,9 +18,5 @@ export function findPathComponentsExceedingUtf8Limit(
|
|||||||
path: string,
|
path: string,
|
||||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||||
): OversizedPathComponent[] {
|
): OversizedPathComponent[] {
|
||||||
return path
|
return findKitPathComponentsExceedingUtf8Limit(path, maxBytes);
|
||||||
.split("/")
|
|
||||||
.filter((component) => component.length > 0)
|
|
||||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
|
||||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
|
||||||
}
|
}
|
||||||
@@ -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";
|
export { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
diff_match_patch: class DiffMatchPatch {},
|
||||||
|
normalizePath: vi.fn((path: string) => path),
|
||||||
|
parseYaml: vi.fn(),
|
||||||
|
Platform: {},
|
||||||
|
}));
|
||||||
|
vi.mock("./PluginDialogModal.ts", () => ({
|
||||||
|
PluginDialogModal: class PluginDialogModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||||
|
ConflictResolveModal: class ConflictResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
core!: { services: unknown };
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/types.ts", () => ({
|
||||||
|
ICXHeader: "ix:",
|
||||||
|
PERIODIC_PLUGIN_SWEEP: 60,
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
cancelTask: vi.fn(),
|
||||||
|
EVEN: Symbol("even"),
|
||||||
|
isCustomisationSyncMetadata: vi.fn(),
|
||||||
|
isPluginMetadata: vi.fn(),
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||||
|
eventHub: {
|
||||||
|
onEvent: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/translation", () => ({
|
||||||
|
$msg: vi.fn((message: string) => message),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||||
|
getObsidianCommunityPluginManager: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { ConfigSync, type PluginDataExDisplayV2 } from "./CmdConfigSync";
|
||||||
|
|
||||||
|
const BASE_DIR = ".obsidian";
|
||||||
|
const DEVICE_NAME = "test-device";
|
||||||
|
const STAT = { mtime: 123456, ctime: 123000 };
|
||||||
|
|
||||||
|
type ApplyFile = {
|
||||||
|
filename: string;
|
||||||
|
datatype: "newnote" | "plain";
|
||||||
|
data: string[];
|
||||||
|
mtime: number;
|
||||||
|
ctime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApplyFile(datatype: ApplyFile["datatype"], data: string[]): ApplyFile {
|
||||||
|
return {
|
||||||
|
filename: "plugins/example/data.json",
|
||||||
|
datatype,
|
||||||
|
data,
|
||||||
|
mtime: STAT.mtime,
|
||||||
|
ctime: STAT.ctime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(file: ApplyFile, currentText = "before", currentBinary = new ArrayBuffer(0)) {
|
||||||
|
const path = `${BASE_DIR}/${file.filename}`;
|
||||||
|
const events: string[] = [];
|
||||||
|
const storageAccess = {
|
||||||
|
ensureDir: vi.fn(async (_path: string) => {
|
||||||
|
events.push("ensure");
|
||||||
|
}),
|
||||||
|
readHiddenFileText: vi.fn(async (_path: string) => {
|
||||||
|
events.push("read-text");
|
||||||
|
return currentText;
|
||||||
|
}),
|
||||||
|
readHiddenFileBinary: vi.fn(async (_path: string) => {
|
||||||
|
events.push("read-binary");
|
||||||
|
return currentBinary;
|
||||||
|
}),
|
||||||
|
writeHiddenFileAuto: vi.fn(
|
||||||
|
async (_path: string, _content: string | ArrayBuffer, _stat?: { mtime: number; ctime: number }) => {
|
||||||
|
events.push("write");
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const storeCustomisationFileV2 = vi.fn(async (_path: string, _term: string) => {
|
||||||
|
events.push("store");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const log = vi.fn();
|
||||||
|
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||||
|
Object.assign(configSync, {
|
||||||
|
core: {
|
||||||
|
services: {
|
||||||
|
API: { getSystemConfigDir: () => BASE_DIR },
|
||||||
|
setting: { getDeviceAndVaultName: () => DEVICE_NAME },
|
||||||
|
},
|
||||||
|
storageAccess,
|
||||||
|
},
|
||||||
|
_log: log,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
});
|
||||||
|
const data = {
|
||||||
|
displayName: "Example plug-in",
|
||||||
|
name: "example",
|
||||||
|
files: [file],
|
||||||
|
} as unknown as PluginDataExDisplayV2;
|
||||||
|
return { configSync, data, events, log, path, storageAccess, storeCustomisationFileV2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedInputs = [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
file: makeApplyFile("plain", ["replacement text"]),
|
||||||
|
currentText: "previous text",
|
||||||
|
expectedText: "replacement text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
file: makeApplyFile("newnote", ["AQID"]),
|
||||||
|
currentBinary: new Uint8Array([9]).buffer,
|
||||||
|
expectedBytes: [1, 2, 3],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const unchangedInputs = [
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
file: makeApplyFile("plain", ["unchanged text"]),
|
||||||
|
currentText: "unchanged text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
file: makeApplyFile("newnote", ["AQID"]),
|
||||||
|
currentBinary: new Uint8Array([1, 2, 3]).buffer,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("ConfigSync V2 content application", () => {
|
||||||
|
it.each(changedInputs)("writes changed $kind content, then stores it", async (input) => {
|
||||||
|
const fixture = setup(
|
||||||
|
input.file,
|
||||||
|
input.kind === "text" ? input.currentText : undefined,
|
||||||
|
input.kind === "binary" ? input.currentBinary : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.ensureDir).toHaveBeenCalledWith(fixture.path);
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(
|
||||||
|
fixture.path,
|
||||||
|
input.kind === "text" ? input.expectedText : expect.any(ArrayBuffer),
|
||||||
|
STAT
|
||||||
|
);
|
||||||
|
if (input.kind === "binary") {
|
||||||
|
const written = fixture.storageAccess.writeHiddenFileAuto.mock.calls[0]?.[1];
|
||||||
|
expect(written).toBeInstanceOf(ArrayBuffer);
|
||||||
|
expect(Array.from(new Uint8Array(written as ArrayBuffer))).toEqual(input.expectedBytes);
|
||||||
|
}
|
||||||
|
expect(fixture.events).toEqual([
|
||||||
|
"ensure",
|
||||||
|
input.kind === "text" ? "read-text" : "read-binary",
|
||||||
|
"write",
|
||||||
|
"store",
|
||||||
|
]);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the file timestamps captured before reading existing content", async () => {
|
||||||
|
const file = makeApplyFile("plain", ["replacement text"]);
|
||||||
|
const fixture = setup(file, "previous text");
|
||||||
|
fixture.storageAccess.readHiddenFileText.mockImplementation(async () => {
|
||||||
|
fixture.events.push("read-text");
|
||||||
|
file.mtime = STAT.mtime + 1;
|
||||||
|
file.ctime = STAT.ctime + 1;
|
||||||
|
return "previous text";
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledWith(fixture.path, "replacement text", STAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(unchangedInputs)("skips writing and storing unchanged $kind content", async (input) => {
|
||||||
|
const fixture = setup(
|
||||||
|
input.file,
|
||||||
|
input.kind === "text" ? input.currentText : undefined,
|
||||||
|
input.kind === "binary" ? input.currentBinary : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.events).toEqual(["ensure", input.kind === "text" ? "read-text" : "read-binary"]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(
|
||||||
|
`The file ${input.file.filename} is already up-to-date`,
|
||||||
|
LOG_LEVEL_VERBOSE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ kind: "text", file: makeApplyFile("plain", ["replacement text"]) },
|
||||||
|
{ kind: "binary", file: makeApplyFile("newnote", ["AQID"]) },
|
||||||
|
])("uses empty $kind content when reading the existing file fails", async ({ kind, file }) => {
|
||||||
|
const fixture = setup(file);
|
||||||
|
const readError = new Error("Could not read existing content");
|
||||||
|
if (kind === "text") {
|
||||||
|
fixture.storageAccess.readHiddenFileText.mockRejectedValueOnce(readError);
|
||||||
|
} else {
|
||||||
|
fixture.storageAccess.readHiddenFileBinary.mockRejectedValueOnce(readError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fixture.storageAccess.writeHiddenFileAuto).toHaveBeenCalledOnce();
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledWith(fixture.path, DEVICE_NAME);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(readError, LOG_LEVEL_VERBOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awaits the write before storing the applied content", async () => {
|
||||||
|
const file = makeApplyFile("plain", ["replacement text"]);
|
||||||
|
const fixture = setup(file, "previous text");
|
||||||
|
let finishWrite!: () => void;
|
||||||
|
let signalWriteStarted!: () => void;
|
||||||
|
const writeGate = new Promise<void>((resolve) => {
|
||||||
|
finishWrite = resolve;
|
||||||
|
});
|
||||||
|
const writeStarted = new Promise<void>((resolve) => {
|
||||||
|
signalWriteStarted = resolve;
|
||||||
|
});
|
||||||
|
fixture.storageAccess.writeHiddenFileAuto.mockImplementation(async () => {
|
||||||
|
fixture.events.push("write-start");
|
||||||
|
signalWriteStarted();
|
||||||
|
await writeGate;
|
||||||
|
fixture.events.push("write-finished");
|
||||||
|
});
|
||||||
|
|
||||||
|
const applying = fixture.configSync.applyDataV2(fixture.data);
|
||||||
|
await writeStarted;
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
finishWrite();
|
||||||
|
await expect(applying).resolves.toBe(true);
|
||||||
|
expect(fixture.events.slice(-3)).toEqual(["write-start", "write-finished", "store"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false after a write failure and does not store the file", async () => {
|
||||||
|
const fixture = setup(makeApplyFile("plain", ["replacement text"]), "previous text");
|
||||||
|
const writeError = new Error("Write failed");
|
||||||
|
fixture.storageAccess.writeHiddenFileAuto.mockRejectedValueOnce(writeError);
|
||||||
|
|
||||||
|
await expect(fixture.configSync.applyDataV2(fixture.data)).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("Applying Example plug-in.. Failed", LOG_LEVEL_NOTICE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(writeError, LOG_LEVEL_VERBOSE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({
|
||||||
|
addIcon: vi.fn(),
|
||||||
|
diff_match_patch: class DiffMatchPatch {},
|
||||||
|
normalizePath: vi.fn((path: string) => path),
|
||||||
|
parseYaml: vi.fn(),
|
||||||
|
Platform: {},
|
||||||
|
}));
|
||||||
|
vi.mock("./PluginDialogModal.ts", () => ({
|
||||||
|
PluginDialogModal: class PluginDialogModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||||
|
ConflictResolveModal: class ConflictResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
core!: { services: unknown; localDatabase: unknown };
|
||||||
|
get localDatabase() {
|
||||||
|
return this.core.localDatabase;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/types.ts", () => ({
|
||||||
|
ICXHeader: "ix:",
|
||||||
|
PERIODIC_PLUGIN_SWEEP: 60,
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/utils.ts", () => ({
|
||||||
|
cancelTask: vi.fn(),
|
||||||
|
EVEN: Symbol("even"),
|
||||||
|
isCustomisationSyncMetadata: vi.fn(),
|
||||||
|
isPluginMetadata: vi.fn(),
|
||||||
|
scheduleTask: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||||
|
PeriodicProcessor: class PeriodicProcessor {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/events.ts", () => ({
|
||||||
|
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||||
|
eventHub: {
|
||||||
|
onEvent: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/translation", () => ({
|
||||||
|
$msg: vi.fn((message: string) => message),
|
||||||
|
}));
|
||||||
|
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||||
|
getObsidianCommunityPluginManager: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("octagonal-wheels/concurrency/scopedIteration", { spy: true });
|
||||||
|
|
||||||
|
import { LOG_LEVEL_VERBOSE, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import * as iterationModule from "octagonal-wheels/concurrency/scopedIteration";
|
||||||
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
|
import { ConfigSync } from "./CmdConfigSync";
|
||||||
|
|
||||||
|
const term = "device";
|
||||||
|
const prefix = `ix:${term}/`;
|
||||||
|
const keyOf = (path: string) => prefix + path;
|
||||||
|
type Row = { _id: string; path: string };
|
||||||
|
const row = (name: string, path = name): Row => ({ _id: keyOf(name), path });
|
||||||
|
|
||||||
|
function setup(files: string[] = [], rows: Row[] = []) {
|
||||||
|
const events: string[] = [];
|
||||||
|
const findEntries = vi.fn(async function* () {
|
||||||
|
yield* rows;
|
||||||
|
});
|
||||||
|
const scanInternalFiles = vi.fn(async () => files as FilePath[]);
|
||||||
|
const storeCustomisationFileV2 = vi.fn(async (path: string, _term: string) => {
|
||||||
|
events.push(`store:${path}`);
|
||||||
|
});
|
||||||
|
const deleteConfigOnDatabase = vi.fn(async (key: string) => {
|
||||||
|
events.push(`delete:${key}`);
|
||||||
|
});
|
||||||
|
const updatePluginList = vi.fn(async (_showMessage: boolean) => {});
|
||||||
|
const log = vi.fn();
|
||||||
|
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||||
|
Object.assign(configSync, {
|
||||||
|
core: {
|
||||||
|
settings: { usePluginSyncV2: true },
|
||||||
|
localDatabase: { findEntries },
|
||||||
|
services: { setting: { getDeviceAndVaultName: () => term } },
|
||||||
|
},
|
||||||
|
scanInternalFiles,
|
||||||
|
isTargetPath: (path: string) => path !== "ignored",
|
||||||
|
filenameWithUnifiedKey: (path: string, _term: string) => keyOf(path),
|
||||||
|
unifiedKeyPrefixOfTerminal: (_term: string) => prefix,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
deleteConfigOnDatabase,
|
||||||
|
updatePluginList,
|
||||||
|
_log: log,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
configSync,
|
||||||
|
events,
|
||||||
|
findEntries,
|
||||||
|
scanInternalFiles,
|
||||||
|
storeCustomisationFileV2,
|
||||||
|
deleteConfigOnDatabase,
|
||||||
|
updatePluginList,
|
||||||
|
log,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigSync V2 scan behaviour", () => {
|
||||||
|
it("finishes existing database entries before storing extra files and retains target filtering", async () => {
|
||||||
|
const fixture = setup(
|
||||||
|
["matched", "extra", "ignored"],
|
||||||
|
[row("matched"), row("stale"), row("encoded", "encoded%path")]
|
||||||
|
);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["matched", term],
|
||||||
|
["extra", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||||
|
expect(fixture.findEntries).toHaveBeenCalledExactlyOnceWith(prefix, `${prefix}\u{10ffff}`, {
|
||||||
|
include_docs: true,
|
||||||
|
});
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledExactlyOnceWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finishes database enumeration before dispatching database work", async () => {
|
||||||
|
const fixture = setup();
|
||||||
|
const enumerationPaused = promiseWithResolvers<void>();
|
||||||
|
const continueEnumeration = promiseWithResolvers<void>();
|
||||||
|
fixture.findEntries.mockImplementation(async function* () {
|
||||||
|
yield row("stale");
|
||||||
|
enumerationPaused.resolve();
|
||||||
|
await continueEnumeration.promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
try {
|
||||||
|
await enumerationPaused.promise;
|
||||||
|
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
continueEnumeration.resolve();
|
||||||
|
await scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits actual database operations to ten and completes that batch before the extra batch", async () => {
|
||||||
|
const fixture = setup(
|
||||||
|
["extra"],
|
||||||
|
Array.from({ length: 11 }, (_, index) => row(String(index)))
|
||||||
|
);
|
||||||
|
const gates = Array.from({ length: 11 }, () => promiseWithResolvers<void>());
|
||||||
|
const tenStarted = promiseWithResolvers<void>();
|
||||||
|
const lastStarted = promiseWithResolvers<void>();
|
||||||
|
let active = 0;
|
||||||
|
let maximum = 0;
|
||||||
|
let started = 0;
|
||||||
|
fixture.deleteConfigOnDatabase.mockImplementation(async (key) => {
|
||||||
|
const index = Number(key.slice(prefix.length));
|
||||||
|
active++;
|
||||||
|
maximum = Math.max(maximum, active);
|
||||||
|
started++;
|
||||||
|
if (started === 10) tenStarted.resolve();
|
||||||
|
if (index === 10) lastStarted.resolve();
|
||||||
|
try {
|
||||||
|
await gates[index].promise;
|
||||||
|
} finally {
|
||||||
|
active--;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
try {
|
||||||
|
await tenStarted.promise;
|
||||||
|
expect(active).toBe(10);
|
||||||
|
expect(started).toBe(10);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
gates[0].resolve();
|
||||||
|
await lastStarted.promise;
|
||||||
|
expect(active).toBe(10);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
gates.forEach((gate) => gate.resolve());
|
||||||
|
await scan;
|
||||||
|
}
|
||||||
|
expect(maximum).toBe(10);
|
||||||
|
expect(active).toBe(0);
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(11);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a database processing error and continues through the extra batch", async () => {
|
||||||
|
const fixture = setup(["extra"], [row("bad"), row("other")]);
|
||||||
|
const error = new Error("delete");
|
||||||
|
fixture.deleteConfigOnDatabase.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("bad")}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains a matched local file for the extra batch when its first store fails", async () => {
|
||||||
|
const fixture = setup(["matched"], [row("matched")]);
|
||||||
|
const error = new Error("first store");
|
||||||
|
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["matched", term],
|
||||||
|
["matched", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("matched")}`, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.events).toEqual(["store:matched"]);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an extra-file processing error and continues with the remaining files", async () => {
|
||||||
|
const fixture = setup(["bad", "other"]);
|
||||||
|
const error = new Error("store");
|
||||||
|
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||||
|
["bad", term],
|
||||||
|
["other", term],
|
||||||
|
]);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith("scanAllConfigFiles - Error: bad", LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConfigSync scoped iteration composition", () => {
|
||||||
|
it("uses bounded child scopes for both batches with the same shared values", async () => {
|
||||||
|
const fixture = setup(["matched", "extra"], [row("matched"), row("stale")]);
|
||||||
|
await fixture.configSync.scanAllConfigFiles(false);
|
||||||
|
|
||||||
|
expect(iterationModule.forEachInScope).toHaveBeenCalledTimes(2);
|
||||||
|
const [database, extra] = vi.mocked(iterationModule.forEachInScope).mock.calls;
|
||||||
|
expect(database[0]).toBe(extra[0]);
|
||||||
|
expect(database[0].value).toEqual({ term, localFileMap: new Map([[keyOf("extra"), "extra"]]) });
|
||||||
|
expect(database[2]).toEqual({ concurrency: 10 });
|
||||||
|
expect(extra[2]).toEqual({ concurrency: 10 });
|
||||||
|
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["database", "extra"])("propagates a %s runner failure outside per-item recovery", async (batch) => {
|
||||||
|
const fixture = setup(["extra"], [row("stale")]);
|
||||||
|
const error = new Error("iteration");
|
||||||
|
const actual = await vi.importActual<typeof import("octagonal-wheels/concurrency/scopedIteration")>(
|
||||||
|
"octagonal-wheels/concurrency/scopedIteration"
|
||||||
|
);
|
||||||
|
if (batch === "extra") {
|
||||||
|
vi.mocked(iterationModule.forEachInScope).mockImplementationOnce(actual.forEachInScope);
|
||||||
|
}
|
||||||
|
vi.mocked(iterationModule.forEachInScope).mockRejectedValueOnce(error);
|
||||||
|
await expect(fixture.configSync.scanAllConfigFiles(false)).rejects.toBe(error);
|
||||||
|
|
||||||
|
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(batch === "extra" ? 1 : 0);
|
||||||
|
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.log).not.toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(fixture.updatePluginList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,7 +59,8 @@ import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
|||||||
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
import { pluginScanningCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||||
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
|
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
|
||||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
import { forEachInScope } from "octagonal-wheels/concurrency/scopedIteration";
|
||||||
|
import { withScope } from "octagonal-wheels/resource/scope";
|
||||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "@/common/events.ts";
|
||||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
@@ -68,6 +69,7 @@ import type { LiveSyncCore } from "@/main.ts";
|
|||||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||||
|
import { createContentApplier } from "octagonal-wheels/content";
|
||||||
|
|
||||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
@@ -285,6 +287,10 @@ export type PluginDataExDisplay = {
|
|||||||
mtime: number;
|
mtime: number;
|
||||||
};
|
};
|
||||||
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
|
||||||
|
type ConfigFileApplicationTarget = {
|
||||||
|
readonly path: FilePath;
|
||||||
|
readonly stat: { readonly mtime: number; readonly ctime: number };
|
||||||
|
};
|
||||||
|
|
||||||
function categoryToFolder(category: string, configDir: string = ""): string {
|
function categoryToFolder(category: string, configDir: string = ""): string {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -1027,44 +1033,63 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||||
} else {
|
} else {
|
||||||
const files = data.files;
|
const files = data.files;
|
||||||
|
const applyBinaryContent = createContentApplier<
|
||||||
|
ConfigFileApplicationTarget,
|
||||||
|
LoadedEntryPluginDataExFile,
|
||||||
|
ArrayBuffer,
|
||||||
|
ArrayBuffer
|
||||||
|
>({
|
||||||
|
read: (target) => this.core.storageAccess.readHiddenFileBinary(target.path),
|
||||||
|
onReadError: (ex, _path, file) => {
|
||||||
|
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
|
return new ArrayBuffer(0);
|
||||||
|
},
|
||||||
|
decode: (file) => base64ToArrayBuffer(file.data),
|
||||||
|
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||||
|
write: (target, content) =>
|
||||||
|
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||||
|
});
|
||||||
|
const applyTextContent = createContentApplier<
|
||||||
|
ConfigFileApplicationTarget,
|
||||||
|
LoadedEntryPluginDataExFile,
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>({
|
||||||
|
read: (target) => this.core.storageAccess.readHiddenFileText(target.path),
|
||||||
|
onReadError: (ex, _path, file) => {
|
||||||
|
this._log(`Could not read the file ${file.filename}`, LOG_LEVEL_VERBOSE);
|
||||||
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
decode: (file) => getDocData(file.data),
|
||||||
|
isSame: (oldData, content) => isDocContentSame(oldData, content),
|
||||||
|
write: (target, content) =>
|
||||||
|
this.core.storageAccess.writeHiddenFileAuto(target.path, content, target.stat),
|
||||||
|
});
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// If files have applied, modified time will be updated to the current time.
|
// If files have applied, modified time will be updated to the current time.
|
||||||
const stat = { mtime: f.mtime, ctime: f.ctime };
|
|
||||||
const path = `${baseDir}/${f.filename}` as FilePath;
|
const path = `${baseDir}/${f.filename}` as FilePath;
|
||||||
|
const target: ConfigFileApplicationTarget = {
|
||||||
|
path,
|
||||||
|
stat: { mtime: f.mtime, ctime: f.ctime },
|
||||||
|
};
|
||||||
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
|
||||||
// const contentEach = createBlob(f.data);
|
// const contentEach = createBlob(f.data);
|
||||||
await this.core.storageAccess.ensureDir(path);
|
await this.core.storageAccess.ensureDir(path);
|
||||||
|
|
||||||
if (f.datatype == "newnote") {
|
if (f.datatype == "newnote") {
|
||||||
let oldData;
|
const result = await applyBinaryContent(target, f);
|
||||||
try {
|
if (result == "unchanged") {
|
||||||
oldData = await this.core.storageAccess.readHiddenFileBinary(path);
|
|
||||||
} catch (ex) {
|
|
||||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
||||||
oldData = new ArrayBuffer(0);
|
|
||||||
}
|
|
||||||
const content = base64ToArrayBuffer(f.data);
|
|
||||||
if (await isDocContentSame(oldData, content)) {
|
|
||||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
|
||||||
} else {
|
} else {
|
||||||
let oldData;
|
const result = await applyTextContent(target, f);
|
||||||
try {
|
if (result == "unchanged") {
|
||||||
oldData = await this.core.storageAccess.readHiddenFileText(path);
|
|
||||||
} catch (ex) {
|
|
||||||
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
|
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
||||||
oldData = "";
|
|
||||||
}
|
|
||||||
const content = getDocData(f.data);
|
|
||||||
if (await isDocContentSame(oldData, content)) {
|
|
||||||
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.core.storageAccess.writeHiddenFileAuto(path, content, stat);
|
|
||||||
}
|
}
|
||||||
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
|
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
|
||||||
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
await this.storeCustomisationFileV2(path, this.services.setting.getDeviceAndVaultName());
|
||||||
@@ -1613,49 +1638,35 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
|
||||||
include_docs: true,
|
include_docs: true,
|
||||||
});
|
});
|
||||||
const tasks = [] as (() => Promise<void>)[];
|
const databaseEntries: { _id: string; path: FilePathWithPrefix }[] = [];
|
||||||
const concurrency = 10;
|
|
||||||
const semaphore = Semaphore(concurrency);
|
|
||||||
for await (const item of entries) {
|
for await (const item of entries) {
|
||||||
if (item.path.indexOf("%") !== -1) {
|
if (item.path.indexOf("%") === -1) databaseEntries.push(item);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
tasks.push(async () => {
|
await withScope({ term, localFileMap }, async (scope) => {
|
||||||
const releaser = await semaphore.acquire();
|
await forEachInScope(scope, databaseEntries, { concurrency: 10 }, async (item, ctx) => {
|
||||||
try {
|
try {
|
||||||
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
|
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
|
||||||
const localPath = localFileMap.get(unifiedFilenameWithKey);
|
const localPath = ctx.value.localFileMap.get(unifiedFilenameWithKey);
|
||||||
if (localPath) {
|
if (localPath) {
|
||||||
await this.storeCustomisationFileV2(localPath, term);
|
await this.storeCustomisationFileV2(localPath, ctx.value.term);
|
||||||
localFileMap.delete(unifiedFilenameWithKey);
|
ctx.value.localFileMap.delete(unifiedFilenameWithKey);
|
||||||
} else {
|
} else {
|
||||||
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
|
await this.deleteConfigOnDatabase(unifiedFilenameWithKey);
|
||||||
}
|
}
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
|
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
} finally {
|
|
||||||
releaser();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
await forEachInScope(scope, localFileMap.values(), { concurrency: 10 }, async (filePath, ctx) => {
|
||||||
await Promise.all(tasks.map((e) => e()));
|
|
||||||
// Extra files
|
|
||||||
const taskExtra = [] as (() => Promise<void>)[];
|
|
||||||
for (const [, filePath] of localFileMap) {
|
|
||||||
taskExtra.push(async () => {
|
|
||||||
const releaser = await semaphore.acquire();
|
|
||||||
try {
|
try {
|
||||||
await this.storeCustomisationFileV2(filePath, term);
|
await this.storeCustomisationFileV2(filePath, ctx.value.term);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
|
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
} finally {
|
|
||||||
releaser();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
await Promise.all(taskExtra.map((e) => e()));
|
|
||||||
fireAndForget(() => this.updatePluginList(false));
|
fireAndForget(() => this.updatePluginList(false));
|
||||||
} else {
|
} else {
|
||||||
const files = filesAll
|
const files = filesAll
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
type DocumentID,
|
||||||
|
type FilePath,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
type MetaEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
plugin!: { app: unknown };
|
||||||
|
core!: { services: unknown; settings: unknown };
|
||||||
|
get app() {
|
||||||
|
return this.plugin.app;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return this.core.settings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||||
|
configureHiddenFileSyncMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||||
|
|
||||||
|
function createProgress(events: string[] = []) {
|
||||||
|
return {
|
||||||
|
log: vi.fn((message: string) => {
|
||||||
|
if (/^(?:\d+\/\d+|Checking \d+\/\d+)$/.test(message)) events.push(message);
|
||||||
|
}),
|
||||||
|
once: vi.fn(),
|
||||||
|
done: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoragePaths(count: number): FilePath[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||||
|
_rev: "1-test",
|
||||||
|
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||||
|
ctime: 1,
|
||||||
|
mtime: 1,
|
||||||
|
size: 0,
|
||||||
|
type: "plain",
|
||||||
|
datatype: "plain",
|
||||||
|
children: [],
|
||||||
|
eden: {},
|
||||||
|
deleted: false,
|
||||||
|
}) as MetaEntry
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenFileSync(count: number, events: string[] = []) {
|
||||||
|
const progress = createProgress(events);
|
||||||
|
const statHidden = vi.fn(async () => {
|
||||||
|
events.push(`stat:${statHidden.mock.calls.length}`);
|
||||||
|
return { mtime: 1 };
|
||||||
|
});
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: vi.fn(),
|
||||||
|
_fileInfoLastProcessed: new Map(),
|
||||||
|
_databaseInfoLastProcessed: new Map(),
|
||||||
|
scanInternalFileNames: vi.fn(async () => createStoragePaths(count)),
|
||||||
|
getAllDatabaseFiles: vi.fn(async () => createDatabaseEntries(count)),
|
||||||
|
getPath: vi.fn((file: MetaEntry) => file.path),
|
||||||
|
updateLastProcessed: vi.fn(),
|
||||||
|
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||||
|
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||||
|
resetLastProcessedDatabase: vi.fn(),
|
||||||
|
resetLastProcessedFile: vi.fn(),
|
||||||
|
useStorageFiles: vi.fn(async () => false),
|
||||||
|
useDatabaseFiles: vi.fn(async () => false),
|
||||||
|
trackStorageFileModification: vi.fn(async () => false),
|
||||||
|
trackDatabaseFileModification: vi.fn(async () => false),
|
||||||
|
core: { storageAccess: { statHidden } },
|
||||||
|
});
|
||||||
|
return { hiddenFileSync, progress, statHidden };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync attempt progress", () => {
|
||||||
|
it("reports applyOfflineChanges attempts at 1 and each 25 items, without a final partial count", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const { hiddenFileSync, progress } = createHiddenFileSync(52, events);
|
||||||
|
|
||||||
|
await hiddenFileSync.applyOfflineChanges(false);
|
||||||
|
|
||||||
|
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => /^\d+\/\d+$/.test(message))).toEqual([
|
||||||
|
"1/52",
|
||||||
|
"26/52",
|
||||||
|
"51/52",
|
||||||
|
]);
|
||||||
|
expect(events.indexOf("51/52")).toBeLessThan(events.indexOf("stat:1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports rebuildMerging attempts at 1 and each 100 items, without a final partial count", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const { hiddenFileSync, progress } = createHiddenFileSync(202, events);
|
||||||
|
|
||||||
|
await hiddenFileSync.rebuildMerging(false);
|
||||||
|
|
||||||
|
expect(progress.log.mock.calls.map(([message]) => message).filter((message) => message.startsWith("Checking "))).toEqual([
|
||||||
|
"Checking 1/202",
|
||||||
|
"Checking 101/202",
|
||||||
|
"Checking 201/202",
|
||||||
|
]);
|
||||||
|
expect(events.indexOf("Checking 101/202")).toBeLessThan(events.indexOf("stat:101"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stat failures from applyOfflineChanges", async () => {
|
||||||
|
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||||
|
const error = new Error("stat failed");
|
||||||
|
statHidden.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.applyOfflineChanges(false)).rejects.toBe(error);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stat failures from rebuildMerging", async () => {
|
||||||
|
const { hiddenFileSync, progress, statHidden } = createHiddenFileSync(1);
|
||||||
|
const error = new Error("stat failed");
|
||||||
|
statHidden.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.rebuildMerging(false)).rejects.toBe(error);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
LOG_LEVEL_VERBOSE,
|
||||||
|
type DocumentID,
|
||||||
|
type FilePath,
|
||||||
|
type FilePathWithPrefix,
|
||||||
|
type MetaEntry,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
|
||||||
|
vi.mock("@/deps.ts", () => ({}));
|
||||||
|
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||||
|
JsonResolveModal: class JsonResolveModal {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||||
|
LiveSyncCommands: class LiveSyncCommands {
|
||||||
|
plugin!: { app: unknown };
|
||||||
|
core!: { services: unknown; settings: unknown };
|
||||||
|
get app() {
|
||||||
|
return this.plugin.app;
|
||||||
|
}
|
||||||
|
get services() {
|
||||||
|
return this.core.services;
|
||||||
|
}
|
||||||
|
get settings() {
|
||||||
|
return this.core.settings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||||
|
configureHiddenFileSyncMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||||
|
|
||||||
|
function createProgress() {
|
||||||
|
return {
|
||||||
|
log: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
done: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenFileSync() {
|
||||||
|
const progress = createProgress();
|
||||||
|
const log = vi.fn();
|
||||||
|
const trackStorageFileModification = vi.fn(async (_file: FilePath) => false);
|
||||||
|
const trackDatabaseFileModification = vi.fn(async (_path: FilePath) => false);
|
||||||
|
const isTargetFile = vi.fn(async (_path: FilePath) => true);
|
||||||
|
const getPath = vi.fn((file: MetaEntry) => file.path);
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
|
||||||
|
Object.assign(hiddenFileSync, {
|
||||||
|
_progress: vi.fn(() => progress),
|
||||||
|
_log: log,
|
||||||
|
trackStorageFileModification,
|
||||||
|
trackDatabaseFileModification,
|
||||||
|
isTargetFile,
|
||||||
|
getPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hiddenFileSync,
|
||||||
|
progress,
|
||||||
|
log,
|
||||||
|
trackStorageFileModification,
|
||||||
|
trackDatabaseFileModification,
|
||||||
|
isTargetFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoragePaths(count: number): FilePath[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => `.obsidian/plugins/example/${index}.json` as FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatabaseEntries(count: number): MetaEntry[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) =>
|
||||||
|
({
|
||||||
|
_id: `i:.obsidian/plugins/example/${index}.json` as DocumentID,
|
||||||
|
_rev: "1-test",
|
||||||
|
path: `i:.obsidian/plugins/example/${index}.json` as FilePathWithPrefix,
|
||||||
|
ctime: 1,
|
||||||
|
mtime: 1,
|
||||||
|
size: 0,
|
||||||
|
type: "plain",
|
||||||
|
datatype: "plain",
|
||||||
|
children: [],
|
||||||
|
eden: {},
|
||||||
|
deleted: false,
|
||||||
|
}) as MetaEntry
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync batch progress consumers", () => {
|
||||||
|
it("counts completed storage modifications even when they return false", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(101);
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
|
||||||
|
expect(trackStorageFileModification).toHaveBeenCalledTimes(101);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed storage items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(100);
|
||||||
|
const failingFile = files[50];
|
||||||
|
const error = new Error("Storage item failed");
|
||||||
|
trackStorageFileModification.mockImplementation(async (file) => {
|
||||||
|
if (file === failingFile) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
|
||||||
|
expect(progress.once).toHaveBeenCalledWith(`Failed to process storage change file:${failingFile}`);
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/100"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts completed database items, including filtered items and false returns", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackDatabaseFileModification, isTargetFile } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(101);
|
||||||
|
const filteredPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||||
|
isTargetFile.mockImplementation(async (path) => path !== filteredPath);
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||||
|
|
||||||
|
expect(isTargetFile).toHaveBeenCalledTimes(101);
|
||||||
|
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(100);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"], ["101/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed database scan items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(101);
|
||||||
|
const failingPath = ".obsidian/plugins/example/50.json" as FilePath;
|
||||||
|
const error = new Error("Database scan item failed");
|
||||||
|
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||||
|
if (path === failingPath) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await hiddenFileSync.trackScannedDatabaseChange(files);
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Failed to process storage change file:"),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["1/101"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the database-file progress interval and counts false returns", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(26);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(trackDatabaseFileModification).toHaveBeenCalledTimes(26);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"], ["26/26"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and skips failed database-file items when counting progress", async () => {
|
||||||
|
const { hiddenFileSync, progress, log, trackDatabaseFileModification } = createHiddenFileSync();
|
||||||
|
const files = createDatabaseEntries(26);
|
||||||
|
const failingPath = ".obsidian/plugins/example/12.json" as FilePath;
|
||||||
|
const error = new Error("Database file item failed");
|
||||||
|
trackDatabaseFileModification.mockImplementation(async (path) => {
|
||||||
|
if (path === failingPath) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.useDatabaseFiles(files)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to process database changes:"));
|
||||||
|
expect(log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||||
|
expect(progress.log.mock.calls).toEqual([["Scanning database files..."], ["1/26"]]);
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts all storage work eagerly and calls done only after all promises resolve", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const files = createStoragePaths(101);
|
||||||
|
const resolveModifications: Array<(result: boolean) => void> = [];
|
||||||
|
trackStorageFileModification.mockImplementation(
|
||||||
|
() => new Promise<boolean>((resolve) => resolveModifications.push(resolve))
|
||||||
|
);
|
||||||
|
|
||||||
|
const operation = hiddenFileSync.trackScannedStorageChanges(files);
|
||||||
|
expect(trackStorageFileModification).toHaveBeenCalledTimes(files.length);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (const resolveModification of resolveModifications) resolveModification(false);
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(progress.done).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call done when the item error handler rejects the batch", async () => {
|
||||||
|
const { hiddenFileSync, progress, trackStorageFileModification } = createHiddenFileSync();
|
||||||
|
const itemError = new Error("Storage item failed");
|
||||||
|
const handlerError = new Error("Progress error handler failed");
|
||||||
|
trackStorageFileModification.mockRejectedValueOnce(itemError);
|
||||||
|
progress.once.mockImplementation(() => {
|
||||||
|
throw handlerError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.trackScannedStorageChanges(createStoragePaths(1))).rejects.toBe(handlerError);
|
||||||
|
expect(progress.done).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
getLogLevel,
|
getLogLevel,
|
||||||
autosaveCache,
|
autosaveCache,
|
||||||
type MapLike,
|
type MapLike,
|
||||||
onlyInNTimes,
|
|
||||||
BASE_IS_NEW,
|
BASE_IS_NEW,
|
||||||
EVEN,
|
EVEN,
|
||||||
displayRev,
|
displayRev,
|
||||||
@@ -45,6 +44,7 @@ import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock"
|
|||||||
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.ts";
|
||||||
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
|
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
|
||||||
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||||
|
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||||
import {
|
import {
|
||||||
hiddenFilesEventCount,
|
hiddenFilesEventCount,
|
||||||
@@ -57,6 +57,7 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
|
|||||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||||
|
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
@@ -571,18 +572,15 @@ export class HiddenFileSync extends LiveSyncCommands {
|
|||||||
) {
|
) {
|
||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
|
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
|
||||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
await performBatchWithProgress(processFiles, 100, {
|
||||||
const processes = processFiles.map(async (file, i) => {
|
processItem: (file) => this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted),
|
||||||
try {
|
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||||
await this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted);
|
onItemError: (ex, file) => {
|
||||||
notifyProgress();
|
|
||||||
} catch (ex) {
|
|
||||||
p.once(`Failed to process storage change file:${file}`);
|
p.once(`Failed to process storage change file:${file}`);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processes);
|
|
||||||
p.done();
|
|
||||||
}
|
}
|
||||||
async scanAllStorageChanges(
|
async scanAllStorageChanges(
|
||||||
showNotice: boolean = false,
|
showNotice: boolean = false,
|
||||||
@@ -1134,9 +1132,8 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
) {
|
) {
|
||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
|
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
|
||||||
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
|
await performBatchWithProgress(processFiles, 100, {
|
||||||
const processes = processFiles.map(async (file) => {
|
processItem: async (file) => {
|
||||||
try {
|
|
||||||
const path = stripAllPrefixes(this.getPath(file));
|
const path = stripAllPrefixes(this.getPath(file));
|
||||||
if (!(await this.isTargetFile(path))) {
|
if (!(await this.isTargetFile(path))) {
|
||||||
this._log(
|
this._log(
|
||||||
@@ -1153,14 +1150,14 @@ Offline Changed files: ${processFiles.length}`;
|
|||||||
includeDeletion
|
includeDeletion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifyProgress();
|
},
|
||||||
} catch (ex) {
|
onProgress: (progress) => p.log(`${progress}/${processFiles.length}`),
|
||||||
|
onItemError: (ex, file) => {
|
||||||
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processes);
|
|
||||||
p.done();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async applyOfflineChanges(showNotice: boolean) {
|
async applyOfflineChanges(showNotice: boolean) {
|
||||||
@@ -1188,9 +1185,14 @@ Untracked database files: ${untrackedDatabase.length}
|
|||||||
Common untracked files: ${bothUntracked.length}`;
|
Common untracked files: ${bothUntracked.length}`;
|
||||||
p.once(stat);
|
p.once(stat);
|
||||||
const semaphores = Semaphore(10);
|
const semaphores = Semaphore(10);
|
||||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${bothUntracked.length}`));
|
const notifyProgress = createCoalescingNotifier<number>({
|
||||||
|
everyCount: 25,
|
||||||
|
leading: true,
|
||||||
|
onNotify: (progress) => p.log(`${progress}/${bothUntracked.length}`),
|
||||||
|
});
|
||||||
|
let attempted = 0;
|
||||||
const allProcesses = bothUntracked.map(async (file) => {
|
const allProcesses = bothUntracked.map(async (file) => {
|
||||||
notifyProgress();
|
notifyProgress.update(++attempted);
|
||||||
const rel = await semaphores.acquire();
|
const rel = await semaphores.acquire();
|
||||||
try {
|
try {
|
||||||
const fileStat = await this.core.storageAccess.statHidden(file);
|
const fileStat = await this.core.storageAccess.statHidden(file);
|
||||||
@@ -1260,20 +1262,18 @@ Offline Changed files: ${files.length}`;
|
|||||||
const logLevel = getLogLevel(showNotice);
|
const logLevel = getLogLevel(showNotice);
|
||||||
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
|
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
|
||||||
p.log("Scanning database files...");
|
p.log("Scanning database files...");
|
||||||
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${files.length}`));
|
await performBatchWithProgress(files, 25, {
|
||||||
const processFiles = files.map(async (file) => {
|
processItem: async (file) => {
|
||||||
try {
|
|
||||||
const path = stripAllPrefixes(this.getPath(file));
|
const path = stripAllPrefixes(this.getPath(file));
|
||||||
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
||||||
notifyProgress();
|
},
|
||||||
} catch (ex) {
|
onProgress: (progress) => p.log(`${progress}/${files.length}`),
|
||||||
|
onItemError: (ex, file) => {
|
||||||
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
||||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||||
}
|
},
|
||||||
return;
|
onDone: () => p.done(),
|
||||||
});
|
});
|
||||||
await Promise.all(processFiles);
|
|
||||||
p.done();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,9 +1435,14 @@ Offline Changed files: ${files.length}`;
|
|||||||
const storageToDatabase = [] as FilePath[];
|
const storageToDatabase = [] as FilePath[];
|
||||||
const databaseToStorage = [] as MetaEntry[];
|
const databaseToStorage = [] as MetaEntry[];
|
||||||
|
|
||||||
const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`));
|
const eachProgress = createCoalescingNotifier<number>({
|
||||||
|
everyCount: 100,
|
||||||
|
leading: true,
|
||||||
|
onNotify: (progress) => p.log(`Checking ${progress}/${allFileNames.size}`),
|
||||||
|
});
|
||||||
|
let attempted = 0;
|
||||||
for (const file of allFileNames) {
|
for (const file of allFileNames) {
|
||||||
eachProgress();
|
eachProgress.update(++attempted);
|
||||||
const storageMTime = await this.core.storageAccess.statHidden(file);
|
const storageMTime = await this.core.storageAccess.statHidden(file);
|
||||||
const mtimeStorage = getComparingMTime(storageMTime);
|
const mtimeStorage = getComparingMTime(storageMTime);
|
||||||
const dbEntry = allDatabaseMap.get(file)!;
|
const dbEntry = allDatabaseMap.get(file)!;
|
||||||
|
|||||||
@@ -98,6 +98,85 @@ function createHiddenRevisionOperation() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("HiddenFileSync storage loading", () => {
|
||||||
|
function createStorageReader(content: string | ArrayBuffer) {
|
||||||
|
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||||
|
const stat = {
|
||||||
|
ctime: 123,
|
||||||
|
mtime: 456,
|
||||||
|
size: typeof content === "string" ? new TextEncoder().encode(content).byteLength : content.byteLength,
|
||||||
|
type: "file" as const,
|
||||||
|
};
|
||||||
|
const storageAccess = {
|
||||||
|
statHidden: vi.fn(async (): Promise<typeof stat | null> => stat),
|
||||||
|
readHiddenFileAuto: vi.fn(async () => content),
|
||||||
|
};
|
||||||
|
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||||
|
Object.assign(hiddenFileSync, { core: { storageAccess } });
|
||||||
|
return { hiddenFileSync, storageAccess, path, stat };
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
kind: "text",
|
||||||
|
content: "first\r\n日本語 🐙",
|
||||||
|
expected: new TextEncoder().encode("first\r\n日本語 🐙"),
|
||||||
|
mime: "text/plain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "binary",
|
||||||
|
content: new Uint8Array([0, 128, 255]).buffer,
|
||||||
|
expected: new Uint8Array([0, 128, 255]),
|
||||||
|
mime: "application/octet-stream",
|
||||||
|
},
|
||||||
|
{ kind: "empty text", content: "", expected: new Uint8Array(0), mime: "text/plain" },
|
||||||
|
{
|
||||||
|
kind: "empty binary",
|
||||||
|
content: new ArrayBuffer(0),
|
||||||
|
expected: new Uint8Array(0),
|
||||||
|
mime: "application/octet-stream",
|
||||||
|
},
|
||||||
|
])("preserves $kind content and its file metadata", async ({ content, expected, mime }) => {
|
||||||
|
const { hiddenFileSync, storageAccess, path, stat } = createStorageReader(content);
|
||||||
|
|
||||||
|
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||||
|
|
||||||
|
expect(storageAccess.statHidden).toHaveBeenCalledWith(path);
|
||||||
|
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||||
|
expect(file).toMatchObject({ name: "data.json", path, stat, isInternal: true, deleted: false });
|
||||||
|
expect(file.body).toBeInstanceOf(Blob);
|
||||||
|
expect(file.body.type).toBe(mime);
|
||||||
|
expect(new Uint8Array(await file.body.arrayBuffer())).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("represents a missing file as a deletion without reading storage content", async () => {
|
||||||
|
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||||
|
storageAccess.statHidden.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const file = await hiddenFileSync.loadFileWithInfo(path);
|
||||||
|
|
||||||
|
expect(storageAccess.readHiddenFileAuto).not.toHaveBeenCalled();
|
||||||
|
expect(file).toMatchObject({
|
||||||
|
name: "data.json",
|
||||||
|
path,
|
||||||
|
stat: { ctime: 0, mtime: 0, size: 0, type: "file" },
|
||||||
|
isInternal: true,
|
||||||
|
deleted: true,
|
||||||
|
});
|
||||||
|
expect(file.body.type).toBe("application/octet-stream");
|
||||||
|
expect(file.body.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a read failure for an existing file", async () => {
|
||||||
|
const { hiddenFileSync, storageAccess, path } = createStorageReader("unavailable");
|
||||||
|
const error = new Error("Storage read failed");
|
||||||
|
storageAccess.readHiddenFileAuto.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(hiddenFileSync.loadFileWithInfo(path)).rejects.toBe(error);
|
||||||
|
expect(storageAccess.readHiddenFileAuto).toHaveBeenCalledWith(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HiddenFileSync configuration-change notices", () => {
|
describe("HiddenFileSync configuration-change notices", () => {
|
||||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createCoalescingNotifier } from "octagonal-wheels/concurrency/coalescingNotifier";
|
||||||
|
|
||||||
|
interface PerformBatchWithProgressCallbacks<T> {
|
||||||
|
/** Runs one item. Its resolved value is ignored. */
|
||||||
|
processItem: (item: T) => Promise<unknown>;
|
||||||
|
/** Receives the number of successfully completed items. */
|
||||||
|
onProgress: (completed: number) => void;
|
||||||
|
/** Handles an item or progress callback error. */
|
||||||
|
onItemError: (error: unknown, item: T) => void;
|
||||||
|
/** Runs once after all per-item promises resolve. */
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an eager batch and coalesces progress notifications by successful item count.
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @param items - Items to process concurrently.
|
||||||
|
* @param eachCount - Number of completed items between progress notifications.
|
||||||
|
* @param callbacks - Item processing, progress, error, and completion callbacks.
|
||||||
|
* @returns A promise that resolves with no value after the batch and completion callback finish.
|
||||||
|
* @throws When notifier configuration is invalid, an error handler throws, or the completion callback throws.
|
||||||
|
* @remarks The first completed item triggers progress immediately. Work and the progress callback share
|
||||||
|
* one error boundary; errors are passed to `onItemError`. A rejected item operation does not advance
|
||||||
|
* the count. The batch has no concurrency limit or retry, and pending progress is not flushed when
|
||||||
|
* processing ends.
|
||||||
|
*/
|
||||||
|
export async function performBatchWithProgress<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
eachCount: number,
|
||||||
|
callbacks: PerformBatchWithProgressCallbacks<T>
|
||||||
|
): Promise<void> {
|
||||||
|
const notifier = createCoalescingNotifier<number>({
|
||||||
|
everyCount: eachCount,
|
||||||
|
leading: true,
|
||||||
|
onNotify: callbacks.onProgress,
|
||||||
|
});
|
||||||
|
let completed = 0;
|
||||||
|
const processes = items.map(async (item) => {
|
||||||
|
try {
|
||||||
|
await callbacks.processItem(item);
|
||||||
|
notifier.update(++completed);
|
||||||
|
} catch (error) {
|
||||||
|
callbacks.onItemError(error, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(processes);
|
||||||
|
callbacks.onDone();
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { performBatchWithProgress } from "./performBatchWithProgress.ts";
|
||||||
|
|
||||||
|
describe("performBatchWithProgress", () => {
|
||||||
|
it("counts resolved false values, continues after item errors, and does not flush partial progress", async () => {
|
||||||
|
const error = new Error("Item failed");
|
||||||
|
const processItem = vi.fn(async (item: number) => {
|
||||||
|
if (item === 2) throw error;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
const result = await performBatchWithProgress([1, 2, 3, 4, 5, 6, 7], 2, {
|
||||||
|
processItem,
|
||||||
|
onProgress,
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(processItem).toHaveBeenCalledTimes(7);
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(error, 2);
|
||||||
|
expect(onProgress.mock.calls).toEqual([[1], [3], [5]]);
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts every item eagerly and calls onDone only after all item promises resolve", async () => {
|
||||||
|
const items = [1, 2, 3];
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
const processItem = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
const operation = performBatchWithProgress(items, 100, { processItem, onProgress, onItemError, onDone });
|
||||||
|
|
||||||
|
expect(processItem).toHaveBeenCalledTimes(items.length);
|
||||||
|
expect(onDone).not.toHaveBeenCalled();
|
||||||
|
for (const resolve of resolvers) resolve();
|
||||||
|
await operation;
|
||||||
|
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a progress callback error through the item error handler", async () => {
|
||||||
|
const error = new Error("Progress callback failed");
|
||||||
|
const onProgress = vi.fn(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const onItemError = vi.fn();
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
await performBatchWithProgress(["item"], 100, {
|
||||||
|
processItem: async () => false,
|
||||||
|
onProgress,
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(error, "item");
|
||||||
|
expect(onDone).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the item error handler throws and skips onDone", async () => {
|
||||||
|
const itemError = new Error("Item failed");
|
||||||
|
const handlerError = new Error("Error handler failed");
|
||||||
|
const onItemError = vi.fn(() => {
|
||||||
|
throw handlerError;
|
||||||
|
});
|
||||||
|
const onDone = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
performBatchWithProgress(["item"], 100, {
|
||||||
|
processItem: async () => {
|
||||||
|
throw itemError;
|
||||||
|
},
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
onItemError,
|
||||||
|
onDone,
|
||||||
|
})
|
||||||
|
).rejects.toBe(handlerError);
|
||||||
|
|
||||||
|
expect(onItemError).toHaveBeenCalledWith(itemError, "item");
|
||||||
|
expect(onDone).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
|
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
createChunkFetchCounterLabel,
|
||||||
|
createMinimumVisibleActivityCount,
|
||||||
|
createPaddedCounterLabel,
|
||||||
|
} from "./StatusBarDisplay.ts";
|
||||||
|
|
||||||
|
vi.mock("octagonal-wheels/concurrency/deferredAction", { spy: true });
|
||||||
|
|
||||||
|
function createdControllers() {
|
||||||
|
return vi.mocked(createDeferredAction).mock.results.map((result) => {
|
||||||
|
if (result.type !== "return") throw new Error("Expected a real timeout controller");
|
||||||
|
return result.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScheduler() {
|
||||||
|
const entries: { callback: () => void; delayMs: number; cancel: ReturnType<typeof vi.fn> }[] = [];
|
||||||
|
const schedule = vi.fn<TimeoutScheduler>((callback, delayMs) => {
|
||||||
|
const cancel = vi.fn();
|
||||||
|
entries.push({ callback, delayMs, cancel });
|
||||||
|
return cancel;
|
||||||
|
});
|
||||||
|
return { schedule, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status timeout composition", () => {
|
||||||
|
it("uses the real shared controller for minimum visibility, resumption, and disposal", () => {
|
||||||
|
const scheduler = createScheduler();
|
||||||
|
const source = reactiveSource(0);
|
||||||
|
const display = createMinimumVisibleActivityCount(source, 150, scheduler.schedule);
|
||||||
|
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||||
|
const controller = createdControllers()[0];
|
||||||
|
const schedule = vi.spyOn(controller, "schedule");
|
||||||
|
const cancel = vi.spyOn(controller, "cancel");
|
||||||
|
const dispose = vi.spyOn(controller, "dispose");
|
||||||
|
|
||||||
|
source.value = 1;
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
source.value = 0;
|
||||||
|
expect(schedule).toHaveBeenCalledExactlyOnceWith(100);
|
||||||
|
expect(scheduler.entries[0].delayMs).toBe(100);
|
||||||
|
source.value = 2;
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(3);
|
||||||
|
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||||
|
scheduler.entries[0].callback();
|
||||||
|
expect(display.value).toBe(2);
|
||||||
|
|
||||||
|
source.value = 0;
|
||||||
|
scheduler.entries[1].callback();
|
||||||
|
expect(display.value).toBe(0);
|
||||||
|
source.value = 3;
|
||||||
|
source.value = 0;
|
||||||
|
display.dispose();
|
||||||
|
display.dispose();
|
||||||
|
scheduler.entries[2].callback();
|
||||||
|
expect(display.value).toBe(3);
|
||||||
|
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the shared controller for the counter linger and cancels through it when activity resumes", () => {
|
||||||
|
const scheduler = createScheduler();
|
||||||
|
const source = reactiveSource(0);
|
||||||
|
const display = createPaddedCounterLabel(source, "A", 25, scheduler.schedule);
|
||||||
|
expect(createDeferredAction).toHaveBeenCalledExactlyOnceWith(expect.any(Function), scheduler.schedule);
|
||||||
|
const controller = createdControllers()[0];
|
||||||
|
const schedule = vi.spyOn(controller, "schedule");
|
||||||
|
const cancel = vi.spyOn(controller, "cancel");
|
||||||
|
const dispose = vi.spyOn(controller, "dispose");
|
||||||
|
source.value = 1;
|
||||||
|
source.value = 0;
|
||||||
|
expect(schedule).toHaveBeenCalledExactlyOnceWith(25);
|
||||||
|
source.value = 2;
|
||||||
|
expect(cancel).toHaveBeenCalledTimes(3);
|
||||||
|
expect(scheduler.entries[0].cancel).toHaveBeenCalledTimes(1);
|
||||||
|
scheduler.entries[0].callback();
|
||||||
|
expect(display.value).toBe(" A\u20072");
|
||||||
|
expect(display.count.value).toBe(2);
|
||||||
|
|
||||||
|
source.value = 0;
|
||||||
|
scheduler.entries[1].callback();
|
||||||
|
expect(display.value).toBe("");
|
||||||
|
expect(display.count.value).toBeUndefined();
|
||||||
|
source.value = 3;
|
||||||
|
source.value = 0;
|
||||||
|
display.dispose();
|
||||||
|
scheduler.entries[2].callback();
|
||||||
|
expect(display.value).toBe(" A\u20070");
|
||||||
|
expect(display.count.value).toBe(0);
|
||||||
|
expect(scheduler.entries[2].cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the scheduler to independent chunk-count controllers and disposes both children", () => {
|
||||||
|
const scheduler = createScheduler();
|
||||||
|
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||||
|
const display = createChunkFetchCounterLabel(source, scheduler.schedule);
|
||||||
|
expect(display.initialCount.value).toBe(2);
|
||||||
|
expect(display.retryingCount.value).toBe(3);
|
||||||
|
expect(createDeferredAction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(createDeferredAction).mock.calls.map((args) => args[1])).toEqual([
|
||||||
|
scheduler.schedule,
|
||||||
|
scheduler.schedule,
|
||||||
|
]);
|
||||||
|
const controllers = createdControllers();
|
||||||
|
const disposers = controllers.map((controller) => vi.spyOn(controller, "dispose"));
|
||||||
|
source.value = { initial: 0, retrying: 0 };
|
||||||
|
expect(scheduler.entries.map((entry) => entry.delayMs)).toEqual([
|
||||||
|
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
]);
|
||||||
|
scheduler.entries[0].callback();
|
||||||
|
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||||
|
expect(display.initialCount.value).toBeUndefined();
|
||||||
|
expect(display.retryingCount.value).toBe(0);
|
||||||
|
display.dispose();
|
||||||
|
display.dispose();
|
||||||
|
expect(scheduler.entries[0].cancel).not.toHaveBeenCalled();
|
||||||
|
expect(scheduler.entries[1].cancel).toHaveBeenCalledTimes(1);
|
||||||
|
for (const dispose of disposers) expect(dispose).toHaveBeenCalledTimes(1);
|
||||||
|
scheduler.entries[1].callback();
|
||||||
|
expect(display.value.replace(/\u2007/g, "")).toBe(" 🔁0");
|
||||||
|
expect(display.initialCount.value).toBeUndefined();
|
||||||
|
expect(display.retryingCount.value).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||||
|
import { createDeferredAction, type TimeoutScheduler } from "octagonal-wheels/concurrency/deferredAction";
|
||||||
|
|
||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
|
|
||||||
|
const scheduleStatusTimeout: TimeoutScheduler = (callback, delayMs) => {
|
||||||
|
const handle = compatGlobal.setTimeout(callback, delayMs);
|
||||||
|
return () => compatGlobal.clearTimeout(handle);
|
||||||
|
};
|
||||||
|
|
||||||
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
|
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
|
||||||
|
|
||||||
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
|
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
|
||||||
@@ -37,32 +43,32 @@ function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => v
|
|||||||
/**
|
/**
|
||||||
* Mirrors an activity count while keeping each visible period on screen for a
|
* Mirrors an activity count while keeping each visible period on screen for a
|
||||||
* minimum total lifetime. The delay applies only when the source becomes zero.
|
* minimum total lifetime. The delay applies only when the source becomes zero.
|
||||||
|
*
|
||||||
|
* @param source - The activity count to observe.
|
||||||
|
* @param minimumVisibleMs - The minimum visible lifetime, clamped to zero.
|
||||||
|
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||||
|
* existing compatible host, preserving its timer method receivers.
|
||||||
*/
|
*/
|
||||||
export function createMinimumVisibleActivityCount(
|
export function createMinimumVisibleActivityCount(
|
||||||
source: ReactiveValue<number>,
|
source: ReactiveValue<number>,
|
||||||
minimumVisibleMs: number
|
minimumVisibleMs: number,
|
||||||
|
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||||
): DisposableReactiveValue<number> {
|
): DisposableReactiveValue<number> {
|
||||||
const minimumLifetime = Math.max(0, minimumVisibleMs);
|
const minimumLifetime = Math.max(0, minimumVisibleMs);
|
||||||
const displayed = reactiveSource(Math.max(0, source.value));
|
const displayed = reactiveSource(Math.max(0, source.value));
|
||||||
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
|
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
|
||||||
let hideTimer: number | undefined;
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const cancelHide = () => {
|
|
||||||
if (hideTimer === undefined) return;
|
|
||||||
compatGlobal.clearTimeout(hideTimer);
|
|
||||||
hideTimer = undefined;
|
|
||||||
};
|
|
||||||
const hideIfIdle = () => {
|
const hideIfIdle = () => {
|
||||||
hideTimer = undefined;
|
|
||||||
if (disposed || Math.max(0, source.value) > 0) return;
|
if (disposed || Math.max(0, source.value) > 0) return;
|
||||||
displayed.value = 0;
|
displayed.value = 0;
|
||||||
visibleSince = undefined;
|
visibleSince = undefined;
|
||||||
};
|
};
|
||||||
|
const hideTimeout = createDeferredAction(hideIfIdle, scheduleTimeout);
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const nextCount = Math.max(0, source.value);
|
const nextCount = Math.max(0, source.value);
|
||||||
cancelHide();
|
hideTimeout.cancel();
|
||||||
if (nextCount > 0) {
|
if (nextCount > 0) {
|
||||||
if (displayed.value === 0) {
|
if (displayed.value === 0) {
|
||||||
visibleSince = Date.now();
|
visibleSince = Date.now();
|
||||||
@@ -80,7 +86,7 @@ export function createMinimumVisibleActivityCount(
|
|||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
hideIfIdle();
|
hideIfIdle();
|
||||||
} else {
|
} else {
|
||||||
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
|
hideTimeout.schedule(remaining);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,7 +94,7 @@ export function createMinimumVisibleActivityCount(
|
|||||||
return asDisposableReactiveValue(displayed, () => {
|
return asDisposableReactiveValue(displayed, () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cancelHide();
|
hideTimeout.dispose();
|
||||||
source.offChanged(update);
|
source.offChanged(update);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -96,24 +102,31 @@ export function createMinimumVisibleActivityCount(
|
|||||||
/**
|
/**
|
||||||
* Formats a counter with a stable width and briefly retains its zero value so
|
* Formats a counter with a stable width and briefly retains its zero value so
|
||||||
* that the completion of queued work remains visible.
|
* that the completion of queued work remains visible.
|
||||||
|
*
|
||||||
|
* @param source - The counter to observe.
|
||||||
|
* @param mark - The label prefix before the padded count.
|
||||||
|
* @param inactiveLingerMs - The delay before clearing a zero count, clamped to zero.
|
||||||
|
* @param scheduleTimeout - The scheduler for this display. Defaults to the
|
||||||
|
* existing compatible host, preserving its timer method receivers.
|
||||||
*/
|
*/
|
||||||
export function createPaddedCounterLabel(
|
export function createPaddedCounterLabel(
|
||||||
source: ReactiveValue<number>,
|
source: ReactiveValue<number>,
|
||||||
mark: string,
|
mark: string,
|
||||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||||
): PaddedCounterLabel {
|
): PaddedCounterLabel {
|
||||||
const linger = Math.max(0, inactiveLingerMs);
|
const linger = Math.max(0, inactiveLingerMs);
|
||||||
const formatted = reactiveSource("");
|
const formatted = reactiveSource("");
|
||||||
const displayedCount = reactiveSource<number | undefined>(undefined);
|
const displayedCount = reactiveSource<number | undefined>(undefined);
|
||||||
let maximumLength = 1;
|
let maximumLength = 1;
|
||||||
let clearTimer: number | undefined;
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
const cancelClear = () => {
|
const clearLabelTimeout = createDeferredAction(() => {
|
||||||
if (clearTimer === undefined) return;
|
if (disposed) return;
|
||||||
compatGlobal.clearTimeout(clearTimer);
|
formatted.value = "";
|
||||||
clearTimer = undefined;
|
displayedCount.value = undefined;
|
||||||
};
|
maximumLength = 1;
|
||||||
|
}, scheduleTimeout);
|
||||||
const format = (count: number) => {
|
const format = (count: number) => {
|
||||||
const requiredLength = `${Math.abs(count)}`.length + 1;
|
const requiredLength = `${Math.abs(count)}`.length + 1;
|
||||||
maximumLength = Math.max(maximumLength, requiredLength);
|
maximumLength = Math.max(maximumLength, requiredLength);
|
||||||
@@ -121,25 +134,19 @@ export function createPaddedCounterLabel(
|
|||||||
};
|
};
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
cancelClear();
|
clearLabelTimeout.cancel();
|
||||||
const count = source.value;
|
const count = source.value;
|
||||||
formatted.value = format(count);
|
formatted.value = format(count);
|
||||||
displayedCount.value = count;
|
displayedCount.value = count;
|
||||||
if (count !== 0) return;
|
if (count !== 0) return;
|
||||||
clearTimer = compatGlobal.setTimeout(() => {
|
clearLabelTimeout.schedule(linger);
|
||||||
clearTimer = undefined;
|
|
||||||
if (disposed) return;
|
|
||||||
formatted.value = "";
|
|
||||||
displayedCount.value = undefined;
|
|
||||||
maximumLength = 1;
|
|
||||||
}, linger);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
source.onChanged(update);
|
source.onChanged(update);
|
||||||
const display = asDisposableReactiveValue(formatted, () => {
|
const display = asDisposableReactiveValue(formatted, () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cancelClear();
|
clearLabelTimeout.dispose();
|
||||||
source.offChanged(update);
|
source.offChanged(update);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -162,14 +169,28 @@ export function createPaddedCounterLabel(
|
|||||||
/**
|
/**
|
||||||
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
* Displays the disjoint initial and retry chunk-fetch counts with the same
|
||||||
* padding and inactive linger behaviour as the other status counters.
|
* padding and inactive linger behaviour as the other status counters.
|
||||||
|
*
|
||||||
|
* @param source - The initial and retrying chunk-fetch counts.
|
||||||
|
* @param scheduleTimeout - The scheduler passed to each independent child display.
|
||||||
*/
|
*/
|
||||||
export function createChunkFetchCounterLabel(
|
export function createChunkFetchCounterLabel(
|
||||||
source: ReactiveValue<{ initial: number; retrying: number }>
|
source: ReactiveValue<{ initial: number; retrying: number }>,
|
||||||
|
scheduleTimeout: TimeoutScheduler = scheduleStatusTimeout
|
||||||
): ChunkFetchCounterLabel {
|
): ChunkFetchCounterLabel {
|
||||||
const initialCount = reactiveSource(0);
|
const initialCount = reactiveSource(0);
|
||||||
const retryingCount = reactiveSource(0);
|
const retryingCount = reactiveSource(0);
|
||||||
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
|
const initialLabel = createPaddedCounterLabel(
|
||||||
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
|
initialCount,
|
||||||
|
"🛄",
|
||||||
|
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
scheduleTimeout
|
||||||
|
);
|
||||||
|
const retryingLabel = createPaddedCounterLabel(
|
||||||
|
retryingCount,
|
||||||
|
"🔁",
|
||||||
|
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||||
|
scheduleTimeout
|
||||||
|
);
|
||||||
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
|
||||||
let updatingCounts = false;
|
let updatingCounts = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -194,3 +195,121 @@ describe("createChunkFetchCounterLabel", () => {
|
|||||||
expect(withoutPadding()).toBe(" 🔁0");
|
expect(withoutPadding()).toBe(" 🔁0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("status timeout ownership", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-10-24T00:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the minimum lifetime from construction when activity is already visible", () => {
|
||||||
|
const source = reactiveSource(2);
|
||||||
|
const display = createMinimumVisibleActivityCount(source, 150);
|
||||||
|
vi.advanceTimersByTime(40);
|
||||||
|
source.value = 0;
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(109);
|
||||||
|
expect(display.value).toBe(2);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(display.value).toBe(0);
|
||||||
|
display.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides immediately for a non-positive minimum lifetime and clamps negative activity", () => {
|
||||||
|
const source = reactiveSource(-2);
|
||||||
|
const display = createMinimumVisibleActivityCount(source, -10);
|
||||||
|
expect(display.value).toBe(0);
|
||||||
|
source.value = 1;
|
||||||
|
source.value = -2;
|
||||||
|
|
||||||
|
expect(display.value).toBe(0);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
display.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a zero-duration counter clear asynchronous", () => {
|
||||||
|
const source = reactiveSource(0);
|
||||||
|
const display = createPaddedCounterLabel(source, "A", 0);
|
||||||
|
source.value = 1;
|
||||||
|
source.value = 0;
|
||||||
|
|
||||||
|
expect(display.value).toBe(" A\u20070");
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
expect(display.value).toBe("");
|
||||||
|
display.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["activity", "label"] as const)(
|
||||||
|
"retains timer receivers, zero handles, and idempotent disposal for %s",
|
||||||
|
(kind) => {
|
||||||
|
const source = reactiveSource(0);
|
||||||
|
const offChanged = vi.spyOn(source, "offChanged");
|
||||||
|
const setTimer = vi.spyOn(compatGlobal as Window, "setTimeout").mockReturnValue(0);
|
||||||
|
const clearTimer = vi.spyOn(compatGlobal as Window, "clearTimeout").mockImplementation(() => {});
|
||||||
|
const display =
|
||||||
|
kind === "activity"
|
||||||
|
? createMinimumVisibleActivityCount(source, 150)
|
||||||
|
: createPaddedCounterLabel(source, "A", 150);
|
||||||
|
|
||||||
|
source.value = 1;
|
||||||
|
source.value = 0;
|
||||||
|
expect(setTimer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setTimer.mock.contexts[0]).toBe(compatGlobal);
|
||||||
|
expect(setTimer.mock.calls[0][1]).toBe(150);
|
||||||
|
source.value = 2;
|
||||||
|
expect(clearTimer).toHaveBeenCalledExactlyOnceWith(0);
|
||||||
|
source.value = 0;
|
||||||
|
display.dispose();
|
||||||
|
display.dispose();
|
||||||
|
source.value = 3;
|
||||||
|
|
||||||
|
expect(setTimer).toHaveBeenCalledTimes(2);
|
||||||
|
expect(clearTimer).toHaveBeenCalledTimes(2);
|
||||||
|
expect(clearTimer.mock.calls).toEqual([[0], [0]]);
|
||||||
|
expect(clearTimer.mock.contexts).toEqual([compatGlobal, compatGlobal]);
|
||||||
|
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps pending clears independent between display instances", () => {
|
||||||
|
const firstSource = reactiveSource(0);
|
||||||
|
const secondSource = reactiveSource(0);
|
||||||
|
const first = createPaddedCounterLabel(firstSource, "A", 100);
|
||||||
|
const second = createPaddedCounterLabel(secondSource, "B", 100);
|
||||||
|
firstSource.value = 1;
|
||||||
|
firstSource.value = 0;
|
||||||
|
secondSource.value = 1;
|
||||||
|
secondSource.value = 0;
|
||||||
|
expect(vi.getTimerCount()).toBe(2);
|
||||||
|
|
||||||
|
first.dispose();
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(first.value).toBe(" A\u20070");
|
||||||
|
expect(second.value).toBe("");
|
||||||
|
second.dispose();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disposes both child timers and its source subscription for chunk counts", () => {
|
||||||
|
const source = reactiveSource({ initial: 2, retrying: 3 });
|
||||||
|
const offChanged = vi.spyOn(source, "offChanged");
|
||||||
|
const display = createChunkFetchCounterLabel(source);
|
||||||
|
source.value = { initial: 0, retrying: 0 };
|
||||||
|
const lastLabel = display.value;
|
||||||
|
expect(vi.getTimerCount()).toBe(2);
|
||||||
|
|
||||||
|
display.dispose();
|
||||||
|
display.dispose();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||||
|
source.value = { initial: 4, retrying: 5 };
|
||||||
|
expect(display.value).toBe(lastLabel);
|
||||||
|
expect(offChanged).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||||
|
|
||||||
|
describe("ReplicateResultProcessor.withCounting", () => {
|
||||||
|
it("counts overlapping callbacks until each settles and preserves results and errors", async () => {
|
||||||
|
const originalCount = 7;
|
||||||
|
const count = reactiveSource(originalCount);
|
||||||
|
const processor = new ReplicateResultProcessor({} as never);
|
||||||
|
const firstResult = { task: "first" };
|
||||||
|
const firstResultGate = promiseWithResolvers<typeof firstResult>();
|
||||||
|
const secondResultGate = promiseWithResolvers<string>();
|
||||||
|
const firstStarted = promiseWithResolvers<undefined>();
|
||||||
|
const secondStarted = promiseWithResolvers<undefined>();
|
||||||
|
const failure = new Error("second callback failed");
|
||||||
|
let firstCountAtStart = 0;
|
||||||
|
let secondCountAtStart = 0;
|
||||||
|
|
||||||
|
const first = processor.withCounting(async () => {
|
||||||
|
firstCountAtStart = count.value;
|
||||||
|
firstStarted.resolve(undefined);
|
||||||
|
return firstResultGate.promise;
|
||||||
|
}, count);
|
||||||
|
await firstStarted.promise;
|
||||||
|
expect(firstCountAtStart).toBe(originalCount + 1);
|
||||||
|
expect(count.value).toBe(originalCount + 1);
|
||||||
|
|
||||||
|
const second = processor.withCounting(async () => {
|
||||||
|
secondCountAtStart = count.value;
|
||||||
|
secondStarted.resolve(undefined);
|
||||||
|
return secondResultGate.promise;
|
||||||
|
}, count);
|
||||||
|
await secondStarted.promise;
|
||||||
|
expect(secondCountAtStart).toBe(originalCount + 2);
|
||||||
|
expect(count.value).toBe(originalCount + 2);
|
||||||
|
|
||||||
|
firstResultGate.resolve(firstResult);
|
||||||
|
await expect(first).resolves.toBe(firstResult);
|
||||||
|
expect(count.value).toBe(originalCount + 1);
|
||||||
|
|
||||||
|
secondResultGate.reject(failure);
|
||||||
|
await expect(second).rejects.toBe(failure);
|
||||||
|
expect(count.value).toBe(originalCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||||
import { fireAndForget, isAnyNote, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
import { fireAndForget, isAnyNote, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
|
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
|
||||||
|
import { withCounting } from "octagonal-wheels/concurrency/counting";
|
||||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
@@ -221,12 +222,7 @@ export class ReplicateResultProcessor {
|
|||||||
* @returns result of the procedure
|
* @returns result of the procedure
|
||||||
*/
|
*/
|
||||||
async withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>) {
|
async withCounting<T>(proc: () => Promise<T>, countValue: ReactiveSource<number>) {
|
||||||
countValue.value++;
|
return withCounting(countValue, proc);
|
||||||
try {
|
|
||||||
return await proc();
|
|
||||||
} finally {
|
|
||||||
countValue.value--;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user