@vrtmrz/livesync-commonlib 0.2.3 → 0.3.1

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 (91) hide show
  1. package/README.md +46 -8
  2. package/dist/API/DirectFileManipulatorV2.d.ts +8 -1
  3. package/dist/API/DirectFileManipulatorV2.js +52 -79
  4. package/dist/API/DirectFileWatchOwner.d.ts +24 -0
  5. package/dist/API/DirectFileWatchOwner.js +256 -0
  6. package/dist/API/processSetting.d.ts +1 -0
  7. package/dist/API/processSetting.js +3 -3
  8. package/dist/common/models/setting.const.defaults.js +1 -0
  9. package/dist/common/models/setting.const.qr.js +1 -0
  10. package/dist/common/models/setting.policy.d.ts +4 -0
  11. package/dist/common/models/setting.policy.js +1 -0
  12. package/dist/common/models/setting.type.d.ts +3 -1
  13. package/dist/common/models/shared.definition.configNames.js +1 -2
  14. package/dist/common/models/tweak.definition.d.ts +2 -1
  15. package/dist/common/pathNamespace.d.ts +4 -0
  16. package/dist/common/pathNamespace.js +10 -0
  17. package/dist/common/pathNormalization.d.ts +2 -0
  18. package/dist/common/pathNormalization.js +7 -2
  19. package/dist/common/settingConstants.d.ts +1 -0
  20. package/dist/common/settingConstants.js +4 -0
  21. package/dist/managers/ChunkFetcher.d.ts +3 -0
  22. package/dist/managers/ChunkFetcher.js +20 -13
  23. package/dist/managers/LiveSyncManagers.d.ts +2 -0
  24. package/dist/managers/LiveSyncManagers.js +1 -0
  25. package/dist/managers/chunkFetchSource.d.ts +14 -0
  26. package/dist/managers/chunkFetchSource.js +12 -0
  27. package/dist/platform/AmbiguousNormalisedPathError.d.ts +16 -0
  28. package/dist/platform/AmbiguousNormalisedPathError.js +21 -0
  29. package/dist/platform/StoragePathResolver.d.ts +15 -0
  30. package/dist/platform/StoragePathResolver.js +113 -0
  31. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.d.ts +74 -0
  32. package/dist/platform/browser/UnicodeResolvingFileSystemAccessStorageAdapter.js +169 -0
  33. package/dist/platform/browser/index.d.ts +1 -0
  34. package/dist/platform/browser/index.js +2 -0
  35. package/dist/platform/browser/storage.d.ts +23 -0
  36. package/dist/platform/browser/storage.js +138 -0
  37. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.d.ts +79 -0
  38. package/dist/platform/node/UnicodeResolvingNodeStorageAdapter.js +218 -0
  39. package/dist/platform/node/index.d.ts +1 -0
  40. package/dist/platform/node/index.js +2 -0
  41. package/dist/platform/node/storage.d.ts +30 -1
  42. package/dist/platform/node/storage.js +88 -0
  43. package/dist/platform/pathComparison.d.ts +53 -0
  44. package/dist/platform/pathComparison.js +39 -0
  45. package/dist/platform/pathTypes.d.ts +154 -0
  46. package/dist/platform/pathTypes.js +0 -0
  47. package/dist/platform/storagePathResolution.d.ts +13 -0
  48. package/dist/platform/storagePathResolution.js +9 -0
  49. package/dist/pouchdb/LiveSyncDBFunctions.js +5 -0
  50. package/dist/pouchdb/LiveSyncLocalDB.d.ts +7 -1
  51. package/dist/pouchdb/LiveSyncLocalDB.js +13 -10
  52. package/dist/pouchdb/pathIdentityCompatibility.d.ts +8 -0
  53. package/dist/pouchdb/pathIdentityCompatibility.js +19 -1
  54. package/dist/replication/RemoteResource.d.ts +3 -0
  55. package/dist/replication/couchdb/LiveSyncReplicator.js +5 -3
  56. package/dist/replication/index.d.ts +2 -0
  57. package/dist/replication/index.js +2 -0
  58. package/dist/replication/journal/JournalSyncCore.d.ts +1 -0
  59. package/dist/replication/journal/JournalSyncCore.js +35 -19
  60. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +11 -3
  61. package/dist/replication/journal/LiveSyncJournalReplicator.js +123 -45
  62. package/dist/replication/trystero/P2PRoomSessionOwner.js +14 -13
  63. package/dist/replication/trystero/TrysteroReplicator.d.ts +9 -5
  64. package/dist/replication/trystero/TrysteroReplicator.js +98 -70
  65. package/dist/replication/trystero/TrysteroReplicator.pathIdentity.d.ts +11 -0
  66. package/dist/replication/trystero/TrysteroReplicator.pathIdentity.js +72 -0
  67. package/dist/replication/trystero/p2pReplicatorConfigurationIdentity.js +2 -0
  68. package/dist/replication/trystero/useP2PReplicatorFeature.js +6 -2
  69. package/dist/serviceModules/Rebuilder.d.ts +1 -0
  70. package/dist/serviceModules/Rebuilder.js +35 -6
  71. package/dist/services/base/FileProcessingService.d.ts +2 -2
  72. package/dist/services/base/RemoteConnection.d.ts +2 -0
  73. package/dist/services/base/RemoteService.d.ts +1 -1
  74. package/dist/services/base/RemoteService.js +13 -5
  75. package/dist/services/implements/injectable/InjectableVaultService.d.ts +1 -1
  76. package/dist/services/lib/couchdbHttpCache.d.ts +5 -0
  77. package/dist/services/lib/couchdbHttpCache.js +36 -0
  78. package/dist/storage.d.ts +4 -0
  79. package/dist/storage.js +19 -0
  80. package/dist/string_and_binary/path.js +4 -7
  81. package/dist/worker/bgWorker.js +1 -1
  82. package/docs/adr/2026_11_shared_storage_path_resolution.md +73 -0
  83. package/docs/conflict-resolution.md +1 -1
  84. package/docs/design_docs/storage_path_resolution.md +305 -0
  85. package/docs/development.md +15 -1
  86. package/docs/platform-storage.md +61 -0
  87. package/docs/proven-in-use.md +30 -13
  88. package/docs/releasing.md +27 -0
  89. package/docs/service-feature-composition.md +7 -1
  90. package/docs/unicode-path-identity.md +104 -12
  91. package/package.json +10 -2
