@vrtmrz/livesync-commonlib 0.1.0-rc.5 → 0.1.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +11 -9
  2. package/dist/common/configForDoc.js +7 -2
  3. package/dist/common/models/setting.type.d.ts +2 -1
  4. package/dist/common/models/tweak.definition.d.ts +1 -1
  5. package/dist/common/settingConstants.d.ts +3 -2
  6. package/dist/common/settingConstants.js +2 -2
  7. package/dist/context.d.ts +2 -1
  8. package/dist/context.js +4 -0
  9. package/dist/platform/node/storage.d.ts +4 -0
  10. package/dist/platform/node/storage.js +86 -25
  11. package/dist/replication/LiveSyncAbstractReplicator.d.ts +2 -1
  12. package/dist/replication/trystero/addP2PEventHandlers.js +1 -4
  13. package/dist/replication/trystero/useP2PReplicatorFeature.js +24 -14
  14. package/dist/serviceModules/Rebuilder.js +8 -3
  15. package/dist/services/BrowserServices.d.ts +2 -0
  16. package/dist/services/BrowserServices.js +4 -1
  17. package/dist/services/HeadlessServices.d.ts +2 -0
  18. package/dist/services/HeadlessServices.js +2 -1
  19. package/dist/services/base/CommonlibMessages.d.ts +318 -0
  20. package/dist/services/base/CommonlibMessages.js +313 -0
  21. package/dist/services/base/MessageTranslator.d.ts +5 -2
  22. package/dist/services/base/MessageTranslator.js +21 -0
  23. package/dist/services/base/ServiceBase.d.ts +1 -1
  24. package/dist/services/base/ServiceBase.js +2 -4
  25. package/dist/services/base/SettingService.d.ts +3 -0
  26. package/dist/services/base/SettingService.js +3 -2
  27. package/docs/development.md +28 -15
  28. package/docs/p2p-transport-lifecycle.md +14 -4
  29. package/docs/platform-storage.md +2 -0
  30. package/docs/proven-in-use.md +18 -12
  31. package/docs/releasing.md +14 -0
  32. package/package.json +1 -26
  33. package/dist/common/i18n.d.ts +0 -18
  34. package/dist/common/i18n.js +0 -101
  35. package/dist/common/messages/combinedMessages.dev.d.ts +0 -7
  36. package/dist/common/messages/combinedMessages.dev.js +0 -44
  37. package/dist/common/messages/combinedMessages.prod.d.ts +0 -9276
  38. package/dist/common/messages/combinedMessages.prod.js +0 -9283
  39. package/dist/common/messages/de.d.ts +0 -297
  40. package/dist/common/messages/de.js +0 -7
  41. package/dist/common/messages/def.d.ts +0 -1123
  42. package/dist/common/messages/def.js +0 -7
  43. package/dist/common/messages/es.d.ts +0 -689
  44. package/dist/common/messages/es.js +0 -7
  45. package/dist/common/messages/fr.d.ts +0 -582
  46. package/dist/common/messages/fr.js +0 -7
  47. package/dist/common/messages/he.d.ts +0 -583
  48. package/dist/common/messages/he.js +0 -7
  49. package/dist/common/messages/ja.d.ts +0 -838
  50. package/dist/common/messages/ja.js +0 -7
  51. package/dist/common/messages/ko.d.ts +0 -800
  52. package/dist/common/messages/ko.js +0 -7
  53. package/dist/common/messages/ru.d.ts +0 -858
  54. package/dist/common/messages/ru.js +0 -7
  55. package/dist/common/messages/zh-tw.d.ts +0 -384
  56. package/dist/common/messages/zh-tw.js +0 -7
  57. package/dist/common/messages/zh.d.ts +0 -1093
  58. package/dist/common/messages/zh.js +0 -7
  59. package/dist/common/messagesJson/de.json +0 -295
  60. package/dist/common/messagesJson/en.json +0 -1121
  61. package/dist/common/messagesJson/es.json +0 -687
  62. package/dist/common/messagesJson/fr.json +0 -580
  63. package/dist/common/messagesJson/he.json +0 -581
  64. package/dist/common/messagesJson/ja.json +0 -836
  65. package/dist/common/messagesJson/ko.json +0 -798
  66. package/dist/common/messagesJson/ru.json +0 -856
  67. package/dist/common/messagesJson/zh-tw.json +0 -382
  68. package/dist/common/messagesJson/zh.json +0 -1091
  69. package/dist/common/rosetta.d.ts +0 -42
  70. package/dist/common/rosetta.js +0 -57
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Self-hosted LiveSync Commonlib
2
2
 
3
- Commonlib is the ESM package which provides shared data, storage, replication, host-context, and RPC primitives for Self-hosted LiveSync, its CLI, Webapp, and WebPeer.
3
+ Commonlib is the ESM package which provides shared data, storage, replication, and host-context primitives for Self-hosted LiveSync, its CLI, Webapp, and WebPeer.
4
4
 
