@vrtmrz/livesync-commonlib 0.1.6 → 0.1.8
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/dist/pouchdb/StreamingFetch.js +198 -108
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { _fetch } from "../common/coreEnvFunctions.js";
|
|
2
2
|
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
|
3
|
+
const FAST_FETCH_CHANGES_PAGE_LIMIT = 1e4;
|
|
4
|
+
const FAST_FETCH_CHANGES_PAGE_TIMEOUT_MS = 1e3;
|
|
3
5
|
class StreamingFetchFailure extends Error {
|
|
4
6
|
constructor(stage, message, retryable, options) {
|
|
5
7
|
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
@@ -174,10 +176,6 @@ function setParamsToURL(url, params) {
|
|
|
174
176
|
}
|
|
175
177
|
return url;
|
|
176
178
|
}
|
|
177
|
-
function isTargetSequence(sequence, targetSequence) {
|
|
178
|
-
if (sequence === void 0 || targetSequence === void 0) return false;
|
|
179
|
-
return sequence.toString() === targetSequence.toString();
|
|
180
|
-
}
|
|
181
179
|
function parseStatusSource(source) {
|
|
182
180
|
const trimmed = source.trim();
|
|
183
181
|
if (!trimmed) {
|
|
@@ -193,7 +191,7 @@ function parseStatusSource(source) {
|
|
|
193
191
|
}
|
|
194
192
|
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes status from CouchDB.", false);
|
|
195
193
|
}
|
|
196
|
-
function
|
|
194
|
+
function parseChangesFeedLine(line) {
|
|
197
195
|
let parsed;
|
|
198
196
|
try {
|
|
199
197
|
parsed = JSON.parse(line);
|
|
@@ -202,7 +200,21 @@ function parseChangeLine(line) {
|
|
|
202
200
|
cause: error
|
|
203
201
|
});
|
|
204
202
|
}
|
|
205
|
-
if (!parsed || typeof parsed !== "object"
|
|
203
|
+
if (!parsed || typeof parsed !== "object") {
|
|
204
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes-feed line.", false);
|
|
205
|
+
}
|
|
206
|
+
if (!("seq" in parsed) && "last_seq" in parsed) {
|
|
207
|
+
const lastSequence = parsed.last_seq;
|
|
208
|
+
if (lastSequence === void 0) {
|
|
209
|
+
throw new StreamingFetchFailure(
|
|
210
|
+
"protocol",
|
|
211
|
+
"Fast Fetch received a changes-feed terminator without a sequence.",
|
|
212
|
+
false
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { type: "terminator", lastSequence };
|
|
216
|
+
}
|
|
217
|
+
if (!("seq" in parsed)) {
|
|
206
218
|
throw new StreamingFetchFailure(
|
|
207
219
|
"protocol",
|
|
208
220
|
"Fast Fetch received a changes-feed row without a sequence.",
|
|
@@ -213,7 +225,7 @@ function parseChangeLine(line) {
|
|
|
213
225
|
if (change.seq === void 0 || change.doc !== void 0 && (!change.doc || typeof change.doc !== "object")) {
|
|
214
226
|
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes-feed row.", false);
|
|
215
227
|
}
|
|
216
|
-
return change;
|
|
228
|
+
return { type: "change", change };
|
|
217
229
|
}
|
|
218
230
|
async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader, decryptFunction, since = "0", onProgress, onCheckpoint) {
|
|
219
231
|
let totalFetched = 0;
|
|
@@ -225,8 +237,7 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
225
237
|
style: "all_docs",
|
|
226
238
|
conflicts: "true",
|
|
227
239
|
revs: "true",
|
|
228
|
-
since: since.toString()
|
|
229
|
-
heartbeat: "30000"
|
|
240
|
+
since: since.toString()
|
|
230
241
|
};
|
|
231
242
|
const fetchHeaders = {
|
|
232
243
|
Accept: "application/json",
|
|
@@ -236,7 +247,8 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
236
247
|
...changesBaseParams,
|
|
237
248
|
feed: "normal",
|
|
238
249
|
since: "now",
|
|
239
|
-
limit: "
|
|
250
|
+
limit: "1",
|
|
251
|
+
include_docs: "false"
|
|
240
252
|
});
|
|
241
253
|
const targetResponse = await fetchResponse(
|
|
242
254
|
targetURL.toString(),
|
|
@@ -244,121 +256,206 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
244
256
|
"capture the changes target"
|
|
245
257
|
);
|
|
246
258
|
const targetStatus = parseStatusSource(await readResponseText(targetResponse, "read the changes target"));
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
249
|
-
throw new StreamingFetchFailure(
|
|
250
|
-
"protocol",
|
|
251
|
-
"Fast Fetch could not obtain an authoritative changes target from CouchDB.",
|
|
252
|
-
false
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
if (isTargetSequence(since, finalTargetSeq)) {
|
|
256
|
-
Logger("Already at the target sequence. Initial data synchronisation is complete.");
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
const fetchURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
260
|
-
...changesBaseParams,
|
|
261
|
-
feed: "normal",
|
|
262
|
-
limit: "0"
|
|
263
|
-
});
|
|
264
|
-
const infoResponse = await fetchResponse(fetchURL.toString(), { headers: fetchHeaders }, "read changes status");
|
|
265
|
-
const info = parseStatusSource(await readResponseText(infoResponse, "read changes status"));
|
|
266
|
-
if (typeof info.pending !== "number" || info.pending < 0) {
|
|
259
|
+
const progressTargetSeq = targetStatus.last_seq;
|
|
260
|
+
if (progressTargetSeq === void 0) {
|
|
267
261
|
throw new StreamingFetchFailure(
|
|
268
262
|
"protocol",
|
|
269
|
-
"Fast Fetch
|
|
263
|
+
"Fast Fetch could not obtain a changes progress target from CouchDB.",
|
|
270
264
|
false
|
|
271
265
|
);
|
|
272
266
|
}
|
|
273
|
-
const pendingDocs = info.pending;
|
|
274
|
-
const docsToFetch = pendingDocs;
|
|
275
|
-
if (pendingDocs === 0) {
|
|
276
|
-
await saveCheckpoint(onCheckpoint, finalTargetSeq);
|
|
277
|
-
Logger("No changes remain before the captured Fast Fetch target.");
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
Logger(
|
|
281
|
-
`Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${finalTargetSeq}, Estimated documents to fetch: ${docsToFetch}.`
|
|
282
|
-
);
|
|
283
|
-
const controller = new AbortController();
|
|
284
|
-
const changesURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), changesBaseParams);
|
|
285
|
-
const response = await fetchResponse(
|
|
286
|
-
changesURL.toString(),
|
|
287
|
-
{
|
|
288
|
-
method: "GET",
|
|
289
|
-
headers: fetchHeaders,
|
|
290
|
-
signal: controller.signal
|
|
291
|
-
},
|
|
292
|
-
"open the changes feed"
|
|
293
|
-
);
|
|
294
|
-
if (!response.body) {
|
|
295
|
-
throw new StreamingFetchFailure("protocol", "Fast Fetch could not read the CouchDB response stream.", false);
|
|
296
|
-
}
|
|
297
|
-
const sizeCaptureStream = new TransformStream({
|
|
298
|
-
transform(chunk, streamController) {
|
|
299
|
-
totalBytes += chunk.byteLength;
|
|
300
|
-
streamController.enqueue(chunk);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
const reader = response.body.pipeThrough(sizeCaptureStream).pipeThrough(new TextDecoderStream()).getReader();
|
|
304
267
|
const batchWriter = generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint);
|
|
305
|
-
let
|
|
268
|
+
let docsToFetch = 0;
|
|
306
269
|
let lastProgress = 0;
|
|
307
270
|
let lastReportTime = Date.now();
|
|
308
|
-
const reportProgress = () => {
|
|
309
|
-
if (totalFetched - lastProgress < 25 && Date.now() - lastReportTime < 2e3) return;
|
|
271
|
+
const reportProgress = (force = false) => {
|
|
272
|
+
if (!force && totalFetched - lastProgress < 25 && Date.now() - lastReportTime < 2e3) return;
|
|
310
273
|
lastProgress = totalFetched;
|
|
311
274
|
lastReportTime = Date.now();
|
|
312
275
|
onProgress?.({
|
|
313
276
|
totalFetched,
|
|
314
277
|
totalValidFetched,
|
|
315
|
-
targetSeq:
|
|
278
|
+
targetSeq: progressTargetSeq,
|
|
316
279
|
docsToFetch,
|
|
317
280
|
totalBytes
|
|
318
281
|
});
|
|
319
282
|
};
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
283
|
+
const readAvailableChanges = async (pageSince) => {
|
|
284
|
+
const statusURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
285
|
+
...changesBaseParams,
|
|
286
|
+
feed: "normal",
|
|
287
|
+
since: pageSince.toString(),
|
|
288
|
+
limit: "1",
|
|
289
|
+
include_docs: "false"
|
|
290
|
+
});
|
|
291
|
+
const response = await fetchResponse(
|
|
292
|
+
statusURL.toString(),
|
|
293
|
+
{ method: "GET", headers: fetchHeaders },
|
|
294
|
+
"read changes status"
|
|
295
|
+
);
|
|
296
|
+
const status = parseStatusSource(await readResponseText(response, "read changes status"));
|
|
297
|
+
if (!Array.isArray(status.results)) {
|
|
298
|
+
throw new StreamingFetchFailure(
|
|
299
|
+
"protocol",
|
|
300
|
+
"Fast Fetch received changes status without a valid results list.",
|
|
301
|
+
false
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const pending = status.pending;
|
|
305
|
+
if (typeof pending !== "number" || !Number.isSafeInteger(pending) || pending < 0) {
|
|
306
|
+
throw new StreamingFetchFailure(
|
|
307
|
+
"protocol",
|
|
308
|
+
"Fast Fetch received changes status without a valid pending count.",
|
|
309
|
+
false
|
|
310
|
+
);
|
|
328
311
|
}
|
|
329
|
-
|
|
330
|
-
if (!
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
312
|
+
const available = status.results.length + pending;
|
|
313
|
+
if (!Number.isSafeInteger(available)) {
|
|
314
|
+
throw new StreamingFetchFailure(
|
|
315
|
+
"protocol",
|
|
316
|
+
"Fast Fetch received a changes count outside the supported range.",
|
|
317
|
+
false
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return available;
|
|
336
321
|
};
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
322
|
+
const fetchPage = async (pageSince, pageLimit) => {
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
let reader;
|
|
325
|
+
let buffer = "";
|
|
326
|
+
let fetchedRows = 0;
|
|
327
|
+
const processLine = async (line) => {
|
|
328
|
+
const parsed = parseChangesFeedLine(line);
|
|
329
|
+
if (parsed.type === "terminator") {
|
|
330
|
+
if (fetchedRows === 0) {
|
|
331
|
+
throw new StreamingFetchFailure(
|
|
332
|
+
"transport",
|
|
333
|
+
"Fast Fetch received no rows after its status probe reported available changes.",
|
|
334
|
+
true
|
|
335
|
+
);
|
|
336
|
+
}
|
|
348
337
|
await batchWriter.flush();
|
|
338
|
+
await saveCheckpoint(onCheckpoint, parsed.lastSequence);
|
|
339
|
+
reportProgress(true);
|
|
340
|
+
return parsed.lastSequence;
|
|
341
|
+
}
|
|
342
|
+
if (fetchedRows >= pageLimit) {
|
|
349
343
|
throw new StreamingFetchFailure(
|
|
350
|
-
"
|
|
351
|
-
|
|
352
|
-
|
|
344
|
+
"protocol",
|
|
345
|
+
`Fast Fetch received more than its ${pageLimit}-row page limit.`,
|
|
346
|
+
false
|
|
353
347
|
);
|
|
354
348
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
349
|
+
const change = parsed.change;
|
|
350
|
+
fetchedRows++;
|
|
351
|
+
totalFetched++;
|
|
352
|
+
if (change.doc) {
|
|
353
|
+
await batchWriter.write(change.doc, change.seq);
|
|
354
|
+
totalValidFetched++;
|
|
355
|
+
} else {
|
|
356
|
+
await batchWriter.flushThrough(change.seq);
|
|
361
357
|
}
|
|
358
|
+
reportProgress();
|
|
359
|
+
return void 0;
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
const changesURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
363
|
+
...changesBaseParams,
|
|
364
|
+
since: pageSince.toString(),
|
|
365
|
+
limit: pageLimit.toString(),
|
|
366
|
+
timeout: FAST_FETCH_CHANGES_PAGE_TIMEOUT_MS.toString()
|
|
367
|
+
});
|
|
368
|
+
const response = await fetchResponse(
|
|
369
|
+
changesURL.toString(),
|
|
370
|
+
{
|
|
371
|
+
method: "GET",
|
|
372
|
+
headers: fetchHeaders,
|
|
373
|
+
signal: controller.signal
|
|
374
|
+
},
|
|
375
|
+
"open the changes feed"
|
|
376
|
+
);
|
|
377
|
+
if (!response.body) {
|
|
378
|
+
throw new StreamingFetchFailure(
|
|
379
|
+
"protocol",
|
|
380
|
+
"Fast Fetch could not read the CouchDB response stream.",
|
|
381
|
+
false
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const decoder = new TextDecoder();
|
|
385
|
+
const byteCountingDecoderStream = new TransformStream({
|
|
386
|
+
transform(chunk, streamController) {
|
|
387
|
+
totalBytes += chunk.byteLength;
|
|
388
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
389
|
+
if (decoded) streamController.enqueue(decoded);
|
|
390
|
+
},
|
|
391
|
+
flush(streamController) {
|
|
392
|
+
const decoded = decoder.decode();
|
|
393
|
+
if (decoded) streamController.enqueue(decoded);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
reader = response.body.pipeThrough(byteCountingDecoderStream).getReader();
|
|
397
|
+
while (true) {
|
|
398
|
+
reportProgress();
|
|
399
|
+
let readResult;
|
|
400
|
+
try {
|
|
401
|
+
readResult = await reader.read();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
throw transportFailure("read the changes feed", error);
|
|
404
|
+
}
|
|
405
|
+
if (readResult.done) {
|
|
406
|
+
if (buffer.trim()) {
|
|
407
|
+
const lastSequence = await processLine(buffer);
|
|
408
|
+
if (lastSequence !== void 0) return lastSequence;
|
|
409
|
+
}
|
|
410
|
+
await batchWriter.flush();
|
|
411
|
+
throw new StreamingFetchFailure(
|
|
412
|
+
"transport",
|
|
413
|
+
"The bounded CouchDB changes feed ended without a last_seq terminator.",
|
|
414
|
+
true
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
buffer += readResult.value;
|
|
418
|
+
const lines = buffer.split("\n");
|
|
419
|
+
buffer = lines.pop() || "";
|
|
420
|
+
for (const line of lines) {
|
|
421
|
+
if (!line.trim()) continue;
|
|
422
|
+
const lastSequence = await processLine(line);
|
|
423
|
+
if (lastSequence !== void 0) return lastSequence;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} finally {
|
|
427
|
+
controller.abort();
|
|
428
|
+
if (reader) {
|
|
429
|
+
try {
|
|
430
|
+
await reader.cancel();
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
reader.releaseLock();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
try {
|
|
438
|
+
let pageSince = since;
|
|
439
|
+
let started = false;
|
|
440
|
+
while (true) {
|
|
441
|
+
const available = await readAvailableChanges(pageSince);
|
|
442
|
+
docsToFetch = Math.max(docsToFetch, totalFetched + available);
|
|
443
|
+
if (available === 0) break;
|
|
444
|
+
if (!started) {
|
|
445
|
+
started = true;
|
|
446
|
+
Logger(
|
|
447
|
+
`Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${progressTargetSeq}, Documents to fetch: ${docsToFetch}.`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const pageLimit = Math.min(FAST_FETCH_CHANGES_PAGE_LIMIT, available);
|
|
451
|
+
const pageTerminator = await fetchPage(pageSince, pageLimit);
|
|
452
|
+
pageSince = pageTerminator;
|
|
453
|
+
}
|
|
454
|
+
if (started) {
|
|
455
|
+
Logger("Fast Fetch is caught up and durable in the local database.");
|
|
456
|
+
reportProgress(true);
|
|
457
|
+
} else {
|
|
458
|
+
Logger("No changes remain for Fast Fetch.");
|
|
362
459
|
}
|
|
363
460
|
} catch (error) {
|
|
364
461
|
const failure = asStreamingFetchFailure(error);
|
|
@@ -374,13 +471,6 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
374
471
|
Logger(`Fast Fetch failed during ${failure.stage}.`, LOG_LEVEL_VERBOSE);
|
|
375
472
|
Logger(failure, LOG_LEVEL_VERBOSE);
|
|
376
473
|
throw failure;
|
|
377
|
-
} finally {
|
|
378
|
-
controller.abort();
|
|
379
|
-
try {
|
|
380
|
-
await reader.cancel();
|
|
381
|
-
} catch {
|
|
382
|
-
}
|
|
383
|
-
reader.releaseLock();
|
|
384
474
|
}
|
|
385
475
|
}
|
|
386
476
|
export {
|