@vrtmrz/livesync-commonlib 0.1.43 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +57 -6
  2. package/dist/API/DirectFileManipulatorV2.d.ts +2 -0
  3. package/dist/API/DirectFileManipulatorV2.js +3 -0
  4. package/dist/common/configForDoc.d.ts +1 -0
  5. package/dist/common/configForDoc.js +11 -1
  6. package/dist/common/models/db.definition.d.ts +1 -0
  7. package/dist/common/models/setting.const.preferred.js +1 -3
  8. package/dist/diagnostics/couchdb.js +86 -13
  9. package/dist/diagnostics/index.d.ts +2 -2
  10. package/dist/diagnostics/index.js +2 -0
  11. package/dist/diagnostics/integrity.js +385 -158
  12. package/dist/diagnostics/names.d.ts +16 -0
  13. package/dist/diagnostics/names.js +50 -0
  14. package/dist/diagnostics/reports.d.ts +13 -10
  15. package/dist/diagnostics/reports.js +191 -50
  16. package/dist/diagnostics/types.d.ts +64 -4
  17. package/dist/diagnostics/usage.d.ts +4 -0
  18. package/dist/diagnostics/usage.js +158 -54
  19. package/dist/managers/EntryManager/EntryManagerImpls.js +3 -2
  20. package/dist/pouchdb/LiveSyncDBFunctions.js +4 -0
  21. package/dist/pouchdb/StreamingFetch.d.ts +5 -1
  22. package/dist/pouchdb/StreamingFetch.js +529 -65
  23. package/dist/replication/RemoteResource.d.ts +23 -2
  24. package/dist/replication/RemoteResource.js +6 -2
  25. package/dist/replication/couchdb/LiveSyncReplicator.d.ts +8 -0
  26. package/dist/replication/couchdb/LiveSyncReplicator.js +132 -1
  27. package/dist/replication/couchdb/fastForward.d.ts +19 -0
  28. package/dist/replication/couchdb/fastForward.js +119 -0
  29. package/dist/replication/index.d.ts +3 -1
  30. package/dist/replication/joinedDevices.d.ts +25 -0
  31. package/dist/replication/joinedDevices.js +123 -0
  32. package/dist/replication/journal/JournalSyncCore.d.ts +2 -1
  33. package/dist/replication/journal/JournalSyncCore.js +11 -1
  34. package/dist/replication/journal/LiveSyncJournalReplicator.d.ts +4 -0
  35. package/dist/replication/journal/LiveSyncJournalReplicator.js +56 -1
  36. package/dist/replication/journal/objectstore/JournalStorageAdapter.d.ts +3 -0
  37. package/dist/replication/journal/objectstore/MinioStorageAdapter.d.ts +2 -1
  38. package/dist/replication/journal/objectstore/MinioStorageAdapter.js +27 -1
  39. package/dist/replication/trystero/P2PRoomSessionOwner.d.ts +1 -1
  40. package/dist/replication/trystero/P2PRoomSessionOwner.js +27 -14
  41. package/dist/serviceModules/Rebuilder.js +41 -15
  42. package/dist/services/BrowserServices.d.ts +4 -2
  43. package/dist/services/BrowserServices.js +2 -5
  44. package/dist/services/HeadlessServices.d.ts +3 -0
  45. package/dist/services/HeadlessServices.js +2 -5
  46. package/dist/services/ServiceHub.d.ts +4 -4
  47. package/dist/services/base/IService.d.ts +10 -18
  48. package/dist/services/base/ReplicatorService.remoteResourceResolver.js +1 -1
  49. package/dist/services/base/SettingService.d.ts +8 -18
  50. package/dist/services/base/SettingService.js +28 -46
  51. package/dist/services/implements/base/SvelteDialog.d.ts +2 -2
  52. package/dist/services/implements/base/UIService.d.ts +2 -0
  53. package/dist/services/implements/base/UIService.js +5 -0
  54. package/dist/services/implements/injectable/InjectableServiceHub.d.ts +4 -4
  55. package/dist/services/implements/injectable/InjectableServices.d.ts +2 -2
  56. package/dist/services/implements/injectable/InjectableSettingService.d.ts +0 -3
  57. package/dist/services/implements/injectable/InjectableSettingService.js +0 -11
  58. package/dist/string_and_binary/chunks.js +7 -5
  59. package/dist/uiCommands.d.ts +26 -0
  60. package/dist/uiCommands.js +57 -0
  61. package/dist/worker/bgWorker.js +1 -1
  62. package/docs/database-diagnostics.md +45 -0
  63. package/docs/development.md +15 -3
  64. package/docs/proven-in-use.md +45 -5
  65. package/docs/releasing.md +20 -13
  66. package/docs/settings-lifecycle.md +37 -5
  67. package/package.json +13 -17
  68. package/dist/services/base/ConfigService.d.ts +0 -7
  69. package/dist/services/base/ConfigService.js +0 -6
  70. package/dist/services/implements/browser/ConfigServiceBrowserCompat.d.ts +0 -16
  71. package/dist/services/implements/browser/ConfigServiceBrowserCompat.js +0 -23
@@ -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 batchLastSequence;
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 checkpoint = batchLastSequence;
127
- let results;
128
- try {
129
- results = await downloadToDB.bulkDocs(documents, { new_edits: false });
130
- } catch (error) {
131
- throw new StreamingFetchFailure(
132
- "storage",
133
- `Fast Fetch could not write a batch to the local database: ${errorMessage(error)}`,
134
- false,
135
- { cause: error }
136
- );
137
- }
138
- const failedResult = results.find(
139
- (result) => "error" in result && Boolean(result.error)
140
- );
141
- if (failedResult) {
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
- batchLastSequence = void 0;
265
+ batchCheckpointSequence = void 0;
156
266
  };
157
- return {
158
- async write(doc, sequence) {
159
- let decryptedDoc;
160
- if (doc._deleted) {
161
- decryptedDoc = doc;
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
- const serialised = JSON.stringify(decryptedDoc);
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 produced an invalid decrypted document: ${errorMessage(error)}`,
277
+ `Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
183
278
  false,
184
279
  { cause: error }
185
280
  );
186
281
  }
187
- batchBuffer.push(decryptedDoc);
188
- currentBatchSizeBytes += serialisedDoc.length;
189
- batchLastSequence = sequence;
190
- if (batchBuffer.length >= BATCH_ITEM_LIMIT || currentBatchSizeBytes >= BATCH_SIZE_LIMIT) {
191
- await flush();
192
- }
193
- },
194
- async flushThrough(sequence) {
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
- await saveCheckpoint(onCheckpoint, sequence);
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
- batchLastSequence = void 0;
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.doc) {
385
- await batchWriter.write(change.doc, change.seq);
386
- totalValidFetched++;
387
- } else {
388
- await batchWriter.flushThrough(change.seq);
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();