@vrtmrz/livesync-commonlib 0.1.44 → 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 (59) hide show
  1. package/README.md +37 -4
  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/managers/EntryManager/EntryManagerImpls.js +3 -2
  9. package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
  10. package/dist/pouchdb/StreamingFetch.d.ts +5 -1
  11. package/dist/pouchdb/StreamingFetch.js +529 -65
  12. package/dist/replication/RemoteResource.d.ts +23 -2
  13. package/dist/replication/RemoteResource.js +6 -2
  14. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +8 -0
  15. package/dist/replication/couchdb/LiveSyncReplicator.js +132 -1
  16. package/dist/replication/couchdb/fastForward.d.ts +19 -0
  17. package/dist/replication/couchdb/fastForward.js +119 -0
  18. package/dist/replication/index.d.ts +3 -1
  19. package/dist/replication/joinedDevices.d.ts +25 -0
  20. package/dist/replication/joinedDevices.js +123 -0
  21. package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
  22. package/dist/replication/journal/JournalSyncCore.js +11 -1
  23. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
  24. package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
  25. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
  26. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
  27. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
  28. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
  29. package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
  30. package/dist/serviceModules/Rebuilder.js +41 -15
  31. package/dist/services/BrowserServices.d.ts +4 -2
  32. package/dist/services/BrowserServices.js +2 -5
  33. package/dist/services/HeadlessServices.d.ts +3 -0
  34. package/dist/services/HeadlessServices.js +2 -5
  35. package/dist/services/ServiceHub.d.ts +4 -4
  36. package/dist/services/base/IService.d.ts +10 -18
  37. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
  38. package/dist/services/base/SettingService.d.ts +8 -18
  39. package/dist/services/base/SettingService.js +28 -46
  40. package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
  41. package/dist/services/implements/base/UIService.d.ts +2 -0
  42. package/dist/services/implements/base/UIService.js +5 -0
  43. package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
  44. package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
  45. package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
  46. package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
  47. package/dist/string_and_binary/chunks.js +7 -5
  48. package/dist/uiCommands.d.ts +26 -0
  49. package/dist/uiCommands.js +57 -0
  50. package/dist/worker/bgWorker.js +1 -1
  51. package/docs/development.md +15 -3
  52. package/docs/proven-in-use.md +38 -4
  53. package/docs/releasing.md +20 -13
  54. package/docs/settings-lifecycle.md +37 -5
  55. package/package.json +12 -17
  56. package/dist/services/base/ConfigService.d.ts +0 -7
  57. package/dist/services/base/ConfigService.js +0 -6
  58. package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
  59. package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
@@ -1,12 +1,16 @@
1
1
  import type { RemoteDBSettings, RemotePreferredTweakResult } from "../common/types.js";
2
+ import type { JoinedDevicesSnapshot, ForgetJoinedDeviceResult } from "./joinedDevices.js";
2
3
  import type { RemoteDBStatus } from "./LiveSyncAbstractReplicator.js";
3
4
  import { type CapabilitySupport } from "./ProviderCapability.js";
5
+ import type { InitialFetchFastForward } from "./couchdb/fastForward.js";
4
6
  /** Stable machine identifiers for finite provider-owned remote resources. */
5
7
  export declare const REMOTE_RESOURCE_KINDS: Readonly<{
6
8
  readonly CONNECTION: "connection";
9
+ readonly JOINED_DEVICES: "joined-devices";
7
10
  readonly PREFERRED_TWEAK: "preferred-tweak";
8
11
  readonly SECURITY_SEED: "security-seed";
9
12
  readonly SYNCHRONISATION_INFORMATION: "synchronisation-information";
13
+ readonly INITIAL_FETCH_FAST_FORWARD: "initial-fetch-fast-forward";
10
14
  }>;
11
15
  export type RemoteResourceKind = (typeof REMOTE_RESOURCE_KINDS)[keyof typeof REMOTE_RESOURCE_KINDS];
12
16
  /** Options understood by a provider's finite remote-connection resource. */
@@ -64,22 +68,39 @@ export interface SynchronisationInformationResource {
64
68
  check(): Promise<boolean>;
65
69
  dispose(): Promise<void>;
66
70
  }
