@vrtmrz/livesync-commonlib 0.1.5 → 0.1.6

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,6 +1,40 @@
1
1
  import type { EntryDoc } from "../common/models/db.definition.js";
2
2
  import type { AnyEntry, EntryLeaf } from "../common/models/db.type.js";
3
3
  type DBSequence = number | string;
4
+ /**
5
+ * Identifies the boundary at which Fast Fetch stopped.
6
+ *
7
+ * - `transport`: the request or response stream was interrupted. Only failures
8
+ * explicitly created at this boundary may be retried.
9
+ * - `authentication`: CouchDB rejected the supplied credentials.
10
+ * - `protocol`: CouchDB returned an unsuccessful or structurally invalid
11
+ * response, or an unexpected exception escaped the processing pipeline.
12
+ * - `decryption`: a remote document could not be decrypted or did not produce a
13
+ * serialisable PouchDB document.
14
+ * - `storage`: a local batch or its durable checkpoint could not be written.
15
+ *
16
+ * This remains a string-literal union because the values are discriminants, not
17
+ * data to enumerate, persist, or map to user-interface labels. The constructor
18
+ * and consumers therefore receive typo checking without a runtime constants
19
+ * object becoming part of the package API.
20
+ */
21
+ export type StreamingFetchFailureStage = "transport" | "authentication" | "protocol" | "decryption" | "storage";
22
+ /**
23
+ * A classified Fast Fetch failure. Retryability is explicit rather than inferred
24
+ * from the stage so that future exceptions can be narrowed without broadening all
25
+ * failures at that boundary.
26
+ */
27
+ export declare class StreamingFetchFailure extends Error {
28
+ readonly stage: StreamingFetchFailureStage;
29
+ readonly retryable: boolean;
30
+ readonly name = "StreamingFetchFailure";
31
+ constructor(stage: StreamingFetchFailureStage, message: string, retryable: boolean, options?: {
32
+ status?: number;
33
+ cause?: unknown;
34
+ });
35
+ readonly status?: number;
36
+ }
37
+ export declare function isRetryableStreamingFetchFailure(error: unknown): error is StreamingFetchFailure;
4
38
  export type FetchChangesForInitialSyncProgress = {
5
39
  totalFetched: number;
6
40
  totalValidFetched: number;
@@ -1,58 +1,172 @@
1
1
  import { _fetch } from "../common/coreEnvFunctions.js";
2
2
  import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
3
- function generatePouchDBWriteStream(downloadToDB, decryptFunction, onCheckpoint) {
3
+ class StreamingFetchFailure extends Error {
4
+ constructor(stage, message, retryable, options) {
5
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
6
+ this.stage = stage;
7
+ this.retryable = retryable;
8
+ this.status = options?.status;
9
+ }
10
+ stage;
11
+ retryable;
12
+ name = "StreamingFetchFailure";
13
+ status;
14
+ }
15
+ function isRetryableStreamingFetchFailure(error) {
16
+ return error instanceof StreamingFetchFailure && error.retryable;
17
+ }
18
+ function errorMessage(error) {
19
+ if (error instanceof Error && error.message) return error.message;
20
+ return String(error);
21
+ }
22
+ function asStreamingFetchFailure(error) {
23
+ if (error instanceof StreamingFetchFailure) return error;
24
+ return new StreamingFetchFailure(
25
+ "protocol",
26
+ `Fast Fetch encountered an unexpected processing failure: ${errorMessage(error)}`,
27
+ false,
28
+ { cause: error }
29
+ );
30
+ }
31
+ function transportFailure(operation, error) {
32
+ return new StreamingFetchFailure("transport", `Fast Fetch could not ${operation}: ${errorMessage(error)}`, true, {
33
+ cause: error
34
+ });
35
+ }
36
+ function responseFailure(response, operation) {
37
+ const status = response.status;
38
+ if (status === 401 || status === 403) {
39
+ return new StreamingFetchFailure(
40
+ "authentication",
41
+ `Fast Fetch could not ${operation}: CouchDB returned HTTP ${status}.`,
42
+ false,
43
+ { status }
44
+ );
45
+ }
46
+ const retryable = status === 408 || status === 429 || status >= 500;
47
+ return new StreamingFetchFailure(
48
+ retryable ? "transport" : "protocol",
49
+ `Fast Fetch could not ${operation}: CouchDB returned HTTP ${status}.`,
50
+ retryable,
51
+ { status }
52
+ );
53
+ }
54
+ function ensureResponseOK(response, operation) {
55
+ if (!response.ok) throw responseFailure(response, operation);
56
+ }
57
+ async function fetchResponse(url, init, operation) {
58
+ let response;
59
+ try {
60
+ response = await _fetch(url, init);
61
+ } catch (error) {
62
+ throw transportFailure(operation, error);
63
+ }
64
+ ensureResponseOK(response, operation);
65
+ return response;
66
+ }
67
+ async function readResponseText(response, operation) {
68
+ try {
69
+ return await response.text();
70
+ } catch (error) {
71
+ throw transportFailure(operation, error);
72
+ }
73
+ }
74
+ async function saveCheckpoint(onCheckpoint, sequence) {
75
+ try {
76
+ await onCheckpoint?.(sequence);
77
+ } catch (error) {
78
+ throw new StreamingFetchFailure(
79
+ "storage",
80
+ `Fast Fetch could not save its checkpoint: ${errorMessage(error)}`,
81
+ false,
82
+ { cause: error }
83
+ );
84
+ }
85
+ }
86
+ function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint) {
4
87
  let batchBuffer = [];
5
88
  let currentBatchSizeBytes = 0;
6
89
  let batchLastSequence;
7
90
  const BATCH_ITEM_LIMIT = 100;
8
91
  const BATCH_SIZE_LIMIT = 2 * 1024 * 1024;
9
- let flushPromise = null;
10
- const flushToDB = async () => {
92
+ const flush = async () => {
11
93
  if (batchBuffer.length === 0) return;
12
- if (flushPromise) {
13
- await flushPromise;
14
- }
94
+ const documents = batchBuffer;
95
+ const checkpoint = batchLastSequence;
96
+ let results;
15
97
  try {
16
- flushPromise = downloadToDB.bulkDocs(batchBuffer, { new_edits: false });
17
- await flushPromise;
18
- if (batchLastSequence !== void 0) {
19
- await onCheckpoint?.(batchLastSequence);
20
- }
98
+ results = await downloadToDB.bulkDocs(documents, { new_edits: false });
21
99
  } catch (error) {
22
- Logger("Error bulk writing to PouchDB:", LOG_LEVEL_VERBOSE);
23
- Logger(error, LOG_LEVEL_VERBOSE);
24
- throw error;
25
- } finally {
26
- batchBuffer = [];
27
- currentBatchSizeBytes = 0;
28
- batchLastSequence = void 0;
29
- flushPromise = null;
100
+ throw new StreamingFetchFailure(
101
+ "storage",
102
+ `Fast Fetch could not write a batch to the local database: ${errorMessage(error)}`,
103
+ false,
104
+ { cause: error }
105
+ );
106
+ }
107
+ const failedResult = results.find(
108
+ (result) => "error" in result && Boolean(result.error)
109
+ );
110
+ if (failedResult) {
111
+ const detail = failedResult.message || failedResult.name || "the local database rejected a document";
112
+ throw new StreamingFetchFailure(
113
+ "storage",
114
+ `Fast Fetch could not write a batch to the local database: ${detail}`,
115
+ false,
116
+ { cause: failedResult }
117
+ );
30
118
  }
119
+ if (checkpoint !== void 0) {
120
+ await saveCheckpoint(onCheckpoint, checkpoint);
121
+ }
122
+ batchBuffer = [];
123
+ currentBatchSizeBytes = 0;
124
+ batchLastSequence = void 0;
31
125
  };
32
- return new WritableStream({
33
- async write(chunk) {
126
+ return {
127
+ async write(doc, sequence) {
128
+ let decryptedDoc;
129
+ try {
130
+ decryptedDoc = await decryptFunction(doc);
131
+ } catch (error) {
132
+ throw new StreamingFetchFailure(
133
+ "decryption",
134
+ `Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
135
+ false,
136
+ { cause: error }
137
+ );
138
+ }
139
+ let serialisedDoc;
34
140
  try {
35
- const decryptedDoc = await decryptFunction(chunk.doc);
36
- batchBuffer.push(decryptedDoc);
37
- currentBatchSizeBytes += JSON.stringify(decryptedDoc).length;
38
- batchLastSequence = chunk.seq;
39
- if (batchBuffer.length >= BATCH_ITEM_LIMIT || currentBatchSizeBytes >= BATCH_SIZE_LIMIT) {
40
- await flushToDB();
41
- }
141
+ const serialised = JSON.stringify(decryptedDoc);
142
+ if (serialised === void 0) throw new Error("the decrypted value is not a document");
143
+ serialisedDoc = serialised;
42
144
  } catch (error) {
43
- Logger("Error processing document stream:", LOG_LEVEL_VERBOSE);
44
- throw error;
145
+ throw new StreamingFetchFailure(
146
+ "decryption",
147
+ `Fast Fetch produced an invalid decrypted document: ${errorMessage(error)}`,
148
+ false,
149
+ { cause: error }
150
+ );
151
+ }
152
+ batchBuffer.push(decryptedDoc);
153
+ currentBatchSizeBytes += serialisedDoc.length;
154
+ batchLastSequence = sequence;
155
+ if (batchBuffer.length >= BATCH_ITEM_LIMIT || currentBatchSizeBytes >= BATCH_SIZE_LIMIT) {
156
+ await flush();
45
157
  }
46
158
  },
47
- async close() {
48
- await flushToDB();
159
+ async flushThrough(sequence) {
160
+ await flush();
161
+ await saveCheckpoint(onCheckpoint, sequence);
49
162
  },
50
- abort(reason) {
51
- Logger(`Stream aborted: ${reason}`, LOG_LEVEL_VERBOSE);
163
+ flush,
164
+ abort() {
52
165
  batchBuffer = [];
53
166
  currentBatchSizeBytes = 0;
167
+ batchLastSequence = void 0;
54
168
  }
55
- });
169
+ };
56
170
  }
57
171
  function setParamsToURL(url, params) {
58
172
  for (const [key, value] of Object.entries(params)) {
@@ -60,21 +174,51 @@ function setParamsToURL(url, params) {
60
174
  }
61
175
  return url;
62
176
  }
63
- function reachedTargetSequence(seq, targetSeq) {
64
- if (seq === void 0 || targetSeq === void 0) return false;
65
- const seqStr = seq.toString();
66
- const targetSeqStr = targetSeq.toString();
67
- if (seqStr === targetSeqStr) return true;
68
- const seqNum = parseInt(seqStr.split("-")[0], 10);
69
- const targetSeqNum = parseInt(targetSeqStr.split("-")[0], 10);
70
- if (!isNaN(seqNum) && !isNaN(targetSeqNum)) {
71
- return seqNum >= targetSeqNum;
177
+ function isTargetSequence(sequence, targetSequence) {
178
+ if (sequence === void 0 || targetSequence === void 0) return false;
179
+ return sequence.toString() === targetSequence.toString();
180
+ }
181
+ function parseStatusSource(source) {
182
+ const trimmed = source.trim();
183
+ if (!trimmed) {
184
+ throw new StreamingFetchFailure("protocol", "Fast Fetch received no changes status from CouchDB.", false);
185
+ }
186
+ const candidates = [trimmed, ...trimmed.split("\n").reverse().filter(Boolean)];
187
+ for (const candidate of candidates) {
188
+ try {
189
+ const parsed = JSON.parse(candidate);
190
+ if (parsed && typeof parsed === "object") return parsed;
191
+ } catch {
192
+ }
193
+ }
194
+ throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes status from CouchDB.", false);
195
+ }
196
+ function parseChangeLine(line) {
197
+ let parsed;
198
+ try {
199
+ parsed = JSON.parse(line);
200
+ } catch (error) {
201
+ throw new StreamingFetchFailure("protocol", "Fast Fetch received a malformed changes-feed row.", false, {
202
+ cause: error
203
+ });
204
+ }
205
+ if (!parsed || typeof parsed !== "object" || !("seq" in parsed)) {
206
+ throw new StreamingFetchFailure(
207
+ "protocol",
208
+ "Fast Fetch received a changes-feed row without a sequence.",
209
+ false
210
+ );
72
211
  }
73
- return false;
212
+ const change = parsed;
213
+ if (change.seq === void 0 || change.doc !== void 0 && (!change.doc || typeof change.doc !== "object")) {
214
+ throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes-feed row.", false);
215
+ }
216
+ return change;
74
217
  }
75
218
  async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader, decryptFunction, since = "0", onProgress, onCheckpoint) {
76
219
  let totalFetched = 0;
77
220
  let totalValidFetched = 0;
221
+ let totalBytes = 0;
78
222
  const changesBaseParams = {
79
223
  feed: "continuous",
80
224
  include_docs: "true",
@@ -88,92 +232,81 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
88
232
  Accept: "application/json",
89
233
  Authorization: authHeader
90
234
  };
91
- let targetSeq = void 0;
92
- try {
93
- const dbInfoRes = await _fetch(remoteDbUrl, {
94
- headers: fetchHeaders
95
- });
96
- if (dbInfoRes.ok) {
97
- const dbInfo = await dbInfoRes.json();
98
- targetSeq = dbInfo.update_seq;
99
- }
100
- } catch (e) {
101
- Logger("Failed to fetch database info for target sequence:", LOG_LEVEL_VERBOSE);
102
- Logger(e, LOG_LEVEL_VERBOSE);
103
- }
104
- const fetchURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
235
+ const targetURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
105
236
  ...changesBaseParams,
106
237
  feed: "normal",
107
- limit: "1"
108
- });
109
- const infoRes = await _fetch(fetchURL.toString(), {
110
- headers: fetchHeaders
238
+ since: "now",
239
+ limit: "0"
111
240
  });
112
- const infoSource = await infoRes.text();
113
- const infoSourceTrimmed = infoSource.trim();
114
- if (!infoSourceTrimmed) {
115
- throw new Error("Failed to fetch changes from CouchDB. No data received.");
241
+ const targetResponse = await fetchResponse(
242
+ targetURL.toString(),
243
+ { headers: fetchHeaders },
244
+ "capture the changes target"
245
+ );
246
+ 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
+ );
116
254
  }
117
- let info;
118
- if (infoSourceTrimmed.startsWith("{") && infoSourceTrimmed.endsWith("}")) {
119
- try {
120
- info = JSON.parse(infoSourceTrimmed);
121
- } catch {
122
- const infoLines = infoSourceTrimmed.split("\n").filter((line) => line.trim() !== "");
123
- const lastLine = infoLines[infoLines.length - 1];
124
- info = JSON.parse(lastLine);
125
- }
126
- } else {
127
- const infoLines = infoSourceTrimmed.split("\n").filter((line) => line.trim() !== "");
128
- const lastLine = infoLines[infoLines.length - 1];
129
- info = JSON.parse(lastLine);
255
+ if (isTargetSequence(since, finalTargetSeq)) {
256
+ Logger("Already at the target sequence. Initial data synchronisation is complete.");
257
+ return;
130
258
  }
131
- const pendingDocs = info.pending || 0;
132
- const docsToFetch = pendingDocs + 1;
133
- if (targetSeq === void 0) {
134
- targetSeq = info.last_seq || info.update_seq || info.seq;
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) {
267
+ throw new StreamingFetchFailure(
268
+ "protocol",
269
+ "Fast Fetch received changes status without a valid pending count.",
270
+ false
271
+ );
135
272
  }
136
- const finalTargetSeq = targetSeq ?? "";
137
- if (reachedTargetSequence(since, finalTargetSeq)) {
138
- Logger("Already at the target sequence. Initial data synchronisation is complete.");
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.");
139
278
  return;
140
279
  }
141
280
  Logger(
142
- `Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${finalTargetSeq}, Total documents to fetch: ${docsToFetch}.`
281
+ `Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${finalTargetSeq}, Estimated documents to fetch: ${docsToFetch}.`
143
282
  );
144
283
  const controller = new AbortController();
145
- const url = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
146
- ...changesBaseParams
147
- });
148
- const response = await _fetch(url.toString(), {
149
- method: "GET",
150
- headers: fetchHeaders,
151
- signal: controller.signal
152
- });
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
+ );
153
294
  if (!response.body) {
154
- throw new Error("ReadableStream is not supported by this browser.");
295
+ throw new StreamingFetchFailure("protocol", "Fast Fetch could not read the CouchDB response stream.", false);
155
296
  }
156
297
  const sizeCaptureStream = new TransformStream({
157
- transform(chunk, controller2) {
158
- const chunkSize = chunk.length || chunk.byteLength || 0;
159
- totalBytes += chunkSize;
160
- controller2.enqueue(chunk);
298
+ transform(chunk, streamController) {
299
+ totalBytes += chunk.byteLength;
300
+ streamController.enqueue(chunk);
161
301
  }
162
302
  });
163
303
  const reader = response.body.pipeThrough(sizeCaptureStream).pipeThrough(new TextDecoderStream()).getReader();
164
- const writeDocStream = generatePouchDBWriteStream(downloadToDB, decryptFunction, onCheckpoint);
165
- const writer = writeDocStream.getWriter();
304
+ const batchWriter = generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint);
166
305
  let buffer = "";
167
306
  let lastProgress = 0;
168
307
  let lastReportTime = Date.now();
169
- let totalBytes = 0;
170
- let abortedAfterTargetReached = false;
171
308
  const reportProgress = () => {
172
- if (totalFetched - lastProgress < 25) {
173
- if (Date.now() - lastReportTime < 2e3) {
174
- return;
175
- }
176
- }
309
+ if (totalFetched - lastProgress < 25 && Date.now() - lastReportTime < 2e3) return;
177
310
  lastProgress = totalFetched;
178
311
  lastReportTime = Date.now();
179
312
  onProgress?.({
@@ -184,80 +317,74 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
184
317
  totalBytes
185
318
  });
186
319
  };
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);
328
+ }
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;
336
+ };
187
337
  try {
188
338
  while (true) {
189
339
  reportProgress();
190
- const { value, done } = await reader.read();
191
- if (value) {
192
- totalBytes += value.length;
340
+ let readResult;
341
+ try {
342
+ readResult = await reader.read();
343
+ } catch (error) {
344
+ throw transportFailure("read the changes feed", error);
193
345
  }
194
- if (done) {
195
- if (buffer.trim()) {
196
- try {
197
- totalFetched++;
198
- const parsed = JSON.parse(buffer);
199
- if (parsed.doc) {
200
- await writer.write({ doc: parsed.doc, seq: parsed.seq });
201
- totalValidFetched++;
202
- } else {
203
- await onCheckpoint?.(parsed.seq);
204
- }
205
- reportProgress();
206
- } catch (e) {
207
- Logger(`Failed to parse the final line: ${buffer}. Skipping it.`);
208
- Logger(e, LOG_LEVEL_VERBOSE);
209
- }
210
- }
211
- await writer.close();
212
- break;
346
+ if (readResult.done) {
347
+ if (buffer.trim() && await processLine(buffer)) return;
348
+ await batchWriter.flush();
349
+ throw new StreamingFetchFailure(
350
+ "transport",
351
+ "The CouchDB changes feed ended before Fast Fetch reached its captured target.",
352
+ true
353
+ );
213
354
  }
214
- buffer += value;
355
+ buffer += readResult.value;
215
356
  const lines = buffer.split("\n");
216
357
  buffer = lines.pop() || "";
217
358
  for (const line of lines) {
218
359
  if (!line.trim()) continue;
219
- try {
220
- const parsed = JSON.parse(line);
221
- totalFetched++;
222
- if (parsed.doc) {
223
- await writer.write({ doc: parsed.doc, seq: parsed.seq });
224
- totalValidFetched++;
225
- } else {
226
- await onCheckpoint?.(parsed.seq);
227
- }
228
- reportProgress();
229
- if (totalFetched >= docsToFetch || reachedTargetSequence(parsed.seq, finalTargetSeq)) {
230
- Logger(
231
- `All documents fetched. Stopping the stream and writing remaining documents to PouchDB...`
232
- );
233
- await writer.close();
234
- abortedAfterTargetReached = true;
235
- controller.abort();
236
- reportProgress();
237
- return;
238
- }
239
- } catch (e) {
240
- Logger(`JSON parsing failed. Skipping line: ${line}`, LOG_LEVEL_VERBOSE);
241
- Logger(e, LOG_LEVEL_VERBOSE);
242
- }
360
+ if (await processLine(line)) return;
243
361
  }
244
362
  }
245
- Logger("Initial data synchronisation via stream has completed.");
246
- reportProgress();
247
363
  } catch (error) {
248
- if (abortedAfterTargetReached && error instanceof DOMException && error.name === "AbortError") {
249
- Logger(
250
- "Stream has been aborted as the target sequence has been reached. Finalising the synchronising process..."
251
- );
252
- return;
364
+ const failure = asStreamingFetchFailure(error);
365
+ if (failure.stage !== "storage") {
366
+ try {
367
+ await batchWriter.flush();
368
+ } catch (flushError) {
369
+ batchWriter.abort();
370
+ throw asStreamingFetchFailure(flushError);
371
+ }
253
372
  }
254
- Logger("An error occurred during synchronisation:", LOG_LEVEL_VERBOSE);
255
- Logger(error, LOG_LEVEL_VERBOSE);
256
- throw error;
373
+ batchWriter.abort();
374
+ Logger(`Fast Fetch failed during ${failure.stage}.`, LOG_LEVEL_VERBOSE);
375
+ Logger(failure, LOG_LEVEL_VERBOSE);
376
+ throw failure;
257
377
  } finally {
378
+ controller.abort();
379
+ try {
380
+ await reader.cancel();
381
+ } catch {
382
+ }
258
383
  reader.releaseLock();
259
384
  }
260
385
  }
261
386
  export {
262
- fetchChangesForInitialSync
387
+ StreamingFetchFailure,
388
+ fetchChangesForInitialSync,
389
+ isRetryableStreamingFetchFailure
263
390
  };
@@ -5,7 +5,7 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/log
5
5
  import { delay } from "octagonal-wheels/promises";
6
6
  import { EVENT_DATABASE_REBUILT } from "../events/coreEvents.js";
7
7
  import { ServiceModuleBase } from "./ServiceModuleBase.js";
8
- import { fetchChangesForInitialSync } from "../pouchdb/StreamingFetch.js";
8
+ import { fetchChangesForInitialSync, isRetryableStreamingFetchFailure } from "../pouchdb/StreamingFetch.js";
9
9
  import { getConfiguredFunctionsForEncryption } from "../pouchdb/encryption.js";
10
10
  import { AuthorizationHeaderGenerator, generateCredentialObject } from "../replication/httplib.js";
11
11
  import { sizeToHumanReadable } from "octagonal-wheels/number";
@@ -376,7 +376,7 @@ Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
376
376
  );
377
377
  break;
378
378
  } catch (ex) {
379
- if (attempt >= FAST_FETCH_RETRY_DELAYS.length) throw ex;
379
+ if (!isRetryableStreamingFetchFailure(ex) || attempt >= FAST_FETCH_RETRY_DELAYS.length) throw ex;
380
380
  checkpoint = this.getFastFetchCheckpoint(remote);
381
381
  since = checkpoint?.sequence ?? since;
382
382
  this._log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrtmrz/livesync-commonlib",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Shared data, storage, and replication primitives for Self-hosted LiveSync clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",