@vrtmrz/livesync-commonlib 0.1.43 → 0.2.0

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 (71) hide show
  1. package/README.md +57 -6
  2. package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
  3. package/dist/API/DirectFileManipulatorV2.js +3 -0
  4. package/dist/common/configForDoc.d.ts +1 -0
  5. package/dist/common/configForDoc.js +11 -1
  6. package/dist/common/models/db.definition.d.ts +1 -0
  7. package/dist/common/models/setting.const.preferred.js +1 -3
  8. package/dist/diagnostics/couchdb.js +86 -13
  9. package/dist/diagnostics/index.d.ts +2 -2
  10. package/dist/diagnostics/index.js +2 -0
  11. package/dist/diagnostics/integrity.js +385 -158
  12. package/dist/diagnostics/names.d.ts +16 -0
  13. package/dist/diagnostics/names.js +50 -0
  14. package/dist/diagnostics/reports.d.ts +13 -10
  15. package/dist/diagnostics/reports.js +191 -50
  16. package/dist/diagnostics/types.d.ts +64 -4
  17. package/dist/diagnostics/usage.d.ts +4 -0
  18. package/dist/diagnostics/usage.js +158 -54
  19. package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
  20. package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
  21. package/dist/pouchdb/StreamingFetch.d.ts +5 -1
  22. package/dist/pouchdb/StreamingFetch.js +529 -65
  23. package/dist/replication/RemoteResource.d.ts +23 -2
  24. package/dist/replication/RemoteResource.js +6 -2
  25. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +8 -0
  26. package/dist/replication/couchdb/LiveSyncReplicator.js +132 -1
  27. package/dist/replication/couchdb/fastForward.d.ts +19 -0
  28. package/dist/replication/couchdb/fastForward.js +119 -0
  29. package/dist/replication/index.d.ts +3 -1
  30. package/dist/replication/joinedDevices.d.ts +25 -0
  31. package/dist/replication/joinedDevices.js +123 -0
  32. package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
  33. package/dist/replication/journal/JournalSyncCore.js +11 -1
  34. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
  35. package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
  36. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
  37. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
  38. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
  39. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
  40. package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
  41. package/dist/serviceModules/Rebuilder.js +41 -15
  42. package/dist/services/BrowserServices.d.ts +4 -2
  43. package/dist/services/BrowserServices.js +2 -5
  44. package/dist/services/HeadlessServices.d.ts +3 -0
  45. package/dist/services/HeadlessServices.js +2 -5
  46. package/dist/services/ServiceHub.d.ts +4 -4
  47. package/dist/services/base/IService.d.ts +10 -18
  48. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
  49. package/dist/services/base/SettingService.d.ts +8 -18
  50. package/dist/services/base/SettingService.js +28 -46
  51. package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
  52. package/dist/services/implements/base/UIService.d.ts +2 -0
  53. package/dist/services/implements/base/UIService.js +5 -0
  54. package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
  55. package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
  56. package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
  57. package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
  58. package/dist/string_and_binary/chunks.js +7 -5
  59. package/dist/uiCommands.d.ts +26 -0
  60. package/dist/uiCommands.js +57 -0
  61. package/dist/worker/bgWorker.js +1 -1
  62. package/docs/database-diagnostics.md +45 -0
  63. package/docs/development.md +15 -3
  64. package/docs/proven-in-use.md +45 -5
  65. package/docs/releasing.md +20 -13
  66. package/docs/settings-lifecycle.md +37 -5
  67. package/package.json +13 -17
  68. package/dist/services/base/ConfigService.d.ts +0 -7
  69. package/dist/services/base/ConfigService.js +0 -6
  70. package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
  71. package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
