@vrtmrz/livesync-commonlib 0.1.44 → 0.2.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 (92) hide show
  1. package/README.md +80 -4
  2. package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
  3. package/dist/API/DirectFileManipulatorV2.js +3 -0
  4. package/dist/API/processSetting.d.ts +1 -0
  5. package/dist/common/configForDoc.d.ts +1 -0
  6. package/dist/common/configForDoc.js +11 -1
  7. package/dist/common/models/db.definition.d.ts +1 -0
  8. package/dist/common/models/setting.const.defaults.js +1 -0
  9. package/dist/common/models/setting.const.preferred.js +1 -3
  10. package/dist/common/models/setting.const.qr.js +1 -0
  11. package/dist/common/models/setting.policy.d.ts +4 -0
  12. package/dist/common/models/setting.policy.js +1 -0
  13. package/dist/common/models/setting.type.d.ts +2 -0
  14. package/dist/common/models/shared.definition.d.ts +20 -0
  15. package/dist/common/models/tweak.definition.d.ts +1 -0
  16. package/dist/common/settingConstants.d.ts +1 -0
  17. package/dist/common/settingConstants.js +6 -2
  18. package/dist/interfaces/FileHandler.d.ts +5 -1
  19. package/dist/interfaces/StorageEventManager.d.ts +5 -0
  20. package/dist/interfaces/StorageEventManager.js +7 -0
  21. package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
  22. package/dist/managers/StorageEventManager.d.ts +19 -10
  23. package/dist/managers/StorageEventManager.js +259 -189
  24. package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
  25. package/dist/pouchdb/StreamingFetch.d.ts +5 -1
  26. package/dist/pouchdb/StreamingFetch.js +529 -65
  27. package/dist/replication/LiveSyncAbstractReplicator.d.ts +2 -0
  28. package/dist/replication/LiveSyncAbstractReplicator.js +5 -2
  29. package/dist/replication/RemoteResource.d.ts +23 -2
  30. package/dist/replication/RemoteResource.js +6 -2
  31. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +14 -0
  32. package/dist/replication/couchdb/LiveSyncReplicator.js +171 -3
  33. package/dist/replication/couchdb/fastForward.d.ts +19 -0
  34. package/dist/replication/couchdb/fastForward.js +119 -0
  35. package/dist/replication/couchdb/replicationProgress.d.ts +6 -0
  36. package/dist/replication/couchdb/replicationProgress.js +105 -0
  37. package/dist/replication/index.d.ts +4 -1
  38. package/dist/replication/joinedDevices.d.ts +25 -0
  39. package/dist/replication/joinedDevices.js +123 -0
  40. package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
  41. package/dist/replication/journal/JournalSyncCore.js +11 -1
  42. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
  43. package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
  44. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
  45. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
  46. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
  47. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
  48. package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
  49. package/dist/replication/trystero/TrysteroReplicator.d.ts +2 -0
  50. package/dist/serviceFeatures/offlineScanner.js +19 -7
  51. package/dist/serviceModules/Rebuilder.js +41 -15
  52. package/dist/serviceModules/ServiceFileAccessBase.js +5 -0
  53. package/dist/serviceModules/ServiceFileHandlerBase.d.ts +8 -5
  54. package/dist/serviceModules/ServiceFileHandlerBase.js +75 -38
  55. package/dist/services/BrowserServices.d.ts +4 -2
  56. package/dist/services/BrowserServices.js +2 -5
  57. package/dist/services/HeadlessServices.d.ts +3 -0
  58. package/dist/services/HeadlessServices.js +2 -5
  59. package/dist/services/ServiceHub.d.ts +4 -4
  60. package/dist/services/base/AppLifecycleService.js +1 -1
  61. package/dist/services/base/ControlService.js +6 -2
  62. package/dist/services/base/FileProcessingService.d.ts +7 -1
  63. package/dist/services/base/FileProcessingService.js +9 -1
  64. package/dist/services/base/IService.d.ts +19 -19
  65. package/dist/services/base/ReplicatorService.d.ts +1 -0
  66. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
  67. package/dist/services/base/SettingService.d.ts +8 -18
  68. package/dist/services/base/SettingService.js +28 -46
  69. package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
  70. package/dist/services/implements/base/UIService.d.ts +2 -0
  71. package/dist/services/implements/base/UIService.js +5 -0
  72. package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
  73. package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
  74. package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
  75. package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
  76. package/dist/services/lib/HandlerUtils.d.ts +60 -0
  77. package/dist/services/lib/HandlerUtils.js +46 -0
  78. package/dist/string_and_binary/chunks.js +7 -5
  79. package/dist/uiCommands.d.ts +26 -0
  80. package/dist/uiCommands.js +57 -0
  81. package/dist/worker/bgWorker.js +1 -1
  82. package/docs/development.md +15 -3
  83. package/docs/offline-scanner.md +15 -13
  84. package/docs/proven-in-use.md +38 -4
  85. package/docs/releasing.md +20 -13
  86. package/docs/settings-lifecycle.md +39 -5
  87. package/docs/storage-events-and-reflection.md +109 -10
  88. package/package.json +13 -18
  89. package/dist/services/base/ConfigService.d.ts +0 -7
  90. package/dist/services/base/ConfigService.js +0 -6
  91. package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
  92. package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
