@vrtmrz/livesync-commonlib 0.1.5 → 0.1.7
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,173 @@
|
|
|
1
1
|
import { _fetch } from "../common/coreEnvFunctions.js";
|
|
2
2
|
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
|
3
|
-
|
|
3
|
+
const FAST_FETCH_CHANGES_PAGE_LIMIT = 1e4;
|
|
4
|
+
class StreamingFetchFailure extends Error {
|
|
5
|
+
constructor(stage, message, retryable, options) {
|
|
6
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
7
|
+
this.stage = stage;
|
|
8
|
+
this.retryable = retryable;
|
|
9
|
+
this.status = options?.status;
|
|
10
|
+
}
|
|
11
|
+
stage;
|
|
12
|
+
retryable;
|
|
13
|
+
name = "StreamingFetchFailure";
|
|
14
|
+
status;
|
|
15
|
+
}
|
|
16
|
+
function isRetryableStreamingFetchFailure(error) {
|
|
17
|
+
return error instanceof StreamingFetchFailure && error.retryable;
|
|
18
|
+
}
|
|
19
|
+
function errorMessage(error) {
|
|
20
|
+
if (error instanceof Error && error.message) return error.message;
|
|
21
|
+
return String(error);
|
|
22
|
+
}
|
|
23
|
+
function asStreamingFetchFailure(error) {
|
|
24
|
+
if (error instanceof StreamingFetchFailure) return error;
|
|
25
|
+
return new StreamingFetchFailure(
|
|
26
|
+
"protocol",
|
|
27
|
+
`Fast Fetch encountered an unexpected processing failure: ${errorMessage(error)}`,
|
|
28
|
+
false,
|
|
29
|
+
{ cause: error }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
function transportFailure(operation, error) {
|
|
33
|
+
return new StreamingFetchFailure("transport", `Fast Fetch could not ${operation}: ${errorMessage(error)}`, true, {
|
|
34
|
+
cause: error
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function responseFailure(response, operation) {
|
|
38
|
+
const status = response.status;
|
|
39
|
+
if (status === 401 || status === 403) {
|
|
40
|
+
return new StreamingFetchFailure(
|
|
41
|
+
"authentication",
|
|
42
|
+
`Fast Fetch could not ${operation}: CouchDB returned HTTP ${status}.`,
|
|
43
|
+
false,
|
|
44
|
+
{ status }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const retryable = status === 408 || status === 429 || status >= 500;
|
|
48
|
+
return new StreamingFetchFailure(
|
|
49
|
+
retryable ? "transport" : "protocol",
|
|
50
|
+
`Fast Fetch could not ${operation}: CouchDB returned HTTP ${status}.`,
|
|
51
|
+
retryable,
|
|
52
|
+
{ status }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
function ensureResponseOK(response, operation) {
|
|
56
|
+
if (!response.ok) throw responseFailure(response, operation);
|
|
57
|
+
}
|
|
58
|
+
async function fetchResponse(url, init, operation) {
|
|
59
|
+
let response;
|
|
60
|
+
try {
|
|
61
|
+
response = await _fetch(url, init);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw transportFailure(operation, error);
|
|
64
|
+
}
|
|
65
|
+
ensureResponseOK(response, operation);
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
async function readResponseText(response, operation) {
|
|
69
|
+
try {
|
|
70
|
+
return await response.text();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw transportFailure(operation, error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function saveCheckpoint(onCheckpoint, sequence) {
|
|
76
|
+
try {
|
|
77
|
+
await onCheckpoint?.(sequence);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new StreamingFetchFailure(
|
|
80
|
+
"storage",
|
|
81
|
+
`Fast Fetch could not save its checkpoint: ${errorMessage(error)}`,
|
|
82
|
+
false,
|
|
83
|
+
{ cause: error }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint) {
|
|
4
88
|
let batchBuffer = [];
|
|
5
89
|
let currentBatchSizeBytes = 0;
|
|
6
90
|
let batchLastSequence;
|
|
7
91
|
const BATCH_ITEM_LIMIT = 100;
|
|
8
92
|
const BATCH_SIZE_LIMIT = 2 * 1024 * 1024;
|
|
9
|
-
|
|
10
|
-
const flushToDB = async () => {
|
|
93
|
+
const flush = async () => {
|
|
11
94
|
if (batchBuffer.length === 0) return;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
95
|
+
const documents = batchBuffer;
|
|
96
|
+
const checkpoint = batchLastSequence;
|
|
97
|
+
let results;
|
|
15
98
|
try {
|
|
16
|
-
|
|
17
|
-
await flushPromise;
|
|
18
|
-
if (batchLastSequence !== void 0) {
|
|
19
|
-
await onCheckpoint?.(batchLastSequence);
|
|
20
|
-
}
|
|
99
|
+
results = await downloadToDB.bulkDocs(documents, { new_edits: false });
|
|
21
100
|
} catch (error) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
batchLastSequence = void 0;
|
|
29
|
-
flushPromise = null;
|
|
101
|
+
throw new StreamingFetchFailure(
|
|
102
|
+
"storage",
|
|
103
|
+
`Fast Fetch could not write a batch to the local database: ${errorMessage(error)}`,
|
|
104
|
+
false,
|
|
105
|
+
{ cause: error }
|
|
106
|
+
);
|
|
30
107
|
}
|
|
108
|
+
const failedResult = results.find(
|
|
109
|
+
(result) => "error" in result && Boolean(result.error)
|
|
110
|
+
);
|
|
111
|
+
if (failedResult) {
|
|
112
|
+
const detail = failedResult.message || failedResult.name || "the local database rejected a document";
|
|
113
|
+
throw new StreamingFetchFailure(
|
|
114
|
+
"storage",
|
|
115
|
+
`Fast Fetch could not write a batch to the local database: ${detail}`,
|
|
116
|
+
false,
|
|
117
|
+
{ cause: failedResult }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (checkpoint !== void 0) {
|
|
121
|
+
await saveCheckpoint(onCheckpoint, checkpoint);
|
|
122
|
+
}
|
|
123
|
+
batchBuffer = [];
|
|
124
|
+
currentBatchSizeBytes = 0;
|
|
125
|
+
batchLastSequence = void 0;
|
|
31
126
|
};
|
|
32
|
-
return
|
|
33
|
-
async write(
|
|
127
|
+
return {
|
|
128
|
+
async write(doc, sequence) {
|
|
129
|
+
let decryptedDoc;
|
|
34
130
|
try {
|
|
35
|
-
|
|
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
|
-
}
|
|
131
|
+
decryptedDoc = await decryptFunction(doc);
|
|
42
132
|
} catch (error) {
|
|
43
|
-
|
|
44
|
-
|
|
133
|
+
throw new StreamingFetchFailure(
|
|
134
|
+
"decryption",
|
|
135
|
+
`Fast Fetch could not decrypt a document: ${errorMessage(error)}`,
|
|
136
|
+
false,
|
|
137
|
+
{ cause: error }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
let serialisedDoc;
|
|
141
|
+
try {
|
|
142
|
+
const serialised = JSON.stringify(decryptedDoc);
|
|
143
|
+
if (serialised === void 0) throw new Error("the decrypted value is not a document");
|
|
144
|
+
serialisedDoc = serialised;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new StreamingFetchFailure(
|
|
147
|
+
"decryption",
|
|
148
|
+
`Fast Fetch produced an invalid decrypted document: ${errorMessage(error)}`,
|
|
149
|
+
false,
|
|
150
|
+
{ cause: error }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
batchBuffer.push(decryptedDoc);
|
|
154
|
+
currentBatchSizeBytes += serialisedDoc.length;
|
|
155
|
+
batchLastSequence = sequence;
|
|
156
|
+
if (batchBuffer.length >= BATCH_ITEM_LIMIT || currentBatchSizeBytes >= BATCH_SIZE_LIMIT) {
|
|
157
|
+
await flush();
|
|
45
158
|
}
|
|
46
159
|
},
|
|
47
|
-
async
|
|
48
|
-
await
|
|
160
|
+
async flushThrough(sequence) {
|
|
161
|
+
await flush();
|
|
162
|
+
await saveCheckpoint(onCheckpoint, sequence);
|
|
49
163
|
},
|
|
50
|
-
|
|
51
|
-
|
|
164
|
+
flush,
|
|
165
|
+
abort() {
|
|
52
166
|
batchBuffer = [];
|
|
53
167
|
currentBatchSizeBytes = 0;
|
|
168
|
+
batchLastSequence = void 0;
|
|
54
169
|
}
|
|
55
|
-
}
|
|
170
|
+
};
|
|
56
171
|
}
|
|
57
172
|
function setParamsToURL(url, params) {
|
|
58
173
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -60,21 +175,61 @@ function setParamsToURL(url, params) {
|
|
|
60
175
|
}
|
|
61
176
|
return url;
|
|
62
177
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
178
|
+
function parseStatusSource(source) {
|
|
179
|
+
const trimmed = source.trim();
|
|
180
|
+
if (!trimmed) {
|
|
181
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received no changes status from CouchDB.", false);
|
|
72
182
|
}
|
|
73
|
-
|
|
183
|
+
const candidates = [trimmed, ...trimmed.split("\n").reverse().filter(Boolean)];
|
|
184
|
+
for (const candidate of candidates) {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(candidate);
|
|
187
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes status from CouchDB.", false);
|
|
192
|
+
}
|
|
193
|
+
function parseChangesFeedLine(line) {
|
|
194
|
+
let parsed;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(line);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received a malformed changes-feed row.", false, {
|
|
199
|
+
cause: error
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (!parsed || typeof parsed !== "object") {
|
|
203
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes-feed line.", false);
|
|
204
|
+
}
|
|
205
|
+
if (!("seq" in parsed) && "last_seq" in parsed) {
|
|
206
|
+
const lastSequence = parsed.last_seq;
|
|
207
|
+
if (lastSequence === void 0) {
|
|
208
|
+
throw new StreamingFetchFailure(
|
|
209
|
+
"protocol",
|
|
210
|
+
"Fast Fetch received a changes-feed terminator without a sequence.",
|
|
211
|
+
false
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return { type: "terminator", lastSequence };
|
|
215
|
+
}
|
|
216
|
+
if (!("seq" in parsed)) {
|
|
217
|
+
throw new StreamingFetchFailure(
|
|
218
|
+
"protocol",
|
|
219
|
+
"Fast Fetch received a changes-feed row without a sequence.",
|
|
220
|
+
false
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const change = parsed;
|
|
224
|
+
if (change.seq === void 0 || change.doc !== void 0 && (!change.doc || typeof change.doc !== "object")) {
|
|
225
|
+
throw new StreamingFetchFailure("protocol", "Fast Fetch received an invalid changes-feed row.", false);
|
|
226
|
+
}
|
|
227
|
+
return { type: "change", change };
|
|
74
228
|
}
|
|
75
229
|
async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader, decryptFunction, since = "0", onProgress, onCheckpoint) {
|
|
76
230
|
let totalFetched = 0;
|
|
77
231
|
let totalValidFetched = 0;
|
|
232
|
+
let totalBytes = 0;
|
|
78
233
|
const changesBaseParams = {
|
|
79
234
|
feed: "continuous",
|
|
80
235
|
include_docs: "true",
|
|
@@ -88,176 +243,237 @@ async function fetchChangesForInitialSync(downloadToDB, remoteDbUrl, authHeader,
|
|
|
88
243
|
Accept: "application/json",
|
|
89
244
|
Authorization: authHeader
|
|
90
245
|
};
|
|
91
|
-
|
|
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`), {
|
|
246
|
+
const targetURL = setParamsToURL(new URL(`${remoteDbUrl}/_changes`), {
|
|
105
247
|
...changesBaseParams,
|
|
106
248
|
feed: "normal",
|
|
107
|
-
|
|
249
|
+
since: "now",
|
|
250
|
+
limit: "1",
|
|
251
|
+
include_docs: "false"
|
|
108
252
|
});
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const infoSourceTrimmed = infoSource.trim();
|
|
114
|
-
if (!infoSourceTrimmed) {
|
|
115
|
-
throw new Error("Failed to fetch changes from CouchDB. No data received.");
|
|
116
|
-
}
|
|
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);
|
|
130
|
-
}
|
|
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;
|
|
135
|
-
}
|
|
136
|
-
const finalTargetSeq = targetSeq ?? "";
|
|
137
|
-
if (reachedTargetSequence(since, finalTargetSeq)) {
|
|
138
|
-
Logger("Already at the target sequence. Initial data synchronisation is complete.");
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
Logger(
|
|
142
|
-
`Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${finalTargetSeq}, Total documents to fetch: ${docsToFetch}.`
|
|
253
|
+
const targetResponse = await fetchResponse(
|
|
254
|
+
targetURL.toString(),
|
|
255
|
+
{ headers: fetchHeaders },
|
|
256
|
+
"capture the changes target"
|
|
143
257
|
);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
});
|
|
153
|
-
if (!response.body) {
|
|
154
|
-
throw new Error("ReadableStream is not supported by this browser.");
|
|
258
|
+
const targetStatus = parseStatusSource(await readResponseText(targetResponse, "read the changes target"));
|
|
259
|
+
const progressTargetSeq = targetStatus.last_seq;
|
|
260
|
+
if (progressTargetSeq === void 0) {
|
|
261
|
+
throw new StreamingFetchFailure(
|
|
262
|
+
"protocol",
|
|
263
|
+
"Fast Fetch could not obtain a changes progress target from CouchDB.",
|
|
264
|
+
false
|
|
265
|
+
);
|
|
155
266
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const chunkSize = chunk.length || chunk.byteLength || 0;
|
|
159
|
-
totalBytes += chunkSize;
|
|
160
|
-
controller2.enqueue(chunk);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
const reader = response.body.pipeThrough(sizeCaptureStream).pipeThrough(new TextDecoderStream()).getReader();
|
|
164
|
-
const writeDocStream = generatePouchDBWriteStream(downloadToDB, decryptFunction, onCheckpoint);
|
|
165
|
-
const writer = writeDocStream.getWriter();
|
|
166
|
-
let buffer = "";
|
|
267
|
+
const batchWriter = generatePouchDBBatchWriter(downloadToDB, decryptFunction, onCheckpoint);
|
|
268
|
+
let docsToFetch = 0;
|
|
167
269
|
let lastProgress = 0;
|
|
168
270
|
let lastReportTime = Date.now();
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const reportProgress = () => {
|
|
172
|
-
if (totalFetched - lastProgress < 25) {
|
|
173
|
-
if (Date.now() - lastReportTime < 2e3) {
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
271
|
+
const reportProgress = (force = false) => {
|
|
272
|
+
if (!force && totalFetched - lastProgress < 25 && Date.now() - lastReportTime < 2e3) return;
|
|
177
273
|
lastProgress = totalFetched;
|
|
178
274
|
lastReportTime = Date.now();
|
|
179
275
|
onProgress?.({
|
|
180
276
|
totalFetched,
|
|
181
277
|
totalValidFetched,
|
|
182
|
-
targetSeq:
|
|
278
|
+
targetSeq: progressTargetSeq,
|
|
183
279
|
docsToFetch,
|
|
184
280
|
totalBytes
|
|
185
281
|
});
|
|
186
282
|
};
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
);
|
|
311
|
+
}
|
|
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;
|
|
321
|
+
};
|
|
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
|
+
}
|
|
337
|
+
await batchWriter.flush();
|
|
338
|
+
await saveCheckpoint(onCheckpoint, parsed.lastSequence);
|
|
339
|
+
reportProgress(true);
|
|
340
|
+
return parsed.lastSequence;
|
|
341
|
+
}
|
|
342
|
+
if (fetchedRows >= pageLimit) {
|
|
343
|
+
throw new StreamingFetchFailure(
|
|
344
|
+
"protocol",
|
|
345
|
+
`Fast Fetch received more than its ${pageLimit}-row page limit.`,
|
|
346
|
+
false
|
|
347
|
+
);
|
|
348
|
+
}
|
|
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);
|
|
357
|
+
}
|
|
189
358
|
reportProgress();
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
});
|
|
367
|
+
const response = await fetchResponse(
|
|
368
|
+
changesURL.toString(),
|
|
369
|
+
{
|
|
370
|
+
method: "GET",
|
|
371
|
+
headers: fetchHeaders,
|
|
372
|
+
signal: controller.signal
|
|
373
|
+
},
|
|
374
|
+
"open the changes feed"
|
|
375
|
+
);
|
|
376
|
+
if (!response.body) {
|
|
377
|
+
throw new StreamingFetchFailure(
|
|
378
|
+
"protocol",
|
|
379
|
+
"Fast Fetch could not read the CouchDB response stream.",
|
|
380
|
+
false
|
|
381
|
+
);
|
|
193
382
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
383
|
+
const decoder = new TextDecoder();
|
|
384
|
+
const byteCountingDecoderStream = new TransformStream({
|
|
385
|
+
transform(chunk, streamController) {
|
|
386
|
+
totalBytes += chunk.byteLength;
|
|
387
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
388
|
+
if (decoded) streamController.enqueue(decoded);
|
|
389
|
+
},
|
|
390
|
+
flush(streamController) {
|
|
391
|
+
const decoded = decoder.decode();
|
|
392
|
+
if (decoded) streamController.enqueue(decoded);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
reader = response.body.pipeThrough(byteCountingDecoderStream).getReader();
|
|
396
|
+
while (true) {
|
|
397
|
+
reportProgress();
|
|
398
|
+
let readResult;
|
|
399
|
+
try {
|
|
400
|
+
readResult = await reader.read();
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw transportFailure("read the changes feed", error);
|
|
403
|
+
}
|
|
404
|
+
if (readResult.done) {
|
|
405
|
+
if (buffer.trim()) {
|
|
406
|
+
const lastSequence = await processLine(buffer);
|
|
407
|
+
if (lastSequence !== void 0) return lastSequence;
|
|
209
408
|
}
|
|
409
|
+
await batchWriter.flush();
|
|
410
|
+
throw new StreamingFetchFailure(
|
|
411
|
+
"transport",
|
|
412
|
+
"The bounded CouchDB changes feed ended without a last_seq terminator.",
|
|
413
|
+
true
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
buffer += readResult.value;
|
|
417
|
+
const lines = buffer.split("\n");
|
|
418
|
+
buffer = lines.pop() || "";
|
|
419
|
+
for (const line of lines) {
|
|
420
|
+
if (!line.trim()) continue;
|
|
421
|
+
const lastSequence = await processLine(line);
|
|
422
|
+
if (lastSequence !== void 0) return lastSequence;
|
|
210
423
|
}
|
|
211
|
-
await writer.close();
|
|
212
|
-
break;
|
|
213
424
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
for (const line of lines) {
|
|
218
|
-
if (!line.trim()) continue;
|
|
425
|
+
} finally {
|
|
426
|
+
controller.abort();
|
|
427
|
+
if (reader) {
|
|
219
428
|
try {
|
|
220
|
-
|
|
221
|
-
|
|
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);
|
|
429
|
+
await reader.cancel();
|
|
430
|
+
} catch {
|
|
242
431
|
}
|
|
432
|
+
reader.releaseLock();
|
|
243
433
|
}
|
|
244
434
|
}
|
|
245
|
-
|
|
246
|
-
|
|
435
|
+
};
|
|
436
|
+
try {
|
|
437
|
+
let pageSince = since;
|
|
438
|
+
let started = false;
|
|
439
|
+
while (true) {
|
|
440
|
+
const available = await readAvailableChanges(pageSince);
|
|
441
|
+
docsToFetch = Math.max(docsToFetch, totalFetched + available);
|
|
442
|
+
if (available === 0) break;
|
|
443
|
+
if (!started) {
|
|
444
|
+
started = true;
|
|
445
|
+
Logger(
|
|
446
|
+
`Starting initial synchronisation. Current sequence: ${since}, Target sequence: ${progressTargetSeq}, Documents to fetch: ${docsToFetch}.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
const pageLimit = Math.min(FAST_FETCH_CHANGES_PAGE_LIMIT, available);
|
|
450
|
+
const pageTerminator = await fetchPage(pageSince, pageLimit);
|
|
451
|
+
pageSince = pageTerminator;
|
|
452
|
+
}
|
|
453
|
+
if (started) {
|
|
454
|
+
Logger("Fast Fetch is caught up and durable in the local database.");
|
|
455
|
+
reportProgress(true);
|
|
456
|
+
} else {
|
|
457
|
+
Logger("No changes remain for Fast Fetch.");
|
|
458
|
+
}
|
|
247
459
|
} catch (error) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
460
|
+
const failure = asStreamingFetchFailure(error);
|
|
461
|
+
if (failure.stage !== "storage") {
|
|
462
|
+
try {
|
|
463
|
+
await batchWriter.flush();
|
|
464
|
+
} catch (flushError) {
|
|
465
|
+
batchWriter.abort();
|
|
466
|
+
throw asStreamingFetchFailure(flushError);
|
|
467
|
+
}
|
|
253
468
|
}
|
|
254
|
-
|
|
255
|
-
Logger(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
reader.releaseLock();
|
|
469
|
+
batchWriter.abort();
|
|
470
|
+
Logger(`Fast Fetch failed during ${failure.stage}.`, LOG_LEVEL_VERBOSE);
|
|
471
|
+
Logger(failure, LOG_LEVEL_VERBOSE);
|
|
472
|
+
throw failure;
|
|
259
473
|
}
|
|
260
474
|
}
|
|
261
475
|
export {
|
|
262
|
-
|
|
476
|
+
StreamingFetchFailure,
|
|
477
|
+
fetchChangesForInitialSync,
|
|
478
|
+
isRetryableStreamingFetchFailure
|
|
263
479
|
};
|
|
@@ -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(
|