71
+ /** Checkpoint handoff owned exclusively by one fresh Fast Fetch. */
72
+ export interface InitialFetchFastForwardResource {
73
+ prepare(): Promise<InitialFetchFastForward | undefined>;
74
+ dispose(): Promise<void>;
75
+ }
76
+ /** An owned reader and editor of device records for one remote settings snapshot. */
77
+ export interface JoinedDevicesResource {
78
+ read(): Promise<JoinedDevicesSnapshot>;
79
+ forget(deviceId: string): Promise<ForgetJoinedDeviceResult>;
80
+ dispose(): Promise<void>;
81
+ }
67
82
  export type RemoteResourceMap = {
83
+ readonly [REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: JoinedDevicesResource;
68
84
  readonly [REMOTE_RESOURCE_KINDS.CONNECTION]: RemoteConnectionProbe;
69
85
  readonly [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: PreferredTweakProbe;
70
86
  readonly [REMOTE_RESOURCE_KINDS.SECURITY_SEED]: SecuritySeedResource;
71
87
  readonly [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: SynchronisationInformationResource;
88
+ readonly [REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: InitialFetchFastForwardResource;
72
89
  };
73
90
  export type RemoteResourceFactoryMap = {
74
91
  readonly [K in RemoteResourceKind]: (setting: RemoteDBSettings) => Promise<RemoteResourceMap[K]>;
75
92
  };
76
- /** Exhaustive resource support catalogue supplied by one provider. */
93
+ /** Resource support catalogue; older hosts may omit the initial-fetch optimisation. */
77
94
  export type RemoteResourceCapabilities = {
78
- readonly [K in RemoteResourceKind]: CapabilitySupport<RemoteResourceFactoryMap[K]>;
95
+ readonly [K in Exclude<RemoteResourceKind, typeof REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD>]: CapabilitySupport<RemoteResourceFactoryMap[K]>;
96
+ } & {
97
+ readonly [REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?: CapabilitySupport<RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]>;
79
98
  };
80
99
  /** Explicit catalogue for providers which own no finite remote resources. */
81
100
  export declare const NO_REMOTE_RESOURCE_CAPABILITIES: RemoteResourceCapabilities;
82
101
  export type ConnectionProbeFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.CONNECTION];
83
102
  export type PreferredTweakProbeFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK];
84
103
  export type SecuritySeedResourceFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.SECURITY_SEED];
104
+ export type InitialFetchFastForwardResourceFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD];
85
105
  export type SynchronisationInformationResourceFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION];
106
+ export type JoinedDevicesResourceFactory = RemoteResourceFactoryMap[typeof REMOTE_RESOURCE_KINDS.JOINED_DEVICES];
@@ -1,15 +1,19 @@
1
1
  import { CAPABILITY_NOT_APPLICABLE } from "./ProviderCapability.js";
2
2
  const REMOTE_RESOURCE_KINDS = Object.freeze({
3
3
  CONNECTION: "connection",
4
+ JOINED_DEVICES: "joined-devices",
4
5
  PREFERRED_TWEAK: "preferred-tweak",
5
6
  SECURITY_SEED: "security-seed",
6
- SYNCHRONISATION_INFORMATION: "synchronisation-information"
7
+ SYNCHRONISATION_INFORMATION: "synchronisation-information",
8
+ INITIAL_FETCH_FAST_FORWARD: "initial-fetch-fast-forward"
7
9
  });