@@ -3,10 +3,13 @@ export type { ReplicatorInstance } from "./ReplicatorInstance.js";
3
3
  export type { ActiveReplicatorContext, CapabilitySupport, ContinuousReplicationRequest, ContinuousReplicationTrigger, ContinuousRunner, InteractionAuthority, InteractionPermissions, NotApplicableCapability, NotImplementedCapability, NoInteraction, PermittedInteractionPermissions, ReplicationBlockReason, ReplicationAttemptFailure, ReplicationCancelled, ReplicationCompleted, ReplicationFailed, ReplicationInteraction, ReplicationOutcome, ReplicationProgressPresentation, ReplicationReadinessRequirements, ReplicationPartial, ReplicationFailureRequest, ReplicatorConfigurationIdentity, ReplicatorProviderDefinition, ReplicatorProviderDefinitionMap, StopActiveTransferRunner, SupportedCapability, UnattendedOneShotRequest, UnattendedReplicationTrigger, UnattendedOneShotRunner, UserInitiatedOneShotRequest, UserInitiatedReplicationAuthority, UserInitiatedOneShotRunner, } from "./ReplicatorProvider.js";
4
4
  export { CAPABILITY_SUPPORT_KINDS, CAPABILITY_UNAVAILABLE_REASONS } from "./ProviderCapability.js";
5
5
  export { NO_REMOTE_RESOURCE_CAPABILITIES, REMOTE_RESOURCE_KINDS } from "./RemoteResource.js";
6
- export type { ConnectionProbeFactory, PreferredTweakProbe, PreferredTweakProbeFactory, RemoteConnectionProbe, RemoteConnectionProbeOptions, RemoteConnectionProbeResult, RemoteResourceCapabilities, RemoteResourceFactoryMap, RemoteResourceKind, RemoteResourceMap, SecuritySeedResource, SecuritySeedResourceFactory, SynchronisationInformationResource, SynchronisationInformationResourceFactory, } from "./RemoteResource.js";
6
+ export type { InitialFetchFastForwardResource, InitialFetchFastForwardResourceFactory, ConnectionProbeFactory, JoinedDevicesResource, JoinedDevicesResourceFactory, PreferredTweakProbe, PreferredTweakProbeFactory, RemoteConnectionProbe, RemoteConnectionProbeOptions, RemoteConnectionProbeResult, RemoteResourceCapabilities, RemoteResourceFactoryMap, RemoteResourceKind, RemoteResourceMap, SecuritySeedResource, SecuritySeedResourceFactory, SynchronisationInformationResource, SynchronisationInformationResourceFactory, } from "./RemoteResource.js";
7
+ export type { InitialFetchFastForward } from "./couchdb/fastForward.js";
7
8
  export { CENTRAL_REMOTE_ADMINISTRATION_ACTIONS, CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS, CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS, CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES, applyCentralRemoteAdministrationMutation, isCentralRemoteAdministrationVerified, milestoneSatisfiesCentralRemoteAdministration, centralRemoteAdministrationVerificationFailed, centralRemoteAdministrationVerified, } from "./CentralRemoteAdministration.js";
