mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-14 20:20:12 +00:00
Preserve CLI database continuity after reconstruction
This commit is contained in:
1 parent
0e458e5e20
commit
d50ee9a83e
21 files changed
+1585
-60
No files matched your search
@@ -9,6 +9,7 @@ import type { IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/b
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { fs as nodeFs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readOptionalFile, writeFileAtomically } from "@/apps/cli/runtimeState";
|
||||
|
||||
const NODE_KV_TYPED_KEY = "__nodeKvType";
|
||||
const NODE_KV_VALUES_KEY = "values";
|
||||
@@ -103,12 +104,11 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
||||
}
|
||||
|
||||
private load() {
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.filePath, "utf-8")) as Record<string, unknown>;
|
||||
this.data = new Map(Object.entries(loaded).map(([key, value]) => [key, deserializeFromNodeKV(value)]));
|
||||
} catch {
|
||||
this.data = new Map();
|
||||
}
|
||||
const contents = readOptionalFile(this.filePath);
|
||||
if (contents === undefined) return;
|
||||
const loaded: unknown = JSON.parse(contents);
|
||||
if (!isRecord(loaded) || Array.isArray(loaded)) throw new Error("Invalid Node key-value database");
|
||||
this.data = new Map(Object.entries(loaded).map(([key, value]) => [key, deserializeFromNodeKV(value)]));
|
||||
}
|
||||
|
||||
private flush() {
|
||||
@@ -116,7 +116,7 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
||||
const serializable = Object.fromEntries(
|
||||
[...this.data.entries()].map(([key, value]) => [key, serializeForNodeKV(value)])
|
||||
);
|
||||
nodeFs.writeFileSync(this.filePath, JSON.stringify(serializable, null, 2), "utf-8");
|
||||
writeFileAtomically(this.filePath, JSON.stringify(serializable, null, 2));
|
||||
}
|
||||
|
||||
async get<T>(key: IDBValidKey): Promise<T> {
|
||||
@@ -163,9 +163,7 @@ class NodeFileKeyValueDatabase implements KeyValueDatabase {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.flush();
|
||||
}
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
this.data.clear();
|
||||
@@ -185,7 +183,8 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
{
|
||||
private _kvDB: KeyValueDatabase | undefined;
|
||||
private _simpleStore: SimpleStore<unknown> | undefined;
|
||||
private filePath: string;
|
||||
private filePath: string | (() => string);
|
||||
private openedFilePath: string | undefined;
|
||||
private _log = createInstanceLogFunction("NodeKeyValueDBService");
|
||||
|
||||
get simpleStore() {
|
||||
@@ -202,7 +201,7 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
return this._kvDB;
|
||||
}
|
||||
|
||||
constructor(context: T, dependencies: NodeKeyValueDBDependencies<T>, filePath: string) {
|
||||
constructor(context: T, dependencies: NodeKeyValueDBDependencies<T>, filePath: string | (() => string)) {
|
||||
super(context);
|
||||
this.filePath = filePath;
|
||||
|
||||
@@ -215,7 +214,13 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
|
||||
private async openKeyValueDB(): Promise<boolean> {
|
||||
try {
|
||||
this._kvDB = new NodeFileKeyValueDatabase(this.filePath);
|
||||
const filePath = typeof this.filePath === "string" ? this.filePath : this.filePath();
|
||||
if (this._kvDB && this.openedFilePath === filePath) return true;
|
||||
await this._kvDB?.close();
|
||||
this._kvDB = undefined;
|
||||
this.openedFilePath = undefined;
|
||||
this._kvDB = new NodeFileKeyValueDatabase(filePath);
|
||||
this.openedFilePath = filePath;
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log("Failed to open Node key-value database", LOG_LEVEL_NOTICE);
|
||||
@@ -226,8 +231,10 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
|
||||
|
||||
private async _everyOnResetDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this._kvDB?.del("queued-files");
|
||||
if (!(await this.openKeyValueDB())) return false;
|
||||
await this._kvDB?.destroy();
|
||||
this._kvDB = undefined;
|
||||
this.openedFilePath = undefined;
|
||||
return await this.openKeyValueDB();
|
||||
} catch (ex) {
|
||||
this._log("Failed to reset Node key-value database", LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
@@ -45,3 +46,70 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeKeyValueDBService persistence", () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-kv-"));
|
||||
});
|
||||
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
function compose(filePath: string | (() => string)) {
|
||||
const handlers: Record<string, () => Promise<boolean>> = {};
|
||||
const event = (name: string) => ({
|
||||
addHandler: (handler: () => Promise<boolean>) => {
|
||||
handlers[name] = handler;
|
||||
},
|
||||
});
|
||||
const dependencies = {
|
||||
appLifecycle: { onSettingLoaded: event("load") },
|
||||
databaseEvents: {
|
||||
onResetDatabase: event("reset"),
|
||||
onDatabaseInitialisation: event("initialise"),
|
||||
onUnloadDatabase: event("unload"),
|
||||
onCloseDatabase: event("close"),
|
||||
},
|
||||
vault: {},
|
||||
} as unknown as NodeKeyValueDBDependencies;
|
||||
return { service: new NodeKeyValueDBService(createServiceContext(), dependencies, filePath), handlers };
|
||||
}
|
||||
|
||||
it("retargets existing SimpleStore handles after database selection and resets only the selected file", async () => {
|
||||
let selected = path.join(directory, "first.json");
|
||||
const { service, handlers } = compose(() => selected);
|
||||
const store = service.openSimpleStore("journal");
|
||||
expect(await handlers.load()).toBe(true);
|
||||
await store.set("checkpoint", new Set(["first"]));
|
||||
selected = path.join(directory, "second.json");
|
||||
expect(await handlers.initialise()).toBe(true);
|
||||
expect(await store.get("checkpoint")).toBeUndefined();
|
||||
await store.set("checkpoint", "second");
|
||||
expect(await handlers.reset()).toBe(true);
|
||||
expect(await store.get("checkpoint")).toBeUndefined();
|
||||
selected = path.join(directory, "first.json");
|
||||
expect(await handlers.initialise()).toBe(true);
|
||||
expect(await store.get("checkpoint")).toEqual(new Set(["first"]));
|
||||
});
|
||||
|
||||
it("does not overwrite newer data when a stale handle closes", async () => {
|
||||
const filePath = path.join(directory, "state.json");
|
||||
const first = compose(filePath);
|
||||
await first.handlers.load();
|
||||
await first.service.kvDB.set("queued-files", ["original.md"]);
|
||||
const second = compose(filePath);
|
||||
await second.handlers.load();
|
||||
await first.service.kvDB.set("queued-files", ["unique-edit.md"]);
|
||||
await second.handlers.close();
|
||||
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))["queued-files"]).toEqual(["unique-edit.md"]);
|
||||
});
|
||||
|
||||
it.each(["{", "null", "[]"])("refuses corrupt state %s without replacing it", async (contents) => {
|
||||
const filePath = path.join(directory, "state.json");
|
||||
fs.writeFileSync(filePath, contents);
|
||||
const { service, handlers } = compose(filePath);
|
||||
expect(await handlers.load()).toBe(false);
|
||||
expect(() => service.kvDB).toThrow("not initialized");
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe(contents);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { ServiceContext, type StandardIo } from "@vrtmrz/livesync-commonlib/cont
|
||||
export class NodeServiceContext extends ServiceContext {
|
||||
constructor(
|
||||
readonly databasePath: string,
|
||||
readonly standardIo: StandardIo
|
||||
readonly standardIo: StandardIo,
|
||||
readonly runtimeVaultPath: string = databasePath
|
||||
) {
|
||||
super({ events: eventHub, translate: translateLiveSyncMessage });
|
||||
}
|
||||
|
||||
@@ -24,11 +24,14 @@ import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
import { createNodeLocalStorage } from "./NodeLocalStorage";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fs, path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import { PouchDB } from "@/apps/cli/lib/pouchdb-node";
|
||||
import { NodeServiceContext } from "./NodeServiceContext";
|
||||
import { setLang } from "@/common/translation";
|
||||
import { runtimeDatabaseDirectory, runtimeScopeDirectory } from "@/apps/cli/runtimeState";
|
||||
import { createDeletableValueStore } from "@vrtmrz/value-store";
|
||||
import type { SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
|
||||
export { NodeServiceContext } from "./NodeServiceContext";
|
||||
|
||||
@@ -86,17 +89,31 @@ class NodeUIService<T extends ServiceContext> extends UIService<T> {
|
||||
}
|
||||
}
|
||||
|
||||
class NodeSettingService<T extends NodeServiceContext> extends InjectableSettingService<T> {
|
||||
declare readonly config: InjectableSettingService<T>["config"];
|
||||
|
||||
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||
super(context, dependencies);
|
||||
const databaseConfig = this.config;
|
||||
const vaultScope = nodePath.basename(runtimeScopeDirectory(context.databasePath, "", context.runtimeVaultPath));
|
||||
this.config = createDeletableValueStore({
|
||||
get: (key) => databaseConfig.getValue(`${key}-${vaultScope}`),
|
||||
set: (key, value) => databaseConfig.setValue(`${key}-${vaultScope}`, value),
|
||||
delete: (key) => databaseConfig.delete(`${key}-${vaultScope}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServiceHub<T> {
|
||||
constructor(basePath: string, context: T) {
|
||||
const runtimeDir = nodePath.join(basePath, ".livesync", "runtime");
|
||||
const localStoragePath = nodePath.join(runtimeDir, "local-storage.json");
|
||||
const keyValueDBPath = nodePath.join(runtimeDir, "keyvalue-db.json");
|
||||
|
||||
const API = new HeadlessAPIService<T>(context);
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const fileProcessing = new InjectableFileProcessingService(context);
|
||||
|
||||
const setting = new InjectableSettingService<T>(context, {
|
||||
const setting = new NodeSettingService<T>(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
deviceValues: createNodeLocalStorage(localStoragePath),
|
||||
@@ -118,8 +135,13 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const scopeDirectory = () => runtimeScopeDirectory(basePath, vault.getVaultName(), context.runtimeVaultPath);
|
||||
const test = new InjectableTestService(context);
|
||||
const databaseEvents = new InjectableDatabaseEventService(context);
|
||||
databaseEvents.onResetDatabase.addHandler(async () => {
|
||||
fs.rmSync(runtimeDatabaseDirectory(basePath, vault.getVaultName()), { force: true, recursive: true });
|
||||
return true;
|
||||
});
|
||||
const path = new PathServiceCompat(context, {
|
||||
settingService: setting,
|
||||
});
|
||||
@@ -156,7 +178,7 @@ export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServ
|
||||
databaseEvents,
|
||||
vault,
|
||||
},
|
||||
keyValueDBPath
|
||||
() => nodePath.join(scopeDirectory(), "keyvalue-db.json")
|
||||
);
|
||||
|
||||
const control = new ControlService(context, {
|
||||
|
||||
Reference in new issue
Block a user