@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.
@@ -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 parseChangeLine(line) {
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" || !("seq" in parsed)) {
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: "0"
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 finalTargetSeq = targetStatus.last_seq;
248
- if (finalTargetSeq === void 0) {
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 received changes status without a valid pending count.",
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 buffer = "";
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: finalTargetSeq,
278
+ targetSeq: progressTargetSeq,
316
279
  docsToFetch,
317
280
  totalBytes
318
281
  });
319
282
  };
320
- const processLine = async (line) => {
321
- const parsed = parseChangeLine(line);
322
- totalFetched++;
323
- if (parsed.doc) {
324
- await batchWriter.write(parsed.doc, parsed.seq);
325
- totalValidFetched++;
326
- } else {
327
- await batchWriter.flushThrough(parsed.seq);
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
- reportProgress();
330
- if (!isTargetSequence(parsed.seq, finalTargetSeq)) return false;
331
- await batchWriter.flush();
332
- Logger("The captured Fast Fetch target is durable in the local database.");
333
- controller.abort();
334
- reportProgress();
335
- return true;
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
- try {
338
- while (true) {
339
- reportProgress();
340
- let readResult;
341
- try {
342
- readResult = await reader.read();
343
- } catch (error) {
344
- throw transportFailure("read the changes feed", error);
345
- }
346
- if (readResult.done) {
347
- if (buffer.trim() && await processLine(buffer)) return;
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
- "transport",
351
- "The CouchDB changes feed ended before Fast Fetch reached its captured target.",
352
- true
344
+ "protocol",
345
+ `Fast Fetch received more than its ${pageLimit}-row page limit.`,
346
+ false
353
347
  );
354
348
  }
355
- buffer += readResult.value;
356
- const lines = buffer.split("\n");
357
- buffer = lines.pop() || "";
358
- for (const line of lines) {
359
- if (!line.trim()) continue;
360
- if (await processLine(line)) return;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",