@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.
- package/README.md +57 -6
- package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
- package/dist/API/DirectFileManipulatorV2.js +3 -0
- package/dist/common/configForDoc.d.ts +1 -0
- package/dist/common/configForDoc.js +11 -1
- package/dist/common/models/db.definition.d.ts +1 -0
- package/dist/common/models/setting.const.preferred.js +1 -3
- package/dist/diagnostics/couchdb.js +86 -13
- package/dist/diagnostics/index.d.ts +2 -2
- package/dist/diagnostics/index.js +2 -0
- package/dist/diagnostics/integrity.js +385 -158
- package/dist/diagnostics/names.d.ts +16 -0
- package/dist/diagnostics/names.js +50 -0
- package/dist/diagnostics/reports.d.ts +13 -10
- package/dist/diagnostics/reports.js +191 -50
- package/dist/diagnostics/types.d.ts +64 -4
- package/dist/diagnostics/usage.d.ts +4 -0
- package/dist/diagnostics/usage.js +158 -54
- package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
- package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
- package/dist/pouchdb/StreamingFetch.d.ts +5 -1
- package/dist/pouchdb/StreamingFetch.js +529 -65
- package/dist/replication/RemoteResource.d.ts +23 -2
- package/dist/replication/RemoteResource.js +6 -2
- package/dist/replication/couchdb/LiveSyncReplicator.d.ts +8 -0
- package/dist/replication/couchdb/LiveSyncReplicator.js +132 -1
- package/dist/replication/couchdb/fastForward.d.ts +19 -0
- package/dist/replication/couchdb/fastForward.js +119 -0
- package/dist/replication/index.d.ts +3 -1
- package/dist/replication/joinedDevices.d.ts +25 -0
- package/dist/replication/joinedDevices.js +123 -0
- package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
- package/dist/replication/journal/JournalSyncCore.js +11 -1
- package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
- package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
- package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
- package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
- package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
- package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
- package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
- package/dist/serviceModules/Rebuilder.js +41 -15
- package/dist/services/BrowserServices.d.ts +4 -2
- package/dist/services/BrowserServices.js +2 -5
- package/dist/services/HeadlessServices.d.ts +3 -0
- package/dist/services/HeadlessServices.js +2 -5
- package/dist/services/ServiceHub.d.ts +4 -4
- package/dist/services/base/IService.d.ts +10 -18
- package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
- package/dist/services/base/SettingService.d.ts +8 -18
- package/dist/services/base/SettingService.js +28 -46
- package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
- package/dist/services/implements/base/UIService.d.ts +2 -0
- package/dist/services/implements/base/UIService.js +5 -0
- package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
- package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
- package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
- package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
- package/dist/string_and_binary/chunks.js +7 -5
- package/dist/uiCommands.d.ts +26 -0
- package/dist/uiCommands.js +57 -0
- package/dist/worker/bgWorker.js +1 -1
- package/docs/database-diagnostics.md +45 -0
- package/docs/development.md +15 -3
- package/docs/proven-in-use.md +45 -5
- package/docs/releasing.md +20 -13
- package/docs/settings-lifecycle.md +37 -5
- package/package.json +13 -17
- package/dist/services/base/ConfigService.d.ts +0 -7
- package/dist/services/base/ConfigService.js +0 -6
- package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
- 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
|
-
/**
|
|
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
|
|
252
|
-
if (
|
|
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
|
-
/**
|
|
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) =>
|
|
535
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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 =
|
|
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 =
|
|
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?:
|
|
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:
|
|
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():
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
4
|
+
if (capability?.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
|
5
5
|
return void 0;
|
|
6
6
|
}
|
|
7
7
|
return await capability.run(setting);
|