Extract optional-file synchronisation workflow owners

This commit is contained in:
vorotamoroz
2026-09-04 09:33:36 +00:00
parent 355d819f43
commit 60b093612b
29 changed files with 4017 additions and 1768 deletions
@@ -20,13 +20,15 @@ one writer for raw events, scheduled scans, and automatic database-to-local
reflection. The constructor-name add-on lookup and the `ConfigSync` and
`HiddenFileSync` add-on identities have been retired.
The Customisation Sync private context coordinates its snapshot operations,
scan queues, periodic work, and focused path and transient-state owners. The
Hidden File Sync private context coordinates reconciliation, periodic work,
and the lifetimes of focused path-admission, notification, processed-state,
change-processing, and conflict-resolution owners. Each receives live settings
and database projections, focused storage, path, and exact-revision
capabilities, and explicit host effects rather than `LiveSyncCore`.
The Customisation Sync private context coordinates lifecycle, raw-event
admission, configuration and periodic policy, view composition, and the
lifetimes of focused path, snapshot, application, scan, and catalogue owners.
The Hidden File Sync private context coordinates lifecycle and handler
admission around focused path-admission, notification, processed-state,
change-processing, conflict-resolution, and reconciliation owners. Each
receives live settings and database projections, focused storage, path, and
exact-revision capabilities, and explicit host effects rather than
`LiveSyncCore`.
The corresponding implemented topology is documented in
[Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md).
@@ -203,14 +205,35 @@ operations receive this capability through one narrow `processedState` port;
they do not receive bundles of individual state callbacks or implement state
keys themselves.
`CustomisationSyncCatalogueState` separately owns the transient catalogue
rows, manifest lookup and mtime cache, their reactive stores, and catalogue
update progress. A small recent-event deduplicator owns raw-event admission
history. These Customisation Sync owners are deliberately not implementations
of a shared Hidden File Sync state abstraction: the former state is a derived,
in-memory projection, while Hidden File Sync markers are persisted operational
reconciliation state with different identity, invalidation, and deletion
rules.
`CatalogueState` separately owns the transient catalogue rows, manifest lookup
and mtime cache, their reactive stores, and catalogue update progress. A small
recent-event deduplicator owns raw-event admission history. These Customisation
Sync owners are deliberately not implementations of a shared Hidden File Sync
state abstraction: the former state is a derived, in-memory projection, while
Hidden File Sync markers are persisted operational reconciliation state with
different identity, invalidation, and deletion rules.
`CatalogueOperations` owns catalogue enumeration, one queue and its progress
subscription, and composes `CatalogueV1`, `CatalogueV2`, and
`CatalogueMigration`. V1 and V2 are mutually exclusive as the selected write
format, but both persisted formats can coexist during migration. Queue work
therefore reads the live setting when it starts, and the shared state continues
to recognise V2 rows independently of that setting. `CatalogueMigration`
remains the distinct bridge from grouped V1 binders to per-file V2 documents.
`SnapshotPersistence` owns the host-neutral V1 grouped and V2 per-file writes
and logical deletion. It returns explicit mutation and refresh outcomes, so it
neither owns catalogue state nor calls back through the context.
`SnapshotOperations` applies those outcomes with the inherited awaited V1 or
fire-and-forget V2 timing. `ApplicationOperations` owns compare, apply,
duplicate, and delete workflows, while `ScanOperations` owns configuration-file
enumeration and V1/V2 reconciliation. Both depend on narrow snapshot and
catalogue ports instead of the context.
The newly extracted catalogue, snapshot, application, scan, and reconciliation
modules omit a Customisation Sync or Hidden File Sync prefix because their
feature directories already supply that scope. Public contexts and views retain
their domain names for compatibility.
`CustomisationSyncPathOperations` binds live configuration-directory, mode,
and device-name projections to the pure category and V1/V2 key functions. It
@@ -232,6 +255,12 @@ This boundary keeps event concurrency and settlement directly testable without
giving the processor full scan, initialisation, notification, or host
responsibilities.
`Reconciliation` owns storage and database enumeration, full scans, offline
comparison, rebuild direction and ordering, processed-state adoption,
initialisation sequencing, and the scoped rebuild interceptor used by maintained
real-Obsidian tests. Storage and database scans remain together because every
offline and initialisation path coordinates both sides.
The joint composition may return several views backed by those contexts:
- a Customisation Sync catalogue and operation view for its dialogue;
@@ -372,11 +401,17 @@ to the non-owner.
The private context, path module, codec module, focused presentation view, and
resource teardown are implemented. A focused path capability binds live
settings and device identity to the pure path functions. A focused
catalogue-state owner holds the rows, manifests, manifest mtime cache, reactive
stores, and update progress, while a bounded deduplicator owns recent raw-event
keys. Enumeration and scan queues remain with the orchestrating context. The
context accepts only narrow, live projections and explicit effects; an
settings and device identity to the pure path functions. A focused catalogue
owner holds one queue, its progress subscription, and shared state, while
separate V1, V2, and migration modules hold format-specific behaviour. A
bounded deduplicator owns recent raw-event keys. Host-neutral snapshot
persistence owns V1 grouped and V2 per-file writes, unchanged-content checks,
and logical deletion. A snapshot coordinator applies its explicit refresh
outcomes without a catalogue-to-context callback cycle. Focused application
and scan owners contain selected-snapshot workflows and full local/database
reconciliation, respectively. The context retains raw-event admission and
scheduling, configuration and periodic policy, owner lifetime, and view
composition. It accepts only narrow, live projections and explicit effects; an
Obsidian adapter at the composition edge owns dialogues, Notices, plug-in
reload, restart, lifecycle, Vault access, and compatibility scan telemetry.
@@ -397,9 +432,10 @@ the ownership, static-path, pattern, and ignore-file sequence. A focused change
notifier owns folder batching, delayed delivery, and teardown of its scheduled
work and Notice effect. A focused change processor owns storage and database
event processing, bounded concurrency, per-path serialisation, activity
publication, and compatibility settlement order. The context retains scan,
initialisation, and reconciliation orchestration. A focused
conflict-resolution owner owns pending-path admission,
publication, and compatibility settlement order. A focused reconciliation
owner owns storage and database scans, offline comparison, rebuilds,
processed-state adoption, initialisation direction and ordering, and its scoped
testing interceptor. A focused conflict-resolution owner owns pending-path admission,
the parallel classification and serial interaction queues, automatic merge,
newer-revision selection, interactive JSON application, settlement, and queue
disposal. An Obsidian adapter owns JSON conflict dialogue instances, progress
@@ -499,12 +535,13 @@ migration without first establishing narrow dependencies.
- The legacy add-on identity and constructor-name lookup are removed.
- The two contexts coordinate separate synchronisation workflows, but their
dependency surfaces are explicit and do not include the complete core.
Customisation Sync delegates its path binding, derived catalogue, and
recent-event state, while Hidden File Sync delegates path admission,
notification, processed-state, change-processing, and conflict lifecycles,
to focused owners. Further extraction should follow a concrete behavioural
boundary rather than create additional serviceFeatures for private
operations.
Customisation Sync delegates its path binding, snapshot persistence and
refresh sequencing, selected-snapshot application, scan reconciliation,
derived catalogue, catalogue queue, and recent-event state, while Hidden File
Sync delegates path admission, notification, processed-state,
change-processing, reconciliation, and conflict lifecycles to focused owners.
Further extraction should follow a concrete behavioural boundary rather than
create additional serviceFeatures for private operations.
## References
@@ -32,9 +32,19 @@ Obsidian composition (`main.ts`)
| |
| +--> `CustomisationSyncContext`
| | +-- `CustomisationSyncPathOperations`
| | +-- `CustomisationSyncCatalogueState`
| | +-- `SnapshotPersistence`
| | +-- `SnapshotOperations`
| | +-- `ApplicationOperations`
| | +-- `ScanOperations`
| | +-- recent-event deduplicator
| | +-- immutable service-handler and testing views
| | |
| | +--> `CatalogueOperations`
| | +-- `CatalogueState`
| | +-- one catalogue queue and progress lifecycle
| | +-- `CatalogueV1`
| | +-- `CatalogueV2`
| | +-- `CatalogueMigration`
| | ^
| | +-- narrow dependencies from
| | `customisationSyncObsidianAdapter`
@@ -64,6 +74,10 @@ Obsidian composition (`main.ts`)
| | +-- changed-folder batching and scheduled delivery
| | +-- suppression and Notice-effect teardown
| |
| +--> `Reconciliation`
| | +-- storage and database scans
| | +-- offline reconciliation, rebuilds, and initialisation
| |
| +-- immutable service-handler, command, repair, and testing views
|
+--> `useCustomisationSyncUI`
@@ -84,37 +98,65 @@ application core.
## Ownership
| Owner | Owns | Does not own |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `useOptionalFileSync` | Construction of both contexts, handler registration and removal, local-owner selection, namespace dispatch, compatibility callback order, and context disposal order. | Persisted feature state, synchronisation algorithms, commands, dialogues, or Notices. |
| `CustomisationSyncContext` | The `ix:` scan and snapshot workflow, snapshot storage and application, periodic scan state, and the lifetimes of its focused owners. | Path derivation, catalogue mutations, recent-event history, Obsidian dialogues, ribbon actions, or handler registration. |
| `CustomisationSyncPathOperations` | Binding live configuration-directory, mode, and device-name projections to the pure category, target-path, V1 key, V2 key, and device-prefix functions. | I/O, mutable state, local-owner selection, or persistence. |
| `CustomisationSyncCatalogueState` | Transient catalogue rows, manifest lookup and mtime cache, reactive catalogue and manifest stores, and catalogue update progress. | Database or storage I/O, scan scheduling, routing, or persistence. |
| Customisation Sync event deduplicator | The bounded, newest-first keys used to admit raw configuration events once. | Scheduling, path ownership, catalogue state, or persisted data. |
| `HiddenFileSyncContext` | The `i:` scan and reconciliation workflow, exact-revision repair composition, periodic scan state, and focused-owner lifetimes. | Path admission state, change-event serialisation, processed-state representation, notification batching, conflict queue state, Obsidian dialogues, or service handler registration. |
| `HiddenFileSyncProcessedState` | Three device-local autosaved maps, exact storage and database keys, retained known mtime, reset behaviour, and storage/database settlement effects. | File transfer, scan scheduling, conflict handling, or presentation. |
| `HiddenFileSyncChangeProcessor` | Storage and database change processing, same-path event serialisation, bounded concurrency, activity counts, and the inherited event-consumption and settlement order. | Full scans, initialisation policy, notification presentation, or conflict interaction. |
| `HiddenFileSyncConflictResolution` | Conflict admission and deduplication, pending paths, the parallel classification and serial interaction queues, automatic merge, newer-revision policy, interactive JSON resolution, and conflict settlement. | Obsidian dialogue instances, general Hidden File Sync scans, processed-state caches, or Service handler registration. |
| `HiddenFileSyncPathAdmission` | The ownership-first eligibility sequence, hidden-path and pattern policy, asynchronous ignore-file check, and the per-context parsed-pattern cache. | Composition-level owner selection, scans, transfer, or persistence. |
| `HiddenFileSyncChangeNotifier` | Changed-folder deduplication, delayed delivery, live suppression and configuration-directory checks, scheduled-task cancellation, and the host Notice show/hide effects. | The Obsidian Notice instance, file extraction, or scan policy. |
| Customisation Sync Obsidian adapter | Obsidian conflict selection, Notice presentation, plug-in reload, restart requests, Vault enumeration, progress telemetry, and platform-derived fallback device names. | Catalogue state, routing, or persisted document operations. |
| Hidden File Sync Obsidian adapter | JSON conflict dialogue lifetime, progress presentation, grouped change Notices, plug-in reload actions, restart scheduling, Vault enumeration, and compatibility activity publication. | Transfer, reconciliation, processed-state, or conflict decisions. |
| `useCustomisationSyncUI` | Command, ribbon, dialogue, open-request subscription, and their unload teardown. | Synchronisation state or Hidden File Sync initialisation behaviour. |
| `useHiddenFileSyncCommands` | Hidden File Sync command registration, setting-change subscription, and their unload teardown. | Synchronisation state or command implementation. |
| Owner | Owns | Does not own |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useOptionalFileSync` | Construction of both contexts, handler registration and removal, local-owner selection, namespace dispatch, compatibility callback order, and context disposal order. | Persisted feature state, synchronisation algorithms, commands, dialogues, or Notices. |
| `CustomisationSyncContext` | Lifecycle and view composition, raw-event admission and scheduling, configuration transitions, periodic scan policy, and focused-owner lifetimes. | Scan reconciliation, snapshot writes or refresh sequencing, catalogue internals, application and comparison algorithms, Obsidian dialogues, ribbon actions, or handler registration. |
| `CustomisationSyncPathOperations` | Binding live configuration-directory, mode, and device-name projections to the pure category, target-path, V1 key, V2 key, and device-prefix functions. | I/O, mutable state, local-owner selection, or persistence. |
| `SnapshotPersistence` | V1 grouped and V2 per-file local-to-database writes, unchanged-content checks, logical deletion, and explicit catalogue-refresh outcomes. | Catalogue state or refresh execution, scans, lifecycle policy, application, dialogues, or plug-in reload. |
| `SnapshotOperations` | Applying persistence outcomes to the catalogue with the inherited awaited V1 and fire-and-forget V2 refresh timing. | Snapshot encoding, catalogue state, scans, lifecycle policy, or host effects. |
| `ApplicationOperations` | Comparing, applying, duplicating, and deleting selected Customisation Sync snapshots through narrow storage, snapshot, and catalogue ports. | Catalogue enumeration, raw-event admission, periodic scheduling, or view composition. |
| `ScanOperations` | Configuration-file enumeration and V1/V2 reconciliation with local and database state. | Periodic scheduling, raw-event admission, snapshot persistence details, or catalogue state. |
| `CatalogueOperations` | Catalogue enumeration and publication, one format-dispatching queue and its progress lifecycle, and composition of the state, V1, V2, and migration modules. | Local-file scanning, snapshot application, raw-event routing, dialogues, or handler registration. |
| `CatalogueState` | Transient catalogue rows, manifest lookup and mtime cache, reactive catalogue and manifest stores, and catalogue update progress. | Database or storage I/O, scan scheduling, routing, or persistence. |
| `CatalogueV1` | Loading and publishing grouped V1 catalogue rows. | V2 decoding, migration, queue lifetime, or local-file scanning. |
| `CatalogueV2` | Building and updating per-file V2 rows and manifests. | V1 loading, migration, queue lifetime, or local-file scanning. |
| `CatalogueMigration` | Translating a grouped V1 binder into V2 per-file documents, deleting the migrated binder, and applying its required V1 refresh. | General catalogue enumeration, queue ownership, or local-file scanning. |
| Customisation Sync event deduplicator | The bounded, newest-first keys used to admit raw configuration events once. | Scheduling, path ownership, catalogue state, or persisted data. |
| `HiddenFileSyncContext` | Lifecycle and view composition, handler admission, configuration transitions, periodic scan state, exact-revision repair composition, and focused-owner lifetimes. | Scan and rebuild algorithms, path admission state, change-event serialisation, processed-state representation, notification batching, conflict queue state, Obsidian dialogues, or service handler registration. |
| `HiddenFileSyncProcessedState` | Three device-local autosaved maps, exact storage and database keys, retained known mtime, reset behaviour, and storage/database settlement effects. | File transfer, scan scheduling, conflict handling, or presentation. |
| `HiddenFileSyncChangeProcessor` | Storage and database change processing, same-path event serialisation, bounded concurrency, activity counts, and the inherited event-consumption and settlement order. | Full scans, initialisation policy, notification presentation, or conflict interaction. |
| `Reconciliation` | Storage and database enumeration, full scans, offline reconciliation, rebuild direction and ordering, processed-state adoption, initialisation sequencing, and the scoped rebuild interceptor. | Individual transfer implementation, conflict interaction, path-pattern state, periodic scheduling, or host lifecycle. |
| `HiddenFileSyncConflictResolution` | Conflict admission and deduplication, pending paths, the parallel classification and serial interaction queues, automatic merge, newer-revision policy, interactive JSON resolution, and conflict settlement. | Obsidian dialogue instances, general Hidden File Sync scans, processed-state caches, or Service handler registration. |
| `HiddenFileSyncPathAdmission` | The ownership-first eligibility sequence, hidden-path and pattern policy, asynchronous ignore-file check, and the per-context parsed-pattern cache. | Composition-level owner selection, scans, transfer, or persistence. |
| `HiddenFileSyncChangeNotifier` | Changed-folder deduplication, delayed delivery, live suppression and configuration-directory checks, scheduled-task cancellation, and the host Notice show/hide effects. | The Obsidian Notice instance, file extraction, or scan policy. |
| Customisation Sync Obsidian adapter | Obsidian conflict selection, Notice presentation, plug-in reload, restart requests, Vault enumeration, progress telemetry, and platform-derived fallback device names. | Catalogue state, routing, or persisted document operations. |
| Hidden File Sync Obsidian adapter | JSON conflict dialogue lifetime, progress presentation, grouped change Notices, plug-in reload actions, restart scheduling, Vault enumeration, and compatibility activity publication. | Transfer, reconciliation, processed-state, or conflict decisions. |
| `useCustomisationSyncUI` | Command, ribbon, dialogue, open-request subscription, and their unload teardown. | Synchronisation state or Hidden File Sync initialisation behaviour. |
| `useHiddenFileSyncCommands` | Hidden File Sync command registration, setting-change subscription, and their unload teardown. | Synchronisation state or command implementation. |
The two domain contexts coordinate one cohesive synchronisation workflow each.
Their private operations and focused owners are not additional serviceFeatures:
they do not independently register host integration or have separate
application lifetimes. `CustomisationSyncPathOperations` is a stateless
capability which binds live inputs to pure path functions. The Hidden File Sync
path-admission owner holds the parsed-pattern cache, and its change notifier
holds the pending folder set and scheduled-task lifetime.
application lifetimes. The newly extracted catalogue, snapshot, application,
scan, and reconciliation modules omit the domain prefix because their feature
directories already supply that scope; public context and view names retain it
for compatibility. `CustomisationSyncPathOperations` is a stateless capability
which binds live inputs to pure path functions.
`SnapshotPersistence` is a host-neutral operation boundary: it returns
structured mutation and refresh outcomes without reaching into the catalogue
or presentation. `SnapshotOperations` consumes those outcomes and preserves
their refresh timing. `ApplicationOperations` and `ScanOperations` depend on
those narrow ports instead of calling back through the context.
`CatalogueOperations` owns one catalogue queue, its progress subscription, and
the shared state projected by the format-specific modules. The live V2 setting
selects V1 loading or V1-to-V2 migration when each queued item starts. Persisted
V1 and V2 documents can coexist during migration, so V2 documents remain
recognisable regardless of the currently selected write format. `CatalogueV1`
and `CatalogueV2` contain only their format-specific catalogue behaviour, while
`CatalogueMigration` remains the explicit bridge between them. The Hidden File
Sync path-admission owner holds the parsed-pattern cache, and its change
notifier holds the pending folder set and scheduled-task lifetime.
`HiddenFileSyncChangeProcessor` is a focused resource owner because its
semaphore, per-path serialisation, activity counters, and event settlement form
one independently testable lifecycle.
`HiddenFileSyncConflictResolution` is another focused resource owner because
conflict admission, pending-path identity, two serialisation stages, and
disposal form a separate lifecycle.
`Reconciliation` keeps storage and database enumeration together because
offline comparison, rebuilds, and each initialisation direction depend on both
sides and their ordered processed-state adoption.
The two state owners intentionally do not implement a common generic state
contract. Customisation Sync projects transient catalogue and presentation
@@ -214,12 +256,16 @@ than being re-exported through a broad context testing view.
The composition is created after the Service Hub and required ServiceModules
exist, and before lifecycle-driven feature work begins. Each context creates
its own periodic processor and focused resource owners.
`CustomisationSyncContext` creates one path capability, one catalogue-state
owner, and one recent-event deduplicator. `HiddenFileSyncContext` creates one
`CustomisationSyncContext` creates one path capability, one snapshot-persistence
boundary, one snapshot coordinator, one application owner, one scan owner, one
catalogue owner, and one recent-event deduplicator. The catalogue owner creates
its shared state, one queue and progress subscription, and the V1, V2, and
migration modules. `HiddenFileSyncContext` creates one
path-admission owner, one change notifier, and one processed-state owner before
composing database write and extraction operations around their narrow ports.
It then creates one change processor and one conflict-resolution owner. The
change processor owns its semaphore and activity state.
It then creates one change processor, one conflict-resolution owner, and one
reconciliation owner. The change processor owns its semaphore and activity
state; the reconciliation owner owns its scoped testing interceptor.
Full conflict scans admit discovered paths into the same queue without
suspending it, so ordinary database conflict notifications continue during a
slow scan. After enumeration, the operation waits for both classification and
@@ -255,10 +301,14 @@ appropriate, a migration decision.
Focused unit tests cover routing, path-option binding, Hidden File Sync
admission ordering and cache invalidation, semantic handler views, context
owner isolation, teardown, initial cache selection, exact-revision repair,
change-event serialisation and settlement, notification batching, conflict
queue admission, revision selection, automatic and interactive merge effect
ordering, conflict dialogue adaptation, grouped Notices, and compatibility
activity publication.
Customisation Sync scan reconciliation and context delegation, single-queue
catalogue disposal and publication, live V1/V2 dispatch, V1 and V2 snapshot
persistence outcomes, logical-deletion idempotence, refresh ordering,
application operations, Hidden File Sync change-event serialisation and
settlement, reconciliation direction and scan ordering, notification batching,
conflict queue admission, revision selection, automatic and interactive merge
effect ordering, conflict dialogue adaptation, grouped Notices, and
compatibility activity publication.
The boundary test prevents either domain context from regaining core or
Obsidian dependencies.
@@ -0,0 +1,346 @@
import { diff_match_patch, parseYaml } from "@/deps.ts";
import type {
diff_result,
FilePath,
FilePathWithPrefix,
LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, getDocData, getDocDataAsArray, isDocContentSame } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { decodeBinary } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import type { SnapshotOperations } from "./snapshotOperations.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
const { deserialize } = createCustomisationSyncCodec({ digestHash, parseYaml });
type ApplicationDatabase = Pick<LiveSyncLocalDB, "getDBEntry">;
type ApplicationStorage = Pick<
StorageAccess,
"ensureDir" | "readHiddenFileBinary" | "readHiddenFileText" | "writeHiddenFileAuto"
>;
type ApplicationPath = Pick<CustomisationSyncPathOperations, "filenameToUnifiedKey">;
type ApplicationSnapshotOperations = Pick<
SnapshotOperations,
"isV2Enabled" | "storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type ApplicationCatalogue = Pick<
CatalogueOperations,
"findPlugins" | "manifestLookup" | "updatePluginList" | "updatePluginListV2"
>;
export type ApplicationOperationsDependencies = {
getLocalDatabase(): ApplicationDatabase;
storageAccess: ApplicationStorage;
path: ApplicationPath;
log: LogFunction;
getConfigDir(): string;
getDeviceAndVaultName(): string;
resolveJsonConflict(
path: FilePath,
files: [LoadedEntryPluginDataExFile, LoadedEntryPluginDataExFile],
remoteName: string,
apply: (content: string) => Promise<boolean>
): Promise<boolean>;
selectTextFile(path: FilePath, diffResult: diff_result, remoteName: string): Promise<"A" | "B" | false>;
reloadPlugin(configDir: string, pluginName: string): Promise<void>;
askRestart(): void;
snapshotOperations: ApplicationSnapshotOperations;
catalogueOperations: ApplicationCatalogue;
};
/**
* Owns the Customisation Sync dialogue's compare, apply, duplicate, and
* delete workflows. It deliberately consumes the shared snapshot capability
* and catalogue owner, leaving lifecycle, event admission, and scanning in
* the context.
*/
export class ApplicationOperations {
constructor(private readonly dependencies: ApplicationOperationsDependencies) {}
private get configDir() {
return this.dependencies.getConfigDir();
}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private get storageAccess() {
return this.dependencies.storageAccess;
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
async compareFileUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
filename: string
): Promise<boolean> {
const dataACopy =
dataA instanceof PluginDataExDisplayV2
? new PluginDataExDisplayV2(dataA, this.dependencies.catalogueOperations.manifestLookup)
: { ...dataA };
const dataBCopy =
dataB instanceof PluginDataExDisplayV2
? new PluginDataExDisplayV2(dataB, this.dependencies.catalogueOperations.manifestLookup)
: { ...dataB };
dataACopy.files = dataACopy.files.filter((file) => file.filename == filename);
dataBCopy.files = dataBCopy.files.filter((file) => file.filename == filename);
return await this.compareUsingDisplayData(dataACopy, dataBCopy, true);
}
async compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach = false) {
const loadFile = async (data: IPluginDataExDisplay) => {
if (data instanceof PluginDataExDisplayV2 || compareEach) {
return data.files[0] as LoadedEntryPluginDataExFile;
}
const loadDoc = await this.localDatabase.getDBEntry(data.documentPath);
if (!loadDoc) return false;
const pluginData = deserialize(getDocDataAsArray(loadDoc.data), {}) as PluginDataEx;
pluginData.documentPath = data.documentPath;
const file = pluginData.files[0];
const doc = { ...loadDoc, ...file, datatype: "newnote" } as LoadedEntryPluginDataExFile;
return doc;
};
const fileA = await loadFile(dataA);
const fileB = await loadFile(dataB);
this._log(`Comparing: ${dataA.documentPath} <-> ${dataB.documentPath}`, LOG_LEVEL_VERBOSE);
if (!fileA || !fileB) {
this._log(
`Could not load ${dataA.name} for comparison: ${!fileA ? dataA.term : ""}${!fileB ? dataB.term : ""}`,
LOG_LEVEL_NOTICE
);
return false;
}
const path = fileA.filename.split("/").pop() as FilePath;
if (path.endsWith(".json")) {
return serialized("config:merge-data", async () => {
this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE);
return await this.dependencies.resolveJsonConflict(path, [fileA, fileB], dataB.term, async (result) => {
try {
return await this.applyData(dataA, result);
} catch (ex) {
this._log("Could not apply merged file");
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
});
});
} else {
const dmp = new diff_match_patch();
let docAData = getDocData(fileA.data);
let docBData = getDocData(fileB.data);
if (fileA?.datatype != "plain") {
docAData = base64ToString(docAData);
}
if (fileB?.datatype != "plain") {
docBData = base64ToString(docBData);
}
const diffMap = dmp.diff_linesToChars_(docAData, docBData);
const diff = dmp.diff_main(diffMap.chars1, diffMap.chars2, false);
dmp.diff_charsToLines_(diff, diffMap.lineArray);
dmp.diff_cleanupSemantic(diff);
const diffResult: diff_result = {
left: { rev: "A", ...fileA, data: docAData },
right: { rev: "B", ...fileB, data: docBData },
diff: diff,
};
const ret = await this.dependencies.selectTextFile(path, diffResult, dataB.term);
if (ret === false) return false;
const resultContent = ret == "A" ? docAData : ret == "B" ? docBData : undefined;
if (resultContent) {
return await this.applyData(dataA, resultContent);
}
return false;
}
}
async duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void> {
const path = `${this.configDir}/${data.files[0].filename}` as FilePath;
await this.dependencies.snapshotOperations.storeCustomizationFiles(path, deviceName);
await this.dependencies.catalogueOperations.updatePluginList(
false,
this.dependencies.path.filenameToUnifiedKey(path, deviceName)
);
}
async applyDataV2(data: PluginDataExDisplayV2, content?: string): Promise<boolean> {
const baseDir = this.configDir;
try {
if (content) {
// Preserve the inherited truthiness check: an explicitly empty
// replacement is treated as the no-content path.
const filename = data.files[0].filename;
this._log(`Applying ${filename} of ${data.displayName || data.name}..`);
const path = `${baseDir}/${filename}` as FilePath;
await this.storageAccess.ensureDir(path);
// If the content has applied, modified time will be updated to the current time.
await this.storageAccess.writeHiddenFileAuto(path, content);
await this.dependencies.snapshotOperations.storeCustomisationFileV2(
path,
this.dependencies.getDeviceAndVaultName()
);
} else {
const files = data.files;
for (const f of files) {
// If files have applied, modified time will be updated to the current time.
const stat = { mtime: f.mtime, ctime: f.ctime };
const path = `${baseDir}/${f.filename}` as FilePath;
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
// const contentEach = createBlob(f.data);
await this.storageAccess.ensureDir(path);
if (f.datatype == "newnote") {
let oldData;
try {
oldData = await this.storageAccess.readHiddenFileBinary(path);
} catch (ex) {
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
oldData = new ArrayBuffer(0);
}
const content = base64ToArrayBuffer(f.data);
if (await isDocContentSame(oldData, content)) {
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
continue;
}
await this.storageAccess.writeHiddenFileAuto(path, content, stat);
} else {
let oldData;
try {
oldData = await this.storageAccess.readHiddenFileText(path);
} catch (ex) {
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
oldData = "";
}
const content = getDocData(f.data);
if (await isDocContentSame(oldData, content)) {
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
continue;
}
await this.storageAccess.writeHiddenFileAuto(path, content, stat);
}
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
await this.dependencies.snapshotOperations.storeCustomisationFileV2(
path,
this.dependencies.getDeviceAndVaultName()
);
}
}
} catch (ex) {
this._log(`Applying ${data.displayName || data.name}.. Failed`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
return true;
}
async applyData(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
this._log(`Applying ${data.displayName || data.name}..`);
if (data instanceof PluginDataExDisplayV2) {
return this.applyDataV2(data, content);
}
return this.applyDataV1(data, content);
}
private async applyDataV1(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
const baseDir = this.configDir;
try {
if (!data.documentPath) throw new LiveSyncError("InternalError: Document path not exist");
const dx = await this.localDatabase.getDBEntry(data.documentPath);
if (dx == false) {
throw new LiveSyncError("Not found on database");
}
const loadedData = deserialize(getDocDataAsArray(dx.data), {}) as PluginDataEx;
for (const f of loadedData.files) {
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
try {
// console.dir(f);
const path = `${baseDir}/${f.filename}`;
await this.storageAccess.ensureDir(path);
if (!content) {
const dt = decodeBinary(f.data);
await this.storageAccess.writeHiddenFileAuto(path, dt);
} else {
await this.storageAccess.writeHiddenFileAuto(path, content);
}
this._log(`Applying ${f.filename} of ${data.displayName || data.name}.. Done`);
} catch (ex) {
this._log(`Applying ${f.filename} of ${data.displayName || data.name}.. Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
const uPath = `${baseDir}/${loadedData.files[0].filename}` as FilePath;
await this.dependencies.snapshotOperations.storeCustomizationFiles(uPath);
// The inherited workflow refreshes once through persistence, then
// explicitly refreshes again with the dialogue's notice flag.
await this.dependencies.catalogueOperations.updatePluginList(true, uPath);
await delay(100);
this._log(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE);
if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") {
await this.dependencies.reloadPlugin(baseDir, data.name);
} else if (data.category == "CONFIG") {
this.dependencies.askRestart();
}
return true;
} catch (ex) {
this._log(`Applying ${data.displayName || data.name}.. Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
}
async deleteData(data: PluginDataEx): Promise<boolean> {
try {
if (data.documentPath) {
const delList: FilePathWithPrefix[] = [];
if (this.dependencies.snapshotOperations.isV2Enabled()) {
const deleteList = this.dependencies.catalogueOperations
.findPlugins(data.documentPath)
.filter((entry) => entry instanceof PluginDataExDisplayV2)
.map((entry) => entry.files)
.flat();
for (const e of deleteList) {
delList.push(e.path);
}
}
delList.push(data.documentPath);
const p = delList.map(async (e) => {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(e);
// Preserve the inherited unconditional refresh after the
// persistence wrapper, including when it emitted no refresh.
await this.dependencies.catalogueOperations.updatePluginList(false, e);
});
await Promise.allSettled(p);
// Preserve the inherited success result even when individual
// deletion/refresh promises settle unsuccessfully.
this._log(
`Deleted: ${data.category}/${data.name} of ${data.category} (${delList.length} items)`,
LOG_LEVEL_NOTICE
);
}
return true;
} catch (ex) {
this._log(`Failed to delete: ${data.documentPath}`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
}
}
@@ -0,0 +1,306 @@
import { describe, expect, it, vi } from "vitest";
const asyncHarness = vi.hoisted(() => ({
delay: vi.fn(async () => undefined),
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
parseYaml: vi.fn(),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
delay: asyncHarness.delay,
fireAndForget: asyncHarness.fireAndForget,
};
});
import type { FilePath, FilePathWithPrefix, LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { PluginManifest } from "@/deps.ts";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import { ApplicationOperations, type ApplicationOperationsDependencies } from "./applicationOperations.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
import { SnapshotOperations } from "./snapshotOperations.ts";
import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
const codec = createCustomisationSyncCodec({ digestHash, parseYaml: () => undefined });
function createOperations() {
const events: string[] = [];
let usePluginSyncV2 = false;
type PersistenceResult = SnapshotPersistenceResult<true>;
const getDBEntry = vi.fn(async (_path: FilePathWithPrefix) => false as false | LoadedEntry);
const ensureDir = vi.fn(async (_path: string) => {
events.push("ensure-dir");
return true;
});
const readHiddenFileBinary = vi.fn(async (_path: string) => new ArrayBuffer(0));
const readHiddenFileText = vi.fn(async (_path: string) => "");
const writeHiddenFileAuto = vi.fn(async (_path: string, _data: string | ArrayBuffer) => {
events.push("write-file");
return true;
});
const storeCustomisationFileV2 = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "saved" as const,
refreshes: [] as const,
})
);
const storeCustomizationFiles = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "saved" as const,
refreshes: [] as const,
})
);
const deleteConfigOnDatabase = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "deleted" as const,
refreshes: [] as const,
})
);
const updatePluginList = vi.fn(async (showMessage: boolean, _path?: FilePathWithPrefix | FilePath) => {
events.push(`refresh-v1:${showMessage}`);
});
const updatePluginListV2 = vi.fn(async (_showMessage: boolean, _path: FilePathWithPrefix) => {
events.push("refresh-v2");
});
const findPlugins = vi.fn(() => [] as readonly IPluginDataExDisplay[]);
const reloadPlugin = vi.fn(async (_configDir: string, _pluginName: string) => {
events.push("reload-plugin");
});
const askRestart = vi.fn(() => {
events.push("ask-restart");
});
const catalogueOperations = {
findPlugins,
manifestLookup: new Map<string, PluginManifest>(),
updatePluginList,
updatePluginListV2,
};
const snapshotOperations = new SnapshotOperations({
getSettings: () => ({ usePluginSyncV2 }),
getDeviceAndVaultName: () => "device-a",
log: vi.fn(),
snapshotPersistence: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations,
});
const dependencies: ApplicationOperationsDependencies = {
getLocalDatabase: () => ({ getDBEntry }),
storageAccess: {
ensureDir,
readHiddenFileBinary,
readHiddenFileText,
writeHiddenFileAuto,
},
path: {
filenameToUnifiedKey: (path, term) => `ix:${term}/CONFIG/${path.split("/").pop()}.md` as FilePathWithPrefix,
},
log: vi.fn(),
getConfigDir: () => ".obsidian",
getDeviceAndVaultName: () => "device-a",
resolveJsonConflict: vi.fn(async () => false),
selectTextFile: vi.fn(async (): Promise<"A" | "B" | false> => false),
reloadPlugin,
askRestart,
snapshotOperations,
catalogueOperations,
};
return {
application: new ApplicationOperations(dependencies),
dependencies,
events,
setUseV2: (value: boolean) => {
usePluginSyncV2 = value;
},
getDBEntry,
persistence: { deleteConfigOnDatabase, storeCustomisationFileV2, storeCustomizationFiles },
catalogue: { findPlugins, updatePluginList, updatePluginListV2 },
storage: { ensureDir, readHiddenFileBinary, readHiddenFileText, writeHiddenFileAuto },
reloadPlugin,
askRestart,
};
}
const display = {
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix,
category: "PLUGIN_DATA",
name: "example",
term: "device-a",
files: [
{ filename: "plugins/example/data.json", data: ["a"], mtime: 1, size: 1 },
{ filename: "plugins/example/other.json", data: ["b"], mtime: 2, size: 1 },
],
mtime: 2,
} satisfies IPluginDataExDisplay;
describe("Customisation Sync application operations", () => {
it("keeps file-level comparison clones and duplication behaviour inside the owner", async () => {
const fixture = createOperations();
const compareUsingDisplayData = vi
.spyOn(fixture.application, "compareUsingDisplayData")
.mockResolvedValue(true);
await expect(
fixture.application.compareFileUsingDisplayData(display, display, "plugins/example/data.json")
).resolves.toBe(true);
const [left, right, compareEach] = compareUsingDisplayData.mock.calls[0];
expect(left.files.map((file) => file.filename)).toEqual(["plugins/example/data.json"]);
expect(right.files.map((file) => file.filename)).toEqual(["plugins/example/data.json"]);
expect(compareEach).toBe(true);
expect(display.files).toHaveLength(2);
await fixture.application.duplicateData(display, "device-b");
expect(fixture.persistence.storeCustomizationFiles).toHaveBeenCalledWith(
".obsidian/plugins/example/data.json",
"device-b"
);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, "ix:device-b/CONFIG/data.json.md");
});
it("uses the compared filename for legacy file comparisons", async () => {
const fixture = createOperations();
await expect(
fixture.application.compareFileUsingDisplayData(display, display, "plugins/example/data.json")
).resolves.toBe(false);
expect(fixture.dependencies.resolveJsonConflict).toHaveBeenCalledWith(
"data.json",
expect.any(Array),
"device-a",
expect.any(Function)
);
});
it("awaits V1 refreshes before the explicit effect and preserves reload ordering", async () => {
const fixture = createOperations();
fixture.setUseV2(false);
fixture.persistence.storeCustomizationFiles.mockImplementation(async () => {
fixture.events.push("persist");
return {
value: true,
status: "saved" as const,
refreshes: [
{
mode: "v1" as const,
timing: "await" as const,
path: "ix:device-a/PLUGIN_MAIN/example.md" as FilePathWithPrefix,
},
],
};
});
fixture.getDBEntry.mockResolvedValue({
data: codec.serialize({
category: "PLUGIN_MAIN",
name: "example",
term: "device-a",
files: [{ filename: "plugins/example/main.js", data: ["source"], mtime: 1, size: 6 }],
mtime: 1,
} satisfies PluginDataEx),
} as LoadedEntry);
const data = { ...display, category: "PLUGIN_MAIN", name: "example" } satisfies IPluginDataExDisplay;
await expect(fixture.application.applyData(data, "replacement")).resolves.toBe(true);
expect(fixture.events).toEqual([
"ensure-dir",
"write-file",
"persist",
"refresh-v1:false",
"refresh-v1:true",
"reload-plugin",
]);
expect(fixture.reloadPlugin).toHaveBeenCalledWith(".obsidian", "example");
expect(asyncHarness.delay).toHaveBeenCalledWith(100);
});
it("starts V2 catalogue refreshes without awaiting them", async () => {
const fixture = createOperations();
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
fixture.setUseV2(true);
fixture.persistence.storeCustomisationFileV2.mockResolvedValue({
value: true,
status: "saved",
refreshes: [
{
mode: "v2",
timing: "fire-and-forget",
path: "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix,
},
],
});
fixture.catalogue.updatePluginListV2.mockImplementation(async () => await refresh);
const data = new PluginDataExDisplayV2(
{
...display,
files: [{ filename: "app.json", data: ["source"], mtime: 1, size: 6 }],
},
new Map()
);
await expect(fixture.application.applyData(data, "replacement")).resolves.toBe(true);
expect(fixture.catalogue.updatePluginListV2).toHaveBeenCalledWith(
false,
"ix:device-a/CONFIG/app.json%app.json"
);
releaseRefresh();
await refresh;
});
it("deletes the V2 files and binder through the direct owners", async () => {
const fixture = createOperations();
fixture.setUseV2(true);
const v2Path = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
const binderPath = "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix;
const v2Entry = new PluginDataExDisplayV2(
{
...display,
documentPath: binderPath,
files: [
{
filename: "data.json",
path: v2Path,
data: ["source"],
mtime: 1,
ctime: 1,
size: 6,
datatype: "plain",
} as never,
],
},
new Map()
);
fixture.catalogue.findPlugins.mockReturnValue([v2Entry]);
await expect(
fixture.application.deleteData({
...display,
documentPath: binderPath,
})
).resolves.toBe(true);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenNthCalledWith(1, v2Path, false);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenNthCalledWith(2, binderPath, false);
expect(fixture.catalogue.updatePluginList).toHaveBeenNthCalledWith(1, false, v2Path);
expect(fixture.catalogue.updatePluginList).toHaveBeenNthCalledWith(2, false, binderPath);
});
});
@@ -0,0 +1,126 @@
import { createBlob, getDocDataAsArray } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type {
AnyEntry,
FilePathWithPrefix,
LOG_LEVEL,
SavingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { ICXHeader } from "@/common/types.ts";
import type { SnapshotPersistence } from "./snapshotPersistence.ts";
import type { CustomisationSyncReadCodec } from "./customisationSyncReadOperations.ts";
type CatalogueMigrationDatabase = Pick<LiveSyncLocalDB, "getDBEntry" | "putDBEntry">;
type CatalogueMigrationCodec = Pick<CustomisationSyncReadCodec, "deserialize"> & {
dummyHead: string;
dummyEnd: string;
};
export type CatalogueMigrationDependencies = {
getLocalDatabase(): CatalogueMigrationDatabase;
path: Pick<IPathService, "path2id">;
log: LogFunction;
snapshotPersistence: Pick<SnapshotPersistence, "deleteConfigOnDatabase">;
refreshV1(showMessage: boolean, path: FilePathWithPrefix): Promise<void>;
codec: CatalogueMigrationCodec;
};
/** Bridges persisted V1 binders into the V2 per-file document format. */
export class CatalogueMigration {
constructor(private readonly dependencies: CatalogueMigrationDependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
async migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise<void> {
const v1Path = entry.path;
this._log(`Migrating ${entry.path} to V2`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
if (entry.deleted) {
this._log(`The entry ${v1Path} is already deleted`, LOG_LEVEL_VERBOSE);
return;
}
// Compatibility question: the inherited conjunction admits any `ix:`
// path or any `.md` path, although the log describes a stricter binder
// check. Preserve it until malformed migration candidates are covered.
if (!v1Path.endsWith(".md") && !v1Path.startsWith(ICXHeader)) {
this._log(`The entry ${v1Path} is not a customisation sync binder`, LOG_LEVEL_VERBOSE);
return;
}
if (v1Path.indexOf("%") !== -1) {
this._log(`The entry ${v1Path} is already migrated`, LOG_LEVEL_VERBOSE);
return;
}
const loadedEntry = await this.dependencies.getLocalDatabase().getDBEntry(v1Path);
if (!loadedEntry) {
this._log(`The entry ${v1Path} is not found`, LOG_LEVEL_VERBOSE);
return;
}
const pluginData = this.dependencies.codec.deserialize(getDocDataAsArray(loadedEntry.data), {}) as {
category: string;
files: Array<{ filename: string; data: string[] }>;
};
const prefixPath = v1Path.slice(0, -".md".length) + "%";
const category = pluginData.category;
for (const f of pluginData.files) {
const stripTable: Record<string, number> = {
CONFIG: 0,
THEME: 2,
SNIPPET: 1,
PLUGIN_MAIN: 2,
PLUGIN_DATA: 2,
PLUGIN_ETC: 2,
};
const deletePrefixCount = stripTable?.[category] ?? 1;
const relativeFilename = f.filename.split("/").slice(deletePrefixCount).join("/");
const v2Path = (prefixPath + relativeFilename) as FilePathWithPrefix;
this._log(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`, LOG_LEVEL_VERBOSE);
const newId = await this.dependencies.path.path2id(v2Path);
const data = createBlob([
this.dependencies.codec.dummyHead,
this.dependencies.codec.dummyEnd,
...getDocDataAsArray(f.data),
]);
const saving: SavingEntry = {
...loadedEntry,
_rev: undefined,
_id: newId,
path: v2Path,
data,
datatype: "plain",
type: "plain",
children: [],
eden: {},
};
const result = await this.dependencies.getLocalDatabase().putDBEntry(saving);
if (result && result.ok) {
this._log(`Migrated ${v1Path} / ${f.filename} to ${v2Path}`, LOG_LEVEL_INFO);
const deletion = await this.dependencies.snapshotPersistence.deleteConfigOnDatabase(v1Path);
const deleted = deletion.value;
if (deleted) {
this._log(`Deleted ${v1Path} successfully`, LOG_LEVEL_INFO);
} else {
this._log(`Failed to delete ${v1Path}`, LOG_LEVEL_NOTICE);
}
// Compatibility: the inherited migration called the context
// deletion wrapper, which awaited its V1 catalogue refresh.
// Apply that refresh explicitly now that deletion is a host-
// neutral persistence operation, and only when deletion emitted
// the same mutation outcome.
for (const refresh of deletion.refreshes) {
if (refresh.mode == "v1" && refresh.timing == "await") {
await this.dependencies.refreshV1(false, refresh.path);
}
}
}
}
}
}
@@ -0,0 +1,216 @@
import { parseYaml } from "@/deps.ts";
import { writable } from "svelte/store";
import type {
AnyEntry,
FilePathWithPrefix,
LoadedEntry,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICXHeader } from "@/common/types.ts";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { CatalogueMigration } from "./catalogueMigration.ts";
import { CatalogueState } from "./catalogueState.ts";
import { CatalogueV1 } from "./catalogueV1.ts";
import { CatalogueV2 } from "./catalogueV2.ts";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import type { SnapshotPersistence } from "./snapshotPersistence.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
const {
serialize,
deserialize,
dummyHead: DUMMY_HEAD,
dummyEnd: DUMMY_END,
} = createCustomisationSyncCodec({ digestHash, parseYaml });
const READ_CODEC = { deserialize, serialize };
const MIGRATION_CODEC = { deserialize, dummyHead: DUMMY_HEAD, dummyEnd: DUMMY_END };
const V2_CODEC = { dummyEnd: DUMMY_END };
type CatalogueSettings = Pick<ObsidianLiveSyncSettings, "usePluginSync" | "usePluginSyncV2">;
type CatalogueDatabase = Pick<LiveSyncLocalDB, "findEntries" | "getDBEntry" | "putDBEntry">;
export type CatalogueOperationsDependencies = {
getSettings(): CatalogueSettings;
getLocalDatabase(): CatalogueDatabase;
path: Pick<IPathService, "getPath" | "path2id">;
log: LogFunction;
snapshotPersistence: Pick<SnapshotPersistence, "deleteConfigOnDatabase">;
publishScanCount(count: number): void;
};
/** Coordinates the shared catalogue state, scan queue, and format modules. */
export class CatalogueOperations {
private readonly dependencies: CatalogueOperationsDependencies;
private readonly catalogueState = new CatalogueState();
private readonly scanProgress = reactiveSource(0);
private readonly pluginScanningChanged: Parameters<ReactiveSource<number>["onChanged"]>[0] = (event) => {
this.enumerationActive.set(event.value != 0);
this.dependencies.publishScanCount(event.value);
};
private readonly pluginScanProcessor: QueueProcessor<AnyEntry, AnyEntry>;
private readonly catalogueV1: CatalogueV1;
private readonly catalogueV2: CatalogueV2;
private readonly catalogueMigration: CatalogueMigration;
readonly enumerationActive = writable(false);
readonly catalogue = this.catalogueState.catalogue;
readonly migrationProgress = this.catalogueState.migrationProgress;
readonly manifests = this.catalogueState.manifests;
constructor(dependencies: CatalogueOperationsDependencies) {
this.dependencies = dependencies;
this.catalogueV1 = new CatalogueV1({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: {
getPath: (entry) => this.getPath(entry),
},
log: (message, level, key) => this._log(message, level, key),
state: this.catalogueState,
});
this.catalogueV2 = new CatalogueV2({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
log: (message, level, key) => this._log(message, level, key),
state: this.catalogueState,
codec: V2_CODEC,
});
this.catalogueMigration = new CatalogueMigration({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: {
path2id: (path) => this.path2id(path),
},
log: (message, level, key) => this._log(message, level, key),
snapshotPersistence: this.dependencies.snapshotPersistence,
refreshV1: async (showMessage, path) => await this.updatePluginList(showMessage, path),
codec: MIGRATION_CODEC,
});
this.scanProgress.onChanged(this.pluginScanningChanged);
// The single queue deliberately chooses V1 loading or migration when
// each item starts. Settings can change after enqueueing an item.
this.pluginScanProcessor = new QueueProcessor(
async (v: AnyEntry[]) => {
const plugin = v[0];
if (this.dependencies.getSettings().usePluginSyncV2) {
await this.migrateV1ToV2(false, plugin);
return [];
}
await this.catalogueV1.load(plugin, READ_CODEC);
return [];
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 10,
delay: 100,
yieldThreshold: 10,
maintainDelay: false,
totalRemainingReactiveSource: this.scanProgress,
}
).startPipeline();
}
private get settings() {
return this.dependencies.getSettings();
}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
private async path2id(filename: FilePathWithPrefix) {
return await this.dependencies.path.path2id(filename);
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
/** The current manifest lookup passed to V2 display rows. */
get manifestLookup() {
return this.catalogueState.manifestLookup;
}
/** Returns every row matching a document path, preserving legacy duplicates. */
findPlugins(documentPath: FilePathWithPrefix | string): readonly IPluginDataExDisplay[] {
return this.catalogueState.findPlugins(documentPath);
}
dispose(): void {
this.pluginScanProcessor.terminate();
this.scanProgress.offChanged(this.pluginScanningChanged);
this.enumerationActive.set(false);
this.dependencies.publishScanCount(0);
}
async reloadPluginList(showMessage: boolean): Promise<void> {
this.catalogueState.clearForReload();
await this.updatePluginList(showMessage);
}
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
if (!this.settings.usePluginSync) {
this.pluginScanProcessor.clearQueue();
this.catalogueState.clearForDisabledRefresh();
return;
}
try {
this.catalogueState.beginUpdate();
const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : "";
const plugins = updatedDocumentPath
? this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", {
include_docs: true,
key: updatedDocumentId,
limit: 1,
})
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) {
if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
continue;
}
const path = v.path || this.getPath(v);
if (updatedDocumentPath && updatedDocumentPath != path) continue;
this.pluginScanProcessor.enqueue(v);
}
} finally {
this.enumerationActive.set(false);
this.catalogueState.endUpdate();
}
this.enumerationActive.set(false);
}
async createPluginDataExFileV2(
unifiedPathV2: FilePathWithPrefix,
loaded?: LoadedEntry
): Promise<false | LoadedEntryPluginDataExFile> {
return await this.catalogueV2.createPluginDataExFileV2(unifiedPathV2, loaded);
}
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix) {
return this.catalogueV2.createPluginDataFromV2(unifiedPathV2);
}
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
await this.catalogueV2.updatePluginListV2(showMessage, unifiedFilenameWithKey);
}
private async migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise<void> {
await this.catalogueMigration.migrateV1ToV2(showMessage, entry);
}
}
@@ -0,0 +1,222 @@
import { get } from "svelte/store";
import { beforeEach, describe, expect, it, vi } from "vitest";
const testState = vi.hoisted(() => ({
processors: [] as Array<{
clearQueue: ReturnType<typeof vi.fn>;
enqueue: ReturnType<typeof vi.fn>;
terminate: ReturnType<typeof vi.fn>;
startPipeline: ReturnType<typeof vi.fn>;
process: (entries: AnyEntry[]) => Promise<AnyEntry[]>;
}>,
reactiveSources: [] as Array<{
value: number;
onChanged: ReturnType<typeof vi.fn>;
offChanged: ReturnType<typeof vi.fn>;
}>,
}));
vi.mock("@/deps.ts", () => ({
parseYaml: vi.fn(),
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
}));
vi.mock("@/common/utils.ts", () => ({
fireAndForget: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("octagonal-wheels/concurrency/processor", () => ({
QueueProcessor: class QueueProcessor {
clearQueue = vi.fn();
enqueue = vi.fn();
terminate = vi.fn();
startPipeline = vi.fn(() => this);
process: (entries: AnyEntry[]) => Promise<AnyEntry[]>;
constructor(process: (entries: AnyEntry[]) => Promise<AnyEntry[]>) {
this.process = process;
testState.processors.push(this);
}
},
}));
vi.mock("octagonal-wheels/dataobject/reactive", () => ({
reactiveSource: vi.fn((value: number) => {
const source = {
value,
onChanged: vi.fn(),
offChanged: vi.fn(),
};
testState.reactiveSources.push(source);
return source;
}),
}));
import { scheduleTask } from "@/common/utils.ts";
import type {
AnyEntry,
DocumentID,
FilePathWithPrefix,
LoadedEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { CatalogueOperations, type CatalogueOperationsDependencies } from "./catalogueOperations.ts";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
const codec = createCustomisationSyncCodec({
digestHash,
parseYaml: () => undefined,
});
const v2Path = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
const v1Path = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
function loadedEntry(): LoadedEntry {
const data = `${codec.dummyHead}${codec.dummyEnd}${btoa("example data")}`;
return {
_id: "entry-id",
_rev: "1-a",
path: v2Path,
type: "plain",
datatype: "plain",
data,
ctime: 10,
mtime: 20,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function createOperations() {
const settings = { usePluginSync: true, usePluginSyncV2: true };
const database = {
findEntries: vi.fn(async function* () {
// No refresh entries are needed by the focused V2 test.
}),
getDBEntry: vi.fn(async () => loadedEntry()),
putDBEntry: vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" })),
};
const deleteConfigOnDatabase = vi.fn(
async (): Promise<SnapshotPersistenceResult<boolean>> => ({
value: true,
status: "missing",
refreshes: [],
})
);
const snapshotPersistence = {
deleteConfigOnDatabase,
};
const dependencies: CatalogueOperationsDependencies = {
getSettings: () => settings,
getLocalDatabase: () => database,
path: {
getPath: (entry) => entry.path,
path2id: async (path) => path as unknown as DocumentID,
},
log: vi.fn(),
snapshotPersistence,
publishScanCount: vi.fn(),
};
return {
database,
dependencies,
settings,
operations: new CatalogueOperations(dependencies),
snapshotPersistence,
};
}
describe("Customisation Sync catalogue operations", () => {
beforeEach(() => {
testState.processors.length = 0;
testState.reactiveSources.length = 0;
vi.clearAllMocks();
});
it("owns and releases the active shared scan processor and its progress subscription", () => {
const { dependencies, operations } = createOperations();
operations.enumerationActive.set(true);
operations.dispose();
expect(testState.processors).toHaveLength(1);
expect(testState.processors[0].terminate).toHaveBeenCalledOnce();
expect(testState.reactiveSources[0].offChanged).toHaveBeenCalledOnce();
expect(get(operations.enumerationActive)).toBe(false);
expect(dependencies.publishScanCount).toHaveBeenCalledWith(0);
});
it("chooses loading or migration when queued work starts", async () => {
const { operations, settings } = createOperations();
const migrate = vi
.spyOn(
operations as unknown as { migrateV1ToV2: (showMessage: boolean, entry: AnyEntry) => Promise<void> },
"migrateV1ToV2"
)
.mockResolvedValue(undefined);
const entry = { path: v1Path, deleted: false } as AnyEntry;
settings.usePluginSyncV2 = false;
await testState.processors[0].process([entry]);
expect(migrate).not.toHaveBeenCalled();
settings.usePluginSyncV2 = true;
await testState.processors[0].process([entry]);
expect(migrate).toHaveBeenCalledOnce();
operations.dispose();
});
it("keeps V2 row publication delayed behind the process-global refresh task", async () => {
const { operations } = createOperations();
await operations.updatePluginListV2(false, v2Path);
expect(get(operations.catalogue)).toEqual([]);
expect(scheduleTask).toHaveBeenCalledWith("updatePluginListV2", 100, expect.any(Function));
const publish = vi.mocked(scheduleTask).mock.calls[0]?.[2] as (() => void) | undefined;
publish?.();
expect(get(operations.catalogue)).toHaveLength(1);
expect(get(operations.catalogue)[0]).toMatchObject({
documentPath: "ix:device-a/PLUGIN_DATA/example.md",
files: [{ filename: "plugins/example/data.json" }],
});
operations.dispose();
});
it("uses the persistence deletion outcome and explicitly awaits migration refresh", async () => {
const { database, operations, snapshotPersistence } = createOperations();
const loadedV1 = {
...loadedEntry(),
path: v1Path,
data: codec.serialize({
category: "CONFIG",
name: "app.json",
term: "device-a",
files: [{ filename: "app.json", data: [btoa("config")], mtime: 10, size: 6 }],
mtime: 10,
}),
} as LoadedEntry;
database.getDBEntry.mockResolvedValue(loadedV1);
snapshotPersistence.deleteConfigOnDatabase.mockResolvedValue({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: v1Path }],
});
const refresh = vi.spyOn(operations, "updatePluginList").mockResolvedValue(undefined);
await (
operations as unknown as {
migrateV1ToV2(showMessage: boolean, entry: LoadedEntry): Promise<void>;
}
).migrateV1ToV2(false, { path: v1Path, deleted: false } as LoadedEntry);
expect(database.putDBEntry).toHaveBeenCalledOnce();
expect(snapshotPersistence.deleteConfigOnDatabase).toHaveBeenCalledWith(v1Path);
expect(refresh).toHaveBeenCalledWith(false, v1Path);
operations.dispose();
});
});
@@ -9,11 +9,12 @@ import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
/**
* Owns the transient catalogue projection used by Customisation Sync.
*
* The database and storage operations remain in the context. This owner only
* coordinates the in-memory rows, their reactive publications, manifest
* lookup, and the update counter which is derived from those operations.
* The database and storage operations remain in the catalogue modules. This
* owner only coordinates the in-memory rows, their reactive publications,
* manifest lookup, and the update counter which is derived from those
* operations.
*/
export class CustomisationSyncCatalogueState {
export class CatalogueState {
private catalogueRows: IPluginDataExDisplay[] = [];
private readonly manifestByKey = new Map<string, PluginManifest>();
private readonly loadedManifestMTimeByKey = new Map<string, number>();
@@ -33,7 +34,7 @@ export class CustomisationSyncCatalogueState {
return this.loadedManifestMTimeByKey;
}
/** Returns the authoritative row for a V1 document path, when present. */
/** Returns the authoritative row for a document path, when present. */
findPlugin(documentPath: FilePathWithPrefix | string): IPluginDataExDisplay | undefined {
return this.catalogueRows.find((entry) => entry.documentPath == documentPath);
}
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { CustomisationSyncCatalogueState } from "./customisationSyncCatalogueState.ts";
import { CatalogueState } from "./catalogueState.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
@@ -30,7 +30,7 @@ function file(filename: string, mtime: number): LoadedEntryPluginDataExFile {
describe("Customisation Sync catalogue state", () => {
it("publishes V1 replacement and keeps V2 replacement delayed", async () => {
const state = new CustomisationSyncCatalogueState();
const state = new CatalogueState();
const setCatalogue = vi.spyOn(state.catalogue, "set");
const row = display();
@@ -54,7 +54,7 @@ describe("Customisation Sync catalogue state", () => {
});
it("retains the first parsed manifest and records failed mtimes", () => {
const state = new CustomisationSyncCatalogueState();
const state = new CatalogueState();
const first = { name: "First", version: "1.0.0" } as PluginManifest;
const parseManifest = vi.fn(() => first);
@@ -69,7 +69,7 @@ describe("Customisation Sync catalogue state", () => {
expect(state.loadedManifestMTime.get("device-a/plugins/example")).toBe(20);
expect(parseManifest).toHaveBeenCalledOnce();
const failedState = new CustomisationSyncCatalogueState();
const failedState = new CatalogueState();
const onParseError = vi.fn();
const failure = new SyntaxError("invalid");
failedState.processManifest(
@@ -88,7 +88,7 @@ describe("Customisation Sync catalogue state", () => {
});
it("clears rows and loaded mtimes on reload while retaining manifest lookup", () => {
const state = new CustomisationSyncCatalogueState();
const state = new CatalogueState();
const key = "device-a/plugins/example";
state.processManifest(key, 20, () => ({ name: "Example" }) as PluginManifest);
state.replacePlugin(display());
@@ -102,7 +102,7 @@ describe("Customisation Sync catalogue state", () => {
});
it("keeps manifest caches through the narrower disabled refresh", () => {
const state = new CustomisationSyncCatalogueState();
const state = new CatalogueState();
const key = "device-a/plugins/example";
state.processManifest(key, 20, () => ({ name: "Example" }) as PluginManifest);
state.replacePlugin(display());
@@ -115,7 +115,7 @@ describe("Customisation Sync catalogue state", () => {
});
it("tracks V2 updates through migration progress", () => {
const state = new CustomisationSyncCatalogueState();
const state = new CatalogueState();
state.beginUpdate();
state.beginUpdate();
+50
View File
@@ -0,0 +1,50 @@
import type { AnyEntry, LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { CatalogueState } from "./catalogueState.ts";
import { loadCustomisationDisplayData, type CustomisationSyncReadCodec } from "./customisationSyncReadOperations.ts";
type CatalogueV1Database = Pick<LiveSyncLocalDB, "getDBEntry" | "putDBEntry">;
export type CatalogueV1Dependencies = {
getLocalDatabase(): CatalogueV1Database;
path: Pick<IPathService, "getPath">;
log: LogFunction;
state: CatalogueState;
};
/** Loads and publishes legacy V1 catalogue rows. */
export class CatalogueV1 {
constructor(private readonly dependencies: CatalogueV1Dependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
async load(entry: AnyEntry, codec: CustomisationSyncReadCodec): Promise<void> {
const path = entry.path || this.dependencies.path.getPath(entry);
const oldEntry = this.dependencies.state.findPlugin(path);
if (oldEntry && oldEntry.mtime == entry.mtime) return;
try {
const pluginData = await loadCustomisationDisplayData(
{
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: this.dependencies.path,
log: this.dependencies.log,
},
path,
codec
);
if (pluginData) {
this.dependencies.state.replacePlugin(pluginData);
}
// Failed to load
} catch (ex) {
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix, LoadedEntry, LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { scheduleTask } from "@/common/utils.ts";
import { CatalogueState } from "./catalogueState.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import { parseCustomisationSyncV2DocumentPath } from "./customisationSyncPaths.ts";
import { decodeCustomisationSyncV2File, loadCustomisationV2Entry } from "./customisationSyncReadOperations.ts";
import type { LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
type CatalogueV2Database = Pick<LiveSyncLocalDB, "getDBEntry">;
export type CatalogueV2Dependencies = {
getLocalDatabase(): CatalogueV2Database;
log: LogFunction;
state: CatalogueState;
codec: { dummyEnd: string };
};
/** Builds, updates, and publishes V2 catalogue rows and manifests. */
export class CatalogueV2 {
constructor(private readonly dependencies: CatalogueV2Dependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
get manifestLookup() {
return this.dependencies.state.manifestLookup;
}
async createPluginDataExFileV2(
unifiedPathV2: FilePathWithPrefix,
loaded?: LoadedEntry
): Promise<false | LoadedEntryPluginDataExFile> {
// Compatibility: a caller-supplied entry bypasses the database lookup
// and the isLoadedEntry check performed by loadCustomisationV2Entry.
const loadedEntry =
loaded ??
(await loadCustomisationV2Entry(
{
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
log: this.dependencies.log,
},
unifiedPathV2
));
if (!loadedEntry) return false;
const { confKey, file, isManifest } = decodeCustomisationSyncV2File(
unifiedPathV2,
loadedEntry,
this.dependencies.codec.dummyEnd
);
if (isManifest) {
this.dependencies.state.processManifest(
confKey,
file.mtime,
() => JSON.parse(file.data[0]) as PluginManifest,
(error) => {
this._log(
`The file ${loadedEntry.path} seems to manifest, but could not be decoded as JSON`,
LOG_LEVEL_VERBOSE
);
this._log(error, LOG_LEVEL_VERBOSE);
}
);
}
return file;
}
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined {
const { category, device, key, pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedPathV2);
if (category == "") return;
return new PluginDataExDisplayV2(
{
documentPath: pathV1,
category,
name: key,
term: `${device}`,
files: [],
mtime: 0,
},
this.dependencies.state.manifestLookup
);
}
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
// The public parameter is retained for the established catalogue
// signature; V2 publication has never used it.
void showMessage;
try {
this.dependencies.state.beginUpdate();
const { pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedFilenameWithKey);
const oldEntry = this.dependencies.state.findPlugin(pathV1);
let entry: PluginDataExDisplayV2 | undefined;
// Compatibility question: when a V1 row is found first for this
// logical path, the inherited implementation constructs a fresh
// V2 row rather than looking for another existing V2 row. Preserve
// that selection until mixed-format catalogue races are covered.
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
entry = this.createPluginDataFromV2(unifiedFilenameWithKey);
} else {
entry = oldEntry;
}
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
// Compatibility: the inherited update always re-adds an empty V2
// row after deleting its final file.
await this.dependencies.state.updateV2Plugin(entry, file, unifiedFilenameWithKey);
scheduleTask("updatePluginListV2", 100, () => {
this.dependencies.state.publishCatalogue();
});
} finally {
this.dependencies.state.endUpdate();
}
}
}
@@ -64,27 +64,17 @@ describe("CustomisationSyncContext commands", () => {
expect(updatePluginList).toHaveBeenCalledWith(false, "ix:example");
});
it("releases every owned processor and reactive subscription", () => {
it("delegates catalogue resource release during disposal", () => {
const hideConfigurationNotice = vi.fn();
const publishScanCount = vi.fn();
const periodicPluginSweepProcessor = { disable: vi.fn() };
const pluginScanProcessor = { terminate: vi.fn() };
const pluginScanProcessorV2 = { terminate: vi.fn() };
const pluginScanningChanged = vi.fn();
const offChanged = vi.fn();
const setEnumerationActive = vi.fn();
const catalogueOperations = { dispose: vi.fn() };
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
hideConfigurationNotice,
publishScanCount,
}),
periodicPluginSweepProcessor,
pluginScanProcessor,
pluginScanProcessorV2,
pluginScanningChanged,
scanProgress: { offChanged },
enumerationActive: { set: setEnumerationActive },
catalogueOperations,
});
configSync.dispose();
@@ -92,11 +82,7 @@ describe("CustomisationSyncContext commands", () => {
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
expect(hideConfigurationNotice).toHaveBeenCalledOnce();
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
expect(pluginScanProcessor.terminate).toHaveBeenCalledOnce();
expect(pluginScanProcessorV2.terminate).toHaveBeenCalledOnce();
expect(offChanged).toHaveBeenCalledWith(pluginScanningChanged);
expect(setEnumerationActive).toHaveBeenCalledWith(false);
expect(publishScanCount).toHaveBeenCalledWith(0);
expect(catalogueOperations.dispose).toHaveBeenCalledOnce();
});
it("characterises the inherited setting-realisation gates pending separate review", async () => {
@@ -90,9 +90,9 @@ describe("Customisation Sync raw-event admission", () => {
it("rejects a path outside the recognised Customisation Sync categories", async () => {
const { configSync, ownsLocalFile } = createConfigSync();
await expect(configSync.serviceHandlers.processOptionalFileEvent(".obsidian/workspace" as FilePath)).resolves.toBe(
false
);
await expect(
configSync.serviceHandlers.processOptionalFileEvent(".obsidian/workspace" as FilePath)
).resolves.toBe(false);
expect(ownsLocalFile).not.toHaveBeenCalled();
expect(scheduleTask).not.toHaveBeenCalled();
});
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
@@ -18,116 +17,21 @@ vi.mock("@/common/utils.ts", () => ({
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
const V1_PATH = "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix;
const V2_PATH = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
describe("Customisation Sync scan delegation", () => {
it("preserves the public scan argument and result through the focused owner", async () => {
const scanAllConfigFiles = vi.fn(async (_showMessage: boolean) => undefined);
const context = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(context, { scanOperations: { scanAllConfigFiles } });
function asyncEntries(entries: object[]) {
return {
async *[Symbol.asyncIterator]() {
yield* entries;
},
};
}
await expect(context.scanAllConfigFiles(true)).resolves.toBeUndefined();
function createConfigSync(options: {
useV2: boolean;
localFiles?: FilePath[];
databasePaths?: FilePathWithPrefix[];
ownsLocalFile?: boolean;
ownsLocalDocument?: boolean;
}) {
const storeCustomizationFiles = vi.fn(async () => true);
const storeCustomisationFileV2 = vi.fn(async () => true);
const deleteConfigOnDatabase = vi.fn(async () => true);
const ownsLocalFile = vi.fn(() => options.ownsLocalFile ?? true);
const ownsLocalDocument = vi.fn(() => options.ownsLocalDocument ?? true);
const databaseEntries = (options.databasePaths ?? []).map((path) => ({ _id: path, path }));
const localDatabase = {
findEntries: vi.fn(() => asyncEntries(databaseEntries)),
allDocsRaw: vi.fn(async () => ({
rows: databaseEntries.map((doc) => ({ doc: { ...doc, deleted: false } })),
})),
};
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
getConfigDir: () => ".obsidian",
getSettings: () => ({
usePluginSync: true,
usePluginSyncV2: options.useV2,
usePluginEtc: true,
pluginSyncExtendedSetting: {},
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
watchInternalFileChanges: false,
notifyPluginOrSettingUpdated: false,
}),
getLocalDatabase: () => localDatabase as never,
ownsLocalFile,
ownsLocalDocument,
}),
scanInternalFiles: vi.fn(async () => options.localFiles ?? []),
pathOperations: {
isTargetPath: vi.fn(() => true),
filenameToUnifiedKey: vi.fn(() => V1_PATH),
filenameWithUnifiedKey: vi.fn(() => V2_PATH),
unifiedKeyPrefixOfTerminal: vi.fn(() => "ix:device-a/"),
},
getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path),
storeCustomizationFiles,
storeCustomisationFileV2,
deleteConfigOnDatabase,
updatePluginList: vi.fn(async () => undefined),
_log: vi.fn(),
});
return {
configSync,
deleteConfigOnDatabase,
ownsLocalDocument,
ownsLocalFile,
storeCustomisationFileV2,
storeCustomizationFiles,
};
}
describe("Customisation Sync scan ownership", () => {
it.each([
["V1", false],
["V2", true],
] as const)("does not store a %s local file assigned to another owner", async (_label, useV2) => {
const fixture = createConfigSync({ useV2, localFiles: [PATH], ownsLocalFile: false });
await fixture.configSync.scanAllConfigFiles(false);
expect(fixture.ownsLocalFile).toHaveBeenCalledWith(PATH);
expect(fixture.storeCustomizationFiles).not.toHaveBeenCalled();
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
});
it("does not create a deletion for an existing V1 document which is no longer locally owned", async () => {
const fixture = createConfigSync({
useV2: false,
databasePaths: [V1_PATH],
ownsLocalDocument: false,
});
await fixture.configSync.scanAllConfigFiles(false);
expect(fixture.ownsLocalDocument).toHaveBeenCalledWith(V1_PATH);
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
expect(scanAllConfigFiles).toHaveBeenCalledOnce();
expect(scanAllConfigFiles).toHaveBeenCalledWith(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,16 @@ vi.mock("@/common/translation", () => ({
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
vi.mock("octagonal-wheels/concurrency/processor", () => ({
QueueProcessor: class QueueProcessor {
clearQueue = vi.fn();
enqueue = vi.fn();
terminate = vi.fn();
startPipeline() {
return this;
}
},
}));
import {
LOG_LEVEL_VERBOSE,
@@ -34,7 +44,7 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { CustomisationSyncCatalogueState } from "./customisationSyncCatalogueState.ts";
import { CatalogueOperations } from "./catalogueOperations.ts";
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
@@ -64,22 +74,31 @@ function loadedManifest(manifestSource: string, mtime: number): LoadedEntry {
function createContext() {
const log = vi.fn();
const catalogueState = new CustomisationSyncCatalogueState();
const pluginManifests = catalogueState.manifestLookup;
const loadedManifest_mTime = catalogueState.loadedManifestMTime;
const setManifests = vi.spyOn(catalogueState.manifests, "set");
const setCatalogue = vi.spyOn(catalogueState.catalogue, "set");
const snapshotPersistence = {
deleteConfigOnDatabase: vi.fn(async () => ({ value: true, status: "missing" as const, refreshes: [] })),
};
const catalogueOperations = new CatalogueOperations({
...createCustomisationSyncTestDependencies({
log,
getLocalDatabase: () => ({ getDBEntry: async () => false }) as never,
}),
snapshotPersistence,
publishScanCount: vi.fn(),
});
const pluginManifests = catalogueOperations.manifestLookup;
const setManifests = vi.spyOn(catalogueOperations.manifests, "set");
const setCatalogue = vi.spyOn(catalogueOperations.catalogue, "set");
const context = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(context, {
dependencies: createCustomisationSyncTestDependencies({
log,
getLocalDatabase: () => ({ getDBEntry: async () => false }) as never,
}),
catalogueState,
catalogueOperations,
});
return {
catalogueOperations,
context,
loadedManifest_mTime,
log,
pluginManifests,
setCatalogue,
@@ -89,7 +108,7 @@ function createContext() {
describe("compatibility: Customisation Sync V2 manifest state", () => {
it("keeps the first parsed manifest when a later file has a different mtime", async () => {
const { context, loadedManifest_mTime, pluginManifests, setManifests } = createContext();
const { catalogueOperations, context, pluginManifests, setManifests } = createContext();
await context.testing.createPluginDataExFileV2(
path,
@@ -101,12 +120,12 @@ describe("compatibility: Customisation Sync V2 manifest state", () => {
);
expect(pluginManifests.get(confKey)).toMatchObject({ name: "First", version: "1.0.0" });
expect(loadedManifest_mTime.get(confKey)).toBe(20);
expect(setManifests).toHaveBeenCalledOnce();
catalogueOperations.dispose();
});
it("records a failed manifest mtime and does not retry the same revision", async () => {
const { context, loadedManifest_mTime, log, pluginManifests, setCatalogue } = createContext();
const { catalogueOperations, context, log, pluginManifests, setCatalogue } = createContext();
const invalid = loadedManifest("{invalid", 20);
await expect(context.testing.createPluginDataExFileV2(path, invalid)).resolves.toMatchObject({
@@ -115,7 +134,6 @@ describe("compatibility: Customisation Sync V2 manifest state", () => {
await context.testing.createPluginDataExFileV2(path, invalid);
expect(pluginManifests.has(confKey)).toBe(false);
expect(loadedManifest_mTime.get(confKey)).toBe(20);
expect(log).toHaveBeenCalledTimes(2);
expect(log).toHaveBeenNthCalledWith(
1,
@@ -125,5 +143,6 @@ describe("compatibility: Customisation Sync V2 manifest state", () => {
);
expect(log).toHaveBeenNthCalledWith(2, expect.any(SyntaxError), LOG_LEVEL_VERBOSE, undefined);
expect(setCatalogue).toHaveBeenCalledOnce();
catalogueOperations.dispose();
});
});
@@ -121,31 +121,18 @@ describe("CustomisationSyncContext dialogue view", () => {
expect(askString).toHaveBeenCalledWith("Duplicate", "device name", "");
});
it("keeps file-level comparison clones and duplication inside the view boundary", async () => {
it("delegates file-level comparison and duplication to the application owner", async () => {
const { configSync } = createConfigSync();
const compareUsingDisplayData = vi.fn<
(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach?: boolean) => Promise<boolean>
>(async () => true);
const storeCustomizationFiles = vi.fn(async () => true);
const updatePluginList = vi.fn(async () => undefined);
const compareFileUsingDisplayData = vi.fn(async () => true);
const duplicateData = vi.fn(async () => undefined);
Object.assign(configSync, {
compareUsingDisplayData,
pathOperations: {
filenameToUnifiedKey: vi.fn(() => "ix:device-b/PLUGIN_DATA/example.md"),
},
storeCustomizationFiles,
updatePluginList,
applicationOperations: { compareFileUsingDisplayData, duplicateData },
});
await expect(configSync.compareFileUsingDisplayData(display, display, "data.json")).resolves.toBe(true);
const [left, right, compareEach] = compareUsingDisplayData.mock.calls[0];
expect(left.files.map((file) => file.filename)).toEqual(["data.json"]);
expect(right.files.map((file) => file.filename)).toEqual(["data.json"]);
expect(compareEach).toBe(true);
expect(display.files).toHaveLength(2);
expect(compareFileUsingDisplayData).toHaveBeenCalledWith(display, display, "data.json");
await configSync.duplicateData(display, "device-b");
expect(storeCustomizationFiles).toHaveBeenCalledWith(".obsidian/data.json", "device-b");
expect(updatePluginList).toHaveBeenCalledWith(false, "ix:device-b/PLUGIN_DATA/example.md");
expect(duplicateData).toHaveBeenCalledWith(display, "device-b");
});
});
+197
View File
@@ -0,0 +1,197 @@
import type {
AnyEntry,
FilePath,
FilePathWithPrefix,
InternalFileEntry,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { $msg } from "@/common/translation";
import { ICXHeader } from "@/common/types.ts";
import {
collectOptionalFileSyncFiles,
type OptionalFileSyncFileTreeDependencies,
} from "@/features/optionalFileSyncFileTree.ts";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import type { SnapshotOperations } from "./snapshotOperations.ts";
type ScanSettings = Pick<ObsidianLiveSyncSettings, "usePluginSyncV2">;
type ScanDatabase = Pick<LiveSyncLocalDB, "allDocsRaw" | "findEntries">;
type ScanPathOperations = Pick<
CustomisationSyncPathOperations,
"isTargetPath" | "filenameToUnifiedKey" | "filenameWithUnifiedKey" | "unifiedKeyPrefixOfTerminal"
> & {
getPath(entry: AnyEntry): FilePathWithPrefix;
};
type ScanSnapshotOperations = Pick<
SnapshotOperations,
"storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type ScanCatalogueOperations = Pick<CatalogueOperations, "updatePluginList">;
export type ScanOperationsDependencies = OptionalFileSyncFileTreeDependencies & {
getSettings(): ScanSettings;
getLocalDatabase(): ScanDatabase;
path: ScanPathOperations;
log: LogFunction;
getConfigDir(): string;
getDeviceAndVaultName(): string;
ownsLocalFile(path: FilePath): boolean;
ownsLocalDocument(path: FilePathWithPrefix): boolean;
snapshotOperations: ScanSnapshotOperations;
catalogueOperations: ScanCatalogueOperations;
};
/**
* Owns Customisation Sync file enumeration and reconciliation with the local
* database. Snapshot writes and catalogue publication remain explicit ports so
* scans do not depend on the context or its lifecycle.
*/
export class ScanOperations {
constructor(private readonly dependencies: ScanOperationsDependencies) {}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
async scanInternalFiles(): Promise<FilePath[]> {
const filenames = (
await collectOptionalFileSyncFiles(this.dependencies, this.dependencies.getConfigDir(), {
maxDepth: 2,
onError: (path, error) => {
this._log(`Could not traverse(CustomisationSync):${path}`, LOG_LEVEL_INFO);
this._log(error, LOG_LEVEL_VERBOSE);
},
})
)
.filter((e) => e.startsWith("."))
.filter((e) => !e.startsWith(".trash"));
return filenames as FilePath[];
}
async scanAllConfigFiles(showMessage: boolean): Promise<void> {
await shareRunningResult("scanAllConfigFiles", async () => {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
this._log("Scanning customizing files.", logLevel, "scan-all-config");
const term = this.dependencies.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
return;
}
const filesAll = await this.scanInternalFiles();
if (this.dependencies.getSettings().usePluginSyncV2) {
await this.scanV2ConfigFiles(filesAll, term);
} else {
await this.scanV1ConfigFiles(filesAll, term);
}
});
}
private async scanV2ConfigFiles(filesAll: readonly FilePath[], term: string): Promise<void> {
const filesAllUnified = filesAll
.filter((e) => this.dependencies.path.isTargetPath(e))
.map((e) => [this.dependencies.path.filenameWithUnifiedKey(e, term), e] as [FilePathWithPrefix, FilePath]);
const localFileMap = new Map(filesAllUnified.map((e) => [e[0], e[1]]));
const prefix = this.dependencies.path.unifiedKeyPrefixOfTerminal(term);
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
include_docs: true,
});
const tasks = [] as (() => Promise<void>)[];
const concurrency = 10;
const semaphore = Semaphore(concurrency);
for await (const item of entries) {
if (item.path.indexOf("%") !== -1) {
continue;
}
tasks.push(async () => {
const releaser = await semaphore.acquire();
try {
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
const localPath = localFileMap.get(unifiedFilenameWithKey);
if (localPath) {
if (this.dependencies.ownsLocalFile(localPath)) {
await this.dependencies.snapshotOperations.storeCustomisationFileV2(localPath, term);
}
localFileMap.delete(unifiedFilenameWithKey);
} else if (this.dependencies.ownsLocalDocument(this.getPath(item))) {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(unifiedFilenameWithKey);
}
} catch (ex) {
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
} finally {
releaser();
}
});
}
await Promise.all(tasks.map((e) => e()));
// Extra files
const taskExtra = [] as (() => Promise<void>)[];
for (const [, filePath] of localFileMap) {
if (!this.dependencies.ownsLocalFile(filePath)) continue;
taskExtra.push(async () => {
const releaser = await semaphore.acquire();
try {
await this.dependencies.snapshotOperations.storeCustomisationFileV2(filePath, term);
} catch (ex) {
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
} finally {
releaser();
}
});
}
await Promise.all(taskExtra.map((e) => e()));
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginList(false));
}
private async scanV1ConfigFiles(filesAll: readonly FilePath[], term: string): Promise<void> {
const files = filesAll
.filter((e) => this.dependencies.path.isTargetPath(e))
.map((e) => ({ key: this.dependencies.path.filenameToUnifiedKey(e), file: e }));
const virtualPathsOfLocalFiles = [...new Set(files.map((e) => e.key))];
const filesOnDB = (
(
await this.localDatabase.allDocsRaw({
startkey: ICXHeader + "",
endkey: `${ICXHeader}\u{10ffff}`,
include_docs: true,
})
).rows.map((e) => e.doc) as InternalFileEntry[]
).filter((e) => !e.deleted);
let deleteCandidate = filesOnDB.map((e) => this.getPath(e)).filter((e) => e.startsWith(`${ICXHeader}${term}/`));
for (const vp of virtualPathsOfLocalFiles) {
const p = files.find((e) => e.key == vp)?.file;
if (!p) {
this._log(`scanAllConfigFiles - File not found: ${vp}`, LOG_LEVEL_VERBOSE);
continue;
}
if (this.dependencies.ownsLocalFile(p)) {
await this.dependencies.snapshotOperations.storeCustomizationFiles(p);
}
deleteCandidate = deleteCandidate.filter((e) => e != vp);
}
for (const vp of deleteCandidate) {
if (this.dependencies.ownsLocalDocument(vp)) {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(vp);
}
}
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginList(false));
}
}
@@ -0,0 +1,327 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type FilePath,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const asyncHarness = vi.hoisted(() => ({
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
fireAndForget: asyncHarness.fireAndForget,
};
});
import { ScanOperations, type ScanOperationsDependencies } from "./scanOperations.ts";
type ScanEntry = {
_id: FilePathWithPrefix;
path: FilePathWithPrefix;
deleted?: boolean;
};
type FixtureOptions = {
useV2?: boolean;
usePluginSync?: boolean;
term?: string;
files?: FilePath[];
targetFiles?: FilePath[];
databaseEntries?: ScanEntry[];
v1Paths?: Record<string, FilePathWithPrefix>;
v2Paths?: Record<string, FilePathWithPrefix>;
ownsLocalFile?: (path: FilePath) => boolean;
ownsLocalDocument?: (path: FilePathWithPrefix) => boolean;
listFiles?: (path: string) => Promise<{ files: readonly string[]; folders: readonly string[] }>;
};
function asyncEntries<T>(entries: readonly T[]) {
return {
async *[Symbol.asyncIterator]() {
yield* entries;
},
};
}
function createFixture(options: FixtureOptions = {}) {
const files = options.files ?? [];
const term = options.term ?? "device-a";
const databaseEntries = options.databaseEntries ?? [];
const v1Paths = options.v1Paths ?? {};
const v2Paths = options.v2Paths ?? {};
const log = vi.fn();
const allDocsRaw = vi.fn(async () => ({
rows: databaseEntries.map((doc) => ({ id: doc._id, doc })),
}));
const findEntries = vi.fn(() => asyncEntries(databaseEntries));
const storeCustomisationFileV2 = vi.fn(async (_path: FilePath, _term: string) => true);
const storeCustomizationFiles = vi.fn(async (_path: FilePath) => true);
const deleteConfigOnDatabase = vi.fn(async (_path: FilePathWithPrefix) => true);
const updatePluginList = vi.fn(async (_showMessage: boolean) => undefined);
const listFiles = vi.fn(
options.listFiles ??
(async () => ({ files, folders: [] }) as { files: readonly string[]; folders: readonly string[] })
);
const dependencies = {
listFiles,
getSettings: () => ({ usePluginSyncV2: options.useV2 ?? false, usePluginSync: options.usePluginSync ?? true }),
getLocalDatabase: () => ({ allDocsRaw, findEntries }),
path: {
isTargetPath: (path: string) => options.targetFiles?.includes(path as FilePath) ?? true,
filenameToUnifiedKey: (path: string) =>
v1Paths[path] ?? (`ix:${term}/CONFIG/${path.split("/").pop()}.md` as FilePathWithPrefix),
filenameWithUnifiedKey: (path: string) =>
v2Paths[path] ??
(`ix:${term}/CONFIG/${path.split("/").pop()}%${path.split("/").pop()}` as FilePathWithPrefix),
unifiedKeyPrefixOfTerminal: (termOverride?: string) => `ix:${termOverride ?? term}/` as FilePathWithPrefix,
getPath: (entry: ScanEntry) => entry.path,
},
log,
getConfigDir: () => ".obsidian",
getDeviceAndVaultName: () => term,
ownsLocalFile: options.ownsLocalFile ?? (() => true),
ownsLocalDocument: options.ownsLocalDocument ?? (() => true),
snapshotOperations: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations: { updatePluginList },
} as unknown as ScanOperationsDependencies;
return {
operations: new ScanOperations(dependencies),
dependencies,
listFiles,
log,
allDocsRaw,
findEntries,
snapshot: { storeCustomisationFileV2, storeCustomizationFiles, deleteConfigOnDatabase },
catalogue: { updatePluginList },
};
}
describe("ScanOperations", () => {
beforeEach(() => {
asyncHarness.fireAndForget.mockClear();
});
it("filters the bounded file tree and logs traversal errors", async () => {
const traversalError = new Error("cannot read folder");
const listFiles = vi.fn(async (path: string) => {
switch (path) {
case ".obsidian":
return {
files: [".obsidian/app.json", "settings.json", ".trash/root.json"],
folders: [".obsidian/plugins", ".trash", ".obsidian/unreadable"],
};
case ".obsidian/plugins":
return {
files: [".obsidian/plugins/example/data.json"],
folders: [".obsidian/plugins/example"],
};
case ".obsidian/plugins/example":
return {
files: [".obsidian/plugins/example/manifest.json"],
folders: [".obsidian/plugins/example/deeper"],
};
case ".trash":
return { files: [".trash/ignored.json"], folders: [] };
case ".obsidian/unreadable":
throw traversalError;
default:
throw new Error(`unexpected traversal: ${path}`);
}
});
const fixture = createFixture({ listFiles });
await expect(fixture.operations.scanInternalFiles()).resolves.toEqual([
".obsidian/app.json",
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/manifest.json",
]);
expect(listFiles).not.toHaveBeenCalledWith(".obsidian/plugins/example/deeper");
expect(fixture.log).toHaveBeenCalledWith(
"Could not traverse(CustomisationSync):.obsidian/unreadable",
LOG_LEVEL_INFO,
undefined
);
expect(fixture.log).toHaveBeenCalledWith(traversalError, LOG_LEVEL_VERBOSE, undefined);
});
it("stops before traversal when the device term is empty", async () => {
const fixture = createFixture({ term: "", usePluginSync: false, files: [".obsidian/app.json"] as FilePath[] });
await fixture.operations.scanAllConfigFiles(true);
expect(fixture.listFiles).not.toHaveBeenCalled();
expect(fixture.allDocsRaw).not.toHaveBeenCalled();
expect(fixture.findEntries).not.toHaveBeenCalled();
expect(fixture.snapshot.storeCustomizationFiles).not.toHaveBeenCalled();
expect(fixture.snapshot.storeCustomisationFileV2).not.toHaveBeenCalled();
expect(fixture.snapshot.deleteConfigOnDatabase).not.toHaveBeenCalled();
expect(fixture.catalogue.updatePluginList).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalledWith("Scanning customizing files.", LOG_LEVEL_NOTICE, "scan-all-config");
expect(fixture.log).toHaveBeenCalledWith("We have to configure the device name", LOG_LEVEL_NOTICE, undefined);
});
it("dispatches according to the current V1/V2 setting on each scan", async () => {
const path = ".obsidian/app.json" as FilePath;
const fixture = createFixture({ files: [path], useV2: false });
let useV2 = false;
fixture.dependencies.getSettings = () => ({ usePluginSyncV2: useV2 });
await fixture.operations.scanAllConfigFiles(false);
useV2 = true;
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledWith(path);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledTimes(1);
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(path, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledTimes(1);
});
it("routes V1 ownership and deletes only stale owned documents", async () => {
const ownedPath = ".obsidian/app.json" as FilePath;
const unownedPath = ".obsidian/appearance.json" as FilePath;
const ownedDocument = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
const unownedDocument = "ix:device-a/CONFIG/appearance.json.md" as FilePathWithPrefix;
const staleDocument = "ix:device-a/CONFIG/stale.json.md" as FilePathWithPrefix;
const fixture = createFixture({
useV2: false,
usePluginSync: false,
files: [ownedPath, unownedPath],
v1Paths: {
[ownedPath]: ownedDocument,
[unownedPath]: unownedDocument,
},
databaseEntries: [
{ _id: ownedDocument, path: ownedDocument },
{ _id: unownedDocument, path: unownedDocument },
{ _id: staleDocument, path: staleDocument },
],
ownsLocalFile: (path) => path == ownedPath,
});
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledWith(ownedPath);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledTimes(1);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledWith(staleDocument);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledTimes(1);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledTimes(1);
expect(fixture.allDocsRaw).toHaveBeenCalledWith({
startkey: "ix:",
endkey: "ix:\u{10ffff}",
include_docs: true,
});
});
it("propagates V1 snapshot failures without publishing a final refresh", async () => {
const path = ".obsidian/app.json" as FilePath;
const failure = new Error("V1 write failed");
const fixture = createFixture({ files: [path] });
fixture.snapshot.storeCustomizationFiles.mockRejectedValueOnce(failure);
await expect(fixture.operations.scanAllConfigFiles(false)).rejects.toBe(failure);
expect(fixture.catalogue.updatePluginList).not.toHaveBeenCalled();
});
it("routes V2 ownership, removes matched keys, and deletes stale documents", async () => {
const ownedPath = ".obsidian/app.json" as FilePath;
const unownedPath = ".obsidian/appearance.json" as FilePath;
const extraPath = ".obsidian/plugins/example/main.js" as FilePath;
const ownedDocument = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const unownedDocument = "ix:device-a/CONFIG/appearance.json%appearance.json" as FilePathWithPrefix;
const staleDocument = "ix:device-a/CONFIG/stale.json" as FilePathWithPrefix;
const skippedDocument = "ix:device-a/CONFIG/skipped%app.json" as FilePathWithPrefix;
const owned = new Set([ownedPath, extraPath]);
const fixture = createFixture({
useV2: true,
files: [ownedPath, unownedPath, extraPath],
v2Paths: {
[ownedPath]: ownedDocument,
[unownedPath]: unownedDocument,
[extraPath]: "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix,
},
databaseEntries: [
{ _id: ownedDocument, path: "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix },
{ _id: unownedDocument, path: "ix:device-a/CONFIG/appearance.json.md" as FilePathWithPrefix },
{ _id: staleDocument, path: staleDocument },
{ _id: skippedDocument, path: skippedDocument },
],
ownsLocalFile: (path) => owned.has(path),
});
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(ownedPath, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(extraPath, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledTimes(2);
expect(fixture.snapshot.storeCustomisationFileV2).not.toHaveBeenCalledWith(unownedPath, "device-a");
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledWith(staleDocument);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledTimes(1);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
expect(fixture.findEntries).toHaveBeenCalledWith("ix:device-a/", "ix:device-a/\u{10ffff}", {
include_docs: true,
});
});
it("catches and logs each V2 entry failure before publishing the refresh", async () => {
const path = ".obsidian/app.json" as FilePath;
const document = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const failure = new Error("V2 write failed");
const fixture = createFixture({
useV2: true,
files: [path],
v2Paths: { [path]: document },
databaseEntries: [{ _id: document, path: "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix }],
});
fixture.snapshot.storeCustomisationFileV2.mockRejectedValueOnce(failure);
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.log).toHaveBeenCalledWith(
`scanAllConfigFiles - Error: ${document}`,
LOG_LEVEL_VERBOSE,
undefined
);
expect(fixture.log).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, undefined);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
});
it("starts the final catalogue refresh without awaiting it", async () => {
const path = ".obsidian/app.json" as FilePath;
const fixture = createFixture({ files: [path] });
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const refreshStarted = vi.fn();
fixture.catalogue.updatePluginList.mockImplementation(async () => {
refreshStarted();
await refresh;
});
await fixture.operations.scanAllConfigFiles(false);
expect(asyncHarness.fireAndForget).toHaveBeenCalledOnce();
expect(refreshStarted).toHaveBeenCalledOnce();
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
releaseRefresh();
await refresh;
});
});
@@ -0,0 +1,82 @@
import type {
FilePath,
FilePathWithPrefix,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { $msg } from "@/common/translation";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { SnapshotPersistence, SnapshotRefresh } from "./snapshotPersistence.ts";
type SnapshotSettings = Pick<ObsidianLiveSyncSettings, "usePluginSyncV2">;
type SnapshotPersistencePort = Pick<
SnapshotPersistence,
"storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type SnapshotCatalogue = Pick<CatalogueOperations, "updatePluginList" | "updatePluginListV2">;
export type SnapshotOperationsDependencies = {
getSettings(): SnapshotSettings;
getDeviceAndVaultName(): string;
log(message: unknown, level?: LOG_LEVEL, key?: string): void;
snapshotPersistence: SnapshotPersistencePort;
catalogueOperations: SnapshotCatalogue;
};
/**
* Adapts host-neutral Customisation Sync snapshot mutations to catalogue
* refreshes. Current-term selection and the inherited refresh timing live in
* this owner so scan, dialogue, and testing callers share one policy.
*/
export class SnapshotOperations {
constructor(private readonly dependencies: SnapshotOperationsDependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
isV2Enabled(): boolean {
return this.dependencies.getSettings().usePluginSyncV2;
}
private async applyPersistenceRefreshes(refreshes: readonly SnapshotRefresh[]) {
for (const refresh of refreshes) {
if (refresh.mode == "v2" && refresh.timing == "fire-and-forget") {
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginListV2(false, refresh.path));
} else if (refresh.mode == "v1" && refresh.timing == "await") {
await this.dependencies.catalogueOperations.updatePluginList(false, refresh.path);
}
}
}
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
const persistence = await this.dependencies.snapshotPersistence.storeCustomisationFileV2(path, term, force);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
async storeCustomizationFiles(path: FilePath, termOverride?: string) {
const term = termOverride || this.dependencies.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
return;
}
const persistence = this.isV2Enabled()
? await this.dependencies.snapshotPersistence.storeCustomisationFileV2(path, term)
: await this.dependencies.snapshotPersistence.storeCustomizationFiles(path, term);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false): Promise<boolean> {
const persistence = await this.dependencies.snapshotPersistence.deleteConfigOnDatabase(
prefixedFileName,
forceWrite
);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
}
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
const asyncHarness = vi.hoisted(() => ({
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
fireAndForget: asyncHarness.fireAndForget,
};
});
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
import { SnapshotOperations, type SnapshotOperationsDependencies } from "./snapshotOperations.ts";
const CONFIG_PATH = ".obsidian/app.json" as FilePath;
const V1_PATH = "ix:device-b/CONFIG/app.json.md" as FilePathWithPrefix;
const V2_PATH = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
function createOperations(usePluginSyncV2: boolean) {
const events: string[] = [];
type PersistenceResult = SnapshotPersistenceResult<true>;
const storeCustomisationFileV2 = vi.fn(
async (_path: FilePath, _term: string, _force?: boolean): Promise<PersistenceResult> => ({
value: true,
status: "saved",
refreshes: [],
})
);
const storeCustomizationFiles = vi.fn(
async (_path: FilePath, _term: string): Promise<PersistenceResult> => ({
value: true,
status: "saved",
refreshes: [],
})
);
const deleteConfigOnDatabase = vi.fn(
async (_path: FilePathWithPrefix, _force?: boolean): Promise<PersistenceResult> => ({
value: true,
status: "deleted",
refreshes: [],
})
);
const updatePluginList = vi.fn(async () => {
events.push("refresh-v1");
});
const updatePluginListV2 = vi.fn(async () => {
events.push("refresh-v2");
});
const dependencies: SnapshotOperationsDependencies = {
getSettings: () => ({ usePluginSyncV2 }),
getDeviceAndVaultName: () => "device-a",
log: vi.fn(),
snapshotPersistence: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations: { updatePluginList, updatePluginListV2 },
};
return {
operations: new SnapshotOperations(dependencies),
events,
persistence: { storeCustomisationFileV2, storeCustomizationFiles, deleteConfigOnDatabase },
catalogue: { updatePluginList, updatePluginListV2 },
};
}
describe("Snapshot Operations", () => {
it("selects V1 persistence with an override term and awaits its refresh", async () => {
const fixture = createOperations(false);
fixture.persistence.storeCustomizationFiles.mockImplementation(async (_path: FilePath, term: string) => {
fixture.events.push(`persist:${term}`);
return {
value: true,
status: "saved",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
};
});
await expect(fixture.operations.storeCustomizationFiles(CONFIG_PATH, "device-b")).resolves.toBe(true);
expect(fixture.persistence.storeCustomizationFiles).toHaveBeenCalledWith(CONFIG_PATH, "device-b");
expect(fixture.events).toEqual(["persist:device-b", "refresh-v1"]);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, V1_PATH);
});
it("selects V2 persistence with the current term and does not await its refresh", async () => {
const fixture = createOperations(true);
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
fixture.persistence.storeCustomisationFileV2.mockImplementation(async (_path: FilePath, term: string) => {
fixture.events.push(`persist:${term}`);
return {
value: true,
status: "saved",
refreshes: [{ mode: "v2", timing: "fire-and-forget", path: V2_PATH }],
};
});
fixture.catalogue.updatePluginListV2.mockImplementation(async () => {
fixture.events.push("refresh-v2-start");
await refresh;
fixture.events.push("refresh-v2-end");
});
await expect(fixture.operations.storeCustomizationFiles(CONFIG_PATH)).resolves.toBe(true);
expect(fixture.events).toEqual(["persist:device-a", "refresh-v2-start"]);
releaseRefresh();
await refresh;
expect(fixture.events).toEqual(["persist:device-a", "refresh-v2-start", "refresh-v2-end"]);
});
it("returns the persistence result after applying deletion refreshes", async () => {
const fixture = createOperations(false);
fixture.persistence.deleteConfigOnDatabase.mockImplementation(async () => ({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
}));
await expect(fixture.operations.deleteConfigOnDatabase(V1_PATH)).resolves.toBe(true);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenCalledWith(V1_PATH, false);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, V1_PATH);
});
});
@@ -0,0 +1,402 @@
import { parseYaml } from "@/deps.ts";
import type {
FilePath,
FilePathWithPrefix,
InternalFileEntry,
LOG_LEVEL,
SavingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createBlob,
createTextBlob,
getDocData,
getDocDataAsArray,
isDocContentSame,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { EVEN } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { arrayBufferToBase64 } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { base64ToArrayBuffer } from "octagonal-wheels/binary/base64";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import { readCustomisationFile } from "./customisationSyncReadOperations.ts";
const {
serialize,
deserialize,
dummyHead: DUMMY_HEAD,
dummyEnd: DUMMY_END,
} = createCustomisationSyncCodec({ digestHash, parseYaml });
type SnapshotPersistenceDatabase = Pick<
LiveSyncLocalDB,
"getDBEntryFromMeta" | "getDBEntryMeta" | "putDBEntry" | "putRaw"
>;
type SnapshotPersistenceStorage = Pick<StorageAccess, "readHiddenFileBinary" | "statHidden">;
type SnapshotPersistencePath = Pick<
CustomisationSyncPathOperations,
"getFileCategory" | "filenameToUnifiedKey" | "filenameWithUnifiedKey"
> &
Pick<IPathService, "isMarkedAsSameChanges" | "markChangesAreSame" | "path2id">;
export type SnapshotPersistenceDependencies = {
getLocalDatabase(): SnapshotPersistenceDatabase;
storageAccess: SnapshotPersistenceStorage;
path: SnapshotPersistencePath;
log: LogFunction;
getConfigDir(): string;
};
export type SnapshotRefresh = {
mode: "v1" | "v2";
timing: "await" | "fire-and-forget";
path: FilePathWithPrefix;
};
export type SnapshotPersistenceStatus = "saved" | "skipped" | "missing" | "deleted" | "already-deleted" | "failed";
export type SnapshotPersistenceResult<Value> = {
value: Value;
status: SnapshotPersistenceStatus;
refreshes: readonly SnapshotRefresh[];
};
type DatabaseSaveResult = Awaited<ReturnType<SnapshotPersistenceDatabase["putDBEntry"]>>;
type StoreResultValue = DatabaseSaveResult | true | undefined;
function result<Value>(
value: Value,
status: SnapshotPersistenceStatus,
refreshes: readonly SnapshotRefresh[] = []
): SnapshotPersistenceResult<Value> {
return { value, status, refreshes };
}
/**
* Persists local Customisation Sync snapshots without owning catalogue state,
* lifecycle, replication, or user-interface behaviour.
*/
export class SnapshotPersistence {
private readonly dependencies: SnapshotPersistenceDependencies;
constructor(dependencies: SnapshotPersistenceDependencies) {
this.dependencies = dependencies;
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
private async readFile(path: FilePath) {
return await readCustomisationFile(
{
storageAccess: this.dependencies.storageAccess,
log: this.dependencies.log,
},
path,
this.dependencies.getConfigDir()
);
}
// Compatibility question: the inherited force parameter is not read.
// Preserve it until its intended write-bypass semantics are decided.
async storeCustomisationFileV2(
path: FilePath,
term: string,
force = false
): Promise<SnapshotPersistenceResult<StoreResultValue>> {
void force;
const vf = this.dependencies.path.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf;
const id = await this.dependencies.path.path2id(prefixedFileName);
const stat = await this.dependencies.storageAccess.statHidden(path);
if (!stat) {
return result(false, "missing");
}
const mtime = stat.mtime;
const content = await this.dependencies.storageAccess.readHiddenFileBinary(path);
const contentBlob = createBlob([DUMMY_HEAD, DUMMY_END, ...(await arrayBufferToBase64(content))]);
// const contentBlob = createBlob(content);
try {
const old = await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false);
let saveData: SavingEntry;
if (old === false) {
saveData = {
_id: id,
path: prefixedFileName,
data: contentBlob,
mtime,
ctime: mtime,
datatype: "plain",
size: contentBlob.size,
children: [],
deleted: false,
type: "plain",
eden: {},
};
} else {
// Compatibility question: this inherited marker check
// precedes loading the old document and can suppress a
// content comparison. Preserve that event-suppression
// ordering until its scan contract is reviewed.
if (
this.dependencies.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG
);
return result(undefined, "skipped");
}
const docXDoc = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
if (docXDoc == false) {
throw new LiveSyncError("Could not load the document");
}
const dataSrc = getDocData(docXDoc.data);
const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE
);
this.dependencies.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1);
return result(true, "skipped");
}
saveData = {
...old,
data: contentBlob,
mtime,
size: contentBlob.size,
datatype: "plain",
children: [],
deleted: false,
type: "plain",
};
}
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
// Compatibility question: the inherited refresh path omits the
// explicit term override and therefore uses the current term.
// Preserve that path until its cross-device semantics are reviewed.
return result(ret, "saved", [
{
mode: "v2",
timing: "fire-and-forget",
path: this.dependencies.path.filenameWithUnifiedKey(path),
},
]);
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
async storeCustomizationFiles(path: FilePath, term: string): Promise<SnapshotPersistenceResult<StoreResultValue>> {
const vf = this.dependencies.path.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
return await serialized(`plugin-${vf}`, async () => {
const category = this.dependencies.path.getFileCategory(path);
let mtime = 0;
let fileTargets = [] as FilePath[];
// let savePath = "";
const name =
category == "CONFIG" || category == "SNIPPET"
? path.split("/").reverse()[0]
: path.split("/").reverse()[1];
const parentPath = path.split("/").slice(0, -1).join("/");
const prefixedFileName = this.dependencies.path.filenameToUnifiedKey(path, term);
const id = await this.dependencies.path.path2id(prefixedFileName);
const dt: PluginDataEx = {
category: category,
files: [],
name: name,
mtime: 0,
term: term,
};
// let scheduleKey = "";
if (
category == "CONFIG" ||
category == "SNIPPET" ||
category == "PLUGIN_ETC" ||
category == "PLUGIN_DATA"
) {
fileTargets = [path];
if (category == "PLUGIN_ETC") {
dt.displayName = path.split("/").slice(-1).join("/");
}
} else if (category == "PLUGIN_MAIN") {
fileTargets = ["manifest.json", "main.js", "styles.css"].map((e) => `${parentPath}/${e}` as FilePath);
} else if (category == "THEME") {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
}
for (const target of fileTargets) {
const data = await this.readFile(target);
if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue;
}
if (data.version) {
dt.version = data.version;
}
if (data.displayName) {
dt.displayName = data.displayName;
}
// Compatibility question: the inherited aggregation uses an
// average rather than the newest member mtime. Preserve that
// scan behaviour until its timestamp policy is reviewed.
mtime = mtime == 0 ? data.mtime : (data.mtime + mtime) / 2;
dt.files.push(data);
}
dt.mtime = mtime;
// Compatibility question: the inherited empty-file path performs a
// deletion refresh and then an unconditional explicit refresh. Keep
// both outcomes, including the extra refresh when deletion succeeds.
if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`);
const deletion = await this.deleteConfigOnDatabase(prefixedFileName);
return result(undefined, deletion.status, [
...deletion.refreshes,
{ mode: "v1", timing: "await", path: prefixedFileName },
]);
}
const content = createTextBlob(serialize(dt));
try {
const old = await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false);
let saveData: SavingEntry;
if (old === false) {
saveData = {
_id: id,
path: prefixedFileName,
data: content,
mtime,
ctime: mtime,
datatype: "newnote",
size: content.size,
children: [],
deleted: false,
type: "newnote",
eden: {},
};
} else {
if (old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return result(true, "skipped");
}
const oldC = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
if (oldC) {
const d = deserialize(getDocDataAsArray(oldC.data), {}) as PluginDataEx;
if (d.files.length == dt.files.length) {
// Compatibility question: the inherited comparison
// looks up each current file by the previous filename
// and compares a missing lookup as empty content.
// Preserve this rename/empty-file behaviour for now.
const diffs = d.files
.map((previous) => ({
prev: previous,
curr: dt.files.find((e) => e.filename == previous.filename),
}))
.map(async (e) => {
try {
return await isDocContentSame(e.curr?.data ?? [], e.prev.data);
} catch {
return false;
}
});
const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE
);
return result(true, "skipped");
}
}
}
saveData = {
...old,
data: content,
mtime,
size: content.size,
datatype: "newnote",
children: [],
deleted: false,
type: "newnote",
};
}
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return result(ret, "saved", [{ mode: "v1", timing: "await", path: saveData.path }]);
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
// Compatibility question: the inherited forceWrite parameter is not read.
// Preserve it until callers define whether deletion should bypass a marker.
async deleteConfigOnDatabase(
prefixedFileName: FilePathWithPrefix,
forceWrite = false
): Promise<SnapshotPersistenceResult<boolean>> {
void forceWrite;
// const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => {
try {
const old = (await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false)) as InternalFileEntry | false;
let saveData: InternalFileEntry;
if (old === false) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`);
return result(true, "missing");
} else {
if (old.deleted) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`);
return result(true, "already-deleted");
}
saveData = {
...old,
mtime,
size: 0,
children: [],
deleted: true,
type: "newnote",
};
}
await this.dependencies.getLocalDatabase().putRaw(saveData);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return result(true, "deleted", [{ mode: "v1", timing: "await", path: prefixedFileName }]);
} catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
}));
vi.mock("@/common/utils.ts", () => ({
EVEN: Symbol("even"),
cancelTask: vi.fn(),
fireAndForget: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
vi.mock("@/features/optionalFileSyncFileTree.ts", () => ({
collectOptionalFileSyncFiles: vi.fn(),
}));
import type { FilePath, FilePathWithPrefix, LoadedEntry, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVEN } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { SnapshotPersistence, type SnapshotPersistenceDependencies } from "./snapshotPersistence.ts";
const CONFIG_PATH = ".obsidian/app.json" as FilePath;
const V1_PATH = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
const V2_PATH = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const codec = createCustomisationSyncCodec({
digestHash: (source) => source.join(""),
parseYaml: () => undefined,
});
function loadedV2Entry(source: string, mtime = 10): LoadedEntry {
const data = `${codec.dummyHead}${codec.dummyEnd}${btoa(source)}`;
return {
_id: "entry-id",
_rev: "1-a",
path: V2_PATH,
type: "plain",
datatype: "plain",
data,
ctime: mtime,
mtime,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function createPersistence(
options: {
category?: "CONFIG" | "PLUGIN_MAIN";
old?: false | LoadedEntry;
stat?: UXStat | null;
content?: string;
currentTerm?: string;
} = {}
) {
const currentTerm = options.currentTerm ?? "device-a";
const statHidden = vi.fn(
async (_path: string): Promise<UXStat | null> =>
options.stat === undefined ? { type: "file", ctime: 10, mtime: 10, size: 5 } : options.stat
);
const readHiddenFileBinary = vi.fn(
async (_path: string) => new TextEncoder().encode(options.content ?? "hello").buffer
);
const getDBEntryMeta = vi.fn(async () => options.old ?? false);
const getDBEntryFromMeta = vi.fn(async (entry: LoadedEntry) => entry);
const putDBEntry = vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" }));
const putRaw = vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" }));
const filenameToUnifiedKey = vi.fn(
(_path: string, term?: string) =>
`ix:${term ?? currentTerm}/${options.category ?? "CONFIG"}/app.json.md` as FilePathWithPrefix
);
const filenameWithUnifiedKey = vi.fn(
(_path: string, term?: string) =>
`ix:${term ?? currentTerm}/${options.category ?? "CONFIG"}/app.json%app.json` as FilePathWithPrefix
);
const dependencies: SnapshotPersistenceDependencies = {
getLocalDatabase: () => ({ getDBEntryMeta, getDBEntryFromMeta, putDBEntry, putRaw }),
storageAccess: { statHidden, readHiddenFileBinary },
path: {
getFileCategory: () => options.category ?? "CONFIG",
filenameToUnifiedKey,
filenameWithUnifiedKey,
path2id: vi.fn(async (path) => path),
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
log: vi.fn(),
getConfigDir: () => ".obsidian",
};
return {
database: { getDBEntryMeta, getDBEntryFromMeta, putDBEntry, putRaw },
dependencies,
filenameToUnifiedKey,
filenameWithUnifiedKey,
persistence: new SnapshotPersistence(dependencies),
readHiddenFileBinary,
statHidden,
};
}
describe("Customisation Sync snapshot persistence", () => {
it("persists a V2 file and returns a fire-and-forget catalogue refresh", async () => {
const fixture = createPersistence();
const mutation = await fixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a");
expect(mutation).toMatchObject({
value: { ok: true, id: "entry-id", rev: "2-b" },
status: "saved",
refreshes: [{ mode: "v2", timing: "fire-and-forget", path: V2_PATH }],
});
expect(fixture.database.putDBEntry).toHaveBeenCalledOnce();
expect(fixture.filenameWithUnifiedKey).toHaveBeenNthCalledWith(1, CONFIG_PATH, "device-a");
expect(fixture.filenameWithUnifiedKey).toHaveBeenNthCalledWith(2, CONFIG_PATH);
});
it("aggregates the V1 plug-in file set and returns an awaited refresh", async () => {
const fixture = createPersistence({ category: "PLUGIN_MAIN" });
const mutation = await fixture.persistence.storeCustomizationFiles(
".obsidian/plugins/example/main.js" as FilePath,
"device-a"
);
expect(mutation).toMatchObject({
value: { ok: true },
status: "saved",
refreshes: [{ mode: "v1", timing: "await", path: "ix:device-a/PLUGIN_MAIN/app.json.md" }],
});
expect(fixture.readHiddenFileBinary).toHaveBeenCalledTimes(3);
expect(fixture.database.putDBEntry).toHaveBeenCalledOnce();
});
it("keeps the inherited duplicate V1 refresh on the empty-file deletion path", async () => {
const old = {
...loadedV2Entry("old"),
path: V1_PATH,
datatype: "newnote",
type: "newnote",
deleted: false,
} as LoadedEntry;
const fixture = createPersistence({ old, stat: null });
const mutation = await fixture.persistence.storeCustomizationFiles(CONFIG_PATH, "device-a");
expect(mutation.value).toBeUndefined();
expect(mutation.status).toBe("deleted");
expect(mutation.refreshes).toEqual([
{ mode: "v1", timing: "await", path: V1_PATH },
{ mode: "v1", timing: "await", path: V1_PATH },
]);
expect(fixture.database.putRaw).toHaveBeenCalledOnce();
});
it.each([
["missing", false, "missing"],
["already deleted", { ...loadedV2Entry("old"), deleted: true } as LoadedEntry, "already-deleted"],
] as const)("treats an absent or %s document as a successful no-op", async (_label, old, status) => {
const fixture = createPersistence({ old, stat: null });
const mutation = await fixture.persistence.deleteConfigOnDatabase(V1_PATH);
expect(mutation).toMatchObject({ value: true, status, refreshes: [] });
expect(fixture.database.putRaw).not.toHaveBeenCalled();
});
it("returns an awaited refresh only when deletion writes a live document", async () => {
const old = {
...loadedV2Entry("old"),
path: V1_PATH,
deleted: false,
} as LoadedEntry;
const fixture = createPersistence({ old });
const mutation = await fixture.persistence.deleteConfigOnDatabase(V1_PATH);
expect(mutation).toMatchObject({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
});
expect(fixture.database.putRaw).toHaveBeenCalledOnce();
});
it("preserves the V2 marker and same-content skips", async () => {
const markerFixture = createPersistence({ old: loadedV2Entry("old") });
const marker = markerFixture.dependencies.path.isMarkedAsSameChanges as ReturnType<typeof vi.fn>;
marker.mockReturnValue(EVEN);
await expect(
markerFixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a")
).resolves.toMatchObject({
value: undefined,
status: "skipped",
refreshes: [],
});
expect(markerFixture.database.putDBEntry).not.toHaveBeenCalled();
const sameContentFixture = createPersistence({ old: loadedV2Entry("hello") });
await expect(
sameContentFixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a")
).resolves.toMatchObject({
value: true,
status: "skipped",
refreshes: [],
});
expect(sameContentFixture.database.putDBEntry).not.toHaveBeenCalled();
});
});
@@ -53,6 +53,9 @@ describe("HiddenFileSyncContext ownership and start-up lifecycle", () => {
expect(getPrivate<unknown>(first.context, "changeProcessor")).not.toBe(
getPrivate<unknown>(second.context, "changeProcessor")
);
expect(getPrivate<unknown>(first.context, "reconciliation")).not.toBe(
getPrivate<unknown>(second.context, "reconciliation")
);
expect(getPrivate<unknown>(first.context, "periodicInternalFileScanProcessor")).toBe(first.periodicProcessor);
expect(getPrivate<unknown>(second.context, "periodicInternalFileScanProcessor")).toBe(second.periodicProcessor);
@@ -67,8 +70,10 @@ describe("HiddenFileSyncContext ownership and start-up lifecycle", () => {
"preserves start-up scan notice selection for the processed-file cache",
async (processedFiles, forcedNotice) => {
const { context } = createContext(processedFiles);
const applyOfflineChanges = vi.fn(async () => undefined);
context.applyOfflineChanges = applyOfflineChanges;
const reconciliation = getPrivate<{
applyOfflineChanges(showNotice: boolean): Promise<unknown>;
}>(context, "reconciliation");
const applyOfflineChanges = vi.spyOn(reconciliation, "applyOfflineChanges").mockResolvedValue(undefined);
await context.serviceHandlers.onDatabaseInitialised(false);
@@ -6,28 +6,15 @@ import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type DocumentID,
type MetaEntry,
type ObsidianLiveSyncSettings,
type LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "@/common/types.ts";
import { ICHeader, ICHeaderEnd } from "@/common/types.ts";
import { type CustomRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
compareMTime,
isInternalMetadata,
TARGET_IS_NEW,
cancelTask,
scheduleTask,
getLogLevel,
onlyInNTimes,
BASE_IS_NEW,
EVEN,
} from "@/common/utils.ts";
import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { isInternalMetadata, cancelTask, scheduleTask } from "@/common/utils.ts";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { $msg } from "@/common/translation";
@@ -42,14 +29,9 @@ import {
type HiddenFileSyncCommandView,
type HiddenFileSyncRepairView,
type HiddenFileSyncServiceHandlerView,
type HiddenFileSyncTestingRebuild,
type HiddenFileSyncTestingView,
} from "./hiddenFileSyncViews.ts";
import { describeHiddenFileSyncDocument, getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts";
import {
collectOptionalFileSyncFiles,
type OptionalFileSyncFileTreeDependencies,
} from "@/features/optionalFileSyncFileTree.ts";
import type { OptionalFileSyncFileTreeDependencies } from "@/features/optionalFileSyncFileTree.ts";
import {
deleteHiddenFileFromStorage,
ensureHiddenFileDirectory,
@@ -81,21 +63,20 @@ import {
createHiddenFileSyncChangeProcessor,
type HiddenFileSyncChangeProcessor,
} from "./hiddenFileSyncChangeProcessor.ts";
import {
createHiddenFileSyncPathAdmission,
type HiddenFileSyncPathAdmission,
} from "./hiddenFileSyncPathAdmission.ts";
import { createHiddenFileSyncPathAdmission, type HiddenFileSyncPathAdmission } from "./hiddenFileSyncPathAdmission.ts";
import {
createHiddenFileSyncChangeNotifier,
type HiddenFileSyncChangeNotifier,
} from "./hiddenFileSyncChangeNotifier.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
import {
createReconciliation,
type InitialisationDirection,
type ReconciliationProgress,
type Reconciliation,
} from "./reconciliation.ts";
export type HiddenFileSyncProgress = {
log(message: string): void;
once(message: string): void;
done(message?: string): void;
};
export type { ReconciliationProgress as HiddenFileSyncProgress } from "./reconciliation.ts";
type SyncDirection = InitialisationDirection;
type HiddenFileSyncSettings = Pick<
ObsidianLiveSyncSettings,
@@ -145,7 +126,7 @@ export type HiddenFileSyncContextDependencies = OptionalFileSyncFileTreeDependen
getKeyValueDatabase(): KeyValueDatabase;
databaseFileAccess: HiddenFileSyncDatabaseFileAccess;
path: Pick<IPathService, "getPath" | "markChangesAreSame" | "path2id" | "unmarkChanges">;
createProgress(prefix?: string, level?: LOG_LEVEL): HiddenFileSyncProgress;
createProgress(prefix?: string, level?: LOG_LEVEL): ReconciliationProgress;
createPeriodicProcessor(process: () => Promise<unknown>): HiddenFileSyncPeriodicProcessor;
isReady(): boolean;
isSuspended(): boolean;
@@ -180,13 +161,13 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
private readonly databaseExtractionOperations: HiddenFileSyncDatabaseExtractionOperations;
private readonly conflictResolution: HiddenFileSyncConflictResolution;
private readonly changeProcessor: HiddenFileSyncChangeProcessor;
private readonly reconciliation: Reconciliation;
private readonly pathAdmission: HiddenFileSyncPathAdmission;
private readonly changeNotifier: HiddenFileSyncChangeNotifier;
readonly serviceHandlers: HiddenFileSyncServiceHandlerView;
readonly testing: HiddenFileSyncTestingView;
readonly repair: HiddenFileSyncRepairView;
private readonly periodicInternalFileScanProcessor: HiddenFileSyncPeriodicProcessor;
private rebuildMergingHook: HiddenFileSyncTestingRebuild | undefined;
private disposed = false;
constructor(dependencies: HiddenFileSyncContextDependencies) {
@@ -303,8 +284,21 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
log: (message, level, key) => dependencies.log(message, level, key),
publishActivity: (eventCount, processingCount) => dependencies.publishActivity(eventCount, processingCount),
});
this.reconciliation = createReconciliation({
listFiles: async (path) => await dependencies.listFiles(path),
getLocalDatabase: () => dependencies.getLocalDatabase(),
storageAccess: dependencies.storageAccess,
getRootPath: () => dependencies.getRootPath(),
getPath: (entry) => dependencies.path.getPath(entry),
isTargetFile: async (path) => await this.pathAdmission.isTargetFile(path),
isIgnoredByIgnoreFile: async (path) => await dependencies.isIgnoredByIgnoreFile(path),
createProgress: (prefix, level) => dependencies.createProgress(prefix, level),
processedState: this.processedState,
changeProcessor: this.changeProcessor,
log: (message, level, key) => dependencies.log(message, level, key),
});
this.repair = createHiddenFileSyncRepairView({
scanInternalFiles: async () => await this.scanInternalFiles(),
scanInternalFiles: async () => await this.reconciliation.scanInternalFiles(),
storeInternalFileToDatabase: async (file, forceWrite) =>
await this.databaseWriteOperations.store(file, forceWrite),
storeInternalFileToDatabaseWithBaseRevision: async (file, baseRevision, createIfDifferent) =>
@@ -337,19 +331,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
readFileWithInfo: async (path) => await readHiddenFileWithInfo(dependencies, path),
showConfigurationChangeNotice: (updatedFolders) =>
this.changeNotifier.showConfigurationChangeNotice(updatedFolders),
interceptRebuildMerging: (interceptor) => {
const previousHook = this.rebuildMergingHook;
const runRebuild = async (showNotice: boolean, targetFiles?: FilePath[] | false) =>
await this.rebuildMerging(showNotice, targetFiles);
const hook: HiddenFileSyncTestingRebuild = async (showNotice, targetFiles) =>
await interceptor(runRebuild, showNotice, targetFiles);
this.rebuildMergingHook = hook;
return () => {
if (this.rebuildMergingHook === hook) {
this.rebuildMergingHook = previousHook;
}
};
},
interceptRebuildMerging: (interceptor) => this.reconciliation.interceptRebuildMerging(interceptor),
});
this.periodicInternalFileScanProcessor = dependencies.createPeriodicProcessor(
async () =>
@@ -361,18 +343,6 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
return this.dependencies.getSettings();
}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private get storageAccess() {
return this.dependencies.storageAccess;
}
private get databaseFileAccess() {
return this.dependencies.databaseFileAccess;
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
@@ -393,10 +363,6 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
this.dependencies.log(message, level, key);
}
private _verbose(message: unknown, key?: string) {
this._log(message, LOG_LEVEL_VERBOSE, key);
}
private _progress(prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE) {
return this.dependencies.createProgress(prefix, level);
}
@@ -410,10 +376,10 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
this.disposed = true;
this.periodicInternalFileScanProcessor?.disable();
this.changeProcessor.dispose();
this.reconciliation.dispose();
this.conflictResolution.dispose();
this.pathAdmission.dispose();
this.changeNotifier.dispose();
this.rebuildMergingHook = undefined;
this.dependencies.closeJsonConflictDialogs();
}
@@ -492,7 +458,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
private async processOptionalFileEvent(path: FilePath): Promise<boolean> {
if (this.isReady()) {
return (await this.trackStorageFileModification(path)) || false;
return (await this.reconciliation.processStorageChange(path)) || false;
}
return false;
}
@@ -511,7 +477,7 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
// We should return true, we made sure that document is a internalMetadata.
return true;
}
if (!(await this.processReplicationResult(doc))) {
if (!(await this.reconciliation.processDatabaseDocument(doc))) {
this._log(`Failed to process sync file:${unprefixedPath}`, LOG_LEVEL_NOTICE);
// Do not yield false, this file had been processed.
}
@@ -521,290 +487,8 @@ export class HiddenFileSyncContext implements HiddenFileSyncCommandView {
return false;
}
private async adoptCurrentStorageFilesAsProcessed(targetFiles: FilePath[] | false) {
const allFiles = await this.scanInternalFileNames();
const files = targetFiles ? allFiles.filter((e) => targetFiles.some((t) => e.indexOf(t) !== -1)) : allFiles;
for (const file of files) {
await this.processedState.updateLastProcessedAsActualFile(file);
}
}
private async adoptCurrentDatabaseFilesAsProcessed(targetFiles: FilePath[] | false) {
const allFiles = await this.getAllDatabaseFiles();
const files = targetFiles
? allFiles.filter((e) => targetFiles.some((t) => e.path.indexOf(t) !== -1))
: allFiles;
for (const file of files) {
const path = stripAllPrefixes(this.getPath(file));
await this.processedState.updateLastProcessedAsActualDatabase(path, file);
}
}
private async trackScannedStorageChanges(
processFiles: FilePath[],
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeleted = true
) {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
const processes = processFiles.map(async (file, i) => {
try {
await this.trackStorageFileModification(file, onlyNew, forceWriteAll, includeDeleted);
notifyProgress();
} catch (ex) {
p.once(`Failed to process storage change file:${file}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
});
await Promise.all(processes);
p.done();
}
async scanAllStorageChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeleted = true
) {
return await skipIfDuplicated("scanAllStorageChanges", async () => {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Scanning Storage -> DB ]\n`, logLevel);
p.log(`Scanning storage files...`);
const knownNames = [...this.processedState.getLastProcessedFileKeys()] as FilePath[];
const existNames = await this.scanInternalFileNames();
const files = new Set([...knownNames, ...existNames]);
this._log(
`Known/Exist ${knownNames.length}/${existNames.length}, Totally ${files.size} files.`,
LOG_LEVEL_VERBOSE
);
const taskNameAndMeta = [...files].map(async (e) => [e, await this.storageAccess.statHidden(e)] as const);
const nameAndMeta = await Promise.all(taskNameAndMeta);
const processFiles = nameAndMeta
.filter(([path, stat]) => {
if (forceWriteAll) return true;
const key = this.processedState.getLastProcessedFileKey(path);
const newKey = this.processedState.storageStateKey(stat);
return key != newKey;
})
.map(([path, stat]) => path);
const staticsMessage = `[Storage hidden file statics]
Known files: ${knownNames.length}
Actual files: ${existNames.length}
All files: ${files.size}
Offline Changed files: ${processFiles.length}`;
// this._log(staticsMessage, logLevel, "scan-changes");
p.once(staticsMessage);
await this.trackScannedStorageChanges(processFiles, showNotice, onlyNew, forceWriteAll, includeDeleted);
p.done();
});
}
/**
* check the file is changed or not, and if changed, process it.
*/
private async trackStorageFileModification(
path: FilePath,
onlyNew = false,
forceWrite = false,
includeDeleted = true
): Promise<boolean | undefined> {
if (!(await this.pathAdmission.isTargetFile(path))) {
this._log(
`Storage file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
return false;
}
return await this.changeProcessor.processStorageChange(path, onlyNew, forceWrite, includeDeleted);
}
// --> Event Source Handler (Database)
private async processReplicationResult(doc: LoadedEntry): Promise<boolean> {
const info = describeHiddenFileSyncDocument(doc, this.getPath(doc));
const path = info.path;
const headerLine = `Tracking DB ${info.path} (${info.revDisplay}) :`;
const ret = await this.trackDatabaseFileModification(path, headerLine);
this._log(`${headerLine} Done: ${info.shortenedId})`, LOG_LEVEL_VERBOSE);
return ret;
}
// <-- Event Source Handler (Database)
// --> Database Event Functions
private async trackScannedDatabaseChange(
processFiles: MetaEntry[],
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeletion = true
) {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
const processes = processFiles.map(async (file) => {
try {
const path = stripAllPrefixes(this.getPath(file));
if (!(await this.pathAdmission.isTargetFile(path))) {
this._log(
`Database file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
} else {
await this.trackDatabaseFileModification(
path,
"[Hidden file scan]",
!forceWriteAll,
onlyNew,
file,
includeDeletion
);
}
notifyProgress();
} catch (ex) {
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
this._log(ex, LOG_LEVEL_VERBOSE);
}
});
await Promise.all(processes);
p.done();
}
async applyOfflineChanges(showNotice: boolean) {
const logLevel = getLogLevel(showNotice);
return await serialized("applyOfflineChanges", async () => {
const p = this._progress("[⚙ Apply untracked changes ]\n", logLevel);
this._log(`Track changes.`, logLevel);
p.log("Enumerating local files...");
const currentStorageFiles = await this.scanInternalFileNames();
p.log("Enumerating database files...");
const currentDatabaseFiles = await this.getAllDatabaseFiles();
const allDatabaseMap = Object.fromEntries(
currentDatabaseFiles.map((e) => [stripAllPrefixes(this.getPath(e)), e])
);
const currentDatabaseFileNames = [...Object.keys(allDatabaseMap)] as FilePath[];
const untrackedLocal = currentStorageFiles.filter((e) => !this.processedState.hasLastProcessedFile(e));
const untrackedDatabase = currentDatabaseFileNames.filter(
(e) => !this.processedState.hasLastProcessedDatabase(e)
);
const bothUntracked = untrackedLocal.filter((e) => untrackedDatabase.indexOf(e) !== -1);
p.log("Applying untracked changes...");
const stat = `Tracking statics:
Local files: ${currentStorageFiles.length}
Database files: ${currentDatabaseFileNames.length}
Untracked local files: ${untrackedLocal.length}
Untracked database files: ${untrackedDatabase.length}
Common untracked files: ${bothUntracked.length}`;
p.once(stat);
const semaphores = Semaphore(10);
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${bothUntracked.length}`));
const allProcesses = bothUntracked.map(async (file) => {
notifyProgress();
const rel = await semaphores.acquire();
try {
const fileStat = await this.storageAccess.statHidden(file);
if (fileStat == null) {
// This should not be happened. But, if it happens, we should skip this.
this._log(`Unexpected error: Failed to stat file during applyOfflineChange :${file}`);
return;
}
const dbInfo = allDatabaseMap[file];
if (dbInfo.deleted || dbInfo._deleted) {
// Applying deletion can be harmful if the local file is not tracked.
// So, we should skip this.
return;
}
const fileMTime = getHiddenFileSyncComparisonMTime(fileStat);
const dbMTime = getHiddenFileSyncComparisonMTime(dbInfo);
const diff = compareMTime(fileMTime, dbMTime);
if (diff == BASE_IS_NEW) {
// Local file is newer than the database file.
// So, we should apply the local file to the database.
await this.trackStorageFileModification(file, true);
} else if (diff == TARGET_IS_NEW) {
// Database file is newer than the local file.
// So, we should apply the database file to the local file.
await this.trackDatabaseFileModification(file, "[Apply]", true, true, dbInfo);
} else if (diff == EVEN) {
// Both are same, we may skip this but should update the last processed key.
this.processedState.updateLastProcessed(file, dbInfo, fileStat);
}
} finally {
rel();
}
});
await Promise.all(allProcesses);
await this.scanAllStorageChanges(showNotice);
await this.scanAllDatabaseChanges(showNotice);
p.done();
});
}
async scanAllDatabaseChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeletion = true
) {
return await skipIfDuplicated("scanAllDatabaseChanges", async () => {
const databaseFiles = await this.getAllDatabaseFiles();
const files = databaseFiles.filter((e) => {
const doc = e;
const key = this.processedState.databaseStateKey(doc);
const path = stripAllPrefixes(this.getPath(doc));
const lastKey = this.processedState.getLastProcessedDatabaseKey(path);
return lastKey != key;
});
const logLevel = getLogLevel(showNotice);
const staticsMessage = `[Database hidden file statics]
All files: ${databaseFiles.length}
Offline Changed files: ${files.length}`;
this._log(staticsMessage, logLevel, "scan-changes");
return await this.trackScannedDatabaseChange(files, showNotice, onlyNew, forceWriteAll, includeDeletion);
});
}
private async useDatabaseFiles(files: MetaEntry[], showNotice = false, onlyNew = false) {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
p.log("Scanning database files...");
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${files.length}`));
const processFiles = files.map(async (file) => {
try {
const path = stripAllPrefixes(this.getPath(file));
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
notifyProgress();
} catch (ex) {
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
return;
});
await Promise.all(processFiles);
p.done();
return true;
}
private async trackDatabaseFileModification(
path: FilePath,
headerLine: string,
preventDoubleProcess = false,
onlyNew = false,
meta: MetaEntry | false = false,
includeDeletion = true
): Promise<boolean> {
return await this.changeProcessor.processDatabaseChange(path, headerLine, {
preventDoubleProcess,
onlyNew,
metaEntry: meta,
includeDeletion,
});
}
private queueConflict(path: FilePathWithPrefix): Promise<boolean> {
this.conflictResolution.queue(path);
return Promise.resolve(true);
@@ -812,188 +496,42 @@ Offline Changed files: ${files.length}`;
// <-- Database Event Functions
// --> Initialization functions
private async rebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false) {
const logLevel = getLogLevel(showNotice);
const p = this._progress("[⚙ Rebuild by Merge ]\n", logLevel);
this._log(`Rebuilding hidden files from the storage and the local database.`, logLevel);
p.log("Enumerating local files...");
const currentStorageFilesAll = await this.scanInternalFileNames();
const currentStorageFiles = targetFiles
? currentStorageFilesAll.filter((e) => targetFiles.some((f) => f == e))
: currentStorageFilesAll;
p.log("Enumerating database files...");
const allDatabaseFiles = await this.getAllDatabaseFiles();
const allDatabaseMap = new Map(allDatabaseFiles.map((e) => [stripAllPrefixes(this.getPath(e)), e]));
const currentDatabaseFiles = targetFiles
? allDatabaseFiles.filter((e) => targetFiles.some((f) => f == stripAllPrefixes(this.getPath(e))))
: allDatabaseFiles;
const allFileNames = new Set([
...currentStorageFiles,
...currentDatabaseFiles.map((e) => stripAllPrefixes(this.getPath(e))),
]);
const storageToDatabase = [] as FilePath[];
const databaseToStorage = [] as MetaEntry[];
const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`));
for (const file of allFileNames) {
eachProgress();
const storageMTime = await this.storageAccess.statHidden(file);
const mtimeStorage = getHiddenFileSyncComparisonMTime(storageMTime);
const dbEntry = allDatabaseMap.get(file)!;
const mtimeDB = getHiddenFileSyncComparisonMTime(dbEntry);
const diff = compareMTime(mtimeStorage, mtimeDB);
if (diff == BASE_IS_NEW) {
storageToDatabase.push(file);
} else if (diff == TARGET_IS_NEW) {
databaseToStorage.push(dbEntry);
} else if (diff == EVEN) {
// For safety, storage to database.
storageToDatabase.push(file);
}
}
p.once(
`Storage to Database: ${storageToDatabase.length} files\n Database to Storage: ${databaseToStorage.length} files`
);
this.processedState.resetLastProcessedDatabase(targetFiles);
this.processedState.resetLastProcessedFile(targetFiles);
const processes = [
this.trackScannedStorageChanges(storageToDatabase, showNotice, false, true),
this.useDatabaseFiles(databaseToStorage, showNotice, false),
];
p.log("Start processing...");
await Promise.all(processes);
p.done();
return [...allFileNames];
async scanAllStorageChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeleted = true
): Promise<unknown> {
return await this.reconciliation.scanAllStorageChanges(showNotice, onlyNew, forceWriteAll, includeDeleted);
}
private async runRebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false) {
return this.rebuildMergingHook
? await this.rebuildMergingHook(showNotice, targetFiles)
: await this.rebuildMerging(showNotice, targetFiles);
async scanAllDatabaseChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeletion = true
): Promise<unknown> {
return await this.reconciliation.scanAllDatabaseChanges(showNotice, onlyNew, forceWriteAll, includeDeletion);
}
private async rebuildFromStorage(showNotice: boolean, targetFiles: FilePath[] | false = false, onlyNew = false) {
// reset processed file markers
const logLevel = getLogLevel(showNotice);
this._verbose(`Rebuilding hidden files from the storage.`);
this._log(`Rebuilding hidden files from the storage.`, logLevel);
const p = this._progress("[⚙ Rebuild by Storage ]\n", logLevel);
p.log("Enumerating local files...");
const currentFilesAll = await this.scanInternalFileNames();
const currentFiles = targetFiles
? currentFilesAll.filter((e) => targetFiles.some((f) => f == e))
: currentFilesAll;
p.once(`Storage to Database: ${currentFiles.length} files.`);
p.log("Start processing...");
this.processedState.resetLastProcessedFile(targetFiles);
await this.trackScannedStorageChanges(currentFiles, showNotice, onlyNew, true);
p.done();
return currentFiles;
}
private async getAllDatabaseFiles() {
const allFiles = (
await this.localDatabase.allDocsRaw({ startkey: ICHeader, endkey: ICHeaderEnd, include_docs: true })
).rows
.filter((e) => isInternalMetadata(e.id as DocumentID))
.map((e) => e.doc) as MetaEntry[];
const files = [] as MetaEntry[];
for (const file of allFiles) {
if (await this.pathAdmission.isTargetFile(stripAllPrefixes(this.getPath(file)))) {
files.push(file);
}
}
return files;
}
private async rebuildFromDatabase(showNotice: boolean, targetFiles: FilePath[] | false = false, onlyNew = false) {
const logLevel = getLogLevel(showNotice);
this._verbose(`Rebuilding hidden files from the local database.`);
const p = this._progress("[⚙ Rebuild by Database ]\n", logLevel);
p.log("Enumerating database files...");
const allFiles = await this.getAllDatabaseFiles();
// THINKING: Should we exclude conflicted or deleted files?
// Current implementation is to include all files, and following processes will handle for them.
// However, in perspective of performance and future-proofing, I feel somewhat justified in doing it here.
const currentFiles = targetFiles
? allFiles.filter((e) => targetFiles.some((f) => f == stripAllPrefixes(this.getPath(e))))
: allFiles;
p.once(`Database to Storage: ${currentFiles.length} files.`);
this.processedState.resetLastProcessedDatabase(targetFiles);
p.log("Start processing...");
await this.useDatabaseFiles(currentFiles, showNotice, onlyNew);
p.done();
return currentFiles;
async applyOfflineChanges(showNotice: boolean): Promise<unknown> {
return await this.reconciliation.applyOfflineChanges(showNotice);
}
async initialiseInternalFileSync(
direction: SyncDirection,
showMessage: boolean,
// filesAll: InternalFileInfo[] | false = false,
targetFilesSrc: string[] | false = false,
initialisationProgress?: HiddenFileSyncProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// Compatibility question: the legacy preflight was already disabled.
// Enabling it would change initialisation timing and could open a
// conflict dialogue while the feature is being configured.
// p.log("Resolving conflicts before starting...");
// await this.conflictResolution.resolveAll();
p.log("Initialising hidden files sync...");
// The initialisation progress owns the user-visible Notice. Its child
// rebuild and scan operations still write ordinary log entries, but
// must not each create another keep-alive Notice.
const showChildNotices = false;
// TODO: Handling ignore files cannot be performed to the hidden files.
const targetFiles = targetFilesSrc
? targetFilesSrc.map((e) => stripAllPrefixes(e as FilePathWithPrefix))
: false;
if (direction == "pushForce" || direction == "push") {
const onlyNew = direction == "push";
p.log(`Started: Storage --> Database ${onlyNew ? "(Only New)" : ""}`);
const updatedFiles = await this.rebuildFromStorage(showChildNotices, targetFiles, onlyNew);
// making doubly sure, No more losing files.
// I did so many times during the development.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
if (direction == "pullForce" || direction == "pull") {
const onlyNew = direction == "pull";
p.log(`Started: Database --> Storage ${onlyNew ? "(Only New)" : ""}`);
const updatedEntries = await this.rebuildFromDatabase(showChildNotices, targetFiles, onlyNew);
const updatedFiles = updatedEntries.map((e) => stripAllPrefixes(this.getPath(e)));
// making doubly sure, No more losing files.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining database and storage changes...");
await this.scanAllDatabaseChanges(showChildNotices, true, false);
await this.scanAllStorageChanges(showChildNotices, true, false);
}
if (direction == "safe") {
p.log(`Started: Database <--> Storage (by modified date)`);
const updatedFiles = await this.runRebuildMerging(showChildNotices, targetFiles);
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
initialisationProgress?: ReconciliationProgress
): Promise<void> {
return await this.reconciliation.initialiseInternalFileSync(
direction,
showMessage,
targetFilesSrc,
initialisationProgress
);
}
// <-- Initialization functions
private suspendExtraSync(): Promise<boolean> {
@@ -1016,7 +554,7 @@ Offline Changed files: ${files.length}`;
}
private async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
let initialisationProgress: HiddenFileSyncProgress | undefined;
let initialisationProgress: ReconciliationProgress | undefined;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
@@ -1058,47 +596,5 @@ Offline Changed files: ${files.length}`;
}
// <-- Configuration handling
// --> Local Storage SubFunctions
private async scanInternalFileNames() {
const findRoot = this.dependencies.getRootPath();
const filenames = await collectOptionalFileSyncFiles(this.dependencies, findRoot, {
shouldInclude: (path) => this.pathAdmission.isTargetFile(path as FilePath),
onError: (path, error) => {
this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO);
this._log(error, LOG_LEVEL_VERBOSE);
},
});
return filenames as FilePath[];
}
private async scanInternalFiles(): Promise<InternalFileInfo[]> {
const fileNames = await this.scanInternalFileNames();
const files = fileNames.map(async (e) => {
return {
path: e,
stat: await this.storageAccess.statHidden(e),
};
});
const result: InternalFileInfo[] = [];
for (const f of files) {
const w = await f;
if (await this.dependencies.isIgnoredByIgnoreFile(w.path)) {
continue;
}
const mtime = w.stat?.mtime ?? 0;
const ctime = w.stat?.ctime ?? mtime;
const size = w.stat?.size ?? 0;
result.push({
...w,
mtime,
ctime,
size,
});
}
return result;
}
// <-- Local Storage SubFunctions
}
@@ -19,6 +19,7 @@ describe("HiddenFileSyncContext lifecycle", () => {
const periodicInternalFileScanProcessor = { disable: vi.fn() };
const conflictResolution = { dispose: vi.fn() };
const changeProcessor = { dispose: vi.fn() };
const reconciliation = { dispose: vi.fn() };
const pathAdmission = { dispose: vi.fn() };
const changeNotifier = { dispose: vi.fn() };
const closeJsonConflictDialogs = vi.fn();
@@ -28,6 +29,7 @@ describe("HiddenFileSyncContext lifecycle", () => {
periodicInternalFileScanProcessor,
conflictResolution,
changeProcessor,
reconciliation,
pathAdmission,
changeNotifier,
eventCount: 4,
@@ -40,6 +42,7 @@ describe("HiddenFileSyncContext lifecycle", () => {
expect(periodicInternalFileScanProcessor.disable).toHaveBeenCalledOnce();
expect(conflictResolution.dispose).toHaveBeenCalledOnce();
expect(changeProcessor.dispose).toHaveBeenCalledOnce();
expect(reconciliation.dispose).toHaveBeenCalledOnce();
expect(pathAdmission.dispose).toHaveBeenCalledOnce();
expect(changeNotifier.dispose).toHaveBeenCalledOnce();
expect(closeJsonConflictDialogs).toHaveBeenCalledOnce();
@@ -60,35 +63,6 @@ describe("HiddenFileSyncContext lifecycle", () => {
expect(callPrivate<() => boolean>(hiddenFileSync, "isReady")()).toBe(false);
});
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const rebuildMerging = vi.fn(async () => []);
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
const scanAllStorageChanges = vi.fn(async () => undefined);
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSyncContext.prototype) as HiddenFileSyncContext;
Object.assign(hiddenFileSync, {
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
adoptCurrentDatabaseFilesAsProcessed,
scanAllStorageChanges,
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
@@ -0,0 +1,683 @@
import {
type AnyEntry,
type DocumentID,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
type MetaEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { type InternalFileInfo, ICHeader, ICHeaderEnd } from "@/common/types.ts";
import {
BASE_IS_NEW,
compareMTime,
EVEN,
getLogLevel,
isInternalMetadata,
onlyInNTimes,
TARGET_IS_NEW,
} from "@/common/utils.ts";
import {
collectOptionalFileSyncFiles,
type OptionalFileSyncFileTreeDependencies,
} from "@/features/optionalFileSyncFileTree.ts";
import type { HiddenFileSyncChangeProcessor } from "./hiddenFileSyncChangeProcessor.ts";
import type { HiddenFileSyncProcessedState } from "./hiddenFileSyncProcessedState.ts";
import { describeHiddenFileSyncDocument, getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts";
import type {
HiddenFileSyncInitialisationDirection as InitialisationDirection,
HiddenFileSyncTestingRebuild,
HiddenFileSyncTestingRebuildInterceptor,
} from "./hiddenFileSyncViews.ts";
export type { HiddenFileSyncInitialisationDirection as InitialisationDirection } from "./hiddenFileSyncViews.ts";
export type ReconciliationProgress = {
log(message: string): void;
once(message: string): void;
done(message?: string): void;
};
type ReconciliationDatabase = Pick<LiveSyncLocalDB, "allDocsRaw">;
type ReconciliationStorage = Pick<StorageAccess, "statHidden">;
type ReconciliationProcessedState = Pick<
HiddenFileSyncProcessedState,
| "databaseStateKey"
| "getLastProcessedDatabaseKey"
| "getLastProcessedFileKey"
| "getLastProcessedFileMTime"
| "hasLastProcessedDatabase"
| "hasLastProcessedFile"
| "getLastProcessedFileKeys"
| "resetLastProcessedDatabase"
| "resetLastProcessedFile"
| "storageStateKey"
| "updateLastProcessed"
| "updateLastProcessedAsActualDatabase"
| "updateLastProcessedAsActualFile"
>;
type ReconciliationChangeProcessor = Pick<
HiddenFileSyncChangeProcessor,
"processStorageChange" | "processDatabaseChange"
>;
export type ReconciliationDependencies = OptionalFileSyncFileTreeDependencies & {
getLocalDatabase(): ReconciliationDatabase;
storageAccess: ReconciliationStorage;
getRootPath(): string;
getPath(entry: AnyEntry): FilePathWithPrefix;
isTargetFile(path: FilePath): Promise<boolean>;
isIgnoredByIgnoreFile(path: string): Promise<boolean>;
createProgress(prefix?: string, level?: LOG_LEVEL): ReconciliationProgress;
processedState: ReconciliationProcessedState;
changeProcessor: ReconciliationChangeProcessor;
log: LogFunction;
};
export type Reconciliation = {
processStorageChange(
path: FilePath,
onlyNew?: boolean,
forceWrite?: boolean,
includeDeleted?: boolean
): Promise<boolean | undefined>;
processDatabaseDocument(doc: LoadedEntry): Promise<boolean>;
scanInternalFiles(): Promise<InternalFileInfo[]>;
scanAllStorageChanges(
showNotice?: boolean,
onlyNew?: boolean,
forceWriteAll?: boolean,
includeDeleted?: boolean
): Promise<unknown>;
scanAllDatabaseChanges(
showNotice?: boolean,
onlyNew?: boolean,
forceWriteAll?: boolean,
includeDeletion?: boolean
): Promise<unknown>;
applyOfflineChanges(showNotice: boolean): Promise<unknown>;
initialiseInternalFileSync(
direction: InitialisationDirection,
showMessage: boolean,
targetFilesSrc?: string[] | false,
initialisationProgress?: ReconciliationProgress
): Promise<void>;
interceptRebuildMerging(interceptor: HiddenFileSyncTestingRebuildInterceptor): () => void;
dispose(): void;
};
class ReconciliationOwner implements Reconciliation {
private rebuildMergingHook: HiddenFileSyncTestingRebuild | undefined;
constructor(private readonly dependencies: ReconciliationDependencies) {}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private get storageAccess() {
return this.dependencies.storageAccess;
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.getPath(entry);
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
private _verbose(message: unknown, key?: string): void {
this._log(message, LOG_LEVEL_VERBOSE, key);
}
private _progress(prefix: string = "", level: LOG_LEVEL = LOG_LEVEL_NOTICE): ReconciliationProgress {
return this.dependencies.createProgress(prefix, level);
}
async processStorageChange(
path: FilePath,
onlyNew = false,
forceWrite = false,
includeDeleted = true
): Promise<boolean | undefined> {
if (!(await this.dependencies.isTargetFile(path))) {
this._log(
`Storage file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
return false;
}
return await this.dependencies.changeProcessor.processStorageChange(path, onlyNew, forceWrite, includeDeleted);
}
async processDatabaseDocument(doc: LoadedEntry): Promise<boolean> {
const info = describeHiddenFileSyncDocument(doc, this.getPath(doc));
const path = info.path;
const headerLine = `Tracking DB ${info.path} (${info.revDisplay}) :`;
const ret = await this.trackDatabaseFileModification(path, headerLine);
this._log(`${headerLine} Done: ${info.shortenedId})`, LOG_LEVEL_VERBOSE);
return ret;
}
private async scanInternalFileNames(): Promise<FilePath[]> {
const findRoot = this.dependencies.getRootPath();
const filenames = await collectOptionalFileSyncFiles(this.dependencies, findRoot, {
shouldInclude: (path) => this.dependencies.isTargetFile(path as FilePath),
onError: (path, error) => {
this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO);
this._log(error, LOG_LEVEL_VERBOSE);
},
});
return filenames as FilePath[];
}
async scanInternalFiles(): Promise<InternalFileInfo[]> {
const fileNames = await this.scanInternalFileNames();
const files = fileNames.map(async (e) => {
return {
path: e,
stat: await this.storageAccess.statHidden(e),
};
});
const result: InternalFileInfo[] = [];
for (const f of files) {
const w = await f;
if (await this.dependencies.isIgnoredByIgnoreFile(w.path)) {
continue;
}
const mtime = w.stat?.mtime ?? 0;
const ctime = w.stat?.ctime ?? mtime;
const size = w.stat?.size ?? 0;
result.push({
...w,
mtime,
ctime,
size,
});
}
return result;
}
private async adoptCurrentStorageFilesAsProcessed(targetFiles: FilePath[] | false): Promise<void> {
const allFiles = await this.scanInternalFileNames();
const files = targetFiles ? allFiles.filter((e) => targetFiles.some((t) => e.indexOf(t) !== -1)) : allFiles;
for (const file of files) {
await this.dependencies.processedState.updateLastProcessedAsActualFile(file);
}
}
private async adoptCurrentDatabaseFilesAsProcessed(targetFiles: FilePath[] | false): Promise<void> {
const allFiles = await this.getAllDatabaseFiles();
const files = targetFiles
? allFiles.filter((e) => targetFiles.some((t) => e.path.indexOf(t) !== -1))
: allFiles;
for (const file of files) {
const path = stripAllPrefixes(this.getPath(file));
await this.dependencies.processedState.updateLastProcessedAsActualDatabase(path, file);
}
}
private async trackScannedStorageChanges(
processFiles: FilePath[],
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeleted = true
): Promise<void> {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Storage -> DB ]\n`, logLevel);
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
const processes = processFiles.map(async (file, i) => {
try {
await this.processStorageChange(file, onlyNew, forceWriteAll, includeDeleted);
notifyProgress();
} catch (ex) {
p.once(`Failed to process storage change file:${file}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
});
await Promise.all(processes);
p.done();
}
async scanAllStorageChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeleted = true
): Promise<unknown> {
return await skipIfDuplicated("scanAllStorageChanges", async () => {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Scanning Storage -> DB ]\n`, logLevel);
p.log(`Scanning storage files...`);
const knownNames = [...this.dependencies.processedState.getLastProcessedFileKeys()] as FilePath[];
const existNames = await this.scanInternalFileNames();
const files = new Set([...knownNames, ...existNames]);
this._log(
`Known/Exist ${knownNames.length}/${existNames.length}, Totally ${files.size} files.`,
LOG_LEVEL_VERBOSE
);
const taskNameAndMeta = [...files].map(async (e) => [e, await this.storageAccess.statHidden(e)] as const);
const nameAndMeta = await Promise.all(taskNameAndMeta);
const processFiles = nameAndMeta
.filter(([path, stat]) => {
if (forceWriteAll) return true;
const key = this.dependencies.processedState.getLastProcessedFileKey(path);
const newKey = this.dependencies.processedState.storageStateKey(stat);
return key != newKey;
})
.map(([path, stat]) => path);
const staticsMessage = `[Storage hidden file statics]
Known files: ${knownNames.length}
Actual files: ${existNames.length}
All files: ${files.size}
Offline Changed files: ${processFiles.length}`;
// this._log(staticsMessage, logLevel, "scan-changes");
p.once(staticsMessage);
await this.trackScannedStorageChanges(processFiles, showNotice, onlyNew, forceWriteAll, includeDeleted);
p.done();
});
}
private async trackScannedDatabaseChange(
processFiles: MetaEntry[],
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeletion = true
): Promise<void> {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ DB -> Storage ]\n`, logLevel);
const notifyProgress = onlyInNTimes(100, (progress) => p.log(`${progress}/${processFiles.length}`));
const processes = processFiles.map(async (file) => {
try {
const path = stripAllPrefixes(this.getPath(file));
if (!(await this.dependencies.isTargetFile(path))) {
this._log(
`Database file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
} else {
await this.trackDatabaseFileModification(
path,
"[Hidden file scan]",
!forceWriteAll,
onlyNew,
file,
includeDeletion
);
}
notifyProgress();
} catch (ex) {
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
this._log(ex, LOG_LEVEL_VERBOSE);
}
});
await Promise.all(processes);
p.done();
}
async applyOfflineChanges(showNotice: boolean): Promise<unknown> {
const logLevel = getLogLevel(showNotice);
return await serialized("applyOfflineChanges", async () => {
const p = this._progress("[⚙ Apply untracked changes ]\n", logLevel);
this._log(`Track changes.`, logLevel);
p.log("Enumerating local files...");
const currentStorageFiles = await this.scanInternalFileNames();
p.log("Enumerating database files...");
const currentDatabaseFiles = await this.getAllDatabaseFiles();
const allDatabaseMap = Object.fromEntries(
currentDatabaseFiles.map((e) => [stripAllPrefixes(this.getPath(e)), e])
);
const currentDatabaseFileNames = [...Object.keys(allDatabaseMap)] as FilePath[];
const untrackedLocal = currentStorageFiles.filter(
(e) => !this.dependencies.processedState.hasLastProcessedFile(e)
);
const untrackedDatabase = currentDatabaseFileNames.filter(
(e) => !this.dependencies.processedState.hasLastProcessedDatabase(e)
);
const bothUntracked = untrackedLocal.filter((e) => untrackedDatabase.indexOf(e) !== -1);
p.log("Applying untracked changes...");
const stat = `Tracking statics:
Local files: ${currentStorageFiles.length}
Database files: ${currentDatabaseFileNames.length}
Untracked local files: ${untrackedLocal.length}
Untracked database files: ${untrackedDatabase.length}
Common untracked files: ${bothUntracked.length}`;
p.once(stat);
const semaphores = Semaphore(10);
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${bothUntracked.length}`));
const allProcesses = bothUntracked.map(async (file) => {
notifyProgress();
const rel = await semaphores.acquire();
try {
const fileStat = await this.storageAccess.statHidden(file);
if (fileStat == null) {
// This should not be happened. But, if it happens, we should skip this.
this._log(`Unexpected error: Failed to stat file during applyOfflineChange :${file}`);
return;
}
const dbInfo = allDatabaseMap[file];
if (dbInfo.deleted || dbInfo._deleted) {
// Applying deletion can be harmful if the local file is not tracked.
// So, we should skip this.
return;
}
const fileMTime = getHiddenFileSyncComparisonMTime(fileStat);
const dbMTime = getHiddenFileSyncComparisonMTime(dbInfo);
const diff = compareMTime(fileMTime, dbMTime);
if (diff == BASE_IS_NEW) {
// Local file is newer than the database file.
// So, we should apply the local file to the database.
await this.processStorageChange(file, true);
} else if (diff == TARGET_IS_NEW) {
// Database file is newer than the local file.
// So, we should apply the database file to the local file.
await this.trackDatabaseFileModification(file, "[Apply]", true, true, dbInfo);
} else if (diff == EVEN) {
// Both are same, we may skip this but should update the last processed key.
this.dependencies.processedState.updateLastProcessed(file, dbInfo, fileStat);
}
} finally {
rel();
}
});
await Promise.all(allProcesses);
await this.scanAllStorageChanges(showNotice);
await this.scanAllDatabaseChanges(showNotice);
p.done();
});
}
async scanAllDatabaseChanges(
showNotice: boolean = false,
onlyNew = false,
forceWriteAll = false,
includeDeletion = true
): Promise<unknown> {
return await skipIfDuplicated("scanAllDatabaseChanges", async () => {
const databaseFiles = await this.getAllDatabaseFiles();
const files = databaseFiles.filter((e) => {
const doc = e;
const key = this.dependencies.processedState.databaseStateKey(doc);
const path = stripAllPrefixes(this.getPath(doc));
const lastKey = this.dependencies.processedState.getLastProcessedDatabaseKey(path);
return lastKey != key;
});
const logLevel = getLogLevel(showNotice);
const staticsMessage = `[Database hidden file statics]
All files: ${databaseFiles.length}
Offline Changed files: ${files.length}`;
this._log(staticsMessage, logLevel, "scan-changes");
return await this.trackScannedDatabaseChange(files, showNotice, onlyNew, forceWriteAll, includeDeletion);
});
}
private async useDatabaseFiles(files: MetaEntry[], showNotice = false, onlyNew = false): Promise<boolean> {
const logLevel = getLogLevel(showNotice);
const p = this._progress(`[⚙ Scanning DB -> Storage ]\n`, logLevel);
p.log("Scanning database files...");
const notifyProgress = onlyInNTimes(25, (progress) => p.log(`${progress}/${files.length}`));
const processFiles = files.map(async (file) => {
try {
const path = stripAllPrefixes(this.getPath(file));
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
notifyProgress();
} catch (ex) {
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
return;
});
await Promise.all(processFiles);
p.done();
return true;
}
private async trackDatabaseFileModification(
path: FilePath,
headerLine: string,
preventDoubleProcess = false,
onlyNew = false,
meta: MetaEntry | false = false,
includeDeletion = true
): Promise<boolean> {
return await this.dependencies.changeProcessor.processDatabaseChange(path, headerLine, {
preventDoubleProcess,
onlyNew,
metaEntry: meta,
includeDeletion,
});
}
private async rebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false): Promise<FilePath[]> {
const logLevel = getLogLevel(showNotice);
const p = this._progress("[⚙ Rebuild by Merge ]\n", logLevel);
this._log(`Rebuilding hidden files from the storage and the local database.`, logLevel);
p.log("Enumerating local files...");
const currentStorageFilesAll = await this.scanInternalFileNames();
const currentStorageFiles = targetFiles
? currentStorageFilesAll.filter((e) => targetFiles.some((f) => f == e))
: currentStorageFilesAll;
p.log("Enumerating database files...");
const allDatabaseFiles = await this.getAllDatabaseFiles();
const allDatabaseMap = new Map(allDatabaseFiles.map((e) => [stripAllPrefixes(this.getPath(e)), e]));
const currentDatabaseFiles = targetFiles
? allDatabaseFiles.filter((e) => targetFiles.some((f) => f == stripAllPrefixes(this.getPath(e))))
: allDatabaseFiles;
const allFileNames = new Set([
...currentStorageFiles,
...currentDatabaseFiles.map((e) => stripAllPrefixes(this.getPath(e))),
]);
const storageToDatabase = [] as FilePath[];
const databaseToStorage = [] as MetaEntry[];
const eachProgress = onlyInNTimes(100, (progress) => p.log(`Checking ${progress}/${allFileNames.size}`));
for (const file of allFileNames) {
eachProgress();
const storageMTime = await this.storageAccess.statHidden(file);
const mtimeStorage = getHiddenFileSyncComparisonMTime(storageMTime);
const dbEntry = allDatabaseMap.get(file)!;
const mtimeDB = getHiddenFileSyncComparisonMTime(dbEntry);
const diff = compareMTime(mtimeStorage, mtimeDB);
if (diff == BASE_IS_NEW) {
storageToDatabase.push(file);
} else if (diff == TARGET_IS_NEW) {
databaseToStorage.push(dbEntry);
} else if (diff == EVEN) {
// For safety, storage to database.
storageToDatabase.push(file);
}
}
p.once(
`Storage to Database: ${storageToDatabase.length} files\n Database to Storage: ${databaseToStorage.length} files`
);
this.dependencies.processedState.resetLastProcessedDatabase(targetFiles);
this.dependencies.processedState.resetLastProcessedFile(targetFiles);
const processes = [
this.trackScannedStorageChanges(storageToDatabase, showNotice, false, true),
this.useDatabaseFiles(databaseToStorage, showNotice, false),
];
p.log("Start processing...");
await Promise.all(processes);
p.done();
return [...allFileNames];
}
private async runRebuildMerging(showNotice: boolean, targetFiles: FilePath[] | false = false): Promise<FilePath[]> {
return this.rebuildMergingHook
? await this.rebuildMergingHook(showNotice, targetFiles)
: await this.rebuildMerging(showNotice, targetFiles);
}
private async rebuildFromStorage(
showNotice: boolean,
targetFiles: FilePath[] | false = false,
onlyNew = false
): Promise<FilePath[]> {
// reset processed file markers
const logLevel = getLogLevel(showNotice);
this._verbose(`Rebuilding hidden files from the storage.`);
this._log(`Rebuilding hidden files from the storage.`, logLevel);
const p = this._progress("[⚙ Rebuild by Storage ]\n", logLevel);
p.log("Enumerating local files...");
const currentFilesAll = await this.scanInternalFileNames();
const currentFiles = targetFiles
? currentFilesAll.filter((e) => targetFiles.some((f) => f == e))
: currentFilesAll;
p.once(`Storage to Database: ${currentFiles.length} files.`);
p.log("Start processing...");
this.dependencies.processedState.resetLastProcessedFile(targetFiles);
await this.trackScannedStorageChanges(currentFiles, showNotice, onlyNew, true);
p.done();
return currentFiles;
}
private async getAllDatabaseFiles(): Promise<MetaEntry[]> {
const allFiles = (
await this.localDatabase.allDocsRaw({ startkey: ICHeader, endkey: ICHeaderEnd, include_docs: true })
).rows
.filter((e) => isInternalMetadata(e.id as DocumentID))
.map((e) => e.doc) as MetaEntry[];
const files = [] as MetaEntry[];
for (const file of allFiles) {
if (await this.dependencies.isTargetFile(stripAllPrefixes(this.getPath(file)))) {
files.push(file);
}
}
return files;
}
private async rebuildFromDatabase(
showNotice: boolean,
targetFiles: FilePath[] | false = false,
onlyNew = false
): Promise<MetaEntry[]> {
const logLevel = getLogLevel(showNotice);
this._verbose(`Rebuilding hidden files from the local database.`);
this._log(`Rebuilding hidden files from the local database.`, logLevel);
const p = this._progress("[⚙ Rebuild by Database ]\n", logLevel);
p.log("Enumerating database files...");
const allFiles = await this.getAllDatabaseFiles();
// THINKING: Should we exclude conflicted or deleted files?
// Current implementation is to include all files, and following processes will handle for them.
// However, in perspective of performance and future-proofing, I feel somewhat justified in doing it here.
const currentFiles = targetFiles
? allFiles.filter((e) => targetFiles.some((f) => f == stripAllPrefixes(this.getPath(e))))
: allFiles;
p.once(`Database to Storage: ${currentFiles.length} files.`);
this.dependencies.processedState.resetLastProcessedDatabase(targetFiles);
p.log("Start processing...");
await this.useDatabaseFiles(currentFiles, showNotice, onlyNew);
p.done();
return currentFiles;
}
async initialiseInternalFileSync(
direction: InitialisationDirection,
showMessage: boolean,
// filesAll: InternalFileInfo[] | false = false,
targetFilesSrc: string[] | false = false,
initialisationProgress?: ReconciliationProgress
): Promise<void> {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// Compatibility question: the legacy preflight was already disabled.
// Enabling it would change initialisation timing and could open a
// conflict dialogue while the feature is being configured.
// p.log("Resolving conflicts before starting...");
// await this.conflictResolution.resolveAll();
p.log("Initialising hidden files sync...");
// The initialisation progress owns the user-visible Notice. Its child
// rebuild and scan operations still write ordinary log entries, but
// must not each create another keep-alive Notice.
const showChildNotices = false;
// TODO: Handling ignore files cannot be performed to the hidden files.
const targetFiles = targetFilesSrc
? targetFilesSrc.map((e) => stripAllPrefixes(e as FilePathWithPrefix))
: false;
if (direction == "pushForce" || direction == "push") {
const onlyNew = direction == "push";
p.log(`Started: Storage --> Database ${onlyNew ? "(Only New)" : ""}`);
const updatedFiles = await this.rebuildFromStorage(showChildNotices, targetFiles, onlyNew);
// making doubly sure, No more losing files.
// I did so many times during the development.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
if (direction == "pullForce" || direction == "pull") {
const onlyNew = direction == "pull";
p.log(`Started: Database --> Storage ${onlyNew ? "(Only New)" : ""}`);
const updatedEntries = await this.rebuildFromDatabase(showChildNotices, targetFiles, onlyNew);
const updatedFiles = updatedEntries.map((e) => stripAllPrefixes(this.getPath(e)));
// making doubly sure, No more losing files.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining database and storage changes...");
await this.scanAllDatabaseChanges(showChildNotices, true, false);
await this.scanAllStorageChanges(showChildNotices, true, false);
}
if (direction == "safe") {
p.log(`Started: Database <--> Storage (by modified date)`);
const updatedFiles = await this.runRebuildMerging(showChildNotices, targetFiles);
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
}
interceptRebuildMerging(interceptor: HiddenFileSyncTestingRebuildInterceptor): () => void {
const previousHook = this.rebuildMergingHook;
const runRebuild = async (showNotice: boolean, targetFiles?: FilePath[] | false) =>
await this.rebuildMerging(showNotice, targetFiles);
const hook: HiddenFileSyncTestingRebuild = async (showNotice, targetFiles) =>
await interceptor(runRebuild, showNotice, targetFiles);
this.rebuildMergingHook = hook;
return () => {
if (this.rebuildMergingHook === hook) {
this.rebuildMergingHook = previousHook;
}
};
}
dispose(): void {
this.rebuildMergingHook = undefined;
}
}
export function createReconciliation(dependencies: ReconciliationDependencies): Reconciliation {
return new ReconciliationOwner(dependencies);
}
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
type FilePath,
type MetaEntry,
type UXStat,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
import {
createReconciliation,
type ReconciliationProgress,
type ReconciliationDependencies,
} from "./reconciliation.ts";
const targetPath = ".obsidian/app.json" as FilePath;
const filteredPath = ".obsidian/plugins/other/data.json" as FilePath;
function createFixture(options: { files?: FilePath[]; databaseFiles?: MetaEntry[] } = {}) {
const files = options.files ?? [targetPath];
const databaseFiles = options.databaseFiles ?? [];
const processedFiles = new Map<string, string>();
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
} satisfies ReconciliationProgress;
const createProgress = vi.fn((_prefix = "", _level = LOG_LEVEL_NOTICE) => progress);
const statHidden = vi.fn(
async (path: FilePath): Promise<UXStat | null> => ({
ctime: 10,
mtime: path == targetPath ? 20 : 30,
size: 10,
type: "file",
})
);
const isTargetFile = vi.fn(async (path: FilePath) => path == targetPath);
const allDocsRaw = vi.fn(async () => ({
rows: databaseFiles.map((doc) => ({ id: doc._id, doc })),
}));
const processedState = {
databaseStateKey: vi.fn((entry: MetaEntry) => `${entry._rev}`),
getLastProcessedDatabaseKey: vi.fn(() => undefined as string | undefined),
getLastProcessedFileKey: vi.fn(() => undefined as string | undefined),
getLastProcessedFileMTime: vi.fn(() => 0),
hasLastProcessedDatabase: vi.fn(() => false),
hasLastProcessedFile: vi.fn(() => false),
getLastProcessedFileKeys: vi.fn(() => processedFiles.keys()),
resetLastProcessedDatabase: vi.fn(),
resetLastProcessedFile: vi.fn(),
storageStateKey: vi.fn((stat: UXStat | null) => `${stat?.mtime ?? 0}`),
updateLastProcessed: vi.fn(),
updateLastProcessedAsActualDatabase: vi.fn(async () => undefined),
updateLastProcessedAsActualFile: vi.fn(async () => undefined),
};
const changeProcessor = {
processStorageChange: vi.fn(async () => true),
processDatabaseChange: vi.fn(async () => true),
};
const dependencies = {
listFiles: vi.fn(async () => ({ files, folders: [] })),
getLocalDatabase: () => ({ allDocsRaw }),
storageAccess: { statHidden },
getRootPath: () => "root",
getPath: (entry: MetaEntry) => entry.path,
isTargetFile,
isIgnoredByIgnoreFile: vi.fn(async () => false),
createProgress,
processedState,
changeProcessor,
log: vi.fn(),
} as unknown as ReconciliationDependencies;
return {
dependencies,
progress,
createProgress,
isTargetFile,
statHidden,
processedState,
changeProcessor,
};
}
function metadata(path: FilePath = targetPath): MetaEntry {
return {
_id: `i:${path}`,
_rev: "2-current",
path: `i:${path}`,
type: "plain",
datatype: "plain",
ctime: 10,
mtime: 20,
size: 10,
children: [],
eden: {},
deleted: false,
} as unknown as MetaEntry;
}
describe("Reconciliation", () => {
it("keeps push initialisation direction and follow-up scan order", async () => {
const fixture = createFixture();
const reconciliation = createReconciliation(fixture.dependencies);
const order: string[] = [];
const scanStorageChanges = vi.spyOn(reconciliation, "scanAllStorageChanges").mockImplementation(async () => {
order.push("storage-scan");
});
const scanDatabaseChanges = vi.spyOn(reconciliation, "scanAllDatabaseChanges").mockImplementation(async () => {
order.push("database-scan");
});
await reconciliation.initialiseInternalFileSync("push", true);
expect(fixture.changeProcessor.processStorageChange).toHaveBeenCalledWith(targetPath, true, true, true);
expect(order).toEqual(["storage-scan", "database-scan"]);
expect(scanStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(fixture.createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
expect(fixture.createProgress).toHaveBeenCalledWith("[⚙ Rebuild by Storage ]\n", LOG_LEVEL_INFO);
expect(fixture.progress.done).toHaveBeenCalledTimes(3);
});
it("restores rebuild interception in stack order without clobbering a newer hook", async () => {
const fixture = createFixture({ files: [] });
const reconciliation = createReconciliation(fixture.dependencies);
const events: string[] = [];
const first = vi.fn(async (run, showNotice, targetFiles) => {
events.push("first:start");
const result = await run(showNotice, targetFiles);
events.push("first:end");
return result;
});
const second = vi.fn(async (run, showNotice, targetFiles) => {
events.push("second:start");
const result = await run(showNotice, targetFiles);
events.push("second:end");
return result;
});
const restoreFirst = reconciliation.interceptRebuildMerging(first);
const restoreSecond = reconciliation.interceptRebuildMerging(second);
restoreFirst();
await reconciliation.initialiseInternalFileSync("safe", false);
expect(second).toHaveBeenCalledOnce();
restoreSecond();
await reconciliation.initialiseInternalFileSync("safe", false);
expect(first).toHaveBeenCalledOnce();
expect(events[0]).toBe("second:start");
expect(events[events.length - 1]).toBe("first:end");
});
it("uses admission and processed-state keys when selecting storage scan work", async () => {
const fixture = createFixture({ files: [targetPath, filteredPath] });
const reconciliation = createReconciliation(fixture.dependencies);
await reconciliation.scanAllStorageChanges(false);
expect(fixture.isTargetFile).toHaveBeenCalledWith(targetPath);
expect(fixture.isTargetFile).toHaveBeenCalledWith(filteredPath);
expect(fixture.processedState.getLastProcessedFileKey).toHaveBeenCalledWith(targetPath);
expect(fixture.changeProcessor.processStorageChange).toHaveBeenCalledWith(targetPath, false, false, true);
expect(fixture.changeProcessor.processStorageChange).not.toHaveBeenCalledWith(filteredPath, false, false, true);
expect(fixture.progress.once).toHaveBeenCalledWith(expect.stringContaining("Offline Changed files: 1"));
});
});
@@ -202,7 +202,8 @@ async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.Proces
"return JSON.stringify({ok:true,path});",
"})()",
].join(""),
env
env,
hiddenFileCliTimeoutMs
);
}
@@ -567,7 +568,9 @@ async function runMixedOwnership(context: RunnerContext, vault: TemporaryVault):
"const hidden=plugin.optionalFileSync.testing.hiddenFileSync;",
"core.services.setting.setDeviceAndVaultName('mixed-ownership');",
"await customisation.scanAllConfigFiles(false);",
"await hidden.scanAllStorageChanges(false,false,true,true);",
// The testing view exposes the command-level one-argument scan.
// Extra internal scan flags passed here were previously ignored by the view wrapper.
"await hidden.scanAllStorageChanges(false);",
"const customisationPaths=[];",
"for await(const entry of core.localDatabase.findEntries('ix:','ix;')){customisationPaths.push(entry.path);}",
"const hiddenPaths=[];",