5
5
  This package is primarily for maintainers of those clients and for integrations which deliberately reuse a reviewed Commonlib contract. It is not yet a general-purpose LiveSync SDK: the package does not provide a stable end-to-end client factory or own host UI, permissions, credentials, process lifecycle, or application persistence.
6
6
 
@@ -24,8 +24,8 @@ The package is ESM-only and declares Node 20 or later. Browser entry points are
24
24
  | `@vrtmrz/livesync-commonlib` | `DirectFileManipulator` for integrations which access CouchDB directly | Deliberately small, but its high-level lifecycle and file-operation semantics are not final |
25
25
  | `@vrtmrz/livesync-commonlib/context` | Instance-owned events, translation injection, and host-neutral standard-I/O types | Focused, package-tested pre-1.0 contract |
26
26
  | `@vrtmrz/livesync-commonlib/browser` | Rooted File System Access API storage | Focused, package-tested pre-1.0 contract |
27
- | `@vrtmrz/livesync-commonlib/node` | Rooted Node storage, Node standard I/O, and selected Node capabilities | Focused platform entry; some convenience exports still require 1.0 classification |
28
- | `@vrtmrz/livesync-commonlib/rpc` | Transport-neutral RPC rooms, sessions, errors, and the PouchDB RPC bridge | Deliberate export, but its 1.0 stability status is not yet accepted |
27
+ | `@vrtmrz/livesync-commonlib/node` | Rooted Node storage, Node standard I/O, and selected Node capabilities | Supported platform façade; keeps Node-only dependencies behind an explicit boundary |
28
+ | `@vrtmrz/livesync-commonlib/rpc` | Existing LiveSync RPC and PouchDB bridge migration | Transitional only; not part of the stable Commonlib 1.0 contract |
29
29
  | `@vrtmrz/livesync-commonlib/remote-configurations` | Multiple-remote profile creation, naming, and selection | Focused, package-tested pre-1.0 contract |
30
30
  | `@vrtmrz/livesync-commonlib/settings` | New-Vault defaults, stored-setting fallbacks, and settings migration results | Focused, package-tested pre-1.0 contract |
31
31
  | `@vrtmrz/livesync-commonlib/compat/*` | Exact legacy imports still required by existing clients | Migration-only; paths may be removed as consumers migrate |
@@ -33,13 +33,13 @@ The package is ESM-only and declares Node 20 or later. Browser entry points are
33
33
 
34
34
  Import only the entry point required by the current runtime. Browser code must not import the Node entry.
35
35
 
36
- TODO: decide whether the Node entry's `fs`, `fsPromises`, `path`, `os`, `readline`, URL, and built-in-module convenience exports belong in the 1.0 public contract or should move behind compatibility paths.
36
+ The Node and browser entries are platform façades. They keep platform-specific adapters and capabilities behind package-owned boundaries, so a consumer does not have to reproduce those imports or their build and review exceptions. The Node entry's selected built-in-module exports are deliberate parts of that façade. Package checks ensure that the root, context, and browser entries do not load the Node implementation.
37
37
 
38
- TODO: decide whether the draft RPC protocol and PouchDB bridge are stable 1.0 contracts, remain explicitly experimental, or move to a separately versioned package. Until that decision, pin the exact package version when consuming the RPC entry.
38
+ The RPC entry exists only while the maintained LiveSync P2P composition depends on the current implementation. Its draft wire protocol and PouchDB bridge are not stable Commonlib 1.0 contracts. RPC is planned to move to Fancy Kit as an independently owned rewrite; new Commonlib consumers should not adopt the transitional entry.
39
39
 
40
40
  ## Host context and initialisation
41
41
 
42
- One service context belongs to one independently composed Commonlib client. It owns that client's event hub and translator. Creating a context without options creates a fresh event hub and uses a translator which returns message keys unchanged:
42
+ One service context belongs to one independently composed Commonlib client. It owns that client's event hub and translator. Creating a context without options creates a fresh event hub and renders Commonlib's typed canonical English messages. A host may inject a translator to provide another language:
43
43
 
