mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Merge main into NFC transport support
This commit is contained in:
commit
4957f8d28f
160 files changed
+12441
-1388
No files matched your search
@@ -1,11 +1,10 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
/**
|
||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||
@@ -14,26 +13,26 @@ import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||
readonly context: T;
|
||||
|
||||
constructor(context: T) {
|
||||
const dialogueController = new AbortController();
|
||||
constructor(context: T, ui: BrowserUiScope = createBrowserUiScope(context)) {
|
||||
const notifications = new BrowserUiNotifications({
|
||||
document: _activeDocument,
|
||||
document: ui.document,
|
||||
});
|
||||
super({
|
||||
ui: createBrowserUi({
|
||||
document: _activeDocument,
|
||||
signal: dialogueController.signal,
|
||||
document: ui.document,
|
||||
signal: ui.signal,
|
||||
renderMarkdown: ({ container, markdown }) => {
|
||||
renderMessageMarkdownInto(container, markdown);
|
||||
},
|
||||
}),
|
||||
notifications,
|
||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
||||
createActionAnchor: () => createNativeElement(ui.document, "a"),
|
||||
});
|
||||
this.context = context;
|
||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
dialogueController.abort();
|
||||
if (ui.signal.aborted) {
|
||||
notifications.dispose();
|
||||
});
|
||||
} else {
|
||||
ui.signal.addEventListener("abort", () => notifications.dispose(), { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { mount } from "svelte";
|
||||
import type { BrowserUiOptions } from "./BrowserUiScope";
|
||||
import { mount, unmount } from "svelte";
|
||||
import MenuView from "./ui/MenuView.svelte";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type MountedMenu = ReturnType<typeof mount>;
|
||||
type MenuSession = {
|
||||
waitingForClose: PromiseWithResolvers<void>;
|
||||
element: HTMLElement;
|
||||
component?: MountedMenu;
|
||||
abortListener?: () => void;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
export class MenuItem {
|
||||
type = "item";
|
||||
title = "";
|
||||
@@ -29,7 +39,15 @@ export class Menu {
|
||||
type = "menu";
|
||||
items: (MenuItem | MenuSeparator)[] = [];
|
||||
|
||||
constructor() {}
|
||||
private readonly document: Document;
|
||||
private readonly signal?: AbortSignal;
|
||||
private activeSession?: MenuSession;
|
||||
|
||||
constructor(options: BrowserUiOptions = {}) {
|
||||
this.document = options.document ?? _activeDocument;
|
||||
this.signal = options.signal;
|
||||
}
|
||||
|
||||
addItem(callback: (item: MenuItem) => void) {
|
||||
const item = new MenuItem();
|
||||
callback(item);
|
||||
@@ -41,32 +59,75 @@ export class Menu {
|
||||
return this;
|
||||
}
|
||||
waitingForClose?: PromiseWithResolvers<void>;
|
||||
showAtPosition(pos: { x: number; y: number }) {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
if (this.waitingForClose) {
|
||||
this.waitingForClose.resolve();
|
||||
|
||||
showAtPosition(pos: { x: number; y: number }): Promise<void> {
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
this.waitingForClose = promiseWithResolvers<void>();
|
||||
mount(MenuView, {
|
||||
target: el,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu: () => {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.signal?.aborted) {
|
||||
this.waitingForClose = undefined;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const waitingForClose = promiseWithResolvers<void>();
|
||||
const element = createNativeElement(this.document, "div");
|
||||
const session: MenuSession = {
|
||||
waitingForClose,
|
||||
element,
|
||||
closed: false,
|
||||
};
|
||||
this.activeSession = session;
|
||||
this.waitingForClose = waitingForClose;
|
||||
|
||||
const closeMenu = () => this.closeSession(session);
|
||||
session.abortListener = closeMenu;
|
||||
this.signal?.addEventListener("abort", closeMenu, { once: true });
|
||||
|
||||
try {
|
||||
session.component = mount(MenuView, {
|
||||
target: element,
|
||||
props: {
|
||||
items: this.items,
|
||||
closeMenu,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void this.waitingForClose.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return this.waitingForClose.promise;
|
||||
});
|
||||
if (!session.closed) {
|
||||
this.document.body.appendChild(element);
|
||||
} else if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
} catch (error) {
|
||||
this.closeSession(session);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return waitingForClose.promise;
|
||||
}
|
||||
|
||||
private closeSession(session: MenuSession): void {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
if (session.abortListener) {
|
||||
this.signal?.removeEventListener("abort", session.abortListener);
|
||||
session.abortListener = undefined;
|
||||
}
|
||||
if (this.activeSession === session) {
|
||||
this.activeSession = undefined;
|
||||
this.waitingForClose = undefined;
|
||||
}
|
||||
|
||||
session.element.remove();
|
||||
if (session.component) {
|
||||
fireAndForget(() => unmount(session.component!));
|
||||
}
|
||||
session.waitingForClose.resolve();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.waitingForClose?.resolve();
|
||||
this.waitingForClose = undefined;
|
||||
if (this.activeSession) {
|
||||
this.closeSession(this.activeSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||
import { createBrowserUiScope, type BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export class BrowserModal {
|
||||
contentEl: HTMLElement;
|
||||
@@ -15,14 +16,14 @@ export class BrowserModal {
|
||||
modalEl: HTMLElement;
|
||||
isOpen: boolean = false;
|
||||
baseEl: HTMLElement;
|
||||
constructor() {
|
||||
const baseEl = createNativeElement(_activeDocument, "popup");
|
||||
constructor(document: Document = _activeDocument) {
|
||||
const baseEl = createNativeElement(document, "popup");
|
||||
this.baseEl = baseEl;
|
||||
this.contentEl = createNativeElement(_activeDocument, "div");
|
||||
this.contentEl = createNativeElement(document, "div");
|
||||
this.contentEl.className = "modal-content";
|
||||
this.titleEl = createNativeElement(_activeDocument, "div");
|
||||
this.titleEl = createNativeElement(document, "div");
|
||||
this.titleEl.className = "modal-title";
|
||||
this.modalEl = createNativeElement(_activeDocument, "div");
|
||||
this.modalEl = createNativeElement(document, "div");
|
||||
this.modalEl.className = "modal";
|
||||
this.modalEl.hidden = true;
|
||||
this.modalEl.appendChild(this.titleEl);
|
||||
@@ -33,7 +34,7 @@ export class BrowserModal {
|
||||
this.isOpen = true;
|
||||
this.modalEl.hidden = false;
|
||||
if (!this.baseEl.parentElement) {
|
||||
_activeDocument.body.appendChild(this.baseEl);
|
||||
this.baseEl.ownerDocument.body.appendChild(this.baseEl);
|
||||
}
|
||||
this.onOpen();
|
||||
}
|
||||
@@ -57,9 +58,10 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
context: C,
|
||||
dependents: SvelteDialogManagerDependencies<C>,
|
||||
component: ComponentHasResult<T, U>,
|
||||
initialData?: U
|
||||
initialData?: U,
|
||||
private readonly ui?: BrowserUiScope
|
||||
) {
|
||||
super();
|
||||
super(ui?.document ?? _activeDocument);
|
||||
this.session = new SvelteDialogSession({
|
||||
surface: this,
|
||||
context,
|
||||
@@ -67,9 +69,15 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
dialogHost: DialogHost,
|
||||
component,
|
||||
initialData,
|
||||
signal: ui?.signal,
|
||||
});
|
||||
}
|
||||
|
||||
override open(): void {
|
||||
if (this.ui?.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
super.open();
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
this.session.onOpen();
|
||||
}
|
||||
@@ -83,11 +91,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
}
|
||||
}
|
||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
dependencies: SvelteDialogManagerDependencies<T>,
|
||||
private readonly ui: BrowserUiScope = createBrowserUiScope(context)
|
||||
) {
|
||||
super(context, dependencies);
|
||||
}
|
||||
|
||||
override async openSvelteDialog<TT, TU>(
|
||||
component: ComponentHasResult<TT, TU>,
|
||||
initialData?: TU
|
||||
): Promise<TT | undefined> {
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(this.context, this.dependents, component, initialData);
|
||||
if (this.ui.signal.aborted) throw this.ui.signal.reason ?? new Error("Plug-in unloaded");
|
||||
const dialog = new LiveSyncBrowserDialog<TT, TU, T>(
|
||||
this.context,
|
||||
this.dependents,
|
||||
component,
|
||||
initialData,
|
||||
this.ui
|
||||
);
|
||||
dialog.open();
|
||||
return await dialog.waitForClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
export interface BrowserUiOptions {
|
||||
readonly document?: Document;
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Browser presentation target and lifetime owned by one application context. */
|
||||
export interface BrowserUiScope {
|
||||
readonly document: Document;
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function createBrowserUiScope(
|
||||
context: Pick<ServiceContext, "events">,
|
||||
options: BrowserUiOptions = {}
|
||||
): BrowserUiScope {
|
||||
const controller = new AbortController();
|
||||
const parent = options.signal;
|
||||
let unregisterUnload: (() => void) | undefined;
|
||||
const end = (reason: unknown): void => {
|
||||
if (controller.signal.aborted) return;
|
||||
unregisterUnload?.();
|
||||
unregisterUnload = undefined;
|
||||
parent?.removeEventListener("abort", onParentAbort);
|
||||
controller.abort(reason);
|
||||
};
|
||||
const onParentAbort = () => end(parent?.reason);
|
||||
if (parent?.aborted) {
|
||||
controller.abort(parent.reason);
|
||||
} else {
|
||||
unregisterUnload = context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => end(new Error("Plug-in unloaded")));
|
||||
parent?.addEventListener("abort", onParentAbort, { once: true });
|
||||
}
|
||||
return { document: options.document ?? _activeDocument, signal: controller.signal };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
import type { BrowserUiScope } from "./BrowserUiScope";
|
||||
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
@@ -20,15 +21,19 @@ export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIServic
|
||||
override get dialogToCopy() {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>, ui?: BrowserUiScope) {
|
||||
const browserConfirm = dependents.API.confirm;
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
});
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(
|
||||
context,
|
||||
{
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: browserConfirm,
|
||||
control: dependents.control,
|
||||
},
|
||||
ui
|
||||
);
|
||||
super(context, {
|
||||
dialogManager: obsidianSvelteDialogManager,
|
||||
APIService: dependents.API,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/
|
||||
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserUiScope, type BrowserUiOptions, type BrowserUiScope } from "./BrowserUiScope";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import { LiveSyncBrowserAPIService, type LiveSyncBrowserAPIServiceOptions } from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
@@ -41,6 +42,7 @@ export interface LiveSyncBrowserRestartPolicy {
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
deviceValues: ValueStore & ValueDeleter;
|
||||
context?: T;
|
||||
ui?: BrowserUiOptions;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
restart?: LiveSyncBrowserRestartPolicy;
|
||||
@@ -56,15 +58,18 @@ class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValu
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
readonly browserUi: BrowserUiScope;
|
||||
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
const context =
|
||||
options.context ??
|
||||
(new ServiceContext({
|
||||
translate: translateLiveSyncMessage,
|
||||
}) as T);
|
||||
const browserUi = createBrowserUiScope(context, options.ui);
|
||||
const API = new LiveSyncBrowserAPIService(context, {
|
||||
...options.API,
|
||||
confirm: new BrowserConfirm(context),
|
||||
confirm: new BrowserConfirm(context, browserUi),
|
||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||
});
|
||||
const conflict = new InjectableConflictService(context);
|
||||
@@ -147,13 +152,17 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(
|
||||
context,
|
||||
{
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
},
|
||||
browserUi
|
||||
);
|
||||
|
||||
super(context, {
|
||||
API,
|
||||
@@ -175,6 +184,7 @@ export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends Injecta
|
||||
ui,
|
||||
vault,
|
||||
});
|
||||
this.browserUi = browserUi;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export async function findCaseInsensitiveFile<T extends object>(
|
||||
requestedPath: string,
|
||||
findExact: () => Promise<T | null>,
|
||||
cachedFiles: ReadonlyMap<string, T>,
|
||||
scan: () => Promise<void>
|
||||
): Promise<T | null> {
|
||||
const exact = await findExact();
|
||||
if (exact) return exact;
|
||||
|
||||
const lowerPath = requestedPath.toLowerCase();
|
||||
const findCached = (): T | null => {
|
||||
for (const [cachedPath, cachedFile] of cachedFiles) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) return cachedFile;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cached = findCached();
|
||||
if (cached) return cached;
|
||||
|
||||
await scan();
|
||||
return findCached();
|
||||
}
|
||||
+37
-1
@@ -70,6 +70,7 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `--force` / `-f`: Required by `rebuild` and `fetch` because they replace a database. It also retains its existing `init-settings` overwrite behaviour.
|
||||
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
- `fetch` and `rebuild` always retain the selected local database suffix after success. Other lasting changes from these commands still require this option.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
||||
@@ -84,8 +85,10 @@ livesync-cli [database-path] [command] [args...]
|
||||
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
|
||||
- Use this command to keep your local `.md` files in sync with the database.
|
||||
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
|
||||
- This command may select a new local database suffix. Add `--write-settings` if later CLI commands should use the resulting database selection; otherwise the existing settings file remains unchanged.
|
||||
- The selected local database suffix is saved automatically, so later commands use the reconstructed database.
|
||||
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
||||
- The selected local database suffix is saved automatically, including when `--write-settings` is omitted.
|
||||
- Differing local files can remain and be preserved as conflict branches alongside the fetched content. For recovery from an intact remote, preserve the existing Vault separately and Fetch into an empty Vault; `--force` does not force file overwriting.
|
||||
- `ls [prefix]`: List files currently stored in the local database.
|
||||
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
@@ -105,6 +108,39 @@ livesync-cli [database-path] [command] [args...]
|
||||
|
||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||
|
||||
### Interrupted reconstruction and older runtime state
|
||||
|
||||
An interrupted `fetch` or `rebuild` leaves a record in
|
||||
`<database-path>/.livesync/reconstruction.json`. Ordinary synchronisation and
|
||||
mutating commands stop while reconstruction is incomplete. Preserve the Vault,
|
||||
database directory, and settings, check the reported failure, then retry the
|
||||
same command with `--force` and the same database, Vault, and settings paths.
|
||||
`rebuild` replaces remote state as well: confirm that the Vault remains the
|
||||
authoritative copy before retrying it. Do not remove the record merely to
|
||||
restart `daemon`.
|
||||
|
||||
If reconstruction finished but its settings update was interrupted, the next
|
||||
invocation completes that update before starting the core. If the settings
|
||||
file was independently edited, the CLI stops for review. The pending record
|
||||
can contain settings and credentials; protect it like the settings file.
|
||||
|
||||
Older CLI versions stored runtime state without recording its database and
|
||||
Vault owner. If unowned state is present, the CLI preserves it and stops
|
||||
ordinary mutating commands. Read commands remain available for inspection.
|
||||
Back up unique edits and establish the authoritative copy before explicitly
|
||||
using `fetch --force` or `rebuild --force`. The old runtime files remain
|
||||
available for diagnosis; they are not automatically imported into a new scope.
|
||||
|
||||
This check runs before scanning, event replay, and synchronisation. Its message
|
||||
identifies each affected file and distinguishes an unknown owner from a file
|
||||
which cannot be read or parsed. Read commands display the warning and continue
|
||||
inspection. After successful reconstruction for the selected database and Vault,
|
||||
retained legacy files are no longer inspected, even if they are malformed.
|
||||
Corruption in the currently selected runtime KV remains an initialisation error.
|
||||
|
||||
Use one CLI process at a time for each database directory. Separate runtime
|
||||
scopes do not make concurrent writers to shared device settings supported.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Node.js
|
||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<NodeFile[]> {
|
||||
|
||||
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(files: string[] = []) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
|
||||
tempDirs.push(directory);
|
||||
for (const file of files) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
|
||||
await fs.writeFile(path.join(directory, file), `content of ${file}`);
|
||||
}
|
||||
return { directory, adapter: new NodeFileSystemAdapter(directory) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const { adapter } = await createVault(["Calculus.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const { adapter } = await createVault(["other.md"]);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns null and reports a directory scan failure", async () => {
|
||||
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
|
||||
const reportDiagnostic = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(reportDiagnostic).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Error scanning directory"),
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -78,12 +78,20 @@ describe("CLI database preparation", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1, force = false) {
|
||||
async function start(
|
||||
command: CLICommand,
|
||||
runner: CliCommandRunner,
|
||||
exitCode = 1,
|
||||
force = false,
|
||||
writeSettings = false
|
||||
) {
|
||||
process.exitCode = undefined;
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
directory,
|
||||
...(force ? ["--force"] : []),
|
||||
...(writeSettings ? ["--write-settings"] : []),
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
@@ -386,6 +394,194 @@ describe("CLI database preparation", () => {
|
||||
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
|
||||
});
|
||||
|
||||
it.each(["fetch", "rebuild"] as const)(
|
||||
"persists the selected database after %s without other command settings",
|
||||
async (command) => {
|
||||
const before = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
await core.services.setting.applyPartial({
|
||||
additionalSuffixOfDatabaseName: "headless-app",
|
||||
customChunkSize: before.customChunkSize + 10,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
await start(command, runner, 0, true);
|
||||
|
||||
const after = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||
expect(after.additionalSuffixOfDatabaseName).toBe("headless-app");
|
||||
expect(after.customChunkSize).toBe(before.customChunkSize);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["suffix", "vault"] as const)("isolates persistent state by selected %s", async (selection) => {
|
||||
await start("ls", async (_options, { core }) => {
|
||||
await core.services.keyValueDB.kvDB.set("scope-probe", "original");
|
||||
await core.services.config.setValue("scope-probe", "original");
|
||||
return false;
|
||||
});
|
||||
if (selection === "suffix") {
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||
settings.additionalSuffixOfDatabaseName = "other";
|
||||
await writeFile(settingsPath, JSON.stringify(settings));
|
||||
} else {
|
||||
vaultPath = join(directory, "other-vault");
|
||||
await mkdir(vaultPath);
|
||||
}
|
||||
const observed: unknown[] = [];
|
||||
await start("ls", async (_options, { core }) => {
|
||||
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
|
||||
observed.push(await core.services.config.getValue("scope-probe"));
|
||||
return false;
|
||||
});
|
||||
expect(observed).toEqual([undefined, null]);
|
||||
});
|
||||
|
||||
it.each(["sync", "daemon", "mirror", "put"] as const)(
|
||||
"blocks %s after failed reconstruction before command preparation",
|
||||
async (command) => {
|
||||
await start("fetch", async () => false, 1, true);
|
||||
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||
const ready = vi.spyOn(ControlService.prototype, "onReady");
|
||||
|
||||
await start(command, runner);
|
||||
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("retains legacy queued work and blocks a scanner until explicit reconstruction succeeds", async () => {
|
||||
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||
const legacyContents = JSON.stringify({
|
||||
"queued-files": ["notes/unique.md"],
|
||||
fileStatusMap: { "notes/local.md": 1 },
|
||||
});
|
||||
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||
await writeFile(legacy, legacyContents);
|
||||
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||
const scan = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
|
||||
|
||||
await start("mirror", runner);
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
|
||||
|
||||
await start("fetch", async () => true, 0, true);
|
||||
const later = vi.fn<CliCommandRunner>(async () => false);
|
||||
await start("sync", later);
|
||||
expect(later).toHaveBeenCalledOnce();
|
||||
expect(await readFile(legacy, "utf-8")).toBe(legacyContents);
|
||||
});
|
||||
|
||||
it("permits normal commands after reconstruction while preserving malformed legacy JSON", async () => {
|
||||
const legacy = join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||
await writeFile(legacy, '{"unfinished":');
|
||||
await start("fetch", async () => true, 0, true);
|
||||
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||
await start("sync", runner);
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(await readFile(legacy, "utf-8")).toBe('{"unfinished":');
|
||||
});
|
||||
|
||||
it.each([
|
||||
["kv", '{"unfinished":', "cannot be parsed"],
|
||||
["snapshot", "[", "cannot be parsed"],
|
||||
["kv", '{"queued-files":["unique.md"]}', "owner is unknown"],
|
||||
["snapshot", '[{"path":"unique.md"}]', "owner is unknown"],
|
||||
])("diagnoses unowned %s (%s) before readiness and ignores it after recovery", async (kind, contents, reason) => {
|
||||
const legacy =
|
||||
kind === "kv"
|
||||
? join(directory, ".livesync/runtime/keyvalue-db.json")
|
||||
: join(vaultPath, ".livesync-snapshot.json");
|
||||
await mkdir(join(directory, ".livesync/runtime"), { recursive: true });
|
||||
await writeFile(legacy, contents);
|
||||
const messages = () => standardIo.writeStderr.mock.calls.flat().join("");
|
||||
const reader = vi.fn<CliCommandRunner>(async () => false);
|
||||
await start("ls", reader);
|
||||
expect(reader).toHaveBeenCalledOnce();
|
||||
expect(messages()).toContain(reason);
|
||||
expect(messages()).toContain(legacy);
|
||||
expect(messages()).toContain("inspection may continue");
|
||||
expect(messages().split("[Legacy state]")).toHaveLength(2);
|
||||
expect(standardIo.writeStdout.mock.calls.flat().join("")).not.toContain("[Legacy state]");
|
||||
|
||||
standardIo.writeStderr.mockClear();
|
||||
const writer = vi.fn<CliCommandRunner>(async () => false);
|
||||
const ready = vi.spyOn(ControlService.prototype, "onReady");
|
||||
await start("mirror", writer);
|
||||
expect(writer).not.toHaveBeenCalled();
|
||||
expect(ready).not.toHaveBeenCalled();
|
||||
expect(messages()).toContain("stopped before scanning");
|
||||
expect(messages()).toContain(reason);
|
||||
expect(messages().split("[Legacy state]")).toHaveLength(2);
|
||||
ready.mockRestore();
|
||||
|
||||
await start("fetch", async () => true, 0, true);
|
||||
standardIo.writeStderr.mockClear();
|
||||
const later = vi.fn<CliCommandRunner>(async () => false);
|
||||
await start("sync", later);
|
||||
expect(later).toHaveBeenCalledOnce();
|
||||
expect(messages()).not.toContain("[Legacy state]");
|
||||
expect(await readFile(legacy, "utf-8")).toBe(contents);
|
||||
});
|
||||
|
||||
it("blocks read commands which request settings writes while reconstruction is interrupted", async () => {
|
||||
await start("fetch", async () => false, 1, true);
|
||||
const runner = vi.fn<CliCommandRunner>(async () => false);
|
||||
const load = vi.spyOn(ControlService.prototype, "onLoad");
|
||||
|
||||
await start("ls", runner, 1, false, true);
|
||||
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates every Vault scope for the reset database and retains other database state", async () => {
|
||||
const originalVault = vaultPath;
|
||||
const otherVault = join(directory, "other-vault");
|
||||
await mkdir(otherVault);
|
||||
const select = async (suffix: string, selectedVault: string) => {
|
||||
vaultPath = selectedVault;
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
|
||||
settings.additionalSuffixOfDatabaseName = suffix;
|
||||
await writeFile(settingsPath, JSON.stringify(settings));
|
||||
};
|
||||
for (const [suffix, selectedVault] of [
|
||||
["", originalVault],
|
||||
["", otherVault],
|
||||
["other", originalVault],
|
||||
]) {
|
||||
await select(suffix, selectedVault);
|
||||
await start("ls", async (_options, { core }) => {
|
||||
await core.services.keyValueDB.kvDB.set("scope-probe", "stored");
|
||||
return false;
|
||||
});
|
||||
}
|
||||
await select("", originalVault);
|
||||
let reset: boolean | undefined;
|
||||
await start("ls", async (_options, { core }) => {
|
||||
reset = await core.services.database.resetDatabase();
|
||||
return false;
|
||||
});
|
||||
expect(reset).toBe(true);
|
||||
|
||||
const observed: unknown[] = [];
|
||||
for (const [suffix, selectedVault] of [
|
||||
["", originalVault],
|
||||
["", otherVault],
|
||||
["other", originalVault],
|
||||
]) {
|
||||
await select(suffix, selectedVault);
|
||||
await start("ls", async (_options, { core }) => {
|
||||
observed.push(await core.services.keyValueDB.kvDB.get("scope-probe"));
|
||||
return false;
|
||||
});
|
||||
}
|
||||
expect(observed).toEqual([undefined, undefined, "stored"]);
|
||||
});
|
||||
|
||||
it("does not import vault files for standalone database commands", async () => {
|
||||
const storedPaths: string[] = [];
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
|
||||
+83
-19
@@ -41,6 +41,14 @@ import {
|
||||
reconcileDurableSettings,
|
||||
settingsEqual,
|
||||
} from "./settingsPersistence";
|
||||
import {
|
||||
inspectLegacyState,
|
||||
isMutatingCommand,
|
||||
isReconstructionCommand,
|
||||
readOptionalFile,
|
||||
ReconstructionState,
|
||||
writeFileAtomically,
|
||||
} from "./runtimeState";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
|
||||
@@ -381,7 +389,7 @@ export async function main(
|
||||
}
|
||||
|
||||
// Resolve database path
|
||||
const databasePath = path.resolve(options.databasePath!);
|
||||
let databasePath = path.resolve(options.databasePath!);
|
||||
// Check if database directory exists
|
||||
try {
|
||||
const stat = await fs.stat(databasePath);
|
||||
@@ -389,18 +397,19 @@ export async function main(
|
||||
writeStderrLine(standardIo, `Error: ${databasePath} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
databasePath = await fs.realpath(databasePath);
|
||||
} catch {
|
||||
writeStderrLine(standardIo, `Error: Database directory ${databasePath} does not exist`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve settings path
|
||||
const settingsPath = options.settingsPath
|
||||
let settingsPath = options.settingsPath
|
||||
? path.resolve(options.settingsPath)
|
||||
: path.join(databasePath, SETTINGS_FILE);
|
||||
// Resolve vault path: mirror positional argument takes priority,
|
||||
// then --vault flag, otherwise fall back to databasePath.
|
||||
const vaultPath =
|
||||
let vaultPath =
|
||||
options.command === "mirror" && options.commandArgs[0]
|
||||
? path.resolve(options.commandArgs[0])
|
||||
: options.vaultPath
|
||||
@@ -414,11 +423,22 @@ export async function main(
|
||||
writeStderrLine(standardIo, `Error: Vault path ${vaultPath} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
vaultPath = await fs.realpath(vaultPath);
|
||||
} catch {
|
||||
writeStderrLine(standardIo, `Error: Vault directory ${vaultPath} does not exist`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
settingsPath = await fs.realpath(settingsPath).catch(() => settingsPath);
|
||||
const reconstruction = new ReconstructionState(databasePath, vaultPath, settingsPath);
|
||||
try {
|
||||
reconstruction.prepare(options.command, options.force ?? false, options.writeSettings ?? false);
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, "[Error] Reconstruction recovery is required:", error);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
infoLog(`Self-hosted LiveSync CLI`);
|
||||
infoLog(`Database Path: ${databasePath}`);
|
||||
infoLog(`Vault Path: ${vaultPath}`);
|
||||
@@ -437,7 +457,7 @@ export async function main(
|
||||
}
|
||||
|
||||
// Create service context and hub
|
||||
const context = new NodeServiceContext(databasePath, standardIo);
|
||||
const context = new NodeServiceContext(databasePath, standardIo, vaultPath);
|
||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||
serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => {
|
||||
let levelStr = "";
|
||||
@@ -479,7 +499,7 @@ export async function main(
|
||||
|
||||
// Setup settings handlers
|
||||
const settingService = serviceHubInstance.setting;
|
||||
const originalSettingsText = await fs.readFile(settingsPath, "utf-8").catch(() => undefined);
|
||||
const originalSettingsText = readOptionalFile(settingsPath);
|
||||
let latestPreparedSettingsText: string | undefined;
|
||||
let preparedSettingsRevision = 0;
|
||||
let commandIsRunning = false;
|
||||
@@ -600,21 +620,19 @@ export async function main(
|
||||
|
||||
const writeSettingsAtomically = async (content: string | undefined): Promise<void> => {
|
||||
if (content === undefined || content === originalSettingsText) return;
|
||||
const temporaryPath = `${settingsPath}.${process.pid}.tmp`;
|
||||
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, content, "utf-8");
|
||||
await fs.rename(temporaryPath, settingsPath);
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.unlink(temporaryPath).catch(() => {});
|
||||
throw error;
|
||||
writeFileAtomically(settingsPath, content);
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the core
|
||||
let unloaded = false;
|
||||
const unload = async () => {
|
||||
if (unloaded) return;
|
||||
unloaded = true;
|
||||
await core.services.control.onUnload();
|
||||
};
|
||||
try {
|
||||
infoLog(`[Starting] Initializing LiveSync...`);
|
||||
|
||||
@@ -623,6 +641,31 @@ export async function main(
|
||||
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
|
||||
process.exit(1);
|
||||
}
|
||||
if ((await core.services.keyValueDB.kvDB.get("cli-legacy-state-reviewed")) !== true) {
|
||||
const legacyIssues = inspectLegacyState(databasePath, vaultPath);
|
||||
if (legacyIssues.length > 0) {
|
||||
const reasons = {
|
||||
unowned: "database and Vault owner is unknown",
|
||||
invalid: "state cannot be parsed in its expected format; owner and contents cannot be established",
|
||||
unreadable: "state cannot be read; owner and contents cannot be established",
|
||||
};
|
||||
const details = legacyIssues.map((issue) => `${issue.path}: ${reasons[issue.reason]}`).join("; ");
|
||||
const advice =
|
||||
"Preserve these files, back up unique edits, and verify the authoritative source before choosing fetch --force or rebuild --force.";
|
||||
const blocked =
|
||||
!isReconstructionCommand(options.command) &&
|
||||
(isMutatingCommand(options.command) || options.writeSettings);
|
||||
if (blocked) {
|
||||
throw new Error(
|
||||
`[Legacy state] ${details}. Command '${options.command}' stopped before scanning, event replay, or synchronisation. ${advice}`
|
||||
);
|
||||
}
|
||||
const action = isReconstructionCommand(options.command)
|
||||
? "Explicit reconstruction will proceed; the legacy files will be retained."
|
||||
: "Read-only inspection may continue; the legacy files will be retained.";
|
||||
writeStderrLine(standardIo, `[Legacy state] ${details}. ${action} ${advice}`);
|
||||
}
|
||||
}
|
||||
const settingsAfterLoadText = latestPreparedSettingsText
|
||||
? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter")
|
||||
: originalSettingsText;
|
||||
@@ -655,7 +698,7 @@ export async function main(
|
||||
try {
|
||||
readyResult = await core.services.control.onReady();
|
||||
} finally {
|
||||
if (!readyResult) await core.services.control.onUnload();
|
||||
if (!readyResult) await unload();
|
||||
}
|
||||
if (!readyResult) {
|
||||
throw new Error("Failed to initialise LiveSync.");
|
||||
@@ -751,6 +794,21 @@ export async function main(
|
||||
settingsTextToCommit = settingsAfterLoadText;
|
||||
}
|
||||
}
|
||||
if (result && isReconstructionCommand(options.command)) {
|
||||
if (!options.writeSettings) {
|
||||
const durable =
|
||||
originalSettingsText === undefined
|
||||
? cloneSettings(durableSettingsBeforeSuspend)
|
||||
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
|
||||
durable.additionalSuffixOfDatabaseName =
|
||||
core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
|
||||
settingsTextToCommit = JSON.stringify(durable, null, 2);
|
||||
}
|
||||
settingsTextToCommit ??= originalSettingsText;
|
||||
if (settingsTextToCommit === undefined)
|
||||
throw new Error("Reconstruction completed without database selection settings.");
|
||||
await core.services.keyValueDB.kvDB.set("cli-legacy-state-reviewed", true);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||
@@ -764,10 +822,16 @@ export async function main(
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
await core.services.control.onUnload();
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
await unload();
|
||||
if (result && isReconstructionCommand(options.command)) {
|
||||
reconstruction.complete(settingsTextToCommit!);
|
||||
reconstruction.commit();
|
||||
} else {
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await unload().catch(() => {});
|
||||
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -15,47 +14,26 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
*/
|
||||
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
|
||||
isFile(file: unknown): file is NodeFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is NodeFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true
|
||||
);
|
||||
}
|
||||
}
|
||||
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||
import { writeFileAtomically } from "@/apps/cli/runtimeState";
|
||||
|
||||
/**
|
||||
* CLI-specific persistence adapter (file-based snapshot)
|
||||
*/
|
||||
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
private snapshotPath: string;
|
||||
private snapshotPath: () => string;
|
||||
|
||||
constructor(
|
||||
basePath: string,
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
snapshotPath?: () => string
|
||||
) {
|
||||
this.snapshotPath = path.join(basePath, ".livesync-snapshot.json");
|
||||
this.snapshotPath = snapshotPath ?? (() => path.join(basePath, ".livesync-snapshot.json"));
|
||||
}
|
||||
|
||||
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
||||
try {
|
||||
await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
|
||||
writeFileAtomically(this.snapshotPath(), JSON.stringify(snapshot, null, 2));
|
||||
} catch (error) {
|
||||
this.reportDiagnostic("Failed to save snapshot:", error);
|
||||
}
|
||||
@@ -63,7 +41,7 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
|
||||
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.snapshotPath, "utf-8");
|
||||
const content = await fs.readFile(this.snapshotPath(), "utf-8");
|
||||
return JSON.parse(content) as (FileEventItem | FileEventItemSentinel)[];
|
||||
} catch {
|
||||
return null;
|
||||
@@ -211,7 +189,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
* Composite adapter for CLI StorageEventManager
|
||||
*/
|
||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||
readonly typeGuard: CLITypeGuardAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly persistence: CLIPersistenceAdapter;
|
||||
readonly watch: CLIWatchAdapter;
|
||||
readonly status: CLIStatusAdapter;
|
||||
@@ -221,10 +199,11 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
basePath: string,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false,
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
snapshotPath?: () => string
|
||||
) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic, snapshotPath);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||
this.status = new CLIStatusAdapter();
|
||||
this.converter = new CLIConverterAdapter();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type { NodeFile } from "@/apps/cli/adapters/NodeTypes";
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { runtimeScopeDirectory } from "../runtimeState";
|
||||
|
||||
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in chokidar.
|
||||
@@ -44,6 +46,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pending snapshots with the selected database and Vault pair", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-snapshot-"));
|
||||
let database = "first";
|
||||
let vault = "vault-a";
|
||||
const adapter = new CLIStorageEventManagerAdapter(directory, undefined, false, undefined, () =>
|
||||
path.join(runtimeScopeDirectory(directory, database, vault), "storage-events.json")
|
||||
);
|
||||
try {
|
||||
await adapter.persistence.saveSnapshot([{ type: "SENTINEL_FLUSH" }]);
|
||||
database = "second";
|
||||
expect(await adapter.persistence.loadSnapshot()).toBeNull();
|
||||
database = "first";
|
||||
vault = "vault-b";
|
||||
expect(await adapter.persistence.loadSnapshot()).toBeNull();
|
||||
vault = "vault-a";
|
||||
expect(await adapter.persistence.loadSnapshot()).toEqual([{ type: "SENTINEL_FLUSH" }]);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
const handlers = makeHandlers();
|
||||
@@ -201,3 +224,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {} }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: false },
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
|
||||
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
||||
import {
|
||||
StorageEventManagerBase,
|
||||
type StorageEventManagerBaseDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/managers/StorageEventManager";
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { runtimeScopeDirectory } from "@/apps/cli/runtimeState";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
// import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
|
||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||
@@ -16,12 +22,23 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled?: boolean
|
||||
) {
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled, (message, detail) => {
|
||||
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
||||
if (detail !== undefined) {
|
||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
const context = core.services.context as NodeServiceContext;
|
||||
const adapter = new CLIStorageEventManagerAdapter(
|
||||
basePath,
|
||||
ignoreRules,
|
||||
watchEnabled,
|
||||
(message, detail) => {
|
||||
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
||||
if (detail !== undefined) {
|
||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
},
|
||||
() =>
|
||||
nodePath.join(
|
||||
runtimeScopeDirectory(context.databasePath, dependencies.vaultService.getVaultName(), basePath),
|
||||
"storage-events.json"
|
||||
)
|
||||
);
|
||||
super(adapter, dependencies);
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
@@ -37,9 +37,10 @@
|
||||
"test:e2e:coverage": "node ../../../test/e2e-cli/run-coverage.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.55",
|
||||
"octagonal-wheels": "0.1.56",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// This module belongs exclusively to the Node.js CLI host.
|
||||
import { Sha256 } from "@aws-crypto/sha256-js";
|
||||
import { fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CLICommand } from "./commands/types";
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
const hash = new Sha256();
|
||||
hash.update(new TextEncoder().encode(value));
|
||||
return Array.from(hash.digestSync(), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function runtimeScopeDirectory(databasePath: string, databaseName: string, vaultPath: string): string {
|
||||
const scope = sha256Hex(vaultPath);
|
||||
return path.join(runtimeDatabaseDirectory(databasePath, databaseName), scope);
|
||||
}
|
||||
|
||||
export function runtimeDatabaseDirectory(databasePath: string, databaseName: string): string {
|
||||
const database = sha256Hex(databaseName);
|
||||
return path.join(databasePath, ".livesync", "runtime", "scopes", database);
|
||||
}
|
||||
|
||||
export function writeFileAtomically(filePath: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
try {
|
||||
const descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, content, "utf-8");
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
syncDirectory(path.dirname(filePath));
|
||||
} finally {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
// Windows does not support opening a directory for fsync.
|
||||
if (process.platform === "win32") return;
|
||||
const descriptor = fs.openSync(directory, "r");
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function readOptionalFile(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isReconstructionCommand(command: CLICommand): command is "fetch" | "rebuild" {
|
||||
return command === "fetch" || command === "rebuild";
|
||||
}
|
||||
|
||||
const READ_COMMANDS = new Set<CLICommand>([
|
||||
"cat",
|
||||
"cat-rev",
|
||||
"ls",
|
||||
"info",
|
||||
"remote-ls",
|
||||
"remote-export",
|
||||
"remote-status",
|
||||
"p2p-peers",
|
||||
]);
|
||||
|
||||
export function isMutatingCommand(command: CLICommand): boolean {
|
||||
return !READ_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
interface ReconstructionMarker {
|
||||
version: 1;
|
||||
command: "fetch" | "rebuild";
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
settingsPath: string;
|
||||
settingsBeforeHash: string;
|
||||
phase: "running" | "completed";
|
||||
settingsText?: string;
|
||||
}
|
||||
|
||||
function hashSettings(content: string | undefined): string {
|
||||
return sha256Hex(JSON.stringify(content ?? null));
|
||||
}
|
||||
|
||||
export class ReconstructionState {
|
||||
readonly markerPath: string;
|
||||
private marker: ReconstructionMarker | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
private readonly vaultPath: string,
|
||||
private readonly settingsPath: string
|
||||
) {
|
||||
this.markerPath = path.join(databasePath, ".livesync", "reconstruction.json");
|
||||
}
|
||||
|
||||
prepare(command: CLICommand, force: boolean, writeSettings = false): void {
|
||||
const contents = readOptionalFile(this.markerPath);
|
||||
if (contents !== undefined) {
|
||||
const parsed: unknown = JSON.parse(contents);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
|
||||
}
|
||||
const marker = parsed as ReconstructionMarker;
|
||||
if (
|
||||
marker.version !== 1 ||
|
||||
!isReconstructionCommand(marker.command) ||
|
||||
(marker.phase !== "running" && marker.phase !== "completed") ||
|
||||
typeof marker.settingsPath !== "string" ||
|
||||
typeof marker.vaultPath !== "string" ||
|
||||
typeof marker.settingsBeforeHash !== "string" ||
|
||||
marker.databasePath !== this.databasePath ||
|
||||
(marker.phase === "completed" && typeof marker.settingsText !== "string")
|
||||
) {
|
||||
throw new Error(`Invalid reconstruction state: ${this.markerPath}. Preserve it for recovery.`);
|
||||
}
|
||||
this.marker = marker;
|
||||
if (marker.phase === "completed") {
|
||||
this.commit();
|
||||
} else if (
|
||||
(isMutatingCommand(command) || writeSettings) &&
|
||||
!(
|
||||
command === marker.command &&
|
||||
force &&
|
||||
marker.vaultPath === this.vaultPath &&
|
||||
marker.settingsPath === this.settingsPath
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`An interrupted ${marker.command} requires recovery. Retry ${marker.command} --force with the same database, Vault, and settings paths. State: ${this.markerPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isReconstructionCommand(command)) {
|
||||
this.marker = {
|
||||
version: 1,
|
||||
command,
|
||||
databasePath: this.databasePath,
|
||||
vaultPath: this.vaultPath,
|
||||
settingsPath: this.settingsPath,
|
||||
phase: "running",
|
||||
settingsBeforeHash: hashSettings(readOptionalFile(this.settingsPath)),
|
||||
};
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
complete(settingsText: string): void {
|
||||
if (!this.marker || this.marker.phase !== "running") throw new Error("Reconstruction was not started.");
|
||||
this.marker = { ...this.marker, phase: "completed", settingsText };
|
||||
this.save();
|
||||
}
|
||||
|
||||
commit(): void {
|
||||
const marker = this.marker;
|
||||
if (!marker || marker.phase !== "completed" || marker.settingsText === undefined) {
|
||||
throw new Error("Reconstruction has no completed settings update.");
|
||||
}
|
||||
const current = readOptionalFile(marker.settingsPath);
|
||||
if (current !== marker.settingsText && hashSettings(current) !== marker.settingsBeforeHash) {
|
||||
throw new Error(
|
||||
`The settings file changed after reconstruction: ${marker.settingsPath}. Preserve the reconstruction state and review both settings versions before continuing.`
|
||||
);
|
||||
}
|
||||
if (current !== marker.settingsText) writeFileAtomically(marker.settingsPath, marker.settingsText);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
fs.rmSync(this.markerPath, { force: true });
|
||||
syncDirectory(path.dirname(this.markerPath));
|
||||
this.marker = undefined;
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
writeFileAtomically(this.markerPath, JSON.stringify(this.marker, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LegacyStateIssue {
|
||||
path: string;
|
||||
reason: "unowned" | "invalid" | "unreadable";
|
||||
}
|
||||
|
||||
export function inspectLegacyState(databasePath: string, vaultPath: string): LegacyStateIssue[] {
|
||||
const issues: LegacyStateIssue[] = [];
|
||||
for (const [filePath, expectArray] of [
|
||||
[path.join(databasePath, ".livesync", "runtime", "keyvalue-db.json"), false],
|
||||
[path.join(vaultPath, ".livesync-snapshot.json"), true],
|
||||
] as const) {
|
||||
let contents: string | undefined;
|
||||
try {
|
||||
contents = readOptionalFile(filePath);
|
||||
} catch {
|
||||
issues.push({ path: filePath, reason: "unreadable" });
|
||||
continue;
|
||||
}
|
||||
if (contents === undefined) continue;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch {
|
||||
issues.push({ path: filePath, reason: "invalid" });
|
||||
continue;
|
||||
}
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) !== expectArray) {
|
||||
issues.push({ path: filePath, reason: "invalid" });
|
||||
} else if (Object.keys(value).length > 0) {
|
||||
issues.push({ path: filePath, reason: "unowned" });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ReconstructionState,
|
||||
inspectLegacyState,
|
||||
readOptionalFile,
|
||||
runtimeDatabaseDirectory,
|
||||
runtimeScopeDirectory,
|
||||
} from "./runtimeState";
|
||||
|
||||
describe("CLI runtime scope compatibility", () => {
|
||||
it("retains SHA-256 directory names for database and Unicode Vault paths", () => {
|
||||
expect(runtimeScopeDirectory("state", "headless-app", "/vault/日本語😀")).toBe(
|
||||
path.join(
|
||||
"state",
|
||||
".livesync",
|
||||
"runtime",
|
||||
"scopes",
|
||||
"511afda5613706862fa4add801b3f9b19e0db2acacc07b9218f2d01ff41ca61d",
|
||||
"542d31d6918ad8bf915e204e41e479fdbd7251690f182fd802378323b4c6de23"
|
||||
)
|
||||
);
|
||||
expect(path.basename(runtimeDatabaseDirectory("state", ""))).toBe(
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI reconstruction state", () => {
|
||||
let directory: string;
|
||||
let settingsPath: string;
|
||||
let state: ReconstructionState;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-reconstruction-"));
|
||||
settingsPath = path.join(directory, "settings.json");
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ additionalSuffixOfDatabaseName: "original" }));
|
||||
state = new ReconstructionState(directory, path.join(directory, "vault"), settingsPath);
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it("retains the persisted settings fingerprint across hash implementations", () => {
|
||||
state.prepare("fetch", true);
|
||||
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
||||
settingsBeforeHash: "29e04e3dd1d10b352678a922ff5a03fbe529d3505e9cf46c2ecc860f44478ae8",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists progress before returning and permits only the matching forced retry", () => {
|
||||
state.prepare("fetch", true);
|
||||
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
||||
phase: "running",
|
||||
command: "fetch",
|
||||
});
|
||||
if (process.platform !== "win32") expect(fs.statSync(state.markerPath).mode & 0o777).toBe(0o600);
|
||||
expect(() => state.prepare("sync", false)).toThrow("interrupted fetch");
|
||||
expect(() => state.prepare("ls", false, true)).toThrow("interrupted fetch");
|
||||
expect(() => state.prepare("fetch", false)).toThrow("interrupted fetch");
|
||||
expect(() => state.prepare("rebuild", true)).toThrow("interrupted fetch");
|
||||
expect(() =>
|
||||
new ReconstructionState(directory, path.join(directory, "other"), settingsPath).prepare("fetch", true)
|
||||
).toThrow("interrupted fetch");
|
||||
expect(() =>
|
||||
new ReconstructionState(
|
||||
directory,
|
||||
path.join(directory, "vault"),
|
||||
path.join(directory, "other.json")
|
||||
).prepare("fetch", true)
|
||||
).toThrow("interrupted fetch");
|
||||
expect(() => state.prepare("ls", false)).not.toThrow();
|
||||
expect(() => state.prepare("fetch", true)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recovers completed settings before permitting another command", () => {
|
||||
state.prepare("fetch", true);
|
||||
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app", retained: true });
|
||||
state.complete(completed);
|
||||
|
||||
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare("sync", false);
|
||||
|
||||
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(completed);
|
||||
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["normal completion", "restart"] as const)(
|
||||
"preserves user settings changed during reconstruction at %s",
|
||||
(phase) => {
|
||||
state.prepare("rebuild", true);
|
||||
state.complete(JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" }));
|
||||
const edited = JSON.stringify({ additionalSuffixOfDatabaseName: "user-selection", unique: "edit" });
|
||||
fs.writeFileSync(settingsPath, edited);
|
||||
|
||||
const commit =
|
||||
phase === "restart"
|
||||
? () =>
|
||||
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare(
|
||||
"sync",
|
||||
false
|
||||
)
|
||||
: () => state.commit();
|
||||
|
||||
expect(commit).toThrow("settings file changed");
|
||||
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(edited);
|
||||
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({ phase: "completed" });
|
||||
}
|
||||
);
|
||||
|
||||
it("clears a completed marker after an interruption following the settings rename", () => {
|
||||
state.prepare("fetch", true);
|
||||
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" });
|
||||
state.complete(completed);
|
||||
fs.writeFileSync(settingsPath, completed);
|
||||
state.prepare("ls", false);
|
||||
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["null", "[]", "{}", '{"version":2}', '{"version":1,"phase":"completed"}'])(
|
||||
"refuses malformed state %s without clearing it",
|
||||
(contents) => {
|
||||
fs.mkdirSync(path.dirname(state.markerPath), { recursive: true });
|
||||
fs.writeFileSync(state.markerPath, contents);
|
||||
expect(() => state.prepare("sync", false)).toThrow("Invalid reconstruction state");
|
||||
expect(fs.readFileSync(state.markerPath, "utf-8")).toBe(contents);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["null", "[]", '"text"', "{", ""])("reports invalid legacy KV %s without changing it", (contents) => {
|
||||
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||
fs.writeFileSync(legacy, contents);
|
||||
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "invalid" }]);
|
||||
expect(fs.readFileSync(legacy, "utf-8")).toBe(contents);
|
||||
});
|
||||
|
||||
it("reports both unreadable KV and invalid snapshot instead of hiding the second problem", () => {
|
||||
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
||||
const snapshot = path.join(directory, ".livesync-snapshot.json");
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.writeFileSync(snapshot, "{}");
|
||||
expect(inspectLegacyState(directory, directory)).toEqual([
|
||||
{ path: legacy, reason: "unreadable" },
|
||||
{ path: snapshot, reason: "invalid" },
|
||||
]);
|
||||
expect(fs.statSync(legacy).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(snapshot, "utf-8")).toBe("{}");
|
||||
});
|
||||
|
||||
it("preserves non-empty unowned legacy state and ignores empty legacy containers", () => {
|
||||
const legacy = path.join(directory, ".livesync", "runtime", "keyvalue-db.json");
|
||||
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
||||
fs.writeFileSync(legacy, "{}");
|
||||
fs.writeFileSync(path.join(directory, ".livesync-snapshot.json"), "[]");
|
||||
expect(inspectLegacyState(directory, directory)).toEqual([]);
|
||||
const queued = JSON.stringify({ "queued-files": ["unique-edit.md"] });
|
||||
fs.writeFileSync(legacy, queued);
|
||||
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "unowned" }]);
|
||||
expect(fs.readFileSync(legacy, "utf-8")).toBe(queued);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { defaultServerConditions, defaultServerMainFields, defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, isBuiltin, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const resolve = (...args: string[]) => path.resolve(...args).replace(/\\/g, "/");
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fs } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
/** Read a string-valued version field from a JSON file during Node builds. */
|
||||
export function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readVersion } from "./readVersion";
|
||||
|
||||
describe("readVersion", () => {
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), "livesync-read-version-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns only string version values", () => {
|
||||
const filePath = join(directory, "version.json");
|
||||
writeFileSync(filePath, JSON.stringify({ version: "1.2.3" }));
|
||||
expect(readVersion(filePath)).toBe("1.2.3");
|
||||
|
||||
for (const contents of ["null", "[]", "{}", '{"version": 42}', '{"version": null}']) {
|
||||
writeFileSync(filePath, contents);
|
||||
expect(readVersion(filePath)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves file read and JSON parse errors", () => {
|
||||
expect(() => readVersion(join(directory, "missing.json"))).toThrow();
|
||||
|
||||
const filePath = join(directory, "invalid.json");
|
||||
writeFileSync(filePath, "not JSON");
|
||||
expect(() => readVersion(filePath)).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
|
||||
/** Compose the service modules shared by app hosts after their storage setup. */
|
||||
export function composeSharedServiceModules(
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
databaseFileAccess: DatabaseFileAccess,
|
||||
storageAccess: StorageAccess
|
||||
): Pick<ServiceModules, "fileHandler" | "rebuilder"> {
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
return { rebuilder, fileHandler };
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "@/apps/cli/serviceModules/CLIServiceModules";
|
||||
import { initialiseServiceModulesFSAPI } from "@/apps/webapp/serviceModules/FSAPIServiceModules";
|
||||
import { FILE_REFLECTION_PROVENANCE_STORE } from "@/serviceModules/FileReflectionProvenance";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
|
||||
const composition = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
dependencies: {} as Record<string, unknown>,
|
||||
instances: {} as Record<string, unknown>,
|
||||
capture(name: string, dependencies: unknown, instance: unknown) {
|
||||
this.calls.push(name);
|
||||
this.dependencies[name] = dependencies;
|
||||
this.instances[name] = instance;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", () => ({
|
||||
StorageAccessManager: class {
|
||||
constructor() {
|
||||
composition.capture("StorageAccessManager", undefined, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", () => ({
|
||||
ServiceRebuilder: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceRebuilder", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/serviceModules/FileHandler", () => ({
|
||||
ServiceFileHandler: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileHandler", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/FileAccessCLI", () => ({
|
||||
FileAccessCLI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessCLI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessCLI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/cli/managers/StorageEventManagerCLI", () => ({
|
||||
StorageEventManagerCLI: class {
|
||||
close = vi.fn(async () => undefined);
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerCLI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/FileAccessFSAPI", () => ({
|
||||
FileAccessFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("FileAccessFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/ServiceFileAccessImpl", () => ({
|
||||
ServiceFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/serviceModules/DatabaseFileAccess", () => ({
|
||||
ServiceDatabaseFileAccessFSAPI: class {
|
||||
constructor(dependencies: unknown) {
|
||||
composition.capture("ServiceDatabaseFileAccessFSAPI", dependencies, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/apps/webapp/managers/StorageEventManagerFSAPI", () => ({
|
||||
StorageEventManagerFSAPI: class {
|
||||
constructor(...args: unknown[]) {
|
||||
composition.capture("StorageEventManagerFSAPI", args, this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function createServices() {
|
||||
const hostServices = Object.fromEntries(
|
||||
[
|
||||
"API",
|
||||
"conflict",
|
||||
"control",
|
||||
"database",
|
||||
"databaseEvents",
|
||||
"fileProcessing",
|
||||
"path",
|
||||
"remote",
|
||||
"replication",
|
||||
"replicator",
|
||||
"setting",
|
||||
"UI",
|
||||
"vault",
|
||||
].map((name) => [name, { name }])
|
||||
);
|
||||
const events = { label: "host event hub" };
|
||||
const addUnloadHandler = vi.fn<(handler: () => Promise<boolean>) => void>();
|
||||
const openSimpleStore = vi.fn((name: string) => {
|
||||
composition.calls.push("openSimpleStore");
|
||||
composition.dependencies.openSimpleStore = name;
|
||||
return { name };
|
||||
});
|
||||
|
||||
return {
|
||||
...hostServices,
|
||||
appLifecycle: { onUnload: { addHandler: addUnloadHandler } },
|
||||
context: { events },
|
||||
events,
|
||||
keyValueDB: { openSimpleStore },
|
||||
addUnloadHandler,
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
return {} as LiveSyncBaseCore<ServiceContext, never>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
composition.calls.length = 0;
|
||||
composition.dependencies = {};
|
||||
composition.instances = {};
|
||||
});
|
||||
|
||||
describe("app service module composition", () => {
|
||||
it("preserves CLI host construction, shared wiring, provenance, and watcher shutdown", async () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
|
||||
const modules = initialiseServiceModulesCLI(
|
||||
"/vault",
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessCLI",
|
||||
"StorageEventManagerCLI",
|
||||
"ServiceFileAccessCLI",
|
||||
"ServiceDatabaseFileAccessCLI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
|
||||
const [basePath, fileAccessOptions] = composition.dependencies.FileAccessCLI as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies
|
||||
.StorageEventManagerCLI as [string, unknown, Record<string, unknown>, unknown, boolean];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record<string, unknown>;
|
||||
const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record<string, unknown>;
|
||||
|
||||
expect(basePath).toBe("/vault");
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventBasePath).toBe("/vault");
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(ignoreRules).toBeUndefined();
|
||||
expect(watchEnabled).toBe(false);
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerCLI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessCLI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.databaseFileAccess).toBe(composition.instances.ServiceDatabaseFileAccessCLI);
|
||||
expect(modules.fileHandler).toBe(composition.instances.ServiceFileHandler);
|
||||
expect(modules.rebuilder).toBe(composition.instances.ServiceRebuilder);
|
||||
expect(fileHandlerDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(rebuilderDependencies).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
const unloadHandler = services.addUnloadHandler.mock.calls[0]?.[0];
|
||||
expect(unloadHandler).toBeDefined();
|
||||
await expect(unloadHandler?.()).resolves.toBe(true);
|
||||
expect(
|
||||
(composition.instances.StorageEventManagerCLI as { close: ReturnType<typeof vi.fn> }).close
|
||||
).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves WebApp host construction and returns its vault access and event manager", () => {
|
||||
const services = createServices();
|
||||
const core = createCore();
|
||||
const rootHandle = {} as FileSystemDirectoryHandle;
|
||||
|
||||
const modules = initialiseServiceModulesFSAPI(
|
||||
rootHandle,
|
||||
core,
|
||||
services as unknown as InjectableServiceHub<ServiceContext>
|
||||
);
|
||||
|
||||
expect(composition.calls).toEqual([
|
||||
"StorageAccessManager",
|
||||
"FileAccessFSAPI",
|
||||
"StorageEventManagerFSAPI",
|
||||
"ServiceFileAccessFSAPI",
|
||||
"ServiceDatabaseFileAccessFSAPI",
|
||||
"openSimpleStore",
|
||||
"ServiceFileHandler",
|
||||
"ServiceRebuilder",
|
||||
]);
|
||||
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
|
||||
const [fileAccessRoot, fileAccessOptions] = composition.dependencies.FileAccessFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [
|
||||
FileSystemDirectoryHandle,
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record<string, unknown>;
|
||||
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessFSAPI as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(fileAccessRoot).toBe(rootHandle);
|
||||
expect(fileAccessOptions).toEqual({
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultService: services.vault,
|
||||
settingService: services.setting,
|
||||
APIService: services.API,
|
||||
pathService: services.path,
|
||||
});
|
||||
expect(eventRoot).toBe(rootHandle);
|
||||
expect(eventCore).toBe(core);
|
||||
expect(eventDependencies).toEqual({
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
APIService: services.API,
|
||||
});
|
||||
expect(storageAccessDependencies).toEqual({
|
||||
API: services.API,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
storageEventManager: composition.instances.StorageEventManagerFSAPI,
|
||||
storageAccessManager: composition.instances.StorageAccessManager,
|
||||
vaultAccess: composition.instances.FileAccessFSAPI,
|
||||
});
|
||||
expect(databaseAccessDependencies).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
path: services.path,
|
||||
storageAccess: modules.storageAccess,
|
||||
vault: services.vault,
|
||||
});
|
||||
expect(modules.vaultAccess).toBe(composition.instances.FileAccessFSAPI);
|
||||
expect(modules.storageEventManager).toBe(composition.instances.StorageEventManagerFSAPI);
|
||||
expect(composition.dependencies.ServiceFileHandler).toEqual({
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: modules.databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: modules.storageAccess,
|
||||
fileReflectionProvenance: expect.any(Object),
|
||||
});
|
||||
expect(composition.dependencies.ServiceRebuilder).toEqual({
|
||||
path: services.path,
|
||||
events: services.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: modules.fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: modules.storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
|
||||
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for FileSystem API
|
||||
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
|
||||
*/
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
// TODO: Refactor: Very, Very heavy.
|
||||
|
||||
const lowerPath = pathStr.toLowerCase();
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
await this.scanDirectory();
|
||||
|
||||
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
|
||||
if (cachedPath.toLowerCase() === lowerPath) {
|
||||
return cachedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await findCaseInsensitiveFile(
|
||||
pathStr,
|
||||
() => this.getAbstractFileByPath(pathStr),
|
||||
this.fileCache,
|
||||
() => this.scanDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(): Promise<FSAPIFile[]> {
|
||||
|
||||
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter creation", () => {
|
||||
it.each(["text", "binary"] as const)(
|
||||
"creates nested %s files with exact content and stored metadata",
|
||||
async (kind) => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
|
||||
const path = "/nested//folder/file.dat";
|
||||
const file =
|
||||
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
|
||||
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
|
||||
const nested = await root.getDirectoryHandle("nested");
|
||||
const folder = await nested.getDirectoryHandle("folder");
|
||||
const stored = await folder.getFileHandle("file.dat");
|
||||
expect(file.path).toBe(path);
|
||||
expect(file.handle).toBe(stored);
|
||||
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
|
||||
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
|
||||
let releaseClose!: () => void;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
const events: string[] = [];
|
||||
const getFile = vi.fn(async () => {
|
||||
events.push("metadata");
|
||||
return new File(["result"], "file.dat", { lastModified: 123 });
|
||||
});
|
||||
const close = vi.fn(async () => {
|
||||
events.push("close-start");
|
||||
await closed;
|
||||
events.push("closed");
|
||||
});
|
||||
const write = vi.fn(async () => {
|
||||
events.push("write");
|
||||
});
|
||||
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const data = new Uint8Array([0, 255]).buffer;
|
||||
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
|
||||
try {
|
||||
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseClose();
|
||||
}
|
||||
const file = await pending;
|
||||
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
|
||||
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
|
||||
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
|
||||
});
|
||||
|
||||
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
|
||||
const failure = new Error("fixture write failed");
|
||||
const getFile = vi.fn();
|
||||
const fileHandle = {
|
||||
createWritable: vi.fn(async () => ({
|
||||
write: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
getFile,
|
||||
};
|
||||
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
|
||||
const adapter = new FSAPIVaultAdapter(root);
|
||||
const pending =
|
||||
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
|
||||
await expect(pending).rejects.toBe(failure);
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FSAPIVaultAdapter.rename", () => {
|
||||
it("moves the file through a temporary copy while preserving content", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
||||
});
|
||||
|
||||
describe("FSAPIFileSystemAdapter path case", () => {
|
||||
it("returns an exact path without scanning the directory", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
||||
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a cached case-insensitive match without rescanning", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
await adapter.scanDirectory();
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rescans once and returns a newly cached case-insensitive match", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("Calculus.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
|
||||
path: "Calculus.md",
|
||||
});
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
|
||||
});
|
||||
|
||||
it("returns null after one scan when no cached path matches", async () => {
|
||||
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
|
||||
const vault = new FSAPIVaultAdapter(root);
|
||||
await vault.create("other.md", "content");
|
||||
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports scan failures through the injected Webapp log", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null and reports scan failure during case-insensitive lookup", async () => {
|
||||
const root = {
|
||||
name: "root",
|
||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
||||
throw new Error("scan failed");
|
||||
},
|
||||
} as unknown as FileSystemDirectoryHandle;
|
||||
const addLog = vi.fn();
|
||||
const adapter = new FSAPIFileSystemAdapter(root, addLog);
|
||||
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
|
||||
|
||||
expect(scanDirectory).toHaveBeenCalledOnce();
|
||||
expect(addLog).toHaveBeenCalledWith(
|
||||
"Error scanning directory '.': Error: scan failed",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"fsapi-scan"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
||||
}
|
||||
|
||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Navigate to parent directory, creating as needed
|
||||
let currentHandle = this.rootHandle;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(data);
|
||||
await writable.close();
|
||||
|
||||
// Get file metadata
|
||||
const fileObject = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
path: p as FilePath,
|
||||
stat: {
|
||||
size: fileObject.size,
|
||||
mtime: fileObject.lastModified,
|
||||
ctime: fileObject.lastModified,
|
||||
type: "file",
|
||||
},
|
||||
handle: fileHandle,
|
||||
};
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||
return await this.createFile(p, data);
|
||||
}
|
||||
|
||||
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
|
||||
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
|
||||
|
||||
type FileSystemObserverRecord = {
|
||||
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
||||
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
|
||||
FileSystemObserver?: FileSystemObserverConstructor;
|
||||
};
|
||||
|
||||
/**
|
||||
* FileSystem API-specific type guard adapter
|
||||
*/
|
||||
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
|
||||
isFile(file: unknown): file is FSAPIFile {
|
||||
return !!(
|
||||
file &&
|
||||
typeof file === "object" &&
|
||||
"path" in file &&
|
||||
"stat" in file &&
|
||||
"handle" in file &&
|
||||
!(file as { isFolder?: boolean }).isFolder
|
||||
);
|
||||
}
|
||||
|
||||
isFolder(item: unknown): item is FSAPIFolder {
|
||||
return !!(
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"path" in item &&
|
||||
(item as { isFolder?: boolean }).isFolder === true &&
|
||||
"handle" in item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
|
||||
|
||||
describe("FSAPIStorageEventManagerAdapter type guard", () => {
|
||||
const typeGuard = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, vi.fn()).typeGuard;
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { stat: {}, handle: undefined }, expected: false },
|
||||
{ label: "an object without a stat", value: { path: "note.md", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "note.md", stat: {} }, expected: false },
|
||||
{
|
||||
label: "a file-shaped object with a handle property",
|
||||
value: { path: "note.md", stat: {}, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
label: "a folder-shaped object",
|
||||
value: { path: "folder", handle: undefined, isFolder: true },
|
||||
expected: false,
|
||||
},
|
||||
])("classifies $label as a file", ({ value, expected }) => {
|
||||
expect(typeGuard.isFile(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "null", value: null, expected: false },
|
||||
{ label: "an object without a path", value: { isFolder: true, handle: undefined }, expected: false },
|
||||
{ label: "an object without the folder marker", value: { path: "folder", handle: undefined }, expected: false },
|
||||
{ label: "an object without a handle", value: { path: "folder", isFolder: true }, expected: false },
|
||||
{
|
||||
label: "a folder-shaped object with a handle property",
|
||||
value: { path: "folder", isFolder: true, handle: undefined },
|
||||
expected: true,
|
||||
},
|
||||
{ label: "a file-shaped object", value: { path: "note.md", stat: {}, handle: undefined }, expected: false },
|
||||
])("classifies $label as a folder", ({ value, expected }) => {
|
||||
expect(typeGuard.isFolder(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
"octagonal-wheels": "0.1.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
|
||||
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
export interface FSAPIServiceModules extends ServiceModules {
|
||||
vaultAccess: FileAccessFSAPI;
|
||||
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
vault: services.vault,
|
||||
});
|
||||
|
||||
// File handler (platform-independent)
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: services.conflict,
|
||||
setting: services.setting,
|
||||
fileProcessing: services.fileProcessing,
|
||||
vault: services.vault,
|
||||
path: services.path,
|
||||
replication: services.replication,
|
||||
storageAccess: storageAccess,
|
||||
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
|
||||
});
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
appLifecycle: services.appLifecycle,
|
||||
setting: services.setting,
|
||||
remote: services.remote,
|
||||
databaseEvents: services.databaseEvents,
|
||||
replication: services.replication,
|
||||
replicator: services.replicator,
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||
|
||||
return {
|
||||
rebuilder,
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { readVersion } from "../readVersion";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
function readVersion(filePath: string): string | undefined {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||
}
|
||||
|
||||
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
|
||||
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
|
||||
// https://vite.dev/config/
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.55"
|
||||
"octagonal-wheels": "0.1.56"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
|
||||
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.menu?.hide();
|
||||
this.menu = new Menu()
|
||||
this.menu = new Menu(this.services.browserUi)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
|
||||
Reference in new issue
Block a user