@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
|
@@ -3,7 +3,61 @@ import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
|
|
3
3
|
import { VERSIONING_DOCID } from "../common/types.js";
|
|
4
4
|
import { assessRemoteFeatureDocument, describeRemoteFeatureRejection } from "./remoteFeatureCompatibility.js";
|
|
5
5
|
import { assertMatchingPathIdentityFeature } from "./pathIdentityCompatibility.js";
|
|
6
|
+
function isObject(value) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function revisionRefKey(ref) {
|
|
10
|
+
return `${ref.id}\0${ref.rev}`;
|
|
11
|
+
}
|
|
12
|
+
function parseRevisionIdentifier(rev) {
|
|
13
|
+
const separator = rev.indexOf("-");
|
|
14
|
+
if (separator <= 0) return void 0;
|
|
15
|
+
const generationText = rev.slice(0, separator);
|
|
16
|
+
const hash = rev.slice(separator + 1);
|
|
17
|
+
if (!/^[1-9]\d*$/.test(generationText) || hash.length === 0 || hash.trim() !== hash) return void 0;
|
|
18
|
+
const generation = Number(generationText);
|
|
19
|
+
if (!Number.isSafeInteger(generation)) return void 0;
|
|
20
|
+
return { generation, hash };
|
|
21
|
+
}
|
|
22
|
+
function ensureRevisionIdentity(value, ref) {
|
|
23
|
+
if (!isObject(value) || value._id !== ref.id || value._rev !== ref.rev) {
|
|
24
|
+
throw new StreamingFetchFailure(
|
|
25
|
+
"protocol",
|
|
26
|
+
`Fast Fetch received an invalid body for revision ${ref.id}@${ref.rev}.`,
|
|
27
|
+
false
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
if (value._deleted !== void 0 && value._deleted !== true) {
|
|
31
|
+
throw new StreamingFetchFailure(
|
|
32
|
+
"protocol",
|
|
33
|
+
`Fast Fetch received an invalid deletion marker for revision ${ref.id}@${ref.rev}.`,
|
|
34
|
+
false
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function hasValidRevisionAncestry(value, revision) {
|
|
40
|
+
const revisions = value._revisions;
|
|
41
|
+
return isObject(revisions) && Number.isSafeInteger(revisions.start) && revisions.start === revision.generation && Array.isArray(revisions.ids) && revisions.ids.length > 0 && revisions.ids.length <= revision.generation && revisions.ids[0] === revision.hash && revisions.ids.every((id) => typeof id === "string" && id.length > 0);
|
|
42
|
+
}
|
|
43
|
+
function ensureRevisionDocument(value, ref) {
|
|
44
|
+
const document = ensureRevisionIdentity(value, ref);
|
|
45
|
+
const revision = parseRevisionIdentifier(ref.rev);
|
|
46
|
+
if (revision === void 0 || !hasValidRevisionAncestry(document, revision)) {
|
|
47
|
+
throw new StreamingFetchFailure(
|
|
48
|
+
"protocol",
|
|
49
|
+
`Fast Fetch received revision ${ref.id}@${ref.rev} without valid revision ancestry.`,
|
|
50
|
+
false
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return document;
|
|
54
|
+
}
|
|
55
|
+
class BulkGetResponseTooLargeError extends Error {
|
|
56
|
+
name = "BulkGetResponseTooLargeError";
|
|
57
|
+
}
|
|
6
58
|
const FAST_FETCH_CHANGES_PAGE_LIMIT = 1e4;
|
|
59
|
+
const FAST_FETCH_REVISION_GROUP_LIMIT = 100;
|
|
60
|
+
const FAST_FETCH_REVISION_GROUP_BYTE_LIMIT = 512 * 1024;
|
|
7
61
|
const FAST_FETCH_CHANGES_PAGE_TIMEOUT_MS = 1e3;
|
|
8
62
|
class StreamingFetchFailure extends Error {
|
|
9
63
|
constructor(stage, message, retryable, options) {
|
|
@@ -76,6 +130,46 @@ async function readResponseText(response, operation) {
|
|
|
76
130
|
throw transportFailure(operation, error);
|
|
77
131
|
}
|
|
78
132
|
}
|
|
133
|
+
async function readResponseTextBounded(response, operation, maxBytes) {
|
|
134
|
+
if (!response.body) {
|
|
135
|
+
const text = await readResponseText(response, operation);
|
|
136
|
+
const byteLength2 = new TextEncoder().encode(text).byteLength;
|
|
137
|
+
if (byteLength2 > maxBytes) throw new BulkGetResponseTooLargeError();
|
|
138
|
+
return { text, byteLength: byteLength2 };
|
|
139
|
+
}
|
|
140
|
+
const reader = response.body.getReader();
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let byteLength = 0;
|
|
143
|
+
try {
|
|
144
|
+
while (true) {
|
|
145
|
+
let result;
|
|
146
|
+
try {
|
|
147
|
+
result = await reader.read();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw transportFailure(operation, error);
|
|
150
|
+
}
|
|
151
|
+
if (result.done) break;
|
|
152
|
+
byteLength += result.value.byteLength;
|
|
153
|
+
if (byteLength > maxBytes) {
|
|
154
|
+
try {
|
|
155
|
+
await reader.cancel();
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
throw new BulkGetResponseTooLargeError();
|
|
159
|
+
}
|
|
160
|
+
chunks.push(result.value);
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
reader.releaseLock();
|
|
164
|
+
}
|
|
165
|
+
const bytes = new Uint8Array(byteLength);
|
|
166
|
+
let offset = 0;
|
|
167
|
+
for (const chunk of chunks) {
|
|
168
|
+
bytes.set(chunk, offset);
|
|
169
|
+
offset += chunk.byteLength;
|
|
170
|
+
}
|
|
171
|
+
return { text: new TextDecoder().decode(bytes), byteLength };
|
|
172
|
+
}
|
|
79
173
|
async function checkRemoteFeaturesForInitialSync(remoteDbUrl, authHeader, customHeaders, settings = {}) {
|
|
80
174
|
const headers = new Headers(customHeaders);
|
|
81
175
|
headers.set("Accept", "application/json");
|
|
@@ -114,92 +208,125 @@ async function saveCheckpoint(onCheckpoint, sequence) {
|
|
|
114
208
|
);
|
|
115
209
|
}
|
|
116
210
|
}
|
|
117
|
-
function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint) {
|
|
211
|
+
function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint, onStoredRevisions) {
|
|
118
212
|
let batchBuffer = [];
|
|
213
|
+
let batchStoredRevisions = [];
|
|
214
|
+
let batchRevisionKeys = /* @__PURE__ */ new Set();
|
|
119
215
|
let currentBatchSizeBytes = 0;
|
|
120
|
-
let
|
|
216
|
+
let batchCheckpointSequence;
|
|
121
217
|
const BATCH_ITEM_LIMIT = 100;
|
|
122
218
|
const BATCH_SIZE_LIMIT = 2 * 1024 * 1024;
|
|
123
219
|
const flush = async () => {
|
|
124
|
-
if (batchBuffer.length === 0) return;
|
|
125
220
|
const documents = batchBuffer;
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
results
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const detail = failedResult.message || failedResult.name || "the local database rejected a document";
|
|
143
|
-
throw new StreamingFetchFailure(
|
|
144
|
-
"storage",
|
|
145
|
-
`Fast Fetch could not write a batch to the local database: ${detail}`,
|
|
146
|
-
false,
|
|
147
|
-
{ cause: failedResult }
|
|
221
|
+
const storedRevisions = batchStoredRevisions;
|
|
222
|
+
const checkpoint = batchCheckpointSequence;
|
|
223
|
+
if (documents.length > 0) {
|
|
224
|
+
let results;
|
|
225
|
+
try {
|
|
226
|
+
results = await downloadToDB.bulkDocs(documents, { new_edits: false });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new StreamingFetchFailure(
|
|
229
|
+
"storage",
|
|
230
|
+
`Fast Fetch could not write a batch to the local database: ${errorMessage(error)}`,
|
|
231
|
+
false,
|
|
232
|
+
{ cause: error }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const failedResult = results.find(
|
|
236
|
+
(result) => "error" in result && Boolean(result.error)
|
|
148
237
|
);
|
|
238
|
+
if (failedResult) {
|
|
239
|
+
const detail = failedResult.message || failedResult.name || "the local database rejected a document";
|
|
240
|
+
throw new StreamingFetchFailure(
|
|
241
|
+
"storage",
|
|
242
|
+
`Fast Fetch could not write a batch to the local database: ${detail}`,
|
|
243
|
+
false,
|
|
244
|
+
{ cause: failedResult }
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await onStoredRevisions?.(storedRevisions);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
throw new StreamingFetchFailure(
|
|
251
|
+
"storage",
|
|
252
|
+
`Fast Fetch could not record stored revision identities: ${errorMessage(error)}`,
|
|
253
|
+
false,
|
|
254
|
+
{ cause: error }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
149
257
|
}
|
|
150
258
|
if (checkpoint !== void 0) {
|
|
151
259
|
await saveCheckpoint(onCheckpoint, checkpoint);
|
|
152
260
|
}
|
|
153
261
|
batchBuffer = [];
|
|
262
|
+
batchStoredRevisions = [];
|
|
263
|
+
batchRevisionKeys = /* @__PURE__ */ new Set();
|
|
154
264
|
currentBatchSizeBytes = 0;
|
|
155
|
-
|
|
265
|
+
batchCheckpointSequence = void 0;
|
|
156
266
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
} else {
|
|
163
|
-
try {
|
|
164
|
-
decryptedDoc = await decryptFunction(doc);
|
|
165
|
-
} catch (error) {
|
|
166
|
-
throw new StreamingFetchFailure(
|
|
167
|
-
"decryption",
|
|
168
|
-
`Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
|
|
169
|
-
false,
|
|
170
|
-
{ cause: error }
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
let serialisedDoc;
|
|
267
|
+
const prepare = async (doc) => {
|
|
268
|
+
let decryptedDoc;
|
|
269
|
+
if (doc._deleted) {
|
|
270
|
+
decryptedDoc = doc;
|
|
271
|
+
} else {
|
|
175
272
|
try {
|
|
176
|
-
|
|
177
|
-
if (serialised === void 0) throw new Error("the decrypted value is not a document");
|
|
178
|
-
serialisedDoc = serialised;
|
|
273
|
+
decryptedDoc = await decryptFunction(doc);
|
|
179
274
|
} catch (error) {
|
|
180
275
|
throw new StreamingFetchFailure(
|
|
181
276
|
"decryption",
|
|
182
|
-
`Fast Fetch
|
|
277
|
+
`Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
|
|
183
278
|
false,
|
|
184
279
|
{ cause: error }
|
|
185
280
|
);
|
|
186
281
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
282
|
+
}
|
|
283
|
+
let serialisedDoc;
|
|
284
|
+
try {
|
|
285
|
+
const serialised = JSON.stringify(decryptedDoc);
|
|
286
|
+
if (serialised === void 0) throw new Error("the decrypted value is not a document");
|
|
287
|
+
serialisedDoc = serialised;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new StreamingFetchFailure(
|
|
290
|
+
"decryption",
|
|
291
|
+
`Fast Fetch produced an invalid decrypted document: ${errorMessage(error)}`,
|
|
292
|
+
false,
|
|
293
|
+
{ cause: error }
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return { document: decryptedDoc, serialisedSize: serialisedDoc.length };
|
|
297
|
+
};
|
|
298
|
+
const writePrepared = async (prepared, revision) => {
|
|
299
|
+
const key = revisionRefKey(revision);
|
|
300
|
+
if (batchRevisionKeys.has(key)) return;
|
|
301
|
+
batchBuffer.push(prepared.document);
|
|
302
|
+
batchStoredRevisions.push({ id: revision.id, rev: revision.rev });
|
|
303
|
+
batchRevisionKeys.add(key);
|
|
304
|
+
currentBatchSizeBytes += prepared.serialisedSize;
|
|
305
|
+
if (batchBuffer.length >= BATCH_ITEM_LIMIT || currentBatchSizeBytes >= BATCH_SIZE_LIMIT) {
|
|
195
306
|
await flush();
|
|
196
|
-
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const write = async (doc, revision) => {
|
|
310
|
+
await writePrepared(await prepare(doc), revision);
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
prepare,
|
|
314
|
+
writePrepared,
|
|
315
|
+
write,
|
|
316
|
+
async advanceCheckpoint(sequence) {
|
|
317
|
+
batchCheckpointSequence = sequence;
|
|
318
|
+
if (batchBuffer.length === 0) await flush();
|
|
319
|
+
},
|
|
320
|
+
hasPendingRevision(revision) {
|
|
321
|
+
return batchRevisionKeys.has(revisionRefKey(revision));
|
|
197
322
|
},
|
|
198
323
|
flush,
|
|
199
324
|
abort() {
|
|
200
325
|
batchBuffer = [];
|
|
326
|
+
batchStoredRevisions = [];
|
|
327
|
+
batchRevisionKeys = /* @__PURE__ */ new Set();
|
|
201
328
|
currentBatchSizeBytes = 0;
|
|
202
|
-
|
|
329
|
+
batchCheckpointSequence = void 0;
|
|
203
330
|
}
|
|
204
331
|
};
|
|
205
332
|
}
|
|
@@ -260,7 +387,7 @@ function parseChangesFeedLine(line) {
|
|
|
260
387
|
}
|
|
261
388
|
return { type: "change", change };
|
|
262
389
|
}
|
|
263
|
-
async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader, decryptFunction, since = "0", onProgress, onCheckpoint, customHeaders) {
|
|
390
|
+
async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader, decryptFunction, since = "0", onProgress, onCheckpoint, customHeaders, onStoredRevisions) {
|
|
264
391
|
let totalFetched = 0;
|
|
265
392
|
let totalValidFetched = 0;
|
|
266
393
|
let totalBytes = 0;
|
|
@@ -275,6 +402,8 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
275
402
|
const fetchHeaders = new Headers(customHeaders);
|
|
276
403
|
fetchHeaders.set("Accept", "application/json");
|
|
277
404
|
fetchHeaders.set("Authorization", authHeader);
|
|
405
|
+
const bulkGetHeaders = new Headers(fetchHeaders);
|
|
406
|
+
bulkGetHeaders.set("Content-Type", "application/json");
|
|
278
407
|
const targetURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
279
408
|
...changesBaseParams,
|
|
280
409
|
feed: "normal",
|
|
@@ -296,7 +425,7 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
296
425
|
false
|
|
297
426
|
);
|
|
298
427
|
}
|
|
299
|
-
const batchWriter = generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint);
|
|
428
|
+
const batchWriter = generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint, onStoredRevisions);
|
|
300
429
|
let docsToFetch = 0;
|
|
301
430
|
let lastProgress = 0;
|
|
302
431
|
let lastReportTime = Date.now();
|
|
@@ -312,6 +441,282 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
312
441
|
totalBytes
|
|
313
442
|
});
|
|
314
443
|
};
|
|
444
|
+
const rowProgress = [];
|
|
445
|
+
let nextCheckpointRow = 0;
|
|
446
|
+
const pendingRevisions = /* @__PURE__ */ new Map();
|
|
447
|
+
let pendingRevisionBytes = 0;
|
|
448
|
+
const pendingRemoteRevisions = /* @__PURE__ */ new Map();
|
|
449
|
+
let pendingRemoteRevisionBytes = 0;
|
|
450
|
+
const releaseCompletedRows = async () => {
|
|
451
|
+
while (nextCheckpointRow < rowProgress.length && rowProgress[nextCheckpointRow].complete) {
|
|
452
|
+
await batchWriter.advanceCheckpoint(rowProgress[nextCheckpointRow].sequence);
|
|
453
|
+
nextCheckpointRow++;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
const settleRevision = (revision) => {
|
|
457
|
+
for (const row of revision.rows) {
|
|
458
|
+
row.pendingRevisions--;
|
|
459
|
+
if (row.pendingRevisions === 0) row.complete = true;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const markRevisionDurable = async (revision, doc) => {
|
|
463
|
+
await batchWriter.write(doc, revision.ref);
|
|
464
|
+
settleRevision(revision);
|
|
465
|
+
};
|
|
466
|
+
const markPreparedRevisionDurable = async (revision, prepared) => {
|
|
467
|
+
await batchWriter.writePrepared(prepared, revision.ref);
|
|
468
|
+
settleRevision(revision);
|
|
469
|
+
};
|
|
470
|
+
const makeBulkGetURL = () => {
|
|
471
|
+
const url = new URL(`${remoteDbUrl}/_bulk_get`);
|
|
472
|
+
url.searchParams.set("revs", "true");
|
|
473
|
+
return url;
|
|
474
|
+
};
|
|
475
|
+
const parseBulkGetDocuments = (source, refs) => {
|
|
476
|
+
let parsed;
|
|
477
|
+
try {
|
|
478
|
+
parsed = JSON.parse(source);
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid _bulk_get response.", false, {
|
|
481
|
+
cause
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (!isObject(parsed) || !Array.isArray(parsed.results)) {
|
|
485
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid _bulk_get response.", false);
|
|
486
|
+
}
|
|
487
|
+
const expected = new Map(refs.map((revision) => [revisionRefKey(revision.ref), revision.ref]));
|
|
488
|
+
const documents = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const result of parsed.results) {
|
|
490
|
+
if (!isObject(result) || typeof result.id !== "string" || !Array.isArray(result.docs)) {
|
|
491
|
+
throw new StreamingFetchFailure(
|
|
492
|
+
"protocol",
|
|
493
|
+
"Fast Fetch received a malformed _bulk_get result.",
|
|
494
|
+
false
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
for (const item of result.docs) {
|
|
498
|
+
if (!isObject(item)) {
|
|
499
|
+
throw new StreamingFetchFailure(
|
|
500
|
+
"protocol",
|
|
501
|
+
"Fast Fetch received a malformed _bulk_get document result.",
|
|
502
|
+
false
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if ("ok" in item) {
|
|
506
|
+
const body = item.ok;
|
|
507
|
+
if (!isObject(body) || typeof body._rev !== "string") {
|
|
508
|
+
throw new StreamingFetchFailure(
|
|
509
|
+
"protocol",
|
|
510
|
+
"Fast Fetch received a malformed _bulk_get document.",
|
|
511
|
+
false
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const ref = { id: result.id, rev: body._rev };
|
|
515
|
+
const key = revisionRefKey(ref);
|
|
516
|
+
if (!expected.has(key) || documents.has(key)) {
|
|
517
|
+
throw new StreamingFetchFailure(
|
|
518
|
+
"protocol",
|
|
519
|
+
"Fast Fetch received an unexpected or duplicate _bulk_get document.",
|
|
520
|
+
false
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
documents.set(key, ensureRevisionDocument(body, ref));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (typeof item.error === "string") {
|
|
527
|
+
if (item.error === "forbidden" || item.error === "unauthorized") {
|
|
528
|
+
throw new StreamingFetchFailure(
|
|
529
|
+
"authentication",
|
|
530
|
+
`Fast Fetch could not read revision ${result.id}: CouchDB denied access.`,
|
|
531
|
+
false
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
throw new StreamingFetchFailure(
|
|
535
|
+
"protocol",
|
|
536
|
+
`Fast Fetch could not read a required revision for ${result.id}: ${String(item.reason ?? item.error)}.`,
|
|
537
|
+
false
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
throw new StreamingFetchFailure(
|
|
541
|
+
"protocol",
|
|
542
|
+
"Fast Fetch received a malformed _bulk_get document result.",
|
|
543
|
+
false
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
for (const revision of refs) {
|
|
548
|
+
if (!documents.has(revisionRefKey(revision.ref))) {
|
|
549
|
+
throw new StreamingFetchFailure(
|
|
550
|
+
"protocol",
|
|
551
|
+
`Fast Fetch did not receive required revision ${revision.ref.id}@${revision.ref.rev}.`,
|
|
552
|
+
false
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return documents;
|
|
557
|
+
};
|
|
558
|
+
const fetchAndWriteBulkGetGroup = async (revisions) => {
|
|
559
|
+
const refs = revisions.map(({ ref }) => ref);
|
|
560
|
+
const requestBody = JSON.stringify({ docs: refs });
|
|
561
|
+
const response = await fetchResponse(
|
|
562
|
+
makeBulkGetURL().toString(),
|
|
563
|
+
{ method: "POST", headers: bulkGetHeaders, body: requestBody },
|
|
564
|
+
"read missing revisions"
|
|
565
|
+
);
|
|
566
|
+
let responseText;
|
|
567
|
+
try {
|
|
568
|
+
const responseBody = await readResponseTextBounded(
|
|
569
|
+
response,
|
|
570
|
+
"read missing revisions",
|
|
571
|
+
revisions.length === 1 ? Number.MAX_SAFE_INTEGER : FAST_FETCH_REVISION_GROUP_BYTE_LIMIT
|
|
572
|
+
);
|
|
573
|
+
responseText = responseBody.text;
|
|
574
|
+
totalBytes += responseBody.byteLength;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (error instanceof BulkGetResponseTooLargeError && revisions.length > 1) {
|
|
577
|
+
const midpoint = Math.ceil(revisions.length / 2);
|
|
578
|
+
await fetchAndWriteBulkGetGroup(revisions.slice(0, midpoint));
|
|
579
|
+
await fetchAndWriteBulkGetGroup(revisions.slice(midpoint));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
const documents = parseBulkGetDocuments(responseText, revisions);
|
|
585
|
+
for (const revision of revisions) {
|
|
586
|
+
const document = documents.get(revisionRefKey(revision.ref));
|
|
587
|
+
if (!document) {
|
|
588
|
+
throw new StreamingFetchFailure(
|
|
589
|
+
"protocol",
|
|
590
|
+
`Fast Fetch did not receive required revision ${revision.ref.id}@${revision.ref.rev}.`,
|
|
591
|
+
false
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
await markRevisionDurable(revision, document);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const flushPendingRemoteRevisions = async () => {
|
|
598
|
+
if (pendingRemoteRevisions.size === 0) return;
|
|
599
|
+
const revisions = [...pendingRemoteRevisions.values()];
|
|
600
|
+
pendingRemoteRevisions.clear();
|
|
601
|
+
pendingRemoteRevisionBytes = 0;
|
|
602
|
+
for (let index = 0; index < revisions.length; index += FAST_FETCH_REVISION_GROUP_LIMIT) {
|
|
603
|
+
await fetchAndWriteBulkGetGroup(revisions.slice(index, index + FAST_FETCH_REVISION_GROUP_LIMIT));
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
const enqueueRemoteRevision = async (revision) => {
|
|
607
|
+
const key = revisionRefKey(revision.ref);
|
|
608
|
+
const existing = pendingRemoteRevisions.get(key);
|
|
609
|
+
if (existing) {
|
|
610
|
+
for (const row of revision.rows) existing.rows.add(row);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const estimatedBytes = new TextEncoder().encode(JSON.stringify({ docs: [revision.ref] })).byteLength;
|
|
614
|
+
if (pendingRemoteRevisions.size > 0 && (pendingRemoteRevisions.size >= FAST_FETCH_REVISION_GROUP_LIMIT || pendingRemoteRevisionBytes + estimatedBytes > FAST_FETCH_REVISION_GROUP_BYTE_LIMIT)) {
|
|
615
|
+
await flushPendingRemoteRevisions();
|
|
616
|
+
}
|
|
617
|
+
pendingRemoteRevisions.set(key, revision);
|
|
618
|
+
pendingRemoteRevisionBytes += estimatedBytes;
|
|
619
|
+
if (pendingRemoteRevisions.size >= FAST_FETCH_REVISION_GROUP_LIMIT || pendingRemoteRevisionBytes >= FAST_FETCH_REVISION_GROUP_BYTE_LIMIT) {
|
|
620
|
+
await flushPendingRemoteRevisions();
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const flushPendingRevisions = async (flushRemoteRemainder = false) => {
|
|
624
|
+
if (pendingRevisions.size > 0) {
|
|
625
|
+
const revisions = [...pendingRevisions.values()];
|
|
626
|
+
pendingRevisions.clear();
|
|
627
|
+
pendingRevisionBytes = 0;
|
|
628
|
+
const revisionsToCheck = [];
|
|
629
|
+
for (const revision of revisions) {
|
|
630
|
+
if (batchWriter.hasPendingRevision(revision.ref)) {
|
|
631
|
+
settleRevision(revision);
|
|
632
|
+
} else {
|
|
633
|
+
revisionsToCheck.push(revision);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (revisionsToCheck.length > 0) {
|
|
637
|
+
const revisionDiff = /* @__PURE__ */ Object.create(null);
|
|
638
|
+
for (const { ref } of revisionsToCheck) {
|
|
639
|
+
const existing = revisionDiff[ref.id] ?? [];
|
|
640
|
+
existing.push(ref.rev);
|
|
641
|
+
revisionDiff[ref.id] = existing;
|
|
642
|
+
}
|
|
643
|
+
let difference;
|
|
644
|
+
try {
|
|
645
|
+
difference = await downloadToDB.revsDiff(revisionDiff);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
throw new StreamingFetchFailure(
|
|
648
|
+
"storage",
|
|
649
|
+
`Fast Fetch could not check local revision history: ${errorMessage(error)}`,
|
|
650
|
+
false,
|
|
651
|
+
{ cause: error }
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!isObject(difference)) {
|
|
655
|
+
throw new StreamingFetchFailure(
|
|
656
|
+
"protocol",
|
|
657
|
+
"Fast Fetch received an invalid local revision check.",
|
|
658
|
+
false
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
for (const revision of revisionsToCheck) {
|
|
662
|
+
const item = Object.prototype.hasOwnProperty.call(difference, revision.ref.id) ? difference[revision.ref.id] : void 0;
|
|
663
|
+
if (item !== void 0 && (!isObject(item) || !Array.isArray(item.missing))) {
|
|
664
|
+
throw new StreamingFetchFailure(
|
|
665
|
+
"protocol",
|
|
666
|
+
"Fast Fetch received an invalid local revision check.",
|
|
667
|
+
false
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const isMissing = item !== void 0 && item.missing.includes(revision.ref.rev);
|
|
671
|
+
if (!isMissing) {
|
|
672
|
+
settleRevision(revision);
|
|
673
|
+
} else if (revision.includedDocument) {
|
|
674
|
+
await markPreparedRevisionDurable(revision, revision.includedDocument);
|
|
675
|
+
} else {
|
|
676
|
+
await enqueueRemoteRevision(revision);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (flushRemoteRemainder || pendingRemoteRevisions.size >= FAST_FETCH_REVISION_GROUP_LIMIT || pendingRemoteRevisionBytes >= FAST_FETCH_REVISION_GROUP_BYTE_LIMIT) {
|
|
682
|
+
await flushPendingRemoteRevisions();
|
|
683
|
+
}
|
|
684
|
+
await releaseCompletedRows();
|
|
685
|
+
};
|
|
686
|
+
const enqueueRevision = async (ref, row, includedDocument) => {
|
|
687
|
+
const key = revisionRefKey(ref);
|
|
688
|
+
const remoteExisting = pendingRemoteRevisions.get(key);
|
|
689
|
+
if (remoteExisting) {
|
|
690
|
+
remoteExisting.rows.add(row);
|
|
691
|
+
if (includedDocument !== void 0) {
|
|
692
|
+
pendingRemoteRevisions.delete(key);
|
|
693
|
+
pendingRemoteRevisionBytes -= new TextEncoder().encode(JSON.stringify({ docs: [ref] })).byteLength;
|
|
694
|
+
await markPreparedRevisionDurable(remoteExisting, includedDocument);
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const existing = pendingRevisions.get(key);
|
|
699
|
+
if (existing) {
|
|
700
|
+
existing.rows.add(row);
|
|
701
|
+
if (existing.includedDocument === void 0 && includedDocument !== void 0) {
|
|
702
|
+
existing.includedDocument = includedDocument;
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const estimatedBytes = new TextEncoder().encode(JSON.stringify({ ref, includedDocument })).byteLength;
|
|
707
|
+
if (pendingRevisions.size > 0 && (pendingRevisions.size >= FAST_FETCH_REVISION_GROUP_LIMIT || pendingRevisionBytes + estimatedBytes > FAST_FETCH_REVISION_GROUP_BYTE_LIMIT)) {
|
|
708
|
+
await flushPendingRevisions();
|
|
709
|
+
}
|
|
710
|
+
pendingRevisions.set(key, {
|
|
711
|
+
ref,
|
|
712
|
+
rows: /* @__PURE__ */ new Set([row]),
|
|
713
|
+
...includedDocument === void 0 ? {} : { includedDocument }
|
|
714
|
+
});
|
|
715
|
+
pendingRevisionBytes += estimatedBytes;
|
|
716
|
+
if (pendingRevisions.size >= FAST_FETCH_REVISION_GROUP_LIMIT || pendingRevisionBytes >= FAST_FETCH_REVISION_GROUP_BYTE_LIMIT) {
|
|
717
|
+
await flushPendingRevisions();
|
|
718
|
+
}
|
|
719
|
+
};
|
|
315
720
|
const readAvailableChanges = async (pageSince) => {
|
|
316
721
|
const statusURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
317
722
|
...changesBaseParams,
|
|
@@ -352,6 +757,11 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
352
757
|
return available;
|
|
353
758
|
};
|
|
354
759
|
const fetchPage = async (pageSince, pageLimit) => {
|
|
760
|
+
if (pendingRevisions.size > 0 || pendingRemoteRevisions.size > 0) {
|
|
761
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch began a page with unresolved revisions.", false);
|
|
762
|
+
}
|
|
763
|
+
rowProgress.length = 0;
|
|
764
|
+
nextCheckpointRow = 0;
|
|
355
765
|
const controller = new AbortController();
|
|
356
766
|
let reader;
|
|
357
767
|
let buffer = "";
|
|
@@ -366,6 +776,15 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
366
776
|
true
|
|
367
777
|
);
|
|
368
778
|
}
|
|
779
|
+
await flushPendingRevisions(true);
|
|
780
|
+
await releaseCompletedRows();
|
|
781
|
+
if (nextCheckpointRow !== rowProgress.length) {
|
|
782
|
+
throw new StreamingFetchFailure(
|
|
783
|
+
"protocol",
|
|
784
|
+
"Fast Fetch reached a page terminator with unresolved leaf revisions.",
|
|
785
|
+
false
|
|
786
|
+
);
|
|
787
|
+
}
|
|
369
788
|
await batchWriter.flush();
|
|
370
789
|
await saveCheckpoint(onCheckpoint, parsed.lastSequence);
|
|
371
790
|
reportProgress(true);
|
|
@@ -381,12 +800,55 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
381
800
|
const change = parsed.change;
|
|
382
801
|
fetchedRows++;
|
|
383
802
|
totalFetched++;
|
|
384
|
-
if (change.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
803
|
+
if (typeof change.id !== "string" || change.id.length === 0 || !Array.isArray(change.changes) || change.changes.length === 0 || change.changes.some((entry) => !isObject(entry) || typeof entry.rev !== "string" || entry.rev.length === 0)) {
|
|
804
|
+
throw new StreamingFetchFailure(
|
|
805
|
+
"protocol",
|
|
806
|
+
"Fast Fetch received a changes row without valid leaf revisions.",
|
|
807
|
+
false
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
const leafRevisions = [...new Set(change.changes.map(({ rev }) => rev))];
|
|
811
|
+
let includedDocument;
|
|
812
|
+
if (change.doc !== void 0) {
|
|
813
|
+
if (!isObject(change.doc) || typeof change.doc._rev !== "string" || !leafRevisions.includes(change.doc._rev)) {
|
|
814
|
+
throw new StreamingFetchFailure(
|
|
815
|
+
"protocol",
|
|
816
|
+
"Fast Fetch received a changes row with an invalid included document.",
|
|
817
|
+
false
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
const includedRef = { id: change.id, rev: change.doc._rev };
|
|
821
|
+
const includedBody = ensureRevisionIdentity(change.doc, includedRef);
|
|
822
|
+
const revision = parseRevisionIdentifier(includedRef.rev);
|
|
823
|
+
if (revision === void 0) {
|
|
824
|
+
throw new StreamingFetchFailure(
|
|
825
|
+
"protocol",
|
|
826
|
+
`Fast Fetch received an invalid revision identifier for ${includedRef.id}.`,
|
|
827
|
+
false
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (revision.generation === 1) {
|
|
831
|
+
const revisionDocument = ensureRevisionDocument(
|
|
832
|
+
{ ...includedBody, _revisions: { start: 1, ids: [revision.hash] } },
|
|
833
|
+
includedRef
|
|
834
|
+
);
|
|
835
|
+
includedDocument = await batchWriter.prepare(revisionDocument);
|
|
836
|
+
} else if (hasValidRevisionAncestry(includedBody, revision)) {
|
|
837
|
+
const revisionDocument = ensureRevisionDocument(includedBody, includedRef);
|
|
838
|
+
includedDocument = await batchWriter.prepare(revisionDocument);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
totalValidFetched++;
|
|
842
|
+
const row = { sequence: change.seq, pendingRevisions: leafRevisions.length, complete: false };
|
|
843
|
+
rowProgress.push(row);
|
|
844
|
+
for (const rev of leafRevisions) {
|
|
845
|
+
await enqueueRevision(
|
|
846
|
+
{ id: change.id, rev },
|
|
847
|
+
row,
|
|
848
|
+
change.doc?._rev === rev ? includedDocument : void 0
|
|
849
|
+
);
|
|
389
850
|
}
|
|
851
|
+
await releaseCompletedRows();
|
|
390
852
|
reportProgress();
|
|
391
853
|
return void 0;
|
|
392
854
|
};
|
|
@@ -493,6 +955,8 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
493
955
|
const failure = asStreamingFetchFailure(error);
|
|
494
956
|
if (failure.stage !== "storage") {
|
|
495
957
|
try {
|
|
958
|
+
await flushPendingRevisions(true);
|
|
959
|
+
await releaseCompletedRows();
|
|
496
960
|
await batchWriter.flush();
|
|
497
961
|
} catch (flushError) {
|
|
498
962
|
batchWriter.abort();
|