@@ -1,4 +1,5 @@
1
1
  import {
2
+ NODEINFO_DOCID,
2
3
  LOG_LEVEL_NOTICE,
3
4
  LOG_LEVEL_VERBOSE,
4
5
  DEVICE_ID_PREFERRED,
@@ -26,6 +27,10 @@ import {
26
27
  outcomeFromFiniteOpenReplication,
27
28
  replicationFailed
28
29
  } from "../ReplicatorProvider.js";
30
+ import {
31
+ forgetJoinedDevice as forgetJoinedDeviceFromStore,
32
+ projectJoinedDevices
33
+ } from "../joinedDevices.js";
29
34
  const MILSTONE_DOCID = "_00000000-milestone.json";
30
35
  async function readRemoteMilestone(client) {
31
36
  const result = await client.downloadJsonWithResult(MILSTONE_DOCID);
@@ -113,7 +118,8 @@ class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
113
118
  plugin_version: this.env.services.API.getPluginVersion(),
114
119
  vault_name: this.env.services.vault.vaultName(),
115
120
  device_name: this.env.services.vault.getVaultName(),
116
- progress
121
+ progress,
122
+ platform: this.env.services.API.getPlatform()
117
123
  },
118
124
  async (info) => {
119
125
  if (encryptedProof) info.encrypted_id_derivation_proof = encryptedProof;
@@ -584,6 +590,55 @@ ${ex.message}`, LOG_LEVEL_NOTICE);
584
590
  throw new Error("Object Storage does not support NFC path identity.");
585
591
  }
586
592
  }
593
+ async getOwnJoinedDeviceId() {
594
+ const nodeInfo = await this.rawDatabase.get(NODEINFO_DOCID);
595
+ const nodeId = nodeInfo?.nodeid;
596
+ if (typeof nodeId !== "string" || nodeId.trim().length === 0) {
597
+ throw new Error("The local node identity is unavailable.");
598
+ }
599
+ return nodeId;
600
+ }
601
+ async getJoinedDevices(setting) {
602
+ this.assertLegacyPathIdentity(setting);
603
+ const trialClient = new JournalSyncCore(
604
+ setting,
605
+ this.simpleStore,
606
+ this.env,
607
+ new MinioStorageAdapter(setting, this.env)
608
+ );
609
+ try {
610
+ const result = await trialClient.downloadJsonWithResult(MILSTONE_DOCID);
611
+ if (result.status === JournalStorageReadStatuses.NOT_FOUND) return projectJoinedDevices(void 0);
612
+ if (result.status === JournalStorageReadStatuses.UNAVAILABLE) throw result.error;
613
+ return projectJoinedDevices(result.value);
614
+ } finally {
615
+ trialClient.dispose();
616
+ }
617
+ }
618
+ async forgetJoinedDevice(setting, deviceId) {
619
+ this.assertLegacyPathIdentity(setting);
620
+ const ownDeviceId = await this.getOwnJoinedDeviceId();
621
+ const trialClient = new JournalSyncCore(
622
+ setting,
623
+ this.simpleStore,
624
+ this.env,
625
+ new MinioStorageAdapter(setting, this.env)
626
+ );
627
+ try {
628
+ const store = {
629
+ read: async () => {
630
+ const result = await trialClient.downloadJsonWithResult(MILSTONE_DOCID);
631
+ if (result.status === JournalStorageReadStatuses.NOT_FOUND) return void 0;
632
+ if (result.status === JournalStorageReadStatuses.UNAVAILABLE) throw result.error;
633
+ return { milestone: result.value, revision: result.revision };
634
+ },
635
+ write: async (milestone, revision) => await trialClient.uploadJsonIfUnchanged(MILSTONE_DOCID, milestone, revision)
636
+ };
637
+ return await forgetJoinedDeviceFromStore(store, deviceId, ownDeviceId);
638
+ } finally {
639
+ trialClient.dispose();
640
+ }
641
+ }
587
642
  getConnectedDeviceList(setting) {
588
643
  return Promise.resolve(false);
589
644
  }
@@ -8,16 +8,19 @@ export declare const JournalStorageReadStatuses: {
8
8
  export type JournalStorageReadResult<T> = {
9
9
  status: typeof JournalStorageReadStatuses.AVAILABLE;
10
10
  value: T;
11
+ readonly revision?: string;
11
12
  } | {
12
13
  status: typeof JournalStorageReadStatuses.NOT_FOUND;
13
14
  } | {
14
15
  status: typeof JournalStorageReadStatuses.UNAVAILABLE;
15
16
  error: unknown;
16
17
  };
18
+ export type JournalStorageConditionalWriteResult = "updated" | "conflict" | "unsupported";
17
19
  export interface IJournalStorage {
18
20
  /** Release transport resources retained by this adapter, when any. */
19
21
  dispose?(): void;
20
22
  upload(key: string, data: Uint8Array, mime: string): Promise<boolean>;
23
+ uploadIfUnchanged?(key: string, data: Uint8Array, mime: string, revision: string): Promise<JournalStorageConditionalWriteResult>;
21
24
  download(key: string, ignoreCache?: boolean): Promise<Uint8Array | false>;
22
25
  downloadWithResult(key: string, ignoreCache?: boolean): Promise<JournalStorageReadResult<Uint8Array>>;
23
26
  listFiles(from: string, limit?: number): Promise<string[]>;
@@ -1,7 +1,7 @@
1
1
  import { S3 } from "@aws-sdk/client-s3";
2
2
  import { type BucketSyncSetting } from "../../../common/types.js";
3
3
  import type { RemoteDBStatus } from "../../LiveSyncAbstractReplicator.js";
4
- import { type IJournalStorage, type JournalStorageReadResult } from "./JournalStorageAdapter.js";
4
+ import { type IJournalStorage, type JournalStorageConditionalWriteResult, type JournalStorageReadResult } from "./JournalStorageAdapter.js";
5
5
  import type { LiveSyncJournalReplicatorEnv } from "../LiveSyncJournalReplicatorEnv.js";
6
6
  export declare class MinioStorageAdapter implements IJournalStorage {
7
7
  _instance?: S3;
@@ -15,6 +15,7 @@ export declare class MinioStorageAdapter implements IJournalStorage {
15
15
  get customHeaders(): [string, string][];
16
16
  _getClient(): S3;
17
17
  upload(key: string, data: Uint8Array, mime: string): Promise<boolean>;
18
+ uploadIfUnchanged(key: string, data: Uint8Array, mime: string, revision: string): Promise<JournalStorageConditionalWriteResult>;
18
19
  downloadWithResult(key: string, ignoreCache?: boolean): Promise<JournalStorageReadResult<Uint8Array>>;
19
20
  download(key: string, ignoreCache?: boolean): Promise<Uint8Array | false>;
20
21
  listFiles(from: string, limit?: number): Promise<string[]>;
@@ -119,6 +119,26 @@ class MinioStorageAdapter {
119
119
  }
120
120
  return false;
121
121
  }
122
+ async uploadIfUnchanged(key, data, mime, revision) {
123
+ if (revision.trim().length === 0) {
124
+ throw new TypeError("A non-empty revision is required for a conditional upload");
125
+ }
126
+ const client = this._getClient();
127
+ const cmd = new PutObjectCommand({
128
+ Bucket: this._settings.bucket,
129
+ Key: `${this._settings.bucketPrefix}${key}`,
130
+ Body: data,
131
+ ContentType: mime,
132
+ IfMatch: revision
133
+ });
134
+ try {
135
+ await this.runTrackedRequest(() => client.send(cmd));
136
+ return "updated";
137
+ } catch (ex) {
138
+ if (isConditionalWriteConflict(ex)) return "conflict";
139
+ throw ex;
140
+ }
141
+ }
122
142
  async downloadWithResult(key, ignoreCache = false) {
123
143
  const client = this._getClient();
124
144
  const cmd = new GetObjectCommand({
@@ -135,7 +155,8 @@ class MinioStorageAdapter {
135
155
  if (r.Body) {
136
156
  return {
137
157
  status: JournalStorageReadStatuses.AVAILABLE,
138
- value: new Uint8Array(await r.Body.transformToByteArray())
158
+ value: new Uint8Array(await r.Body.transformToByteArray()),
159
+ ...r.ETag?.trim() ? { revision: r.ETag } : {}
139
160
  };
140
161
  }
141
162
  return {
@@ -227,6 +248,11 @@ class MinioStorageAdapter {
227
248
  }
228
249
  }
229
250
  }
251
+ function isConditionalWriteConflict(error) {
252
+ if (!error || typeof error !== "object") return false;
253
+ const { httpStatusCode } = error.$metadata ?? {};
254
+ return httpStatusCode === 409 || httpStatusCode === 412;
255
+ }
230
256
  function isMissingObjectError(error) {
231
257
  if (!error || typeof error !== "object") return false;
232
258
  const { name, Code, code } = error;
@@ -73,7 +73,7 @@ export declare class P2PRoomSessionOwner implements P2PRoomSessionAccess {
73
73
  private hasUsableTurnServer;
74
74
  private withRoomOpenDeadline;
75
75
  private assertReconciliationCurrent;
76
- /** Capture immutable session inputs while keeping policy live. */
76
+ /** Read the device identity before capturing the current synchronous session inputs. */
77
77
  private getEffectiveBinding;
78
78
  /** Apply policy which can change without retiring the room session. */
79
79
  private reconcileCurrentSessionPolicy;
@@ -70,7 +70,7 @@ class P2PRoomSessionOwner {
70
70
  } else if (active && !supportsPathIdentity) {
71
71
  Logger("P2P does not support NFC path identity.", LOG_LEVEL_NOTICE);
72
72
  }
73
- this.fencePreparationForCurrentSettings(enabled);
73
+ await this.fencePreparationForCurrentSettings(enabled);
74
74
  await this.reconcileTransport();
75
75
  }
76
76
  /**
@@ -89,8 +89,8 @@ class P2PRoomSessionOwner {
89
89
  }
90
90
  const demand = /* @__PURE__ */ Symbol("p2p-finite-room-demand");
91
91
  this.finiteDemands.add(demand);
92
- this.fencePreparationForCurrentSettings(true);
93
92
  try {
93
+ await this.fencePreparationForCurrentSettings(true);
94
94
  await this.reconcileTransport();
95
95
  if (!this.finiteDemands.has(demand)) {
96
96
  throw new Error("The P2P room demand was retired before the operation started.");
@@ -143,7 +143,8 @@ class P2PRoomSessionOwner {
143
143
  await this.closeTransport();
144
144
  return;
145
145
  }
146
- const binding = this.getEffectiveBinding();
146
+ const binding = await this.getEffectiveBinding();
147
+ if (!binding.enabled || !this.hasRoomDemand()) return;
147
148
  if (this.current?.host.isServing && this.bindingsMatch(this.activeBinding, binding) && this.connectionSettingsRemainUsable(this.activeBinding?.settings)) {
148
149
  this.reconcileCurrentSessionPolicy(this.current);
149
150
  Logger("P2P replicator is already open.");
@@ -156,7 +157,7 @@ class P2PRoomSessionOwner {
156
157
  const operationGeneration = this.runtimeGeneration;
157
158
  try {
158
159
  const connectionSettings = await this.prepareConnectionSettings(binding, operationGeneration);
159
- this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
160
+ await this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
160
161
  const connectionBinding = { ...binding, settings: connectionSettings };
161
162
  candidate = this.createSession(this.buildSessionEnv(connectionBinding));
162
163
  if (hasManagedP2PTurnConfiguration(binding.settings)) {
@@ -167,7 +168,7 @@ class P2PRoomSessionOwner {
167
168
  if (!candidate.host.isServing) {
168
169
  throw new Error("The P2P room did not start serving.");
169
170
  }
170
- const currentBinding = this.getEffectiveBinding();
171
+ const currentBinding = await this.getEffectiveBinding();
171
172
  if (!currentBinding.enabled || !this.hasRoomDemand() || !this.bindingsMatch(binding, currentBinding) || operationGeneration !== this.runtimeGeneration || !this.connectionSettingsRemainUsable(connectionSettings)) {
172
173
  await candidate.retire();
173
174
  return;
@@ -243,13 +244,21 @@ class P2PRoomSessionOwner {
243
244
  if (expiresAt === void 0) return !managed;
244
245
  return Number.isFinite(expiresAt) && expiresAt > Date.now() + P2P_ICE_SERVER_EXPIRY_MARGIN_MS;
245
246
  }
246
- fencePreparationForCurrentSettings(enabled) {
247
+ async fencePreparationForCurrentSettings(enabled) {
247
248
  if (!enabled) {
248
249
  this.invalidatePreparation();
249
250
  return;
250
251
  }
251
- const pendingChanged = this.pendingPreparation !== void 0 && this.pendingPreparation.signature !== this.getEffectiveBinding().signature;
252
- if (pendingChanged) this.invalidatePreparation();
252
+ const pending = this.pendingPreparation;
253
+ if (!pending) return;
254
+ if (pending.configurationIdentity !== getP2PReplicatorConfigurationIdentity(this.env.services.setting.currentSettings())) {
255
+ this.invalidatePreparation();
256
+ return;
257
+ }
258
+ const binding = await this.getEffectiveBinding();
259
+ if (this.pendingPreparation === pending && pending.signature !== binding.signature) {
260
+ this.invalidatePreparation();
261
+ }
253
262
  }
254
263
  invalidatePreparation() {
255
264
  this.runtimeGeneration += 1;
@@ -269,6 +278,7 @@ class P2PRoomSessionOwner {
269
278
  const controller = new AbortController();
270
279
  const pending = {
271
280
  signature: binding.signature,
281
+ configurationIdentity: getP2PReplicatorConfigurationIdentity(binding.settings),
272
282
  controller
273
283
  };
274
284
  this.pendingPreparation = pending;
@@ -288,7 +298,7 @@ class P2PRoomSessionOwner {
288
298
  if (!this.connectionSettingsRemainUsable(connectionSettings)) {
289
299
  throw new Error("The prepared managed TURN settings are missing usable ICE servers or expiry.");
290
300
  }
291
- this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
301
+ await this.assertReconciliationCurrent(binding, operationGeneration, connectionSettings);
292
302
  return connectionSettings;
293
303
  } finally {
294
304
  if (this.pendingPreparation === pending) {
@@ -359,23 +369,26 @@ class P2PRoomSessionOwner {
359
369
  if (timeout !== void 0) globalThis.clearTimeout(timeout);
360
370
  }
361
371
  }
362
- assertReconciliationCurrent(binding, operationGeneration, connectionSettings) {
372
+ async assertReconciliationCurrent(binding, operationGeneration, connectionSettings) {
373
+ if (operationGeneration !== this.runtimeGeneration || !this.hasRoomDemand()) {
374
+ throw new StaleRoomReconciliationError();
375
+ }
376
+ const currentBinding = await this.getEffectiveBinding();
363
377
  if (operationGeneration !== this.runtimeGeneration || !this.hasRoomDemand()) {
364
378
  throw new StaleRoomReconciliationError();
365
379
  }
366
- const currentBinding = this.getEffectiveBinding();
367
380
  if (!currentBinding.enabled) throw new StaleRoomReconciliationError();
368
381
  if (binding && !this.bindingsMatch(binding, currentBinding)) throw new StaleRoomReconciliationError();
369
382
  if (connectionSettings && !this.connectionSettingsRemainUsable(connectionSettings)) {
370
383
  throw new StaleRoomReconciliationError();
371
384
  }
372
385
  }
373
- /** Capture immutable session inputs while keeping policy live. */
374
- getEffectiveBinding() {
386
+ /** Read the device identity before capturing the current synchronous session inputs. */
387
+ async getEffectiveBinding() {
388
+ const deviceName = await this.env.services.config.getValue(SETTING_KEY_P2P_DEVICE_NAME) || this.env.services.vault.getVaultName();
375
389
  const settings = omitP2PRuntimeSettings({
376
390
  ...this.env.services.setting.currentSettings()
377
391
  });
378
- const deviceName = this.env.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.env.services.vault.getVaultName();
379
392
  const database = this.env.services.database.localDatabase.localDatabase;
380
393
  this.automationCoordinator.reconcileIdentity(
381
394
  JSON.stringify([
@@ -404,7 +404,7 @@ Are you sure you wish to proceed?`;
404
404
  await this.suspendReflectingDatabase(!autoResume);
405
405
  await this.control.applySettings();
406
406
  await this.resetLocalDatabase();
407
- this.clearFastFetchCheckpoint();
407
+ await this.clearFastFetchCheckpoint();
408
408
  await delay(1e3);
409
409
  if (isRemediationModeActive(this.setting.currentSettings())) {
410
410
  this._log(
@@ -470,10 +470,11 @@ Are you sure you wish to proceed?`;
470
470
  await this.checkPathIdentitiesBeforeReconstruction(false);
471
471
  await checkRemoteFeaturesForInitialSync(remote, authHeader, customHeaders, settings);
472
472
  this.appLifecycle.resetIsReady();
473
- let checkpoint = this.getFastFetchCheckpoint(remote);
473
+ let checkpoint = await this.getFastFetchCheckpoint(remote);
474
474
  await this.suspendReflectingDatabase();
475
475
  await this.control.applySettings();
476
476
  let since = checkpoint?.sequence ?? "0";
477
+ let startsFresh = !checkpoint;
477
478
  if (checkpoint) {
478
479
  this._log(
479
480
  `Resuming fast database fetch from sequence: ${checkpoint.sequence}`,
@@ -497,16 +498,19 @@ Are you sure you wish to proceed?`;
497
498
  LOG_LEVEL_NOTICE,
498
499
  "fetch-init-resume"
499
500
  );
500
- this.clearFastFetchCheckpoint();
501
+ await this.clearFastFetchCheckpoint();
501
502
  await this.resetLocalDatabase();
502
503
  await delay(1e3);
503
504
  localDB = this.database.localDatabase.localDatabase;
504
505
  since = "0";
506
+ startsFresh = true;
505
507
  }
506
508
  const securitySeed = await this.replicator.createRemoteResource(REMOTE_RESOURCE_KINDS.SECURITY_SEED, settings);
507
509
  if (!securitySeed) {
508
510
  throw new Error("The selected provider cannot supply a Security Seed for Fast Fetch.");
509
511
  }
512
+ let fastForwardResource;
513
+ let fastForward;
510
514
  try {
511
515
  const enc = getConfiguredFunctionsForEncryption(
512
516
  settings.passphrase,
@@ -515,6 +519,13 @@ Are you sure you wish to proceed?`;
515
519
  () => securitySeed.read(),
516
520
  settings.E2EEAlgorithm
517
521
  );
522
+ if (startsFresh && !settings.doNotSuspendOnFetching && (await localDB.info()).doc_count === 0) {
523
+ fastForwardResource = await this.replicator.createRemoteResource(
524
+ REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD,
525
+ this.setting.currentSettings()
526
+ );
527
+ fastForward = await fastForwardResource?.prepare();
528
+ }
518
529
  for (let attempt = 0; ; attempt++) {
519
530
  try {
520
531
  await fetchChangesForInitialSync(
@@ -531,13 +542,17 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
531
542
  "fetch-init-progress"
532
543
  );
533
544
  },
534
- (sequence) => this.saveFastFetchCheckpoint(remote, sequence),
535
- customHeaders
545
+ async (sequence) => {
546
+ await this.saveFastFetchCheckpoint(remote, sequence);
547
+ fastForward?.recordCheckpoint(sequence);
548
+ },
549
+ customHeaders,
550
+ fastForward?.recordStoredRevisions
536
551
  );
537
552
  break;
538
553
  } catch (ex) {
539
554
  if (!isRetryableStreamingFetchFailure(ex) || attempt >= FAST_FETCH_RETRY_DELAYS.length) throw ex;
540
- checkpoint = this.getFastFetchCheckpoint(remote);
555
+ checkpoint = await this.getFastFetchCheckpoint(remote);
541
556
  since = checkpoint?.sequence ?? since;
542
557
  this._log(
543
558
  `Fast fetch interrupted. Retrying from sequence: ${since}`,
@@ -547,6 +562,13 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
547
562
  await delay(FAST_FETCH_RETRY_DELAYS[attempt]);
548
563
  }
549
564
  }
565
+ if (fastForward) {
566
+ const advanced = await fastForward.fastForward();
567
+ this._log(
568
+ advanced ? "Advanced the ordinary replication checkpoints through the verified initial Fast Fetch." : "The initial Fast Fetch did not advance ordinary replication checkpoints.",
569
+ LOG_LEVEL_VERBOSE
570
+ );
571
+ }
550
572
  const allDocs = await localDB.allDocs({ include_docs: false });
551
573
  this._log(
552
574
  `Fast database fetch completed. Total documents in local database: ${allDocs.total_rows}`,
@@ -559,9 +581,13 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
559
581
  throw new Error("Fast Fetch could not be finalised.");
560
582
  }
561
583
  }
562
- this.clearFastFetchCheckpoint();
584
+ await this.clearFastFetchCheckpoint();
563
585
  } finally {
564
- await securitySeed.dispose();
586
+ try {
587
+ await fastForwardResource?.dispose();
588
+ } finally {
589
+ await securitySeed.dispose();
590
+ }
565
591
  }
566
592
  }
567
593
  /**
@@ -627,8 +653,8 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
627
653
  }
628
654
  this.events.emitEvent(EVENT_DATABASE_REBUILT);
629
655
  }
630
- getFastFetchCheckpoint(remote) {
631
- const rawCheckpoint = this.setting.getSmallConfig(FAST_FETCH_CHECKPOINT_KEY);
656
+ async getFastFetchCheckpoint(remote) {
657
+ const rawCheckpoint = await this.setting.config.getValue(FAST_FETCH_CHECKPOINT_KEY);
632
658
  if (!rawCheckpoint) return void 0;
633
659
  try {
634
660
  const checkpoint = JSON.parse(rawCheckpoint);
@@ -640,11 +666,11 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
640
666
  }
641
667
  } catch {
642
668
  }
643
- this.clearFastFetchCheckpoint();
669
+ await this.clearFastFetchCheckpoint();
644
670
  return void 0;
645
671
  }
646
- saveFastFetchCheckpoint(remote, sequence) {
647
- this.setting.setSmallConfig(
672
+ async saveFastFetchCheckpoint(remote, sequence) {
673
+ await this.setting.config.setValue(
648
674
  FAST_FETCH_CHECKPOINT_KEY,
649
675
  JSON.stringify({
650
676
  remote,
@@ -653,8 +679,8 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
653
679
  })
654
680
  );
655
681
  }
656
- clearFastFetchCheckpoint() {
657
- this.setting.deleteSmallConfig(FAST_FETCH_CHECKPOINT_KEY);
682
+ async clearFastFetchCheckpoint() {
683
+ await this.setting.config.delete(FAST_FETCH_CHECKPOINT_KEY);
658
684
  }
659
685
  }
660
686
  export {
@@ -1,9 +1,9 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
2
3
  import { ServiceContext } from "./base/ServiceBase.js";
3
4
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
4
5
  import { ControlService } from "./base/ControlService.js";
5
6
  import type { AppLifecycleService } from "./base/AppLifecycleService.js";
6
- import type { ConfigService } from "./base/ConfigService.js";
7
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";
@@ -12,7 +12,7 @@ import type { ObsidianLiveSyncSettings } from "../common/types.js";
12
12
  export type BrowserServiceHostDependencies<T extends ServiceContext> = {
13
13
  API: InjectableAPIService<T>;
14
14
  appLifecycle: AppLifecycleService<T>;
15
- config: ConfigService<T>;
15
+ config: IConfigService;
16
16
  control: ControlService<T>;
17
17
  replicator: ReplicatorService<T>;
18
18
  };
@@ -29,6 +29,8 @@ export interface BrowserServiceHost<T extends ServiceContext> {
29
29
  export type BrowserServiceHubOptions<T extends ServiceContext> = {
30
30
  context?: T;
31
31
  host: BrowserServiceHost<T>;
32
+ /** Exact-key storage selected and owned by the application. */
33
+ deviceValues: IConfigService;
32
34
  openKeyValueDatabase?: KeyValueDatabaseFactory;
33
35
  onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
34
36
  };
@@ -11,7 +11,6 @@ import { InjectableTestService } from "./implements/injectable/InjectableTestSer
11
11
  import { InjectableTweakValueService } from "./implements/injectable/InjectableTweakValueService.js";
12
12
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
13
13
  import { ServiceContext } from "./base/ServiceBase.js";
14
- import { ConfigServiceBrowserCompat } from "./implements/browser/ConfigServiceBrowserCompat.js";
15
14
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
16
15
  import { BrowserDatabaseService, BrowserKeyValueDBService } from "./implements/browser/BrowserDatabaseService.js";
17
16
  import { ControlService } from "./base/ControlService.js";
@@ -34,6 +33,7 @@ class BrowserServiceHub extends InjectableServiceHub {
34
33
  const fileProcessing = new InjectableFileProcessingService(context);
35
34
  const setting = new InjectableSettingService(context, {
36
35
  APIService: API,
36
+ deviceValues: options.deviceValues,
37
37
  onDisplayLanguageChanged: options.onDisplayLanguageChanged
38
38
  });
39
39
  const appLifecycle = new BrowserAppLifecycleService(context, { settingService: setting });
@@ -60,10 +60,7 @@ class BrowserServiceHub extends InjectableServiceHub {
60
60
  setting,
61
61
  API
62
62
  });
63
- const config = new ConfigServiceBrowserCompat(context, {
64
- settingService: setting,
65
- APIService: API
66
- });
63
+ const config = setting.config;
67
64
  const replicator = new InjectableReplicatorService(context, {
68
65
  settingService: setting,
69
66
  appLifecycleService: appLifecycle,
@@ -1,3 +1,4 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import { ServiceContext } from "./base/ServiceBase.js";
2
3
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
3
4
  import type { DatabaseService } from "./base/DatabaseService.js";
@@ -9,6 +10,8 @@ export declare class HeadlessServiceHub<T extends ServiceContext> extends Inject
9
10
  constructor(_context: T | undefined, overrideServiceConstructor: {
10
11
  /** PouchDB with the adapters required by this headless runtime. */
11
12
  pouchDB: PouchDBConstructor;
13
+ /** Exact-key storage selected and owned by the application. */
14
+ deviceValues: IConfigService;
12
15
  database?: Constructor<DatabaseService<T>>;
13
16
  openKeyValueDatabase?: KeyValueDatabaseFactory;
14
17
  onDisplayLanguageChanged?: (language: ObsidianLiveSyncSettings["displayLanguage"]) => void;
@@ -11,7 +11,6 @@ import { InjectableTestService } from "./implements/injectable/InjectableTestSer
11
11
  import { InjectableTweakValueService } from "./implements/injectable/InjectableTweakValueService.js";
12
12
  import { InjectableVaultServiceCompat } from "./implements/injectable/InjectableVaultService.js";
13
13
  import { ServiceContext } from "./base/ServiceBase.js";
14
- import { ConfigServiceBrowserCompat } from "./implements/browser/ConfigServiceBrowserCompat.js";
15
14
  import { InjectableServiceHub } from "./implements/injectable/InjectableServiceHub.js";
16
15
  import { UIService } from "./implements/base/UIService.js";
17
16
  import { HeadlessAPIService } from "./implements/headless/HeadlessAPIService.js";
@@ -56,6 +55,7 @@ class HeadlessServiceHub extends InjectableServiceHub {
56
55
  const fileProcessing = new InjectableFileProcessingService(context);
57
56
  const setting = new InjectableSettingService(context, {
58
57
  APIService: API,
58
+ deviceValues: overrideServiceConstructor.deviceValues,
59
59
  onDisplayLanguageChanged: overrideServiceConstructor.onDisplayLanguageChanged
60
60
  });
61
61
  const appLifecycle = new HeadlessAppLifecycleService(context, {
@@ -85,10 +85,7 @@ class HeadlessServiceHub extends InjectableServiceHub {
85
85
  vault,
86
86
  setting
87
87
  });
88
- const config = new ConfigServiceBrowserCompat(context, {
89
- settingService: setting,
90
- APIService: API
91
- });
88
+ const config = setting.config;
92
89
  const replicator = new InjectableReplicatorService(context, {
93
90
  settingService: setting,
94
91
  appLifecycleService: appLifecycle,
@@ -1,5 +1,5 @@
1
+ import type { IConfigService } from "./base/IService.js";
1
2
  import type { UIService } from "./implements/base/UIService.js";
2
- import type { ConfigService } from "./base/ConfigService.js";
3
3
  import type { TestService } from "./base/TestService.js";
4
4
  import type { VaultService } from "./base/VaultService.js";
5
5
  import type { TweakValueService } from "./base/TweakValueService.js";
@@ -34,7 +34,7 @@ export type ServiceInstances<T extends ServiceContext = ServiceContext> = {
34
34
  vault?: VaultService<T>;
35
35
  test?: TestService<T>;
36
36
  ui?: UIService<T>;
37
- config?: ConfigService<T>;
37
+ config?: IConfigService;
38
38
  keyValueDB?: KeyValueDBService<T>;
39
39
  control?: ControlService<T>;
40
40
  };
@@ -56,7 +56,7 @@ export declare abstract class ServiceHub<T extends ServiceContext = ServiceConte
56
56
  protected abstract _vault: VaultService<T>;
57
57
  protected abstract _test: TestService<T>;
58
58
  protected abstract _ui: UIService<T>;
59
- protected abstract _config: ConfigService<T>;
59
+ protected abstract _config: IConfigService;
60
60
  protected abstract _keyValueDB: KeyValueDBService<T>;
61
61
  protected abstract _control: ControlService<T>;
62
62
  protected _injected: ServiceInstances<T>;
@@ -76,7 +76,7 @@ export declare abstract class ServiceHub<T extends ServiceContext = ServiceConte
76
76
  get vault(): VaultService<T>;
77
77
  get test(): TestService<T>;
78
78
  get UI(): UIService<T>;
79
- get config(): ConfigService<T>;
79
+ get config(): IConfigService;
80
80
  get keyValueDB(): KeyValueDBService<T>;
81
81
  get control(): ControlService<T>;
82
82
  }
@@ -1,3 +1,4 @@
1
+ import type { ValueStore, ValueDeleter } from "@vrtmrz/value-store";
1
2
  import type { FetchHttpHandler } from "@smithy/fetch-http-handler";
2
3
  import { type LOG_LEVEL } from "octagonal-wheels/common/logger";
3
4
  import type { AnyEntry, AUTO_MERGED, CouchDBCredentials, ConfigPassphraseStore, diff_result, DocumentID, EntryDoc, EntryHasPath, FileEventItem, FilePath, FilePathWithPrefix, LoadedEntry, MetaEntry, MISSING_OR_ERROR, ObsidianLiveSyncSettings, RemoteDBSettings, RemotePreferredTweakResult, SettingsMigrationState, TweakAssessment, TweakValues, UXFileInfo, UXFileInfoStub } from "../../common/types.js";
@@ -17,6 +18,7 @@ import type { RemoteResourceKind, RemoteResourceMap } from "../../replication/Re
17
18
  import type { CentralRemoteAdministrationRequest, CentralRemoteAdministrationResult } from "../../replication/CentralRemoteAdministration.js";
18
19
  import type { MultipleHandlerFunction } from "../lib/HandlerUtils.js";
19
20
  import type { VaultScanResult } from "./VaultScanResult.js";
21
+ import type { UICommandRegistry } from "../../uiCommands.js";
20
22
  declare global {
21
23
  interface OPTIONAL_SYNC_FEATURES {
22
24
  DISABLE: "DISABLE";
@@ -312,16 +314,11 @@ export interface ISettingService {
312
314
  getSettingsMigrationState(): SettingsMigrationState | undefined;
313
315
  getDeviceAndVaultName(): string;
314
316
  setDeviceAndVaultName(name: string): void;
315
- saveDeviceAndVaultName(): void;
316
- /**
317
- * Read host-provided, device-local configuration by its exact key.
318
- * This storage is not part of the synchronised settings document.
319
- */
320
- getDeviceLocalConfig(key: string): string | null;
321
- /** Store host-provided, device-local configuration by its exact key. */
322
- setDeviceLocalConfig(key: string, value: string): void;
323
- /** Delete host-provided, device-local configuration by its exact key. */
324
- deleteDeviceLocalConfig(key: string): void;
317
+ saveDeviceAndVaultName(): Promise<void>;
318
+ /** Exact-key host storage; values are local to this application instance. */
319
+ readonly deviceValues: ValueStore & ValueDeleter;
320
+ /** Storage scoped by the current Vault and database suffix. */
321
+ readonly config: IConfigService;
325
322
  onBeforeSaveSettingData(nextSettings: ObsidianLiveSyncSettings, previousSettings: ObsidianLiveSyncSettings): Promise<(Partial<ObsidianLiveSyncSettings> | void)[]>;
326
323
  saveSettingData(): Promise<void>;
327
324
  /** Save a new configuration wrapper and its device-local key, retaining the protected values. */
@@ -333,9 +330,6 @@ export interface ISettingService {
333
330
  onSettingLoaded(settings: ObsidianLiveSyncSettings): Promise<boolean>;
334
331
  onSettingChanged(settings: ObsidianLiveSyncSettings): Promise<boolean>;
335
332
  onSettingSaved(settings: ObsidianLiveSyncSettings): Promise<boolean>;
336
- getSmallConfig(key: string): string | null;
337
- setSmallConfig(key: string, value: string): void;
338
- deleteSmallConfig(key: string): void;
339
333
  }
340
334
  export interface ITweakValueService {
341
335
  fetchRemotePreferred(trialSetting: RemoteDBSettings): Promise<RemotePreferredTweakResult>;
@@ -373,15 +367,13 @@ export interface ITestService {
373
367
  addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void;
374
368
  }
375
369
  export interface IUIService {
370
+ readonly commands: UICommandRegistry;
376
371
  promptCopyToClipboard(title: string, value: string): Promise<boolean>;
377
372
  showMarkdownDialog<T extends string[]>(title: string, contentMD: string, buttons: T): Promise<(typeof buttons)[number] | false>;
378
373
  get confirm(): Confirm;
379
374
  }
380
- export interface IConfigService {
381
- getSmallConfig(key: string): string | null;
382
- setSmallConfig(key: string, value: string): void;
383
- deleteSmallConfig(key: string): void;
384
- }
375
+ /** Asynchronous device configuration selected by the host composition. */
376
+ export type IConfigService = ValueStore & ValueDeleter;
385
377
  export interface IServiceHub {
386
378
  API: IAPIService;
387
379
  path: IPathService;
@@ -1,7 +1,7 @@
1
1
  import { CAPABILITY_SUPPORT_KINDS } from "../../replication/ProviderCapability.js";
2
2
  async function resolveRemoteResource(capabilities, kind, setting) {
3
3
  const capability = capabilities[kind];
4
- if (capability.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
4
+ if (capability?.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
5
5
  return void 0;
6
6
  }
7
7
  return await capability.run(setting);