44
44
  ```ts
45
45
  import {
@@ -61,7 +61,9 @@ The host may instead extend `ServiceContext` with platform-specific capabilities
61
61
 
62
62
  Context construction does not select storage, connect to a database, start replication, acquire browser permission, or install process-wide state. The host separately owns platform-resource acquisition, adapter construction, and the lifecycle order of its particular service composition.
63
63
 
64
- TODO: define a reviewed high-level composition factory and its initialisation, readiness, error, and disposal contract. Until that exists, service implementations exposed under `compat/*` retain their owning client's lifecycle rather than becoming a general public composition API.
64
+ After the Self-hosted LiveSync 1.0 work, the planned high-level composition is a narrow `createLiveSyncFileClient` factory for direct database integrations, not a general factory which exposes Commonlib's internal Service Hub. It will resolve only after its private context, services, database, and protocol settings are ready, and its returned file client will own its watches, reconnect work, and orderly disposal. Obsidian, CLI, Webapp, and other full hosts retain their host-specific compositions.
65
+
66
+ The factory is not implemented yet. Until it is available, service implementations exposed under `compat/*` retain their owning client's lifecycle rather than becoming a general public composition API.
65
67
 
66
68
  ## Rooted storage
67
69
 
@@ -114,9 +116,9 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
114
116
 
115
117
  The package is currently an infrastructure and compatibility boundary. The context, rooted-storage, and standard-I/O result contracts have focused cross-platform or instance-isolation tests. Platform details which cannot be shared, such as file timestamp fidelity and browser permission handling, remain host concerns and are documented separately.
116
118
 
117
- `DirectFileManipulator` is useful for existing integrations, but its enumeration, watch ownership, failure, conflict, concurrency, readiness, and disposal semantics are not yet a stable high-level SDK contract.
119
+ `DirectFileManipulator` is useful for existing integrations, but its enumeration, watch ownership, failure, conflict, concurrency, readiness, and disposal semantics are not a stable high-level SDK contract. It is the migration source for the planned file client, not the shape to preserve as the new API.
118
120
 
119
- TODO: define and document the first high-level client façade before declaring list, get, put, delete, watch, and close behaviour stable.
121
+ The accepted replacement direction is an asynchronously created file client with stable `list`, `get`, `put`, `delete`, `watch`, and `close` operations. The operation result, conflict, concurrency, watch checkpoint, retry, and error contracts still require focused decisions and tests before that client can be published.
120
122
 
121
123
  Package developers should read [the developer guide](docs/development.md). The focused contracts are described in [the storage guide](docs/platform-storage.md), [the standard-I/O guide](docs/platform-standard-io.md), [the settings lifecycle guide](docs/settings-lifecycle.md), and [the remote configuration profile guide](docs/remote-configurations.md).
122
124
 
@@ -1,7 +1,12 @@
1
1
  import { isCloudantURI } from "../pouchdb/utils_couchdb.js";
2
2
  import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "./logger.js";
3
3
  import { getConfName } from "./settingConstants.js";
4
- import { ChunkAlgorithmNames, E2EEAlgorithmNames, E2EEAlgorithms } from "./types.js";
4
+ import {
5
+ ChunkAlgorithmNames,
6
+ E2EEAlgorithmNames,
7
+ E2EEAlgorithms,
8
+ REMOTE_COUCHDB
9
+ } from "./types.js";
5
10
  var ConditionType = /* @__PURE__ */ ((ConditionType2) => {
6
11
  ConditionType2["PLATFORM_CASE_INSENSITIVE"] = "platform-case-insensitive";
7
12
  ConditionType2["PLATFORM_CASE_SENSITIVE"] = "platform-case-sensitive";
@@ -90,7 +95,7 @@ const DoctorRegulationV0_24_30 = {
90
95
  value: 60,
91
96
  valueDisplay: "60 (detected on if less than 55)",
92
97
  level: 2 /* Recommended */,
93
- detectionFunc: (settings) => settings?.chunkSplitterVersion === "v3-rabin-karp" && !isCloudantURI(settings?.couchDB_URI || ""),
98
+ detectionFunc: (settings) => settings?.remoteType === REMOTE_COUCHDB && settings?.chunkSplitterVersion === "v3-rabin-karp" && !isCloudantURI(settings?.couchDB_URI || ""),
94
99
  reason: "With the V3 Rabin-Karp chunk splitter and Self-hosted CouchDB, the chunk size is set to 60 (means around 6MB) by default. This is in effect the maximum chunk size, which in practice is divided more finely."
95
100
  }
96
101
  }
@@ -1,7 +1,8 @@
1
1
  import type { ChunkAlgorithms, E2EEAlgorithms, HashAlgorithms, MODE_AUTOMATIC, MODE_PAUSED, MODE_SELECTIVE, MODE_SHINY, RemoteTypes } from "./setting.const.js";
2
- import type { I18N_LANGS } from "../rosetta.js";
3
2
  import type { CustomRegExpSourceList } from "./shared.type.util.js";
4
3
  import type { JWTAlgorithm } from "./auth.type.js";
4
+ /** Display languages recognised by the current LiveSync settings schema. */
5
+ export type I18N_LANGS = "" | "def" | "de" | "es" | "fr" | "he" | "ja" | "ko" | "ru" | "zh" | "zh-tw";
5
6
  /**
6
7
  * Represents the connection details required to connect to a CouchDB instance.
7
8
  */
@@ -34,7 +34,7 @@ export declare const TweakValuesTemplate: {
34
34
  showOnlyIconsOnEditor?: boolean;
35
35
  hideFileWarningNotice?: boolean;
36
36
  networkWarningStyle?: "" | "icon" | "hidden";
37
- displayLanguage?: import("../rosetta.js").I18N_LANGS;
37
+ displayLanguage?: import("./setting.type.js").I18N_LANGS;
38
38
  trashInsteadDelete?: boolean;
39
39
  doNotDeleteFolder?: boolean;
40
40
  batchSave?: boolean;
@@ -1,3 +1,4 @@
1
+ import { type MessageTranslator } from "../services/base/MessageTranslator.js";
1
2
  import { type ConfigurationItem, type ObsidianLiveSyncSettings } from "./types.js";
2
3
  type ExtractPropertiesByType<T, U> = {
3
4
  [K in keyof T as T[K] extends U ? K : never]: T[K] extends U ? K : never;
@@ -43,7 +44,7 @@ export declare const AllSettingDefault: {
43
44
  showOnlyIconsOnEditor: boolean;
44
45
  hideFileWarningNotice: boolean;
45
46
  networkWarningStyle: "" | "icon" | "hidden";
46
- displayLanguage: import("./rosetta.js").I18N_LANGS;
47
+ displayLanguage: import("./models/setting.type.js").I18N_LANGS;
47
48
  trashInsteadDelete: boolean;
48
49
  doNotDeleteFolder: boolean;
49
50
  batchSave: boolean;
@@ -202,7 +203,7 @@ export type AllBooleanItemKey = FilterBooleanKeys<AllSettings>;
202
203
  export type AllSettingItemKey = AllStringItemKey | AllNumericItemKey | AllBooleanItemKey;
203
204
  export type ValueOf<T extends AllSettingItemKey> = T extends AllStringItemKey ? string : T extends AllNumericItemKey ? number : T extends AllBooleanItemKey ? boolean : AllSettings[T];
204
205
  export declare const SettingInformation: Partial<Record<keyof AllSettings, ConfigurationItem>>;
205
- type SettingLabelTranslator = (message: string) => string;
206
+ type SettingLabelTranslator = MessageTranslator;
206
207
  export declare function getConfig(key: AllSettingItemKey, translate?: SettingLabelTranslator): false | {
207
208
  name: string;
208
209
  desc?: string;
@@ -1,4 +1,4 @@
1
- import { $t } from "./i18n.js";
1
+ import { englishMessageTranslator } from "../services/base/MessageTranslator.js";
2
2
  import {
3
3
  DEFAULT_SETTINGS,
4
4
  configurationNames
@@ -399,7 +399,7 @@ const SettingInformation = {
399
399
  desc: "Files with modification times greater than this value (in seconds since the Unix epoch) will not have their events reflected. Set to 0 to disable this limit."
400
400
  }
401
401
  };
402
- function translateInfo(infoSrc, translate = (message) => $t(message)) {
402
+ function translateInfo(infoSrc, translate = englishMessageTranslator) {
403
403
  if (!infoSrc) return false;
404
404
  const info = { ...infoSrc };
405
405
  info.name = translate(info.name);
package/dist/context.d.ts CHANGED
@@ -5,5 +5,6 @@
5
5
  */
6
6
  export { ServiceContext, createServiceContext, type ServiceContextContract, type ServiceContextOptions, } from "./services/base/ServiceBase.js";
7
7
  export { createLiveSyncEventHub, type LiveSyncEventHub } from "./hub/hub.js";
8
- export { passthroughMessageTranslator, type MessageTranslator, type TranslationParameters, } from "./services/base/MessageTranslator.js";
8
+ export { englishMessageTranslator, passthroughMessageTranslator, type MessageTranslator, type TranslationParameters, } from "./services/base/MessageTranslator.js";
9
+ export { commonlibEnglishMessages, type CommonlibMessageKey } from "./services/base/CommonlibMessages.js";
9
10
  export type { StandardIo, StandardIoChunk } from "./platform/standardIo.js";
package/dist/context.js CHANGED
@@ -4,11 +4,15 @@ import {
4
4
  } from "./services/base/ServiceBase.js";
5
5
  import { createLiveSyncEventHub } from "./hub/hub.js";
6
6
  import {
7
+ englishMessageTranslator,
7
8
  passthroughMessageTranslator
8
9
  } from "./services/base/MessageTranslator.js";
10
+ import { commonlibEnglishMessages } from "./services/base/CommonlibMessages.js";
9
11
  export {
10
12
  ServiceContext,
13
+ commonlibEnglishMessages,
11
14
  createLiveSyncEventHub,
12
15
  createServiceContext,
16
+ englishMessageTranslator,
13
17
  passthroughMessageTranslator
14
18
  };
@@ -10,6 +10,7 @@ export declare class NodeStorageAdapter implements IStorageAdapter<NodeStorageSt
10
10
  private readonly rootPath;
11
11
  constructor(rootPath: string);
12
12
  private resolvePath;
13
+ private assertNoSymbolicLinks;
13
14
  exists(path: string): Promise<boolean>;
14
15
  trystat(path: string): Promise<NodeStorageStat | null>;
15
16
  stat(path: string): Promise<NodeStorageStat | null>;
@@ -20,10 +21,13 @@ export declare class NodeStorageAdapter implements IStorageAdapter<NodeStorageSt
20
21
  write(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
21
22
  writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void>;
22
23
  append(path: string, data: string, options?: UXDataWriteOptions): Promise<void>;
24
+ /** Atomically rename or move an entry without leaving the injected root. */
25
+ rename(sourcePath: string, targetPath: string): Promise<void>;
23
26
  list(basePath: string): Promise<{
24
27
  files: string[];
25
28
  folders: string[];
26
29
  }>;
30
+ private writeFile;
27
31
  private applyTimes;
28
32
  }
29
33
  /** Bind a Node storage adapter to an explicitly selected root directory. */
@@ -1,16 +1,40 @@
1
+ import { constants as nodeFsConstants } from "node:fs";
1
2
  import * as nodeFsPromises from "node:fs/promises";
2
3
  import * as nodePath from "node:path";
3
4
  import { validateStoragePath } from "../storagePath.js";
5
+ const NO_FOLLOW = nodeFsConstants.O_NOFOLLOW ?? 0;
6
+ class SymbolicLinkStoragePathError extends Error {
7
+ constructor(path) {
8
+ super(`Storage paths must not contain symbolic links: ${path}`);
9
+ this.name = "SymbolicLinkStoragePathError";
10
+ }
11
+ }
4
12
  class NodeStorageAdapter {
13
+ rootPath;
5
14
  constructor(rootPath) {
6
- this.rootPath = rootPath;
15
+ this.rootPath = nodePath.resolve(rootPath);
7
16
  }
8
- rootPath;
9
- resolvePath(path, allowRoot = true) {
10
- return nodePath.join(this.rootPath, validateStoragePath(path, allowRoot));
17
+ async resolvePath(path, allowRoot = true) {
18
+ const fullPath = nodePath.join(this.rootPath, validateStoragePath(path, allowRoot));
19
+ await this.assertNoSymbolicLinks(fullPath);
20
+ return fullPath;
21
+ }
22
+ async assertNoSymbolicLinks(fullPath) {
23
+ const relativePath = nodePath.relative(this.rootPath, fullPath);
24
+ let currentPath = this.rootPath;
25
+ for (const segment of relativePath.split(nodePath.sep).filter(Boolean)) {
26
+ currentPath = nodePath.join(currentPath, segment);
27
+ try {
28
+ const stat = await nodeFsPromises.lstat(currentPath);
29
+ if (stat.isSymbolicLink()) throw new SymbolicLinkStoragePathError(currentPath);
30
+ } catch (error) {
31
+ if (error.code === "ENOENT") return;
32
+ throw error;
33
+ }
34
+ }
11
35
  }
12
36
  async exists(path) {
13
- const fullPath = this.resolvePath(path);
37
+ const fullPath = await this.resolvePath(path);
14
38
  try {
15
39
  await nodeFsPromises.access(fullPath);
16
40
  return true;
@@ -20,14 +44,15 @@ class NodeStorageAdapter {
20
44
  }
21
45
  async trystat(path) {
22
46
  try {
23
- const stat = await nodeFsPromises.stat(this.resolvePath(path));
47
+ const stat = await nodeFsPromises.stat(await this.resolvePath(path));
24
48
  return {
25
49
  size: stat.size,
26
50
  mtime: Math.floor(stat.mtimeMs),
27
51
  ctime: Math.floor(stat.ctimeMs),
28
52
  type: stat.isDirectory() ? "folder" : "file"
29
53
  };
30
- } catch {
54
+ } catch (error) {
55
+ if (error instanceof SymbolicLinkStoragePathError) throw error;
31
56
  return null;
32
57
  }
33
58
  }
@@ -35,10 +60,12 @@ class NodeStorageAdapter {
35
60
  return await this.trystat(path);
36
61
  }
37
62
  async mkdir(path) {
38
- await nodeFsPromises.mkdir(this.resolvePath(path), { recursive: true });
63
+ const fullPath = await this.resolvePath(path);
64
+ await nodeFsPromises.mkdir(fullPath, { recursive: true });
65
+ await this.assertNoSymbolicLinks(fullPath);
39
66
  }
40
67
  async remove(path) {
41
- const fullPath = this.resolvePath(path, false);
68
+ const fullPath = await this.resolvePath(path, false);
42
69
  const stat = await nodeFsPromises.stat(fullPath);
43
70
  if (stat.isDirectory()) {
44
71
  await nodeFsPromises.rm(fullPath, { recursive: true, force: true });
@@ -47,33 +74,55 @@ class NodeStorageAdapter {
47
74
  }
48
75
  }
49
76
  async read(path) {
50
- return await nodeFsPromises.readFile(this.resolvePath(path, false), "utf-8");
77
+ const handle = await nodeFsPromises.open(
78
+ await this.resolvePath(path, false),
79
+ nodeFsConstants.O_RDONLY | NO_FOLLOW
80
+ );
81
+ try {
82
+ return await handle.readFile("utf-8");
83
+ } finally {
84
+ await handle.close();
85
+ }
51
86
  }
52
87
  async readBinary(path) {
53
- const buffer = await nodeFsPromises.readFile(this.resolvePath(path, false));
54
- return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
88
+ const handle = await nodeFsPromises.open(
89
+ await this.resolvePath(path, false),
90
+ nodeFsConstants.O_RDONLY | NO_FOLLOW
91
+ );
92
+ try {
93
+ const buffer = await handle.readFile();
94
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
95
+ } finally {
96
+ await handle.close();
97
+ }
55
98
  }
56
99
  async write(path, data, options) {
57
- const fullPath = this.resolvePath(path, false);
100
+ const fullPath = await this.resolvePath(path, false);
58
101
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
59
- await nodeFsPromises.writeFile(fullPath, data, "utf-8");
60
- await this.applyTimes(fullPath, options);
102
+ await this.writeFile(fullPath, data, options, false);
61
103
  }
62
104
  async writeBinary(path, data, options) {
63
- const fullPath = this.resolvePath(path, false);
105
+ const fullPath = await this.resolvePath(path, false);
64
106
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
65
- await nodeFsPromises.writeFile(fullPath, new Uint8Array(data));
66
- await this.applyTimes(fullPath, options);
107
+ await this.writeFile(fullPath, new Uint8Array(data), options, false);
67
108
  }
68
109
  async append(path, data, options) {
69
- const fullPath = this.resolvePath(path, false);
110
+ const fullPath = await this.resolvePath(path, false);
70
111
  await nodeFsPromises.mkdir(nodePath.dirname(fullPath), { recursive: true });
71
- await nodeFsPromises.appendFile(fullPath, data, "utf-8");
72
- await this.applyTimes(fullPath, options);
112
+ await this.writeFile(fullPath, data, options, true);
113
+ }
114
+ /** Atomically rename or move an entry without leaving the injected root. */
115
+ async rename(sourcePath, targetPath) {
116
+ const fullSourcePath = await this.resolvePath(sourcePath, false);
117
+ const fullTargetPath = await this.resolvePath(targetPath, false);
118
+ await nodeFsPromises.mkdir(nodePath.dirname(fullTargetPath), { recursive: true });
119
+ await this.assertNoSymbolicLinks(fullSourcePath);
120
+ await this.assertNoSymbolicLinks(fullTargetPath);
121
+ await nodeFsPromises.rename(fullSourcePath, fullTargetPath);
73
122
  }
74
123
  async list(basePath) {
75
124
  try {
76
- const entries = await nodeFsPromises.readdir(this.resolvePath(basePath), { withFileTypes: true });
125
+ const entries = await nodeFsPromises.readdir(await this.resolvePath(basePath), { withFileTypes: true });
77
126
  const files = [];
78
127
  const folders = [];
79
128
  for (const entry of entries) {
@@ -82,15 +131,27 @@ class NodeStorageAdapter {
82
131
  else if (entry.isFile()) files.push(entryPath);
83
132
  }
84
133
  return { files, folders };
85
- } catch {
134
+ } catch (error) {
135
+ if (error instanceof SymbolicLinkStoragePathError) throw error;
86
136
  return { files: [], folders: [] };
87
137
  }
88
138
  }
89
- async applyTimes(fullPath, options) {
139
+ async writeFile(fullPath, data, options, append) {
140
+ await this.assertNoSymbolicLinks(fullPath);
141
+ const flags = nodeFsConstants.O_WRONLY | nodeFsConstants.O_CREAT | NO_FOLLOW | (append ? nodeFsConstants.O_APPEND : nodeFsConstants.O_TRUNC);
142
+ const handle = await nodeFsPromises.open(fullPath, flags, 438);
143
+ try {
144
+ await handle.writeFile(data, typeof data === "string" ? "utf-8" : void 0);
145
+ await this.applyTimes(handle, options);
146
+ } finally {
147
+ await handle.close();
148
+ }
149
+ }
150
+ async applyTimes(handle, options) {
90
151
  if (!options?.mtime && !options?.ctime) return;
91
152
  const accessTime = options.mtime ? new Date(options.mtime) : /* @__PURE__ */ new Date();
92
153
  const modificationTime = options.mtime ? new Date(options.mtime) : /* @__PURE__ */ new Date();
93
- await nodeFsPromises.utimes(fullPath, accessTime, modificationTime);
154
+ await handle.utimes(accessTime, modificationTime);
94
155
  }
95
156
  }
96
157
  function createNodeStorage({ rootPath }) {
@@ -1,6 +1,7 @@
1
1
  import { type EntryDoc, type DatabaseConnectingStatus, type RemoteDBSettings, type EntryLeaf, type TweakValues, type NodeData } from "../common/types.js";
2
2
  import type { RequiredServices } from "../interfaces/ServiceModule.js";
3
3
  import type { TranslationParameters } from "../services/base/MessageTranslator.js";
4
+ import type { CommonlibMessageKey } from "../services/base/CommonlibMessages.js";
4
5
  export type ReplicationCallback = (e: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean> | boolean;
5
6
  export type ReplicationStat = {
6
7
  sent: number;
@@ -37,7 +38,7 @@ export declare abstract class LiveSyncAbstractReplicator {
37
38
  get database(): import("../pouchdb/LiveSyncLocalDB.js").LiveSyncLocalDB;
38
39
  get rawDatabase(): PouchDB.Database<EntryDoc>;
39
40
  get currentSettings(): import("../common/types.js").ObsidianLiveSyncSettings;
40
- protected translate<TKey extends string>(key: TKey, params?: TranslationParameters): string;
41
+ protected translate(key: CommonlibMessageKey, params?: TranslationParameters): string;
41
42
  sendChunks(setting: RemoteDBSettings, remoteDB: PouchDB.Database<EntryDoc> | undefined, showResult: boolean, fromSeq?: number | string): Promise<boolean>;
42
43
  abstract getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise<Uint8Array<ArrayBuffer>>;
43
44
  ensurePBKDF2Salt(setting: RemoteDBSettings, showMessage?: boolean, useCache?: boolean): Promise<boolean>;
@@ -19,14 +19,11 @@ function addP2PEventHandlers(source, events) {
19
19
  });
20
20
  events.onEvent(EVENT_SETTING_SAVED, async (settings) => {
21
21
  const instance = current();
22
- const isOpen = instance.isServing ?? instance.server?.isServing ?? false;
23
22
  if (settings.P2P_Enabled && settings.P2P_AutoStart) {
24
23
  await instance.open();
25
24
  return;
26
25
  }
27
- if (isOpen) {
28
- await instance.close();
29
- }
26
+ await instance.close();
30
27
  });
31
28
  }
32
29
  async function openP2PReplicator(instance) {
@@ -8,6 +8,7 @@ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFa
8
8
  let replicator = new LiveSyncTrysteroReplicator({
9
9
  services: host.services
10
10
  });
11
+ let replacementPromise;
11
12
  if (openReplicationUIFactory) {
12
13
  replicator.env.openReplicationUI = openReplicationUIFactory(replicator);
13
14
  }
@@ -24,22 +25,31 @@ function useP2PReplicatorFeature(host, openReplicationUIFactory, openRebuildUIFa
24
25
  async (settingOverride = {}) => {
25
26
  const settings = { ...host.services.setting.currentSettings(), ...settingOverride };
26
27
  if (settings.remoteType == REMOTE_P2P) {
27
- const existingReplicator = replicator;
28
+ if (replacementPromise) return await replacementPromise;
29
+ const operation = (async () => {
30
+ const existingReplicator = replicator;
31
+ try {
32
+ await existingReplicator?.close();
33
+ } catch (e) {
34
+ Logger(`Error closing existing p2p replicator`);
35
+ Logger(e, LOG_LEVEL_VERBOSE);
36
+ }
37
+ const newReplicator = new LiveSyncTrysteroReplicator({ services: host.services });
38
+ if (openReplicationUIFactory) {
39
+ newReplicator.env.openReplicationUI = openReplicationUIFactory(newReplicator);
40
+ }
41
+ if (openRebuildUIFactory) {
42
+ newReplicator.env.openRebuildUI = openRebuildUIFactory(newReplicator);
43
+ }
44
+ replicator = newReplicator;
45
+ return replicator;
46
+ })();
47
+ replacementPromise = operation;
28
48
  try {
29
- await existingReplicator?.close();
30
- } catch (e) {
31
- Logger(`Error closing existing p2p replicator`);
32
- Logger(e, LOG_LEVEL_VERBOSE);
49
+ return await operation;
50
+ } finally {
51
+ if (replacementPromise === operation) replacementPromise = void 0;
33
52
  }
34
- const newReplicator = new LiveSyncTrysteroReplicator({ services: host.services });
35
- if (openReplicationUIFactory) {
36
- newReplicator.env.openReplicationUI = openReplicationUIFactory(newReplicator);
37
- }
38
- if (openRebuildUIFactory) {
39
- newReplicator.env.openRebuildUI = openRebuildUIFactory(newReplicator);
40
- }
41
- replicator = newReplicator;
42
- return replicator;
43
53
  }
44
54
  return void 0;
45
55
  }
@@ -1,5 +1,5 @@
1
1
  import { FlagFilesHumanReadable } from "../common/models/redflag.const.js";
2
- import { REMOTE_COUCHDB, REMOTE_MINIO } from "../common/models/setting.const.js";
2
+ import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "../common/models/setting.const.js";
3
3
  import { DEFAULT_SETTINGS } from "../common/models/setting.const.defaults.js";
4
4
  import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
5
5
  import { delay } from "octagonal-wheels/promises";
@@ -108,6 +108,9 @@ Please enable them from the settings screen after setup is complete.`,
108
108
  await this.resetLocalDatabase();
109
109
  await delay(1e3);
110
110
  await this.databaseEvents.initialiseDatabase(true, true, true);
111
+ if (this.setting.currentSettings().remoteType === REMOTE_P2P) {
112
+ return;
113
+ }
111
114
  await this.replication.markLocked();
112
115
  await this._tryResetRemoteDatabase();
113
116
  await this.replication.markLocked();
@@ -273,8 +276,10 @@ Are you sure you wish to proceed?`;
273
276
  await this.replication.markResolved();
274
277
  await delay(500);
275
278
  await this.replication.replicateAllFromRemote(true);
276
- await delay(1e3);
277
- await this.replication.replicateAllFromRemote(true);
279
+ if (this.setting.currentSettings().remoteType !== REMOTE_P2P) {
280
+ await delay(1e3);
281
+ await this.replication.replicateAllFromRemote(true);
282
+ }
278
283
  if (autoResume) {
279
284
  await this.finishRebuild();
280
285
  }
@@ -8,6 +8,7 @@ import type { ReplicatorService } from "./base/ReplicatorService.js";
8
8
  import type { UIService } from "./implements/base/UIService.js";
9
9
  import type { InjectableAPIService } from "./implements/injectable/InjectableAPIService.js";
10
10
  import type { KeyValueDatabaseFactory } from "../interfaces/KeyValueDatabase.js";
11
+ import type { ObsidianLiveSyncSettings } from "../common/types.js";
11
12
  export type BrowserServiceHostDependencies<T extends ServiceContext> = {
12
13
  API: InjectableAPIService<T>;
13
14
  appLifecycle: AppLifecycleService<T>;
@@ -29,6 +30,7 @@ export type BrowserServiceHubOptions<T extends ServiceContext> = {
29
30
  context?: T;
30
31
  host: BrowserServiceHost<T>;
31
32
  openKeyValueDatabase?: KeyValueDatabaseFactory;
33
+ onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
32
34
  };
33
35
  export declare class BrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
34
36
  get vault(): InjectableVaultServiceCompat<T>;
@@ -32,7 +32,10 @@ class BrowserServiceHub extends InjectableServiceHub {
32
32
  const API = options.host.createAPI(context);
33
33
  const conflict = new InjectableConflictService(context);
34
34
  const fileProcessing = new InjectableFileProcessingService(context);
35
- const setting = new InjectableSettingService(context, { APIService: API });
35
+ const setting = new InjectableSettingService(context, {
36
+ APIService: API,
37
+ onDisplayLanguageChanged: options.onDisplayLanguageChanged
38
+ });
36
39
  const appLifecycle = new BrowserAppLifecycleService(context, { settingService: setting });
37
40
  const remote = new InjectableRemoteService(context, {
38
41
  pouchDB: PouchDB,
@@ -4,11 +4,13 @@ import type { DatabaseService } from "./base/DatabaseService.js";
4
4
  import type { Constructor } from "../common/utils.type.js";
5
5
  import type { KeyValueDatabaseFactory } from "../interfaces/KeyValueDatabase.js";
6
6
  import type { PouchDBConstructor } from "../pouchdb/PouchDBConstructor.js";
7
+ import type { ObsidianLiveSyncSettings } from "../common/types.js";
7
8
  export declare class HeadlessServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
8
9
  constructor(_context: T | undefined, overrideServiceConstructor: {
9
10
  /** PouchDB with the adapters required by this headless runtime. */
10
11
  pouchDB: PouchDBConstructor;
11
12
  database?: Constructor<DatabaseService<T>>;
12
13
  openKeyValueDatabase?: KeyValueDatabaseFactory;
14
+ onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
13
15
  });
14
16
  }
@@ -54,7 +54,8 @@ class HeadlessServiceHub extends InjectableServiceHub {
54
54
  const conflict = new InjectableConflictService(context);
55
55
  const fileProcessing = new InjectableFileProcessingService(context);
56
56
  const setting = new InjectableSettingService(context, {
57
- APIService: API
57
+ APIService: API,
58
+ onDisplayLanguageChanged: overrideServiceConstructor.onDisplayLanguageChanged
58
59
  });
59
60
  const appLifecycle = new HeadlessAppLifecycleService(context, {
60
61
  settingService: setting