8
9
  export type { MilestoneCentralRemoteAdministrationObservation, CentralRemoteAdministrationAction, CentralRemoteAdministrationFailureReason, CentralRemoteAdministrationObservation, CentralRemoteAdministrationRequest, CentralRemoteAdministrationReplicator, CentralRemoteAdministrationResult, CentralRemoteAdministrationRunner, CentralRemoteAdministrationVerificationFailed, CentralRemoteAdministrationVerified, } from "./CentralRemoteAdministration.js";
9
10
  export { CENTRAL_COMPATIBILITY_REJECTION_REASONS } from "./CentralCompatibility.js";
10
11
  export type { CentralCompatibilityRecoveryHint, CentralCompatibilityRejectionReason } from "./CentralCompatibility.js";
11
12
  export { assessRemoteFeatureDocument, describeRemoteFeatureRejection, ENCRYPTED_INTERNAL_METADATA_FEATURE, NFC_PATH_IDENTITY_FEATURE, REMOTE_FEATURE_GENERATION, usesEncryptedInternalMetadata, } from "../pouchdb/remoteFeatureCompatibility.js";
12
13
  export type { RemoteFeatureAssessment } from "../pouchdb/remoteFeatureCompatibility.js";
14
+ export type { JoinedDevice, JoinedDevicesSnapshot, ForgetJoinedDeviceResult } from "./joinedDevices.js";
15
+ export type { ReplicationStatics, CouchDBReplicationPhase, CouchDBReplicationProgress, ReplicationCheckpointProgress, } from "../common/models/shared.definition.js";
@@ -0,0 +1,25 @@
1
+ import { type EntryMilestoneInfo } from "../common/types.js";
2
+ export interface JoinedDevice {
3
+ readonly id: string;
4
+ readonly accepted: boolean;
5
+ readonly platform?: string;
6
+ readonly pluginVersion?: string;
7
+ readonly appVersion?: string;
8
+ readonly lastConnected?: number;
9
+ }
10
+ export interface JoinedDevicesSnapshot {
11
+ readonly milestonePresent: boolean;
12
+ readonly locked: boolean;
13
+ readonly cleaned: boolean;
14
+ readonly devices: readonly JoinedDevice[];
15
+ }
16
+ export declare function projectJoinedDevices(milestone: EntryMilestoneInfo | undefined): JoinedDevicesSnapshot;
17
+ export type ForgetJoinedDeviceResult = "forgotten" | "not-found" | "conflict" | "unsupported";
18
+ export interface JoinedDevicesStore {
19
+ read(): Promise<{
20
+ milestone: EntryMilestoneInfo;
21
+ revision?: string;
22
+ } | undefined>;
23
+ write(milestone: EntryMilestoneInfo, revision: string): Promise<"updated" | "conflict" | "unsupported">;
24
+ }
25
+ export declare function forgetJoinedDevice(store: JoinedDevicesStore, deviceId: string, ownDeviceId: string): Promise<ForgetJoinedDeviceResult>;
@@ -0,0 +1,123 @@
1
+ import { DEVICE_ID_PREFERRED } from "../common/types.js";
2
+ function projectJoinedDevices(milestone) {
3
+ if (milestone === void 0) {
4
+ return { milestonePresent: false, locked: false, cleaned: false, devices: [] };
5
+ }
6
+ const source = milestone;
7
+ const nodeInfo = asRecord(source.node_info);
8
+ const acceptedNodes = Array.isArray(source.accepted_nodes) ? source.accepted_nodes.filter((id) => typeof id === "string") : [];
9
+ const accepted = new Set(acceptedNodes);
10
+ const ids = /* @__PURE__ */ new Set();
11
+ for (const id of acceptedNodes) {
12
+ if (id !== DEVICE_ID_PREFERRED) ids.add(id);
13
+ }
14
+ addRecordKeys(ids, source.node_info);
15
+ addRecordKeys(ids, source.node_chunk_info);
16
+ addRecordKeys(ids, source.tweak_values);
17
+ const devices = [...ids].filter((id) => id !== DEVICE_ID_PREFERRED).sort().map((id) => {
18
+ const nodeData = nodeInfo && hasOwn(nodeInfo, id) ? asRecord(nodeInfo[id]) : void 0;
19
+ const platform = readString(nodeData, "platform");
20
+ const pluginVersion = readString(nodeData, "plugin_version");
21
+ const appVersion = readString(nodeData, "app_version");
22
+ const lastConnected = readTimestamp(nodeData?.last_connected);
23
+ return {
24
+ id,
25
+ accepted: accepted.has(id),
26
+ ...platform === void 0 ? {} : { platform },
27
+ ...pluginVersion === void 0 ? {} : { pluginVersion },
28
+ ...appVersion === void 0 ? {} : { appVersion },
29
+ ...lastConnected === void 0 ? {} : { lastConnected }
30
+ };
31
+ });
32
+ return {
33
+ milestonePresent: true,
34
+ locked: source.locked === true,
35
+ cleaned: source.cleaned === true,
36
+ devices
37
+ };
38
+ }
39
+ const MAX_CONFLICT_RETRIES = 3;
40
+ async function forgetJoinedDevice(store, deviceId, ownDeviceId) {
41
+ if (ownDeviceId.length === 0) {
42
+ throw new Error("The current device ID must not be empty");
43
+ }
44
+ if (deviceId.length === 0) {
45
+ throw new Error("The device ID must not be empty");
46
+ }
47
+ if (deviceId === DEVICE_ID_PREFERRED) {
48
+ throw new Error("The preferred-device marker cannot be forgotten");
49
+ }
50
+ if (deviceId === ownDeviceId) {
51
+ throw new Error("The current device cannot be forgotten");
52
+ }
53
+ let conflictRetries = 0;
54
+ while (true) {
55
+ const current = await store.read();
56
+ if (current === void 0 || !hasDevice(current.milestone, deviceId)) {
57
+ return "not-found";
58
+ }
59
+ if (typeof current.revision !== "string") {
60
+ return "unsupported";
61
+ }
62
+ const updatedMilestone = removeDevice(current.milestone, deviceId);
63
+ const result = await store.write(updatedMilestone, current.revision);
64
+ if (result === "updated") return "forgotten";
65
+ if (result === "unsupported") return "unsupported";
66
+ if (conflictRetries >= MAX_CONFLICT_RETRIES) return "conflict";
67
+ conflictRetries++;
68
+ }
69
+ }
70
+ function asRecord(value) {
71
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
72
+ return value;
73
+ }
74
+ function hasOwn(record, key) {
75
+ return Object.prototype.hasOwnProperty.call(record, key);
76
+ }
77
+ function addRecordKeys(ids, value) {
78
+ const record = asRecord(value);
79
+ if (!record) return;
80
+ for (const id of Object.keys(record)) {
81
+ if (id !== DEVICE_ID_PREFERRED) ids.add(id);
82
+ }
83
+ }
84
+ function readString(record, key) {
85
+ const value = record?.[key];
86
+ return typeof value === "string" ? value : void 0;
87
+ }
88
+ function readTimestamp(value) {
89
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
90
+ }
91
+ function hasDevice(milestone, deviceId) {
92
+ const source = milestone;
93
+ const accepted = Array.isArray(source.accepted_nodes) && source.accepted_nodes.includes(deviceId);
94
+ return accepted || hasMapKey(source.node_info, deviceId) || hasMapKey(source.node_chunk_info, deviceId) || hasMapKey(source.tweak_values, deviceId);
95
+ }
96
+ function hasMapKey(value, deviceId) {
97
+ const record = asRecord(value);
98
+ return record !== void 0 && hasOwn(record, deviceId);
99
+ }
100
+ function removeDevice(milestone, deviceId) {
101
+ const source = milestone;
102
+ const updated = { ...source };
103
+ for (const key of ["node_info", "node_chunk_info", "tweak_values"]) {
104
+ if (hasOwn(source, key)) {
105
+ updated[key] = removeMapKey(source[key], deviceId);
106
+ }
107
+ }
108
+ if (hasOwn(source, "accepted_nodes") && Array.isArray(source.accepted_nodes)) {
109
+ updated.accepted_nodes = source.accepted_nodes.filter((id) => id !== deviceId);
110
+ }
111
+ return updated;
112
+ }
113
+ function removeMapKey(value, deviceId) {
114
+ const record = asRecord(value);
115
+ if (!record) return value;
116
+ const updated = { ...record };
117
+ delete updated[deviceId];
118
+ return updated;
119
+ }
120
+ export {
121
+ forgetJoinedDevice,
122
+ projectJoinedDevices
123
+ };
@@ -3,7 +3,7 @@ import type { ReplicationCallback, ReplicationStat } from "../LiveSyncAbstractRe
3
3
  import { type SimpleStore } from "../../common/utils.js";
