mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-13 11:01:16 +00:00
cli: implement local→CouchDB file watching via chokidar
- Add chokidar ^4.0.0 as dependency (root package.json, runtime-package.json) - Mark chokidar as external in vite.config.ts (not bundled, loaded at runtime) - Implement CLIWatchAdapter.beginWatch() with chokidar: - ignoreInitial: true (startup files handled by mirror scan) - awaitWriteFinish to prevent partial-write events - Excludes dotfiles and .livesync/ directory at watcher level - Maps add/change/unlink/addDir/unlinkDir to IStorageEventWatchHandlers - Fatal error handler: logs clearly and releases watcher resources - Add close() to CLIWatchAdapter, StorageEventManagerCLI for clean shutdown - Register onUnload hook in CLIServiceModules to close watcher on shutdown
This commit is contained in:
36
package-lock.json
generated
36
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@smithy/protocol-http": "^5.3.9",
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@trystero-p2p/nostr": "^0.23.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -984,7 +985,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2378,7 +2378,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@minhducsun2002/leb128": {
|
||||
"version": "1.0.0",
|
||||
@@ -4224,7 +4225,6 @@
|
||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
@@ -4738,7 +4738,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -4943,7 +4942,6 @@
|
||||
"integrity": "sha512-gjjrFC4+kPVK/fN9URDJWrssU5Gqh8Az8pKG/NSfQ2V+ky8b/y1BgBg0Ug13+hOGp5pzInonmGRPn7vOgSLgzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@blazediff/core": "1.9.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
@@ -4967,7 +4965,6 @@
|
||||
"integrity": "sha512-dtVSBZZha2k/7P7EAXXrEAoxuIKl8Yv9f2Dk4GN/DGfmhf4DQvkvu+57okR2wq/gan1xppKjL/aBxK/kbYrbGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
@@ -5409,7 +5406,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6152,7 +6148,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6385,7 +6380,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
@@ -6648,7 +6642,8 @@
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -7441,7 +7436,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -7555,7 +7549,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9695,7 +9688,6 @@
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -11203,7 +11195,6 @@
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
@@ -11270,7 +11261,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11296,7 +11286,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lilconfig": "^3.1.1"
|
||||
},
|
||||
@@ -11943,7 +11932,6 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
@@ -12956,7 +12944,8 @@
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/sublevel-pouchdb": {
|
||||
"version": "9.0.0",
|
||||
@@ -13025,7 +13014,6 @@
|
||||
"integrity": "sha512-0a/huwc8e2es+7KFi70esqsReRfRbrT8h1cJSY/+z1lF0yKM6TT+//HYu28Yxstr50H7ifaqZRDGd0KuKDxP7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -13336,7 +13324,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13455,7 +13442,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -14086,7 +14072,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14236,7 +14221,6 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14873,7 +14857,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14907,7 +14890,6 @@
|
||||
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.1",
|
||||
"@vitest/mocker": "4.1.1",
|
||||
@@ -15015,7 +14997,8 @@
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/wait-port": {
|
||||
"version": "1.1.0",
|
||||
@@ -15667,7 +15650,6 @@
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"@smithy/fetch-http-handler": "^5.3.10",
|
||||
"@smithy/md5-js": "^4.2.9",
|
||||
"@smithy/middleware-apply-body-checksum": "^4.3.9",
|
||||
|
||||
@@ -39,12 +39,6 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
const cached = this.fileCache.get(pathStr);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await this.refreshFile(pathStr);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
} from "@lib/managers/adapters";
|
||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
||||
import type { Stats } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
@@ -56,22 +58,11 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific status adapter (console logging)
|
||||
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
||||
*/
|
||||
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
||||
private lastUpdate = 0;
|
||||
private updateInterval = 5000; // Update every 5 seconds
|
||||
|
||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdate > this.updateInterval) {
|
||||
if (status.totalQueued > 0 || status.processing > 0) {
|
||||
// console.log(
|
||||
// `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
||||
// );
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
}
|
||||
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
// intentional no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,15 +91,100 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific watch adapter (optional file watching with chokidar)
|
||||
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||
*/
|
||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
constructor(private basePath: string) {}
|
||||
private _watcher: FSWatcher | undefined;
|
||||
|
||||
constructor(private basePath: string, private watchEnabled: boolean = false) {}
|
||||
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath) as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _toNodeFileStub(filePath: string): NodeFile {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath) as FilePath,
|
||||
stat: {
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
size: 0,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||
return {
|
||||
path: path.relative(this.basePath, dirPath) as FilePath,
|
||||
isFolder: true,
|
||||
};
|
||||
}
|
||||
|
||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
// File watching is not activated in the CLI.
|
||||
// Because the CLI is designed for push/pull operations, not real-time sync.
|
||||
// console.error("[CLIWatchAdapter] File watching is not enabled in CLI version");
|
||||
if (!this.watchEnabled) return;
|
||||
const watcher = chokidarWatch(this.basePath, {
|
||||
ignored: [
|
||||
/(^|[/\\])\./,
|
||||
],
|
||||
ignoreInitial: true,
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 500,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
watcher.on("add", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onCreate(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("change", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onChange(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("unlink", (filePath) => {
|
||||
const nodeFile = this._toNodeFileStub(filePath);
|
||||
handlers.onDelete(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("addDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onCreate(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("unlinkDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onDelete(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
||||
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
||||
void watcher.close();
|
||||
this._watcher = undefined;
|
||||
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||
this._watcher = watcher;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this._watcher) {
|
||||
return this._watcher.close();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -123,11 +199,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
readonly status: CLIStatusAdapter;
|
||||
readonly converter: CLIConverterAdapter;
|
||||
|
||||
constructor(basePath: string) {
|
||||
constructor(basePath: string, watchEnabled: boolean = false) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath);
|
||||
this.watch = new CLIWatchAdapter(basePath);
|
||||
this.watch = new CLIWatchAdapter(basePath, watchEnabled);
|
||||
this.status = new CLIStatusAdapter();
|
||||
this.converter = new CLIConverterAdapter();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.watch.close();
|
||||
}
|
||||
}
|
||||
|
||||
126
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
126
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { IStorageEventWatchHandlers } from "@lib/managers/adapters";
|
||||
import type { NodeFile } from "../adapters/NodeTypes";
|
||||
|
||||
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in chokidar.
|
||||
|
||||
const mockWatcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
once: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
}),
|
||||
close: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
vi.mock("chokidar", () => ({
|
||||
watch: vi.fn(() => mockWatcher),
|
||||
}));
|
||||
|
||||
import * as chokidar from "chokidar";
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeHandlers(): IStorageEventWatchHandlers {
|
||||
return {
|
||||
onCreate: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CLIStorageEventManagerAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Restore the default once() behaviour (ready fires synchronously).
|
||||
mockWatcher.once.mockImplementation((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
});
|
||||
});
|
||||
|
||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("beginWatch calls chokidar.watch when watchEnabled=true", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||
expect(chokidar.watch).toHaveBeenCalledWith(
|
||||
"/base",
|
||||
expect.objectContaining({ ignoreInitial: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
// Find the callback registered for the "add" event.
|
||||
const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add");
|
||||
expect(addCall).toBeDefined();
|
||||
const addCallback = addCall![1] as (filePath: string, stats: any) => void;
|
||||
|
||||
const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 };
|
||||
addCallback(`${basePath}/subdir/note.md`, fakeStats);
|
||||
|
||||
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.path).toBe("subdir/note.md");
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
await adapter.close();
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("close() is safe when no watcher was started", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
|
||||
// Should not throw.
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
expect(mockWatcher.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error event triggers process.exit(1)", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
|
||||
const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error");
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorCallback = errorCall![1] as (err: Error) => void;
|
||||
|
||||
errorCallback(new Error("disk failure"));
|
||||
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,10 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
constructor(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||
dependencies: StorageEventManagerBaseDependencies
|
||||
dependencies: StorageEventManagerBaseDependencies,
|
||||
watchEnabled?: boolean
|
||||
) {
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath);
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, watchEnabled);
|
||||
super(adapter, dependencies);
|
||||
this.core = core;
|
||||
}
|
||||
@@ -25,4 +26,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
// No-op in CLI version
|
||||
// Internal file handling is not needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the file watcher. Call this during graceful shutdown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
return this.adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
|
||||
@@ -22,7 +22,8 @@ import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||
export function initialiseServiceModulesCLI(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
services: InjectableServiceHub<ServiceContext>
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
watchEnabled: boolean = false,
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
@@ -42,6 +43,12 @@ export function initialiseServiceModulesCLI(
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
}, false);
|
||||
|
||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||
services.appLifecycle.onUnload.addHandler(async () => {
|
||||
await storageEventManager.close();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Storage access using CLI file system adapter
|
||||
|
||||
@@ -11,11 +11,50 @@ const defaultExternal = [
|
||||
"crypto",
|
||||
"pouchdb-adapter-leveldb",
|
||||
"commander",
|
||||
"chokidar",
|
||||
"punycode",
|
||||
"werift",
|
||||
];
|
||||
// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses
|
||||
// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is
|
||||
// unavailable. Node.js has neither, so we inject a minimal FileReader shim before
|
||||
// any module-scope code evaluates.
|
||||
const fileReaderPolyfillBanner = `
|
||||
if (typeof globalThis.FileReader === "undefined") {
|
||||
globalThis.FileReader = class FileReader {
|
||||
constructor() { this.result = null; this.onload = null; this.onerror = null; }
|
||||
readAsDataURL(blob) {
|
||||
blob.arrayBuffer().then((buf) => {
|
||||
var b64 = require("buffer").Buffer.from(buf).toString("base64");
|
||||
this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64;
|
||||
if (this.onload) this.onload({ target: this });
|
||||
}).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); });
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
function injectBanner(): import("vite").Plugin {
|
||||
return {
|
||||
name: "inject-banner",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) {
|
||||
// Insert after the shebang line if present, otherwise at the top.
|
||||
if (chunk.code.startsWith("#!")) {
|
||||
const newline = chunk.code.indexOf("\n");
|
||||
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||
} else {
|
||||
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [svelte(), injectBanner()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
||||
|
||||
Reference in New Issue
Block a user