package/README.md CHANGED
@@ -14,6 +14,19 @@ This package is primarily for maintainers of those clients and for integrations
14
14
 
15
15
  ## Status and versioning
16
16
 
17
+ Commonlib [0.3.0](https://www.npmjs.com/package/@vrtmrz/livesync-commonlib/v/0.3.0)
18
+ is published on npm. Self-hosted LiveSync
19
+ [1.0.44](https://github.com/vrtmrz/obsidian-livesync/releases/tag/1.0.44)
20
+ uses Commonlib 0.2.3. The [0.2.3 release notes](updates.md#023) record its
21
+ handler-setter source compatibility change and positive-infinity formatting
22
+ exception.
23
+
24
+ Version 0.3.0 requires custom clients passed to
25
+ `LiveSyncJournalReplicator.ensureBucketIsCompatible` to implement
26
+ `hasRemoteJournals(): Promise<boolean>`. The standard Journal client already
27
+ provides this operation. See the [0.3.0 release notes](updates.md#030) for the
28
+ custom-client requirement and path identity migration conditions.
29
+
17
30
  Commonlib is pre-1.0. The compatibility policy described here begins with 0.2.0 and does not retroactively apply to published 0.1.x versions. Within 0.2.x, Commonlib preserves published APIs, types, behaviour, supported persisted data, and exported `compat/*` paths; intentional breaking changes use 0.3.0. Consumers may declare ranges such as `^0.2.0`; retain a lockfile and use `npm ci` for reproducible installs. An entry in the package export map means that the import is deliberate and package-tested; it does not make every semantic detail of that entry a final 1.0 guarantee.
18
31
 
19
32
  Treat every `compat/*` subpath as a migration-only interface. A source-directory import, Git submodule, or consumer-defined TypeScript alias is not a supported package boundary.
@@ -27,6 +40,11 @@ ordinary replication before resuming local writers. Existing hosts can omit
27
40
  this optimisation. Resume cursors retain their existing meaning; this resource
28
41
  does not provide an arbitrary-time checkpoint jump or On Demand repair.
29
42
 
43
+ Unreleased CouchDB work separates encrypted HTTP responses by Security Seed
44
+ while retaining conditional revalidation within a Seed. See
45
+ [CouchDB response caching](docs/couchdb-http-cache.md) for the request,
46
+ replication-preparation, and host-adapter boundaries.
47
+
30
48
  ## Package entry points
31
49
 
32
50
  | Entry point | Intended use | Current contract status |
@@ -35,6 +53,7 @@ does not provide an arbitrary-time checkpoint jump or On Demand repair.
35
53
  | `@vrtmrz/livesync-commonlib/conflicts` | Keyed conflict-check scheduling with bounded concurrency and explicit completion | Focused, package-tested pre-1.0 contract |
36
54
  | `@vrtmrz/livesync-commonlib/context` | Instance-owned events, translation injection, and host-neutral standard-I/O types | Focused, package-tested pre-1.0 contract |
37
55
  | `@vrtmrz/livesync-commonlib/diagnostics` | Read-only Usage and integrity inspection, name projection, and reports | Focused pre-1.0 diagnostic contract |
56
+ | `@vrtmrz/livesync-commonlib/storage` | Path types, namespace and comparison helpers, and strict Unicode path resolution | Unreleased additive contract; host integration remains explicit |
38
57
  | `@vrtmrz/livesync-commonlib/hashing` | Chunk ID calculation and exact file-content hashing | Focused, package-tested pre-1.0 contract |
39
58
  | `@vrtmrz/livesync-commonlib/browser` | Rooted File System Access API storage | Focused, package-tested pre-1.0 contract |
40
59
  | `@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 |
@@ -52,8 +71,8 @@ The existing `compat/services/lib` boundary retains `bindableFunction` and `lazy
52
71
 
53
72
  The `compat/pouchdb/chunks` entry retains the obsolete `transferChunks`,
54
73
  `balanceChunkPurgedDBs`, and `fetchAllUsedChunks` helpers for existing consumers.
55
- The unreleased Self-hosted LiveSync integration removes its purge-based cleanup
56
- recovery and uses the standard locked-remote recovery dialogue. Garbage
74
+ Self-hosted LiveSync 1.0.44 uses the standard locked-remote recovery dialogue
75
+ in place of its purge-based cleanup recovery. Garbage
57
76
  Collection V3 propagates ordinary deletion revisions and does not use these
58
77
  helpers. Their compatibility exports and existing transfer behaviour remain
59
78
  available; new integrations should not adopt this obsolete cleanup path.
@@ -68,7 +87,7 @@ The P2P entry exposes eight focused views over one service owner, together with
68
87
 
69
88
  ## Blob helper migration
70
89
 
71
- The unreleased Blob extraction retains `createBlob`, `createTextBlob`, and
90
+ Commonlib 0.2.3 retains `createBlob`, `createTextBlob`, and
72
91
  `createBinaryBlob` through the existing `compat/common/utils` entry. Existing
73
92
  callers can keep those imports. `createBlob` preserves an existing Blob's
74
93
  identity and MIME type; text and binary inputs produce new Blobs with
@@ -81,8 +100,8 @@ Its normalisation function is named `toBlob`; its creation functions retain
81
100
  compares two Blobs in bounded parts and accepts an optional synchronous or
82
101
  asynchronous ArrayBuffer equality predicate. See the
83
102
  [Octagonal Wheels Blob guide](https://github.com/vrtmrz/fancy-kit/blob/main/packages/octagonal-wheels/guides/imports-and-runtime.md#blob-creation-and-normalisation)
84
- for input and runtime contracts. This unreleased Commonlib integration now pins
85
- the published Octagonal Wheels 0.1.56 dependency.
103
+ for input and runtime contracts. Commonlib 0.2.3 pins the published
104
+ Octagonal Wheels 0.1.56 dependency.
86
105
 
87
106
  Commonlib continues to own document classification, decoding in `readAsBlob`,
88
107
  and content-comparison policy. The legacy `isDocContentSame` retains its
@@ -292,7 +311,26 @@ See [the remote configuration profile guide](docs/remote-configurations.md) for
292
311
 
293
312
  CouchDB stores the representations already in use as feature identifiers in its version document. Assess that document before synchronisation or direct file operations, and retain unknown identifiers for host diagnostics. See [the remote feature compatibility contract](docs/remote-feature-compatibility.md) for the wire format, update rules, and recovery boundaries.
294
313
 
295
- The maintained CLI and Obsidian hosts can opt into [Unicode NFC path identity](docs/unicode-path-identity.md) with CouchDB. Existing databases require an explicit Rebuild or Fetch migration before using it.
314
+ Commonlib 0.3.0 provides [Unicode NFC path identity](docs/unicode-path-identity.md) with CouchDB, Object Storage, and P2P for maintained CLI and Obsidian host integrations. Application adoption remains in [Self-hosted LiveSync #1309](https://github.com/vrtmrz/obsidian-livesync/pull/1309). Changing an existing database's path identity requires coordinated Rebuild and Fetch. P2P participants must all be updated and migrated.
315
+
316
+ ## Direct database watches
317
+
318
+ An unreleased extension lets direct CouchDB integrations select
319
+ `pathNormalizationVersion: 1` in `DirectFileManipulator` options. Omitted or
320
+ `0` retains Legacy identity. The configured mode must match the database;
321
+ NFC does not relabel a populated Legacy database. A confirmed empty database
322
+ can declare the selected feature before its first Metadata write. Hosts still
323
+ own physical path resolution and the coordinated migration of existing data.
324
+
325
+ Direct CouchDB reads use the same Chunk arrival waiting and bounded retry processing as application replication. They retrieve missing Chunks through the prepared source connection, including its decryption transforms, without writing fetched Chunks back to that source.
326
+
327
+ Existing integrations can use `DirectFileManipulator.beginWatch(callback, checkIsInterested?, onCheckpoint?)`. The file callback retains its `(doc: ReadyEntry, seq?: string | number)` signature. Resolving it acknowledges delivery. An incomplete read or rejected delivery remains pending and retries after delays from one second up to 30 seconds, while other documents continue. At most four file-processing operations run concurrently. A newer change for the same document supersedes its pending read, including deletion, rename, or exclusion by `checkIsInterested`.
328
+
329
+ The optional third callback, `(seq: string | number) => Promise<void> | void`, receives the completed feed position before the first unfinished change. Persist that checkpoint before resolving the callback. Commonlib retries rejected checkpoint notifications and advances `since` only after acknowledgement. A successful file callback can have a later sequence than this safe checkpoint; do not save its sequence independently. Sequence tokens are opaque. Without a checkpoint callback, `since` advances in memory.
330
+
331
+ Reconnects replay from `since`; a new process must restore the host's saved checkpoint before starting its watch. There is no persistent retry queue, so consumers must tolerate repeated delivery after reconnect or restart. `endWatch()` cancels watch retry and reconnect timers and prevents pending reads from delivering into a stopped or replacement watch. `close()` also retires the Chunk fetcher. Already-invoked host callbacks finish their own work, and callbacks for the same document remain serialised across stop and restart. Outstanding transport reads occupy a processing slot until they settle. A disconnected watch still owns its scheduled reconnect and rejects a duplicate `beginWatch()` call.
332
+
333
+ Logical deletions and note-shaped tombstones are delivered with empty `data` without loading deleted Chunks. A bare tombstone can reuse pending note metadata; without known metadata it remains ignored because an obfuscated document ID does not supply a file path.
296
334
 
297
335
  ## Contract scope
298
336
 
@@ -319,14 +357,14 @@ MIT
319
357
 
320
358
  The shared formatter displays positive infinity as `∞y`, including through the existing `timeDeltaToHumanReadable` entry. Other duration formatting cases retain their previous behaviour.
321
359
 
322
- The unreleased extraction keeps `timeDeltaToHumanReadable`,
360
+ Commonlib 0.2.3 keeps `timeDeltaToHumanReadable`,
323
361
  `splitCustomRegExpList`, and `constructCustomRegExpList` at the existing
324
362
  `compat/common/utils` entry. They delegate to `formatDuration` in
325
363
  `octagonal-wheels/notation/duration` and the delimiter helpers in
326
364
  `octagonal-wheels/string`. Commonlib's internal `asCopy` helper delegates to
327
365
  `cloneViaJSON` in `octagonal-wheels/encoding/json`; it does not add a Commonlib
328
366
  public entry. New general-purpose callers can use the focused Octagonal Wheels
329
- entries once a release containing them is available.
367
+ entries in Octagonal Wheels 0.1.56 or later.
330
368
 
331
369
  Duration units and rounding remain unchanged. The delimiter adapters retain
332
370
  their tagged types and string coercion; splitting removes empty parts and
@@ -15,6 +15,7 @@ export type DirectFileManipulatorOptions = {
15
15
  obfuscatePassphrase: string | undefined;
16
16
  idDerivationVersion?: 0 | 1;
17
17
  idDerivationKey?: string;
18
+ pathNormalizationVersion?: 0 | 1;
18
19
  encryptInternalMetadata?: boolean;
19
20
  useDynamicIterationCount?: boolean;
20
21
  customChunkSize?: number;
@@ -137,7 +138,13 @@ export declare class DirectFileManipulator implements LiveSyncLocalDBEnv {
137
138
  watching: boolean;
138
139
  changes: PouchDB.Core.Changes<EntryDoc> | undefined;
139
140
  since: string;
140
- beginWatch(callback: (doc: ReadyEntry, seq?: string | number) => Promise<any> | void, checkIsInterested?: (doc: MetaEntry) => boolean): boolean;
141
+ private watchOwner?;
142
+ /**
143
+ * Watch file changes, retrying incomplete reads and rejected consumer callbacks.
144
+ * Resolving the file callback acknowledges processing. The optional checkpoint
145
+ * callback must resolve after the host has persisted its safe resume position.
146
+ */
147
+ beginWatch(callback: (doc: ReadyEntry, seq?: string | number) => Promise<any> | void, checkIsInterested?: (doc: MetaEntry) => boolean, onCheckpoint?: (seq: string | number) => Promise<void> | void): boolean;
141
148
  endWatch(): void;
142
149
  followUpdates(callback: (doc: ReadyEntry, seq?: string | number) => Promise<any> | void, checkIsInterested?: (doc: MetaEntry) => boolean): Promise<string | number>;
143
150
  }
@@ -1,21 +1,21 @@
1
1
  import { MemoryValueStore } from "@vrtmrz/value-store";
2
+ import { createDirectChunkFetchSource } from "../managers/chunkFetchSource.js";
2
3
  import {
3
4
  DEFAULT_SETTINGS,
4
5
  DEFAULT_SYNC_PARAMETERS,
5
6
  ProtocolVersions,
6
7
  DOCID_SYNC_PARAMETERS,
7
- VERSIONING_DOCID,
8
8
  E2EEAlgorithms,
9
9
  VER
10
10
  } from "../common/types.js";
11
11
  import { checkRemoteVersion } from "../pouchdb/negotiation.js";
12
12
  import {
13
- assessRemoteFeatureDocument,
14
- NFC_PATH_IDENTITY_FEATURE,
15
13
  requiredRemoteFeatures,
16
14
  usesEncryptedInternalMetadata
17
15
  } from "../pouchdb/remoteFeatureCompatibility.js";
18
16
  import { assessRemoteDocumentIds } from "../pouchdb/remoteIdCompatibility.js";
17
+ import { assertDatabasePathIdentity } from "../pouchdb/pathIdentityCompatibility.js";
18
+ import { getPathNormalizationVersion } from "../common/pathNormalization.js";
19
19
  import { PouchDB } from "../pouchdb/pouchdb-http.js";
20
20
  import { LiveSyncLocalDB } from "../pouchdb/LiveSyncLocalDB.js";
21
21
  import { isErrorOfMissingDoc } from "../pouchdb/utils_couchdb.js";
@@ -41,11 +41,7 @@ import {
41
41
  import { HeadlessServiceHub } from "../services/HeadlessServices.js";
42
42
  import { HeadlessDatabaseService } from "../services/implements/headless/HeadlessDatabaseService.js";
43
43
  import { ServiceContext } from "../services/base/ServiceBase.js";
44
- import { compatGlobal } from "../common/coreEnvFunctions.js";
45
- function isNoteEntry(doc) {
46
- if (!doc) return false;
47
- return doc.type == "newnote" || doc.type == "plain";
48
- }
44
+ import { DirectFileWatchOwner, isNoteEntry } from "./DirectFileWatchOwner.js";
49
45
  function isReadyEntry(doc) {
50
46
  if (!doc) return false;
51
47
  return "data" in doc;
@@ -74,15 +70,7 @@ class DirectFileManipulator {
74
70
  if (idCompatibility === "mismatched") {
75
71
  throw new Error("Direct database document IDs do not match the configured ID key.");
76
72
  }
77
- try {
78
- const versionInfo = await this.liveSyncLocalDB.localDatabase.get(VERSIONING_DOCID);
79
- const assessment = assessRemoteFeatureDocument(versionInfo);
80
- if (assessment.status === "supported" && assessment.usedFeatures.includes(NFC_PATH_IDENTITY_FEATURE)) {
81
- throw new Error("Direct database version or features are not compatible.");
82
- }
83
- } catch (error) {
84
- if (!isErrorOfMissingDoc(error)) throw error;
85
- }
73
+ await assertDatabasePathIdentity(this.liveSyncLocalDB.localDatabase, idSettings);
86
74
  const requiredFeatures = requiredRemoteFeatures(this.settings);
87
75
  if (!await checkRemoteVersion(
88
76
  this.liveSyncLocalDB.localDatabase,
@@ -136,7 +124,9 @@ class DirectFileManipulator {
136
124
  });
137
125
  this.services.setting.settings = getSettings();
138
126
  this.services.databaseEvents.onDatabaseInitialisation.addHandler(this.$everyOnInitializeDatabase.bind(this));
139
- this.liveSyncLocalDB = new LiveSyncLocalDB(this.options.url, this);
127
+ this.liveSyncLocalDB = new LiveSyncLocalDB(this.options.url, this, {
128
+ createChunkFetchSource: createDirectChunkFetchSource
129
+ });
140
130
  void this.ready.promise.catch(() => {
141
131
  });
142
132
  void this.init();
@@ -214,6 +204,7 @@ class DirectFileManipulator {
214
204
  return this.settings;
215
205
  }
216
206
  async close() {
207
+ this.endWatch();
217
208
  await this.liveSyncLocalDB.close();
218
209
  return this.liveSyncLocalDB.onunload();
219
210
  }
@@ -229,6 +220,9 @@ class DirectFileManipulator {
229
220
  passphrase: this.options.passphrase ?? "",
230
221
  idDerivationVersion: this.options.idDerivationVersion ?? 0,
231
222
  idDerivationKey: this.options.idDerivationKey ?? "",
223
+ pathNormalizationVersion: getPathNormalizationVersion({
224
+ pathNormalizationVersion: this.options.pathNormalizationVersion
225
+ }),
232
226
  deleteMetadataOfDeletedFiles: DEFAULT_SETTINGS.deleteMetadataOfDeletedFiles,
233
227
  customChunkSize: this.options.customChunkSize ?? DEFAULT_SETTINGS.customChunkSize,
234
228
  doNotPaceReplication: DEFAULT_SETTINGS.doNotPaceReplication,
@@ -375,74 +369,53 @@ class DirectFileManipulator {
375
369
  }
376
370
  }
377
371
  watching = false;
378
- // _abortController?: AbortController;
379
372
  changes;
380
373
  since = "";
381
- beginWatch(callback, checkIsInterested) {
382
- if (this.watching) return false;
383
- this.watching = true;
384
- this.changes = this.liveSyncLocalDB.localDatabase.changes({
385
- include_docs: true,
386
- since: this.since,
387
- selector: {
388
- type: { $ne: "leaf" }
389
- },
390
- live: true
391
- }).on("change", async (change) => {
392
- const doc = change.doc;
393
- if (!doc) {
394
- return;
395
- }
396
- if (!isNoteEntry(doc)) {
397
- return;
398
- }
399
- if (checkIsInterested) {
400
- if (!checkIsInterested(doc)) {
401
- Logger(`WATCH: SKIP ${doc.path}: OUT OF TARGET FOLDER`, LOG_LEVEL_VERBOSE, "watch");
402
- return;
374
+ watchOwner;
375
+ /**
376
+ * Watch file changes, retrying incomplete reads and rejected consumer callbacks.
377
+ * Resolving the file callback acknowledges processing. The optional checkpoint
378
+ * callback must resolve after the host has persisted its safe resume position.
379
+ */
380
+ beginWatch(callback, checkIsInterested, onCheckpoint) {
381
+ const manipulator = this;
382
+ this.watchOwner ??= new DirectFileWatchOwner({
383
+ state: {
384
+ get watching() {
385
+ return manipulator.watching;
386
+ },
387
+ set watching(value) {
388
+ manipulator.watching = value;
389
+ },
390
+ get changes() {
391
+ return manipulator.changes;
392
+ },
393
+ set changes(value) {
394
+ manipulator.changes = value;
395
+ },
396
+ get since() {
397
+ return manipulator.since;
398
+ },
399
+ set since(value) {
400
+ manipulator.since = value;
403
401
  }
404
- }
405
- Logger(`WATCH: PROCESSING: ${doc.path}`, LEVEL_VERBOSE, "watch");
406
- let docX;
407
- try {
408
- docX = await this.getByMeta(doc);
409
- } catch (ex) {
410
- Logger(`WATCH: DOCUMENT LOAD FAILED: ${doc.path}`, LEVEL_INFO, "watch");
411
- Logger(ex, LEVEL_VERBOSE, "watch");
412
- return;
413
- }
414
- try {
415
- await callback(docX, change.seq);
416
- Logger(`WATCH: PROCESS DONE: ${doc.path}`, LEVEL_INFO, "watch");
417
- } catch (ex) {
418
- Logger(`WATCH: PROCESS FAILED`, LEVEL_INFO, "watch");
419
- Logger(ex, LEVEL_VERBOSE, "watch");
420
- }
421
- }).on("complete", () => {
422
- Logger(`WATCH: FINISHED`, LEVEL_INFO, "watch");
423
- this.watching = false;
424
- this.changes = void 0;
425
- }).on("error", (err) => {
426
- Logger(`WATCH: ERROR: `, LEVEL_INFO, "watch");
427
- Logger(err, LEVEL_VERBOSE, "watch");
428
- if (this.watching) {
429
- Logger(`WATCH: CONNECTION HAS BEEN CLOSED, RECONNECTING...`, LEVEL_INFO, "watch");
430
- this.watching = false;
431
- this.changes = void 0;
432
- compatGlobal.setTimeout(() => {
433
- this.beginWatch(callback, checkIsInterested);
434
- }, 1e4);
435
- } else {
436
- Logger(`WATCH: CONNECTION HAS BEEN CLOSED.`, LEVEL_INFO, "watch");
402
+ },
403
+ openChanges(since) {
404
+ return manipulator.liveSyncLocalDB.localDatabase.changes({
405
+ include_docs: true,
406
+ since,
407
+ selector: { type: { $ne: "leaf" } },
408
+ live: true
409
+ });
410
+ },
411
+ readDocument(doc) {
412
+ return manipulator.getByMeta(doc);
437
413
  }
438
414
  });
415
+ return this.watchOwner.beginWatch(callback, checkIsInterested, onCheckpoint);
439
416
  }
440
417
  endWatch() {
441
- if (this.changes) {
442
- Logger(`WATCH: CANCELLING PROCESS.`, LEVEL_INFO, "watch");
443
- this.changes.cancel();
444
- Logger(`WATCH: CANCELLING SIGNAL HAS BEEN SENT.`, LEVEL_INFO, "watch");
445
- }
418
+ this.watchOwner?.endWatch();
446
419
  }
447
420
  async followUpdates(callback, checkIsInterested) {
448
421
  try {
@@ -0,0 +1,24 @@
1
+ import type { EntryDoc, NewEntry, PlainEntry } from "../common/types.js";
2
+ import type { MetaEntry, ReadyEntry } from "./DirectFileManipulatorV2.js";
3
+ export interface DirectFileWatchState {
4
+ watching: boolean;
5
+ changes: PouchDB.Core.Changes<EntryDoc> | undefined;
6
+ since: string;
7
+ }
8
+ export interface DirectFileWatchHost {
9
+ readonly state: DirectFileWatchState;
10
+ openChanges(since: string): PouchDB.Core.Changes<EntryDoc>;
11
+ readDocument(doc: MetaEntry): Promise<ReadyEntry>;
12
+ }
13
+ type FileCallback = (doc: ReadyEntry, seq?: string | number) => Promise<unknown> | void;
14
+ type SelectionCallback = (doc: MetaEntry) => boolean;
15
+ type CheckpointCallback = (seq: string | number) => Promise<void> | void;
16
+ export declare function isNoteEntry(doc: EntryDoc | false | undefined): doc is NewEntry | PlainEntry;
17
+ /** Own watch sessions and work which remains in flight across stop and restart. */
18
+ export declare class DirectFileWatchOwner {
19
+ private readonly context;
20
+ constructor(host: DirectFileWatchHost);
21
+ beginWatch(callback: FileCallback, checkIsInterested?: SelectionCallback, onCheckpoint?: CheckpointCallback): boolean;
22
+ endWatch(): void;
23
+ }
24
+ export {};
@@ -0,0 +1,256 @@
1
+ import { compatGlobal } from "../common/coreEnvFunctions.js";
2
+ import { LEVEL_INFO, LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
3
+ import { bindContext } from "octagonal-wheels/context";
4
+ import { withScope } from "octagonal-wheels/resource/scope";
5
+ function isNoteEntry(doc) {
6
+ return !!doc && (doc.type == "newnote" || doc.type == "plain");
7
+ }
8
+ function isActive(session) {
9
+ return session.owner.session === session;
10
+ }
11
+ function isCurrent({ session, item, epoch }) {
12
+ return isActive(session) && session.connected && session.connection === epoch && session.pending.get(item.id) === item;
13
+ }
14
+ function retryDelay(failures) {
15
+ return Math.min(3e4, 1e3 * 2 ** Math.min(failures - 1, 5));
16
+ }
17
+ function documentPath(doc) {
18
+ return doc && "path" in doc ? doc.path : void 0;
19
+ }
20
+ function safePosition(session) {
21
+ return session.pending.values().next().value?.before ?? session.latest;
22
+ }
23
+ function cancelRetry(session) {
24
+ if (session.retryTimer !== void 0) clearTimeout(session.retryTimer);
25
+ session.retryTimer = void 0;
26
+ }
27
+ function cancelFeed(session) {
28
+ const state = session.owner.host.state;
29
+ const changes = state.changes;
30
+ state.changes = void 0;
31
+ changes?.cancel();
32
+ }
33
+ function stop(session) {
34
+ session.owner.host.state.watching = false;
35
+ session.connected = false;
36
+ session.connection++;
37
+ cancelRetry(session);
38
+ if (session.reconnectTimer !== void 0) clearTimeout(session.reconnectTimer);
39
+ session.reconnectTimer = void 0;
40
+ session.pending.clear();
41
+ session.owner.session = void 0;
42
+ cancelFeed(session);
43
+ }
44
+ function schedule(session) {
45
+ cancelRetry(session);
46
+ if (!isActive(session) || !session.connected) return;
47
+ let next = Infinity;
48
+ if (session.owner.inFlight < 4) {
49
+ for (const item of session.pending.values()) {
50
+ if (!item.running && !session.owner.delivering.has(item.id)) next = Math.min(next, item.due);
51
+ }
52
+ }
53
+ if (!session.checkpointTask && safePosition(session).order > session.durableOrder) {
54
+ next = Math.min(next, session.checkpointDue);
55
+ }
56
+ if (next !== Infinity) {
57
+ session.retryTimer = compatGlobal.setTimeout(bindContext(session, retry), Math.max(0, next - Date.now()));
58
+ }
59
+ }
60
+ function retry(session) {
61
+ session.retryTimer = void 0;
62
+ void pump(session);
63
+ }
64
+ async function persistCheckpoint({ session, position, epoch, previous }) {
65
+ if (previous) await previous;
66
+ if (!isActive(session) || session.connection !== epoch) return;
67
+ try {
68
+ await session.onCheckpoint?.(position.seq);
69
+ if (!isActive(session)) return;
70
+ session.owner.host.state.since = String(position.seq);
71
+ if (session.connection === epoch) session.durableOrder = position.order;
72
+ session.checkpointFailures = 0;
73
+ session.checkpointDue = 0;
74
+ } catch (ex) {
75
+ Logger(`WATCH: CHECKPOINT FAILED`, LEVEL_INFO, "watch");
76
+ Logger(ex, LEVEL_VERBOSE, "watch");
77
+ if (isActive(session) && session.connection === epoch) {
78
+ session.checkpointDue = Date.now() + retryDelay(++session.checkpointFailures);
79
+ }
80
+ }
81
+ }
82
+ function finishCheckpoint({ session, task }) {
83
+ session.checkpointTask = void 0;
84
+ if (session.owner.checkpointPending === task) session.owner.checkpointPending = void 0;
85
+ if (isActive(session)) {
86
+ void flushCheckpoint(session);
87
+ schedule(session);
88
+ }
89
+ }
90
+ function flushCheckpoint(session) {
91
+ if (session.checkpointTask) return session.checkpointTask;
92
+ if (!isActive(session) || !session.connected || Date.now() < session.checkpointDue) return Promise.resolve();
93
+ const position = safePosition(session);
94
+ if (position.order <= session.durableOrder) return Promise.resolve();
95
+ const context = {
96
+ session,
97
+ position,
98
+ epoch: session.connection,
99
+ previous: session.owner.checkpointPending
100
+ };
101
+ const task = persistCheckpoint(context).finally(bindContext(context, finishCheckpoint));
102
+ context.task = task;
103
+ session.checkpointTask = task;
104
+ session.owner.checkpointPending = task;
105
+ return task;
106
+ }
107
+ function releaseDelivery({ session, item }) {
108
+ session.owner.delivering.delete(item.id);
109
+ }
110
+ async function deliver(scope) {
111
+ const { session, item, ready } = scope.value;
112
+ session.owner.delivering.add(item.id);
113
+ scope.defer(bindContext(scope.value, releaseDelivery));
114
+ await session.callback(ready, item.seq);
115
+ }
116
+ async function processDocument(context) {
117
+ const { session, item } = context;
118
+ let loading = true;
119
+ try {
120
+ const doc = item.doc;
121
+ if (isNoteEntry(doc) && (!session.checkIsInterested || session.checkIsInterested(doc))) {
122
+ Logger(`WATCH: PROCESSING: ${doc.path}`, LEVEL_VERBOSE, "watch");
123
+ const ready = doc.deleted || doc._deleted ? { ...doc, data: [] } : await session.owner.host.readDocument(doc);
124
+ if (!isCurrent(context)) return;
125
+ loading = false;
126
+ await withScope({ ...context, ready }, deliver);
127
+ if (!isCurrent(context)) return;
128
+ Logger(`WATCH: PROCESS DONE: ${doc.path}`, LEVEL_INFO, "watch");
129
+ }
130
+ if (!isCurrent(context)) return;
131
+ session.pending.delete(item.id);
132
+ void flushCheckpoint(session);
133
+ } catch (ex) {
134
+ if (!isCurrent(context)) return;
135
+ Logger(
136
+ loading ? `WATCH: DOCUMENT LOAD FAILED: ${documentPath(item.doc)}` : `WATCH: PROCESS FAILED`,
137
+ LEVEL_INFO,
138
+ "watch"
139
+ );
140
+ Logger(ex, LEVEL_VERBOSE, "watch");
141
+ item.due = Date.now() + retryDelay(++item.failures);
142
+ }
143
+ }
144
+ function releaseFileOperation({ session, item }) {
145
+ item.running = false;
146
+ session.owner.inFlight--;
147
+ if (session.owner.session) void pump(session.owner.session);
148
+ }
149
+ async function processFileOperation(scope) {
150
+ scope.defer(bindContext(scope.value, releaseFileOperation));
151
+ await processDocument(scope.value);
152
+ }
153
+ function pump(session) {
154
+ if (!isActive(session) || !session.connected) return Promise.resolve();
155
+ const tasks = [];
156
+ for (const item of session.pending.values()) {
157
+ if (session.owner.inFlight >= 4) break;
158
+ if (item.running || item.due > Date.now() || session.owner.delivering.has(item.id)) continue;
159
+ item.running = true;
160
+ session.owner.inFlight++;
161
+ tasks.push(withScope({ session, item, epoch: session.connection }, processFileOperation));
162
+ }
163
+ void flushCheckpoint(session);
164
+ schedule(session);
165
+ return Promise.all(tasks).then(() => {
166
+ });
167
+ }
168
+ async function reconnectAfterCheckpoint(session) {
169
+ session.reconnectTimer = void 0;
170
+ if (session.checkpointTask) await session.checkpointTask;
171
+ if (isActive(session)) connect(session);
172
+ }
173
+ function reconnect({ session, epoch }) {
174
+ if (!isActive(session) || !session.connected || session.connection !== epoch) return;
175
+ session.owner.host.state.watching = false;
176
+ session.connected = false;
177
+ session.connection++;
178
+ cancelRetry(session);
179
+ session.pending.clear();
180
+ cancelFeed(session);
181
+ Logger(`WATCH: CONNECTION HAS BEEN CLOSED, RECONNECTING...`, LEVEL_INFO, "watch");
182
+ session.reconnectTimer = compatGlobal.setTimeout(bindContext(session, reconnectAfterCheckpoint), 1e4);
183
+ }
184
+ async function receiveChange({ session, epoch }, change) {
185
+ if (!isActive(session) || !session.connected || session.connection !== epoch) return;
186
+ const before = session.latest;
187
+ session.latest = { order: before.order + 1, seq: change.seq };
188
+ const id = change.id ?? change.doc?._id ?? documentPath(change.doc) ?? `row:${session.latest.order}`;
189
+ const previous = session.pending.get(id);
190
+ const doc = change.doc?._deleted && previous?.doc ? { ...previous.doc, ...change.doc } : change.doc;
191
+ session.pending.set(id, {
192
+ id,
193
+ doc,
194
+ seq: change.seq,
195
+ before: previous?.before ?? before,
196
+ running: false,
197
+ failures: 0,
198
+ due: 0
199
+ });
200
+ await pump(session);
201
+ }
202
+ function receiveError(context, error) {
203
+ if (!isActive(context.session) || context.session.connection !== context.epoch) return;
204
+ Logger(`WATCH: ERROR: `, LEVEL_INFO, "watch");
205
+ Logger(error, LEVEL_VERBOSE, "watch");
206
+ reconnect(context);
207
+ }
208
+ function connect(session) {
209
+ if (!isActive(session)) return;
210
+ const context = { session, epoch: ++session.connection };
211
+ const { state } = session.owner.host;
212
+ state.watching = true;
213
+ session.connected = true;
214
+ session.latest = { order: 0, seq: state.since };
215
+ session.durableOrder = 0;
216
+ session.checkpointFailures = 0;
217
+ session.checkpointDue = 0;
218
+ try {
219
+ state.changes = session.owner.host.openChanges(state.since).on("change", bindContext(context, receiveChange)).on("complete", bindContext(context, reconnect)).on("error", bindContext(context, receiveError));
220
+ } catch (ex) {
221
+ Logger(`WATCH: ERROR: `, LEVEL_INFO, "watch");
222
+ Logger(ex, LEVEL_VERBOSE, "watch");
223
+ reconnect(context);
224
+ }
225
+ }
226
+ class DirectFileWatchOwner {
227
+ context;
228
+ constructor(host) {
229
+ this.context = { host, inFlight: 0, delivering: /* @__PURE__ */ new Set() };
230
+ }
231
+ beginWatch(callback, checkIsInterested, onCheckpoint) {
232
+ if (this.context.session || this.context.host.state.watching) return false;
233
+ const session = {
234
+ owner: this.context,
235
+ callback,
236
+ checkIsInterested,
237
+ onCheckpoint,
238
+ pending: /* @__PURE__ */ new Map(),
239
+ latest: { order: 0, seq: this.context.host.state.since },
240
+ durableOrder: 0,
241
+ connection: 0,
242
+ connected: false,
243
+ checkpointFailures: 0,
244
+ checkpointDue: 0
245
+ };
246
+ this.context.session = session;
247
+ connect(session);
248
+ }
249
+ endWatch() {
250
+ if (this.context.session) stop(this.context.session);
251
+ }
252
+ }
253
+ export {
254
+ DirectFileWatchOwner,
255
+ isNoteEntry
256
+ };
@@ -156,6 +156,7 @@ export declare function decodeSettingsFromSetupURI(uri: string, passphrase: stri
156
156
  jwtSub: string;
157
157
  jwtExpDuration: number;
158
158
  useRequestAPI: boolean;
159
+ useCouchDBCacheControlHeadersOnly?: boolean;
159
160
  accessKey: string;
160
161
  secretKey: string;
161
162
  bucket: string;
@@ -10,11 +10,11 @@ import {
10
10
  import { decryptString, encryptString } from "../encryption/stringEncryption.js";
11
11
  import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
12
12
  import { configuredIdKey } from "../common/idDerivation.js";
13
- import { getPathNormalizationVersion } from "../common/pathNormalization.js";
13
+ import { assertSupportedPathNormalizationVersion, getPathNormalizationVersion } from "../common/pathNormalization.js";
14
14
  function encodeSettingsToQRCodeData(settings) {
15
15
  settings = omitP2PRuntimeSettings(settings);
16
16
  configuredIdKey(settings);
17
- getPathNormalizationVersion(settings);
17
+ assertSupportedPathNormalizationVersion(settings.pathNormalizationVersion);
18
18
  const fullIndexes = Object.entries(KeyIndexOfSettings);
19
19
  let maxIndex = 0;
20
20
  for (const [, index] of fullIndexes) {
@@ -137,7 +137,7 @@ async function encodeSettingsToSetupURI(settingString, passphrase, removePropert
137
137
  const setting = {
138
138
  ...omitP2PRuntimeSettings(settingString)
139
139
  };
140
- getPathNormalizationVersion(setting);
140
+ assertSupportedPathNormalizationVersion(setting.pathNormalizationVersion);
141
141
  configuredIdKey({
142
142
  idDerivationVersion: setting.idDerivationVersion ?? 0,
143
143
  idDerivationKey: setting.idDerivationKey ?? ""
@@ -189,6 +189,7 @@ const SETTINGS_SCHEMA_DEFAULTS = {
189
189
  jwtSub: "",
190
190
  jwtExpDuration: 5,
191
191
  useRequestAPI: false,
192
+ useCouchDBCacheControlHeadersOnly: false,
192
193
  bucketPrefix: "",
193
194
  chunkSplitterVersion: ChunkAlgorithms.RabinKarp,
194
195
  E2EEAlgorithm: E2EEAlgorithms.V2,