8
10
  const NO_REMOTE_RESOURCE_CAPABILITIES = Object.freeze({
9
11
  [REMOTE_RESOURCE_KINDS.CONNECTION]: CAPABILITY_NOT_APPLICABLE,
12
+ [REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: CAPABILITY_NOT_APPLICABLE,
10
13
  [REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: CAPABILITY_NOT_APPLICABLE,
11
14
  [REMOTE_RESOURCE_KINDS.SECURITY_SEED]: CAPABILITY_NOT_APPLICABLE,
12
- [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE
15
+ [REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
16
+ [REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE
13
17
  });
14
18
  export {
15
19
  NO_REMOTE_RESOURCE_CAPABILITIES,
@@ -1,9 +1,11 @@
1
1
  import { type EntryDoc, type RemoteDBSettings, type EntryLeaf, type SyncParameters, type DatabaseEntry, type NodeData, type RemotePreferredTweakResult } from "../../common/types.js";
2
+ import type { InitialFetchFastForwardResource } from "../RemoteResource.js";
2
3
  import { LiveSyncAbstractReplicator, type LiveSyncReplicatorEnv, type RemoteDBStatus } from "../LiveSyncAbstractReplicator.js";
3
4
  import type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "../../services/base/RemoteConnection.js";
4
5
  import type { IPathService } from "../../services/base/IService.js";
5
6
  import { type CentralCompatibilityDecisionRecorder } from "../CentralCompatibility.js";
6
7
  import { type ReplicationOutcome } from "../ReplicatorProvider.js";
8
+ import { type ForgetJoinedDeviceResult, type JoinedDevicesSnapshot } from "../joinedDevices.js";
7
9
  export type { OwnedCouchDBConnection, RemoteConnectionOpenOptions } from "../../services/base/RemoteConnection.js";
8
10
  /**
9
11
  * Compatibility constructor environment for the CouchDB Replicator facade.
@@ -45,6 +47,8 @@ export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicato
45
47
  private continuousStopRequested;
46
48
  isMobile(): boolean;
47
49
  constructor(env: LiveSyncCouchDBReplicatorEnv);
50
+ /** Own the checkpoint handoff for one fresh, suspended Fast Fetch only. */
51
+ createInitialFetchFastForwardResource(setting: RemoteDBSettings): InitialFetchFastForwardResource;
48
52
  getInitialSyncParameters(setting: RemoteDBSettings): Promise<SyncParameters>;
49
53
  getSyncParameters(setting: RemoteDBSettings): Promise<SyncParameters>;
50
54
  putSyncParameters(setting: RemoteDBSettings, params: SyncParameters): Promise<boolean>;
@@ -152,6 +156,10 @@ export declare class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicato
152
156
  compactRemote(setting: RemoteDBSettings): Promise<boolean>;
153
157
  getRemoteStatus(setting: RemoteDBSettings): Promise<RemoteDBStatus | false>;
154
158
  countCompromisedChunks(setting?: RemoteDBSettings): Promise<number | boolean>;
159
+ private connectForJoinedDevices;
160
+ private getOwnJoinedDeviceId;
161
+ getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
162
+ forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
155
163
  getConnectedDeviceList(setting?: RemoteDBSettings): Promise<false | {
156
164
  node_info: Record<string, NodeData>;
157
165
  accepted_nodes: string[];
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  VER,
3
3
  MILESTONE_DOCID,
4
+ NODEINFO_DOCID,
4
5
  REPLICATION_BUSY_TIMEOUT,
5
6
  LOG_LEVEL_INFO,
6
7
  LOG_LEVEL_NOTICE,
@@ -26,6 +27,7 @@ import { requiredRemoteFeatures, usesEncryptedInternalMetadata } from "../../pou
26
27
  import { assessRemoteDocumentIds } from "../../pouchdb/remoteIdCompatibility.js";
27
28
  import { isErrorOfMissingDoc } from "../../pouchdb/utils_couchdb.js";
28
29
  import { preprocessOutgoing } from "../../pouchdb/encryption.js";
30
+ import { prepareInitialFetchFastForward } from "./fastForward.js";
29
31
  import { ensureDatabaseIsCompatible, getEffectiveTweakValues } from "../../pouchdb/LiveSyncDBFunctions.js";
30
32
  import {
31
33
  LiveSyncAbstractReplicator
@@ -54,6 +56,10 @@ import {
54
56
  replicationBlocked,
55
57
  replicationFailed
56
58
  } from "../ReplicatorProvider.js";
59
+ import {
60
+ forgetJoinedDevice as forgetJoinedDeviceFromStore,
61
+ projectJoinedDevices
62
+ } from "../joinedDevices.js";
57
63
  const currentVersionRange = {
58
64
  min: 0,
59
65
  max: 2400,
@@ -123,6 +129,59 @@ class LiveSyncCouchDBReplicator extends LiveSyncAbstractReplicator {
123
129
  super(env);
124
130
  this.env = env;
125
131
  }
132
+ /** Own the checkpoint handoff for one fresh, suspended Fast Fetch only. */
133
+ createInitialFetchFastForwardResource(setting) {
134
+ const snapshot = { ...setting };
135
+ const identity = (value) => JSON.stringify([
136
+ value.couchDB_URI,
137
+ value.couchDB_DBNAME,
138
+ value.couchDB_USER,
139
+ value.couchDB_PASSWORD,
140
+ value.couchDB_CustomHeaders,
141
+ value.useJWT,
142
+ value.jwtAlgorithm,
143
+ value.jwtKey,
144
+ value.jwtExpDuration,
145
+ value.jwtKid,
146
+ value.jwtSub,
147
+ value.encrypt,
148
+ value.passphrase,
149
+ value.E2EEAlgorithm,
150
+ value.enableCompression,
151
+ value.pathNormalizationVersion,
152
+ value.readChunksOnline,
153
+ value.useRequestAPI
154
+ ]);
155
+ const binding = identity(snapshot);
156
+ let connection;
157
+ let prepared = false;
158
+ let disposed = false;
159
+ return {
160
+ prepare: async () => {
161
+ if (prepared || disposed) return void 0;
162
+ prepared = true;
163
+ if (this.currentSettings.doNotSuspendOnFetching || snapshot.useRequestAPI) return void 0;
164
+ const local = this.rawDatabase;
165
+ const isCurrent = () => !disposed && this.rawDatabase === local && this.currentSettings.suspendFileWatching && this.currentSettings.suspendParseReplicationResult && identity(this.currentSettings) === binding;
166
+ if (!isCurrent()) return void 0;
167
+ const openedConnection = await this.openOwnedConnection(snapshot);
168
+ if (disposed) {
169
+ await openedConnection.close();
170
+ return void 0;
171
+ }
172
+ connection = openedConnection;
173
+ return await prepareInitialFetchFastForward(local, connection.db, {
174
+ push: {},
175
+ ...snapshot.readChunksOnline ? { pull: { ...selectorOnDemandPull } } : {}
176
+ }, isCurrent);
177
+ },
178
+ dispose: async () => {
179
+ if (disposed) return;
180
+ disposed = true;
181
+ if (connection) await connection.close();
182
+ }
183
+ };
184
+ }
126
185
  getInitialSyncParameters(setting) {
127
186
  return Promise.resolve({
128
187
  ...DEFAULT_SYNC_PARAMETERS,
@@ -925,7 +984,8 @@ ${sendingDocs}/(${sentDocsCount} done)`,
925
984
  plugin_version: this.env.services.API.getPluginVersion(),
926
985
  vault_name: this.env.services.vault.vaultName(),
927
986
  device_name: this.env.services.vault.getVaultName(),
928
- progress
987
+ progress,
988
+ platform: this.env.services.API.getPlatform()
929
989
  };
930
990
  let tweakAssessment;
931
991
  const ensure = await ensureDatabaseIsCompatible(
@@ -1608,6 +1668,77 @@ ${sendingDocs}/(${sentDocsCount} done)`,
1608
1668
  }
1609
1669
  return await this.withRemoteConnection(dbRet, countCompromisedChunks);
1610
1670
  }
1671
+ async connectForJoinedDevices(setting) {
1672
+ let result;
1673
+ try {
1674
+ result = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), false);
1675
+ } catch {
1676
+ throw new Error("Could not connect to remote CouchDB for joined-device management.");
1677
+ }
1678
+ if (typeof result === "string") {
1679
+ throw new Error("Could not connect to remote CouchDB for joined-device management.");
1680
+ }
1681
+ return result;
1682
+ }
1683
+ async getOwnJoinedDeviceId() {
1684
+ const nodeInfo = await this.rawDatabase.get(NODEINFO_DOCID);
1685
+ const nodeId = nodeInfo?.nodeid;
1686
+ if (typeof nodeId !== "string" || nodeId.trim().length === 0) {
1687
+ throw new Error("The local node identity is unavailable.");
1688
+ }
1689
+ return nodeId;
1690
+ }
1691
+ async getJoinedDevices(setting) {
1692
+ const connection = await this.connectForJoinedDevices(setting);
1693
+ return await this.withRemoteConnection(connection, async (db) => {
1694
+ let milestone;
1695
+ try {
1696
+ milestone = await db.get(MILESTONE_DOCID);
1697
+ } catch (ex) {
1698
+ if (!isErrorOfMissingDoc(ex)) throw ex;
1699
+ }
1700
+ return projectJoinedDevices(milestone);
1701
+ });
1702
+ }
1703
+ async forgetJoinedDevice(setting, deviceId) {
1704
+ const ownDeviceId = await this.getOwnJoinedDeviceId();
1705
+ const connection = await this.connectForJoinedDevices(setting);
1706
+ return await this.withRemoteConnection(connection, async (db) => {
1707
+ const store = {
1708
+ read: async () => {
1709
+ let milestone;
1710
+ try {
1711
+ milestone = await db.get(MILESTONE_DOCID);
1712
+ } catch (ex) {
1713
+ if (isErrorOfMissingDoc(ex)) return void 0;
1714
+ throw ex;
1715
+ }
1716
+ return { milestone, revision: milestone._rev };
1717
+ },
1718
+ write: async (milestone, revision) => {
1719
+ try {
1720
+ await db.put({ ...milestone, _rev: revision });
1721
+ } catch (ex) {
1722
+ if (ex && typeof ex === "object" && ex.status === 409) {
1723
+ return "conflict";
1724
+ }
1725
+ throw ex;
1726
+ }
1727
+ let verifiedMilestone;
1728
+ try {
1729
+ verifiedMilestone = await db.get(MILESTONE_DOCID);
1730
+ } catch {
1731
+ throw new Error("Could not verify joined-device removal.");
1732
+ }
1733
+ if (projectJoinedDevices(verifiedMilestone).devices.some((device) => device.id === deviceId)) {
1734
+ throw new Error("Could not verify joined-device removal.");
1735
+ }
1736
+ return "updated";
1737
+ }
1738
+ };
1739
+ return await forgetJoinedDeviceFromStore(store, deviceId, ownDeviceId);
1740
+ });
1741
+ }
1611
1742
  async getConnectedDeviceList(setting = this.currentSettings) {
1612
1743
  const dbRet = await this.connectRemoteCouchDBWithSetting(setting, this.isMobile(), true);
1613
1744
  if (typeof dbRet === "string") {
@@ -0,0 +1,19 @@
1
+ import type { EntryDoc } from "../../common/types.js";
2
+ type Sequence = string | number;
3
+ /** Evidence retained only for the currently running, fresh Fast Fetch. */
4
+ export interface InitialFetchFastForward {
5
+ recordStoredRevisions(revisions: readonly {
6
+ id: string;
7
+ rev: string;
8
+ }[]): void;
9
+ recordCheckpoint(sequence: Sequence): void;
10
+ fastForward(): Promise<boolean>;
11
+ }
12
+ /**
13
+ * Prepare ordinary checkpoint pairs before a fresh import into an empty database.
14
+ * The importer must remain the sole writer until fastForward settles.
15
+ * The remote database must remain in place throughout the import and handoff:
16
+ * PouchDB's HTTP ID identifies a server and database name, not its incarnation.
17
+ */
18
+ export declare function prepareInitialFetchFastForward(localDatabase: PouchDB.Database<EntryDoc>, remoteDatabase: PouchDB.Database<EntryDoc>, options: PouchDB.Replication.SyncOptions, isCurrent: () => boolean): Promise<InitialFetchFastForward | undefined>;
19
+ export {};
@@ -0,0 +1,119 @@
1
+ import generateReplicationId from "pouchdb-generate-replication-id";
2
+ import Checkpointer from "pouchdb-checkpointer";
3
+ import { uuid } from "pouchdb-utils";
4
+ const MAX_TRACKED_REVISIONS = 1e5;
5
+ async function prepareInitialFetchFastForward(localDatabase, remoteDatabase, options, isCurrent) {
6
+ const local = localDatabase;
7
+ const remote = remoteDatabase;
8
+ if ((await local.info()).doc_count !== 0 || !isCurrent()) return void 0;
9
+ const localId = await local.id();
10
+ const remoteId = await remote.id();
11
+ const pairs = [
12
+ { source: local, target: remote, options: { ...options, ...options.push } },
13
+ { source: remote, target: local, options: { ...options, ...options.pull } }
14
+ ];
15
+ const checkpoints = [];
16
+ for (const pair of pairs) {
17
+ const id = await generateReplicationId(pair.source, pair.target, pair.options);
18
+ for (const database of [local, remote]) {
19
+ try {
20
+ await database.get(id);
21
+ return void 0;
22
+ } catch (error) {
23
+ if (error.status !== 404) throw error;
24
+ }
25
+ }
26
+ const session = uuid();
27
+ await new Checkpointer(pair.source, pair.target, id, { cancelled: false }).writeCheckpoint(0, session);
28
+ checkpoints.push({
29
+ id,
30
+ session,
31
+ localRevision: (await local.get(id))._rev,
32
+ remoteRevision: (await remote.get(id))._rev
33
+ });
34
+ }
35
+ const imported = /* @__PURE__ */ new Set();
36
+ let withinBudget = true;
37
+ let lastSequence;
38
+ let consumed = false;
39
+ const key = (id, rev) => JSON.stringify([id, rev]);
40
+ return {
41
+ recordStoredRevisions(revisions) {
42
+ if (!withinBudget) return;
43
+ for (const revision of revisions) {
44
+ imported.add(key(revision.id, revision.rev));
45
+ if (imported.size > MAX_TRACKED_REVISIONS) {
46
+ imported.clear();
47
+ withinBudget = false;
48
+ return;
49
+ }
50
+ }
51
+ },
52
+ recordCheckpoint(sequence) {
53
+ lastSequence = sequence;
54
+ },
55
+ async fastForward() {
56
+ if (consumed) return false;
57
+ consumed = true;
58
+ if (!withinBudget || lastSequence === void 0 || !isCurrent()) return false;
59
+ if (await local.id() !== localId || await remote.id() !== remoteId) return false;
60
+ const localSequence = (await local.info()).update_seq;
61
+ let since = 0;
62
+ while (true) {
63
+ const changes = await local.changes({
64
+ since,
65
+ limit: 1e3,
66
+ style: "all_docs",
67
+ return_docs: true
68
+ });
69
+ for (const change of changes.results) {
70
+ for (const revision of change.changes) {
71
+ if (!imported.has(key(change.id, revision.rev))) return false;
72
+ }
73
+ }
74
+ if (changes.results.length < 1e3) break;
75
+ since = changes.last_seq;
76
+ }
77
+ if (!isCurrent() || (await local.info()).update_seq !== localSequence) return false;
78
+ const guard = (database, id, expectedRevision, session) => ({
79
+ adapter: database.adapter,
80
+ async get(requestedId) {
81
+ let document;
82
+ try {
83
+ document = await database.get(requestedId);
84
+ } catch (cause) {
85
+ throw new Error("The initial-fetch checkpoint is no longer available.", { cause });
86
+ }
87
+ if (requestedId !== id || document._rev !== expectedRevision || document.session_id !== session) {
88
+ throw new Error("The initial-fetch checkpoint changed before fast-forwarding.");
89
+ }
90
+ return document;
91
+ },
92
+ async put(document) {
93
+ if (document._id !== id || document._rev !== expectedRevision) {
94
+ throw new Error("The initial-fetch checkpoint no longer matches its import.");
95
+ }
96
+ const response = await database.put(document);
97
+ expectedRevision = response.rev;
98
+ return response;
99
+ }
100
+ });
101
+ for (const [index, checkpoint] of checkpoints.entries()) {
102
+ if (!isCurrent() || (await local.info()).update_seq !== localSequence) return false;
103
+ const guardedLocal = guard(local, checkpoint.id, checkpoint.localRevision, checkpoint.session);
104
+ const guardedRemote = guard(remote, checkpoint.id, checkpoint.remoteRevision, checkpoint.session);
105
+ const source = index === 0 ? guardedLocal : guardedRemote;
106
+ const target = index === 0 ? guardedRemote : guardedLocal;
107
+ await new Checkpointer(source, target, checkpoint.id, { cancelled: false }).writeCheckpoint(
108
+ index === 0 ? localSequence : lastSequence,
109
+ checkpoint.session
110
+ );
111
+ }
112
+ imported.clear();
113
+ return true;
114
+ }
115
+ };
116
+ }
117
+ export {
118
+ prepareInitialFetchFastForward
119
+ };
@@ -3,10 +3,12 @@ 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";
@@ -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[];