4
4
  import { type CheckPointInfo } from "./JournalSyncTypes.js";
5
5
  import type { LiveSyncJournalReplicatorEnv } from "./LiveSyncJournalReplicatorEnv.js";
6
- import { type IJournalStorage, type JournalStorageReadResult } from "./objectstore/JournalStorageAdapter.js";
6
+ import { type IJournalStorage, type JournalStorageConditionalWriteResult, type JournalStorageReadResult } from "./objectstore/JournalStorageAdapter.js";
7
7
  type ProcessingEntry = PouchDB.Core.PutDocument<EntryDoc> & PouchDB.Core.GetMeta;
8
8
  export declare class JournalSyncCore {
9
9
  _settings: BucketSyncSetting;
@@ -24,6 +24,7 @@ export declare class JournalSyncCore {
24
24
  constructor(settings: BucketSyncSetting, store: SimpleStore<CheckPointInfo>, env: LiveSyncJournalReplicatorEnv, storage: IJournalStorage);
25
25
  downloadJson<T>(key: string): Promise<T | false>;
26
26
  downloadJsonWithResult<T>(key: string): Promise<JournalStorageReadResult<T>>;
27
+ uploadJsonIfUnchanged<T>(key: string, body: T, revision: string): Promise<JournalStorageConditionalWriteResult>;
27
28
  uploadJson<T>(key: string, body: T): Promise<boolean>;
28
29
  applyNewConfig(settings: BucketSyncSetting, store: SimpleStore<CheckPointInfo>, env: LiveSyncJournalReplicatorEnv): void;
29
30
  updateInfo(info: Partial<ReplicationStat>): void;
@@ -127,7 +127,8 @@ class JournalSyncCore {
127
127
  try {
128
128
  return {
129
129
  status: JournalStorageReadStatuses.AVAILABLE,
130
- value: JSON.parse(new TextDecoder().decode(result.value))
130
+ value: JSON.parse(new TextDecoder().decode(result.value)),
131
+ ...result.revision === void 0 ? {} : { revision: result.revision }
131
132
  };
132
133
  } catch (ex) {
133
134
  Logger(`Could not parse downloaded json ${key}`);
@@ -138,6 +139,15 @@ class JournalSyncCore {
138
139
  };
139
140
  }
140
141
  }
142
+ async uploadJsonIfUnchanged(key, body, revision) {
143
+ if (revision.trim().length === 0) {
144
+ throw new TypeError("A non-empty revision is required for a conditional upload");
145
+ }
146
+ const uploadIfUnchanged = this.storage.uploadIfUnchanged;
147
+ if (!uploadIfUnchanged) return "unsupported";
148
+ const data = new TextEncoder().encode(JSON.stringify(body));
149
+ return await uploadIfUnchanged.call(this.storage, key, data, "application/json", revision);
150
+ }
141
151
  async uploadJson(key, body) {
142
152
  try {
143
153
  const data = new TextEncoder().encode(JSON.stringify(body));
@@ -7,6 +7,7 @@ import type { SimpleStore } from "../../common/utils.js";
7
7
  import type { LiveSyncJournalReplicatorEnv } from "./LiveSyncJournalReplicatorEnv.js";
8
8
  import { type CentralCompatibilityDecisionRecorder } from "../CentralCompatibility.js";
9
9
  import { type ReplicationOutcome } from "../ReplicatorProvider.js";
10
+ import { type ForgetJoinedDeviceResult, type JoinedDevicesSnapshot } from "../joinedDevices.js";
10
11
  export declare class LiveSyncJournalReplicator extends LiveSyncAbstractReplicator {
11
12
  env: LiveSyncJournalReplicatorEnv;
12
13
  get client(): JournalSyncCore;
@@ -63,6 +64,9 @@ export declare class LiveSyncJournalReplicator extends LiveSyncAbstractReplicato
63
64
  getRemoteStatus(setting: RemoteDBSettings): Promise<false | RemoteDBStatus>;
64
65
  private supportsLegacyPathIdentity;
65
66
  private assertLegacyPathIdentity;
67
+ private getOwnJoinedDeviceId;
68
+ getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
69
+ forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
66
70
  getConnectedDeviceList(setting?: RemoteDBSettings): Promise<false | {
67
71
  node_info: Record<string, NodeData>;
68
72
  accepted_nodes: string[];
@@ -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([
@@ -144,6 +144,7 @@ export declare class TrysteroReplicator {
144
144
  idDerivationVersion: 0 | 1;
145
145
  idDerivationProof: string;
146
146
  liveSync?: boolean;
147
+ batchSaveInLiveSync?: boolean;
147
148
  syncOnSave?: boolean;
148
149
  syncOnStart?: boolean;
149
150
  syncOnFileOpen?: boolean;
@@ -332,6 +333,7 @@ export declare class TrysteroReplicator {
332
333
  idDerivationVersion: 0 | 1;
333
334
  idDerivationProof: string;
334
335
  liveSync?: boolean;
336
+ batchSaveInLiveSync?: boolean;
335
337
  syncOnSave?: boolean;
336
338
  syncOnStart?: boolean;
337
339
  syncOnFileOpen?: boolean;
@@ -360,8 +360,7 @@ async function syncFileBetweenDBandStorage(host, log, file, doc) {
360
360
  case BASE_IS_NEW:
361
361
  if (!host.services.vault.isFileSizeTooLarge(file.stat.size)) {
362
362
  log("STORAGE -> DB :" + file.path);
363
- await host.serviceModules.fileHandler.storeFileToDB(file);
364
- return FilePairProcessResults.COMPLETED;
363
+ return await host.serviceModules.fileHandler.storeFileToDB(file) ? FilePairProcessResults.COMPLETED : FilePairProcessResults.FAILED;
365
364
  } else {
366
365
  log(
367
366
  `STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
@@ -390,11 +389,22 @@ async function syncFileBetweenDBandStorage(host, log, file, doc) {
390
389
  return FilePairProcessResults.SKIPPED;
391
390
  }
392
391
  case EVEN:
393
- if (doc._rev) {
394
- await host.serviceModules.fileHandler.tryRecordUntrackedFileRevision(file, doc._rev);
392
+ if (host.services.vault.isFileSizeTooLarge(file.stat.size) || host.services.vault.isFileSizeTooLarge(doc.size)) {
393
+ log(`STORAGE ? DB : ${file.path} has been skipped due to file size exceeding the limit`, LOG_LEVEL_NOTICE);
394
+ return FilePairProcessResults.SKIPPED;
395
+ }
396
+ if (doc._rev && await host.serviceModules.fileHandler.tryRecordUntrackedFileRevision(file, doc._rev)) {
397
+ log("STORAGE == DB :" + file.path, LOG_LEVEL_DEBUG);
398
+ return FilePairProcessResults.COMPLETED;
399
+ }
400
+ log("STORAGE ? DB :" + file.path + " Checking content", LOG_LEVEL_DEBUG);
401
+ try {
402
+ return await host.serviceModules.fileHandler.storeFileToDB(file, false, false, { revalidateStorage: true }) ? FilePairProcessResults.COMPLETED : FilePairProcessResults.FAILED;
403
+ } catch (error) {
404
+ log(`STORAGE ? DB : Could not reconcile ${file.path}`, LOG_LEVEL_NOTICE);
405
+ log(error, LOG_LEVEL_VERBOSE);
406
+ return FilePairProcessResults.FAILED;
395
407
  }
396
- log("STORAGE == DB :" + file.path, LOG_LEVEL_DEBUG);
397
- return FilePairProcessResults.COMPLETED;
398
408
  default:
399
409
  log("STORAGE ?? DB :" + file.path + " Something got weird");
400
410
  return FilePairProcessResults.FAILED;
@@ -501,7 +511,9 @@ async function collectDatabaseFiles(host, settings, log, showingNotice) {
501
511
  async function updateToDatabase(host, log, logLevel, file) {
502
512
  if (!host.services.vault.isFileSizeTooLarge(file.stat.size)) {
503
513
  const path = file.path;
504
- await host.serviceModules.fileHandler.storeFileToDB(file);
514
+ if (!await host.serviceModules.fileHandler.storeFileToDB(file)) {
515
+ return FilePairProcessResults.FAILED;
516
+ }
505
517
  host.services.context.events.emitEvent("event-file-changed", { file: path, automated: true });
506
518
  return FilePairProcessResults.COMPLETED;
507
519
  } else {