@delali/sirannon-db 0.1.7 → 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.
- package/README.md +117 -969
- package/dist/backup-scheduler/index.d.ts +1 -1
- package/dist/backup-scheduler/index.mjs +2 -2
- package/dist/baseline-Br77Fnhb.d.ts +6 -0
- package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
- package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
- package/dist/chunk-5NOIGN5Y.mjs +18 -0
- package/dist/chunk-67M7KAH6.mjs +90 -0
- package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
- package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
- package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
- package/dist/chunk-GVCNMPOS.mjs +130 -0
- package/dist/chunk-H237TXZW.mjs +1407 -0
- package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
- package/dist/chunk-HR5CWTLC.mjs +661 -0
- package/dist/chunk-JU64Y7HM.mjs +299 -0
- package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
- package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
- package/dist/chunk-LNY2VVHE.mjs +18 -0
- package/dist/chunk-NVQS53NT.mjs +665 -0
- package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
- package/dist/chunk-OQVZBEBY.mjs +283 -0
- package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
- package/dist/chunk-TJF5GZSV.mjs +187 -0
- package/dist/chunk-TUD5CJ76.mjs +126 -0
- package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
- package/dist/client/index.d.ts +216 -321
- package/dist/client/index.mjs +1192 -1342
- package/dist/client/topology.d.ts +61 -0
- package/dist/client/topology.mjs +682 -0
- package/dist/client-base-CLWmH5Ln.d.ts +234 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.mjs +11 -0
- package/dist/codegen/index.d.ts +30 -0
- package/dist/codegen/index.mjs +5 -0
- package/dist/core/index.d.ts +37 -16
- package/dist/core/index.mjs +2322 -298
- package/dist/core/writer-worker.mjs +28 -6
- package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
- package/dist/driver/better-sqlite3.d.ts +1 -1
- package/dist/driver/better-sqlite3.mjs +6 -6
- package/dist/driver/node.d.ts +1 -1
- package/dist/driver/node.mjs +6 -6
- package/dist/driver/wa-sqlite.d.ts +1 -136
- package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
- package/dist/file-migrations/index.d.ts +7 -5
- package/dist/file-migrations/index.mjs +42 -37
- package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
- package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
- package/dist/protocol-6KrSq2Hy.d.ts +66 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/index.mjs +137 -0
- package/dist/replication/coordinator/etcd.d.ts +5 -3
- package/dist/replication/coordinator/etcd.mjs +554 -462
- package/dist/replication/index.d.ts +51 -105
- package/dist/replication/index.mjs +1415 -2061
- package/dist/server/index.d.ts +111 -87
- package/dist/server/index.mjs +1917 -489
- package/dist/server-options-1JHu8pid.d.ts +191 -0
- package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
- package/dist/transport/grpc.d.ts +7 -4
- package/dist/transport/grpc.mjs +191 -97
- package/dist/transport/memory.d.ts +5 -134
- package/dist/transport/memory.mjs +3 -1
- package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
- package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
- package/dist/types-CL6piSnD.d.ts +43 -0
- package/dist/types-C_D8IhpO.d.ts +60 -0
- package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
- package/package.json +44 -6
- package/dist/chunk-TKGHYWQ6.mjs +0 -35
- package/dist/types-D4p4UyDK.d.ts +0 -26
package/dist/client/index.mjs
CHANGED
|
@@ -1,1490 +1,1340 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
import { DatabaseClient, createEndpointTransport, toBaseUrl, DEFAULT_HTTP_REQUEST_TIMEOUT_MS, postJson, decodeTaggedValues, RemoteError, encodeTaggedValues, toWsUrl, STAGED_STREAM_CAPABILITY, verifyDeviceSyncCapabilities, unrefTimer, WebSocketTransport } from '../chunk-H237TXZW.mjs';
|
|
2
|
+
export { HttpTransport, RemoteDatabase, RemoteError, RemoteLiveQuery, RemoteSubscriptionBuilderImpl, SQL_REFUSED_MESSAGE, ServerCapabilities, WebSocketTransport } from '../chunk-H237TXZW.mjs';
|
|
3
|
+
|
|
4
|
+
// src/core/sync/canonicalise.ts
|
|
5
|
+
function canonicaliseForChecksum(value) {
|
|
6
|
+
return canonicaliseValue(value);
|
|
7
|
+
}
|
|
8
|
+
function canonicaliseValue(v) {
|
|
9
|
+
if (v === null || v === void 0) return "null";
|
|
10
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
11
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
12
|
+
if (typeof v === "bigint") return `{"__sint":"${v.toString()}"}`;
|
|
13
|
+
if (typeof v === "number") {
|
|
14
|
+
if (Number.isInteger(v)) return `{"__sint":"${v.toString()}"}`;
|
|
15
|
+
if (Number.isFinite(v)) return JSON.stringify(v);
|
|
16
|
+
return "null";
|
|
17
|
+
}
|
|
18
|
+
if (typeof v === "object") {
|
|
19
|
+
if (v instanceof Uint8Array) {
|
|
20
|
+
const buf = Buffer.isBuffer(v) ? v : Buffer.from(v);
|
|
21
|
+
return `{"__blob":"${buf.toString("base64")}"}`;
|
|
22
|
+
}
|
|
23
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(v)) {
|
|
24
|
+
return `{"__blob":"${v.toString("base64")}"}`;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(v)) {
|
|
27
|
+
return `[${v.map(canonicaliseValue).join(",")}]`;
|
|
28
|
+
}
|
|
29
|
+
const obj = v;
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (const key of Object.keys(obj).sort()) {
|
|
32
|
+
const val = obj[key];
|
|
33
|
+
if (val === void 0) continue;
|
|
34
|
+
parts.push(`${JSON.stringify(key)}:${canonicaliseValue(val)}`);
|
|
35
|
+
}
|
|
36
|
+
return `{${parts.join(",")}}`;
|
|
37
|
+
}
|
|
38
|
+
return "null";
|
|
39
|
+
}
|
|
17
40
|
|
|
18
|
-
// src/
|
|
19
|
-
var
|
|
20
|
-
code
|
|
21
|
-
constructor(code, message) {
|
|
41
|
+
// src/core/errors.ts
|
|
42
|
+
var SirannonError = class extends Error {
|
|
43
|
+
constructor(message, code) {
|
|
22
44
|
super(message);
|
|
23
|
-
this.name = "RemoteError";
|
|
24
45
|
this.code = code;
|
|
46
|
+
this.name = "SirannonError";
|
|
25
47
|
}
|
|
26
48
|
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
var RemoteDatabase = class {
|
|
34
|
-
constructor(id, transport, onDispose) {
|
|
35
|
-
this.id = id;
|
|
36
|
-
this.transport = transport;
|
|
37
|
-
this.onDispose = onDispose;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Execute a SELECT and return all matching rows.
|
|
41
|
-
*
|
|
42
|
-
* ```ts
|
|
43
|
-
* const users = await db.query<{ id: number; name: string }>(
|
|
44
|
-
* 'SELECT * FROM users WHERE age > ?',
|
|
45
|
-
* [21],
|
|
46
|
-
* )
|
|
47
|
-
* ```
|
|
48
|
-
*/
|
|
49
|
-
async query(sql, params, options) {
|
|
50
|
-
const response = await this.transport.query(sql, params, options?.readConcern);
|
|
51
|
-
return response.rows;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Execute a mutation (INSERT, UPDATE, DELETE) and return
|
|
55
|
-
* the number of affected rows and last insert row ID.
|
|
56
|
-
*/
|
|
57
|
-
async execute(sql, params) {
|
|
58
|
-
return this.transport.execute(sql, params);
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Execute multiple statements as a single atomic transaction.
|
|
62
|
-
* Returns an array of results, one per statement.
|
|
63
|
-
*
|
|
64
|
-
* The whole list travels in one request and commits or rolls back as a
|
|
65
|
-
* unit, so the client is never in the loop between statements.
|
|
66
|
-
*/
|
|
67
|
-
async transaction(statements) {
|
|
68
|
-
const response = await this.transport.transaction(statements);
|
|
69
|
-
return response.results;
|
|
49
|
+
var MigrationError = class extends SirannonError {
|
|
50
|
+
constructor(message, version, code = "MIGRATION_ERROR") {
|
|
51
|
+
super(message, code);
|
|
52
|
+
this.version = version;
|
|
53
|
+
this.name = "MigrationError";
|
|
70
54
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return response.results;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/core/sync/errors.ts
|
|
58
|
+
var ReplicationError = class extends SirannonError {
|
|
59
|
+
constructor(message, code = "REPLICATION_ERROR", details) {
|
|
60
|
+
super(message, code);
|
|
61
|
+
this.details = details;
|
|
62
|
+
this.name = "ReplicationError";
|
|
80
63
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
*/
|
|
101
|
-
async loadAll(sql, rows, options) {
|
|
102
|
-
const batchSize = options?.batchSize ?? DEFAULT_LOAD_BATCH_SIZE;
|
|
103
|
-
if (!Number.isInteger(batchSize) || batchSize <= 0) {
|
|
104
|
-
throw new RemoteError("INVALID_ARGUMENT", "loadAll batchSize must be a positive integer");
|
|
105
|
-
}
|
|
106
|
-
const durability = options?.durability;
|
|
107
|
-
const total = { rowsLoaded: 0, changes: 0 };
|
|
108
|
-
const send = async (paramsBatch, checkpoint) => {
|
|
109
|
-
const summary = await this.transport.load(sql, paramsBatch, durability, checkpoint);
|
|
110
|
-
total.rowsLoaded += summary.rowsLoaded;
|
|
111
|
-
total.changes += summary.changes;
|
|
112
|
-
};
|
|
113
|
-
let batch = [];
|
|
114
|
-
let previous = null;
|
|
115
|
-
const rotate = async () => {
|
|
116
|
-
if (previous !== null) await send(previous, false);
|
|
117
|
-
previous = batch;
|
|
118
|
-
batch = [];
|
|
119
|
-
};
|
|
120
|
-
if (isAsyncIterable(rows)) {
|
|
121
|
-
for await (const row of rows) {
|
|
122
|
-
batch.push(row);
|
|
123
|
-
if (batch.length >= batchSize) await rotate();
|
|
124
|
-
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/core/sync/hlc.ts
|
|
67
|
+
var MAX_LOGICAL = 65535;
|
|
68
|
+
var HLC = class _HLC {
|
|
69
|
+
wallMs;
|
|
70
|
+
logical;
|
|
71
|
+
nodeId;
|
|
72
|
+
constructor(nodeId) {
|
|
73
|
+
this.nodeId = nodeId;
|
|
74
|
+
this.wallMs = 0;
|
|
75
|
+
this.logical = 0;
|
|
76
|
+
}
|
|
77
|
+
/** Generate a new HLC timestamp, advancing the clock. */
|
|
78
|
+
now() {
|
|
79
|
+
const physicalMs = Date.now();
|
|
80
|
+
if (physicalMs > this.wallMs) {
|
|
81
|
+
this.wallMs = physicalMs;
|
|
82
|
+
this.logical = 0;
|
|
125
83
|
} else {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
84
|
+
this.logical += 1;
|
|
85
|
+
if (this.logical > MAX_LOGICAL) {
|
|
86
|
+
throw new ReplicationError("HLC logical counter overflow");
|
|
129
87
|
}
|
|
130
88
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
* across many `load` calls by hand, pass `checkpoint: false` on every call
|
|
148
|
-
* but the last so the one fsyncing WAL checkpoint runs once at the end; the
|
|
149
|
-
* configured durability is restored after each call regardless.
|
|
150
|
-
*/
|
|
151
|
-
async load(sql, paramsBatch, durability, checkpoint) {
|
|
152
|
-
return this.transport.load(sql, paramsBatch, durability, checkpoint);
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Start building a CDC subscription for the given table.
|
|
156
|
-
* Chain `.filter()` to narrow the events, then call `.subscribe()`
|
|
157
|
-
* with a callback to begin receiving real-time change events.
|
|
158
|
-
*
|
|
159
|
-
* ```ts
|
|
160
|
-
* const sub = await db
|
|
161
|
-
* .on('orders')
|
|
162
|
-
* .filter({ status: 'pending' })
|
|
163
|
-
* .subscribe(event => console.log(event))
|
|
164
|
-
*
|
|
165
|
-
* // Later:
|
|
166
|
-
* sub.unsubscribe()
|
|
167
|
-
* ```
|
|
168
|
-
*/
|
|
169
|
-
on(table) {
|
|
170
|
-
return new RemoteSubscriptionBuilderImpl(table, this.transport);
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Close the transport for this database. After calling `close()`,
|
|
174
|
-
* all pending requests are rejected and new calls will throw.
|
|
175
|
-
*/
|
|
176
|
-
close() {
|
|
177
|
-
this.transport.close();
|
|
178
|
-
this.onDispose?.();
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
// src/core/cdc/encoding.ts
|
|
183
|
-
var BLOB_TAG = "__sirannon_blob";
|
|
184
|
-
var INT_TAG = "__sirannon_int";
|
|
185
|
-
var SAFE_INT_BOUND = 9007199254740991n;
|
|
186
|
-
var HEX_BYTES_RE = /^[0-9a-fA-F]*$/;
|
|
187
|
-
var INT_PAYLOAD_RE = /^-?\d{1,19}$/;
|
|
188
|
-
function encodeTaggedValues(value) {
|
|
189
|
-
if (typeof value === "bigint" || isBinaryValue(value)) {
|
|
190
|
-
return encodeTaggedLeaf(value);
|
|
191
|
-
}
|
|
192
|
-
if (value === null || typeof value !== "object") return value;
|
|
193
|
-
if (Array.isArray(value)) {
|
|
194
|
-
return value.map(encodeTaggedValues);
|
|
195
|
-
}
|
|
196
|
-
if (typeof value.toJSON === "function") {
|
|
197
|
-
return value;
|
|
198
|
-
}
|
|
199
|
-
const obj = value;
|
|
200
|
-
const out = {};
|
|
201
|
-
for (const k of Object.keys(obj)) {
|
|
202
|
-
out[k] = encodeTaggedValues(obj[k]);
|
|
203
|
-
}
|
|
204
|
-
return out;
|
|
205
|
-
}
|
|
206
|
-
function encodeTaggedLeaf(value) {
|
|
207
|
-
if (typeof value === "bigint") {
|
|
208
|
-
if (value >= -SAFE_INT_BOUND && value <= SAFE_INT_BOUND) {
|
|
209
|
-
return Number(value);
|
|
89
|
+
return _HLC.encode(this.wallMs, this.logical, this.nodeId);
|
|
90
|
+
}
|
|
91
|
+
/** Merge a remote HLC timestamp into the local clock and return the updated value. */
|
|
92
|
+
receive(remote) {
|
|
93
|
+
const r = _HLC.decode(remote);
|
|
94
|
+
const physicalMs = Date.now();
|
|
95
|
+
if (physicalMs > this.wallMs && physicalMs > r.wallMs) {
|
|
96
|
+
this.wallMs = physicalMs;
|
|
97
|
+
this.logical = 0;
|
|
98
|
+
} else if (r.wallMs > this.wallMs) {
|
|
99
|
+
this.wallMs = r.wallMs;
|
|
100
|
+
this.logical = r.logical + 1;
|
|
101
|
+
} else if (this.wallMs > r.wallMs) {
|
|
102
|
+
this.logical += 1;
|
|
103
|
+
} else {
|
|
104
|
+
this.logical = Math.max(this.logical, r.logical) + 1;
|
|
210
105
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (isBinaryValue(value)) {
|
|
214
|
-
return { [BLOB_TAG]: encodeHexBytes(value) };
|
|
215
|
-
}
|
|
216
|
-
return value;
|
|
217
|
-
}
|
|
218
|
-
function decodeTaggedValues(value) {
|
|
219
|
-
if (value === null || typeof value !== "object") return value;
|
|
220
|
-
if (isBinaryValue(value)) return value;
|
|
221
|
-
if (Array.isArray(value)) {
|
|
222
|
-
return value.map(decodeTaggedValues);
|
|
223
|
-
}
|
|
224
|
-
const obj = value;
|
|
225
|
-
const keys = Object.keys(obj);
|
|
226
|
-
if (keys.length === 1) {
|
|
227
|
-
const tag = keys[0];
|
|
228
|
-
const tagValue = obj[tag];
|
|
229
|
-
if (tag === BLOB_TAG && typeof tagValue === "string") {
|
|
230
|
-
return decodeHexBytes(tagValue);
|
|
231
|
-
}
|
|
232
|
-
if (tag === INT_TAG && typeof tagValue === "string") {
|
|
233
|
-
if (!INT_PAYLOAD_RE.test(tagValue)) {
|
|
234
|
-
throw new Error("Invalid tagged integer payload");
|
|
235
|
-
}
|
|
236
|
-
return BigInt(tagValue);
|
|
106
|
+
if (this.logical > MAX_LOGICAL) {
|
|
107
|
+
throw new ReplicationError("HLC logical counter overflow");
|
|
237
108
|
}
|
|
109
|
+
return _HLC.encode(this.wallMs, this.logical, this.nodeId);
|
|
238
110
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
function isBinaryValue(value) {
|
|
246
|
-
return value instanceof Uint8Array || typeof Buffer !== "undefined" && Buffer.isBuffer(value);
|
|
247
|
-
}
|
|
248
|
-
function encodeHexBytes(bytes) {
|
|
249
|
-
if (typeof Buffer !== "undefined") {
|
|
250
|
-
return Buffer.from(bytes).toString("hex").toUpperCase();
|
|
251
|
-
}
|
|
252
|
-
let hex = "";
|
|
253
|
-
for (const byte of bytes) {
|
|
254
|
-
hex += byte.toString(16).padStart(2, "0");
|
|
111
|
+
/** Lexicographic comparison of two encoded HLC strings. Returns -1, 0, or 1. */
|
|
112
|
+
static compare(a, b) {
|
|
113
|
+
if (a < b) return -1;
|
|
114
|
+
if (a > b) return 1;
|
|
115
|
+
return 0;
|
|
255
116
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
if (typeof Buffer !== "undefined") {
|
|
263
|
-
return Buffer.from(hex, "hex");
|
|
264
|
-
}
|
|
265
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
266
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
267
|
-
const byte = Number.parseInt(hex.slice(i, i + 2), 16);
|
|
268
|
-
if (Number.isNaN(byte)) {
|
|
269
|
-
throw new Error("Invalid CDC BLOB hex payload");
|
|
117
|
+
/** Parse an encoded HLC string into its wall-clock, logical, and nodeId components. */
|
|
118
|
+
static decode(hlc) {
|
|
119
|
+
const parts = hlc.split("-");
|
|
120
|
+
if (parts.length < 3) {
|
|
121
|
+
throw new ReplicationError(`Invalid HLC format: ${hlc}`);
|
|
270
122
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// src/client/transport/http.ts
|
|
277
|
-
var HttpTransport = class {
|
|
278
|
-
baseUrl;
|
|
279
|
-
headers;
|
|
280
|
-
closed = false;
|
|
281
|
-
constructor(baseUrl, headers) {
|
|
282
|
-
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
283
|
-
this.headers = {
|
|
284
|
-
"content-type": "application/json",
|
|
285
|
-
...headers
|
|
123
|
+
return {
|
|
124
|
+
wallMs: Number.parseInt(parts[0], 16),
|
|
125
|
+
logical: Number.parseInt(parts[1], 16),
|
|
126
|
+
nodeId: parts.slice(2).join("-")
|
|
286
127
|
};
|
|
287
128
|
}
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
readConcern
|
|
293
|
-
});
|
|
294
|
-
return { rows: decodeTaggedValues(response.rows ?? []) };
|
|
295
|
-
}
|
|
296
|
-
async execute(sql, params) {
|
|
297
|
-
return this.post("/execute", { sql, params: encodeTaggedValues(params) });
|
|
298
|
-
}
|
|
299
|
-
async transaction(statements) {
|
|
300
|
-
return this.post("/transaction", {
|
|
301
|
-
statements: statements.map((stmt) => ({ sql: stmt.sql, params: encodeTaggedValues(stmt.params) }))
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
async batch(sql, paramsBatch, writeConcern) {
|
|
305
|
-
return this.post("/batch", {
|
|
306
|
-
sql,
|
|
307
|
-
paramsBatch: paramsBatch.map((entry) => encodeTaggedValues(entry)),
|
|
308
|
-
writeConcern
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
async load(sql, paramsBatch, durability, checkpoint) {
|
|
312
|
-
return this.post("/load", {
|
|
313
|
-
sql,
|
|
314
|
-
paramsBatch: paramsBatch.map((entry) => encodeTaggedValues(entry)),
|
|
315
|
-
durability,
|
|
316
|
-
checkpoint
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
async subscribe(_table, _filter, _callback) {
|
|
320
|
-
throw new RemoteError(
|
|
321
|
-
"TRANSPORT_ERROR",
|
|
322
|
-
'Subscriptions require WebSocket transport. Create the client with { transport: "websocket" } to use real-time subscriptions.'
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
close() {
|
|
326
|
-
this.closed = true;
|
|
129
|
+
static encode(wallMs, logical, nodeId) {
|
|
130
|
+
const wallHex = wallMs.toString(16).padStart(12, "0");
|
|
131
|
+
const logicalHex = logical.toString(16).padStart(4, "0");
|
|
132
|
+
return `${wallHex}-${logicalHex}-${nodeId}`;
|
|
327
133
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// src/core/sync/conflict/lww.ts
|
|
137
|
+
var LWWResolver = class {
|
|
138
|
+
resolve(ctx) {
|
|
139
|
+
if (ctx.remoteChange.operation === "delete") {
|
|
140
|
+
return { action: "accept_remote" };
|
|
331
141
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
try {
|
|
335
|
-
response = await fetch(url, {
|
|
336
|
-
method: "POST",
|
|
337
|
-
headers: this.headers,
|
|
338
|
-
body: JSON.stringify(body)
|
|
339
|
-
});
|
|
340
|
-
} catch (err) {
|
|
341
|
-
throw new RemoteError(
|
|
342
|
-
"CONNECTION_ERROR",
|
|
343
|
-
`Failed to connect to ${url}: ${err instanceof Error ? err.message : String(err)}`
|
|
344
|
-
);
|
|
142
|
+
if (ctx.localHlc === null) {
|
|
143
|
+
return { action: "accept_remote" };
|
|
345
144
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
145
|
+
const cmp = HLC.compare(ctx.remoteHlc, ctx.localHlc);
|
|
146
|
+
if (cmp > 0) {
|
|
147
|
+
return { action: "accept_remote" };
|
|
148
|
+
}
|
|
149
|
+
if (cmp < 0) {
|
|
150
|
+
return { action: "keep_local" };
|
|
351
151
|
}
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
throw new RemoteError(
|
|
355
|
-
errorData.error?.code ?? "UNKNOWN_ERROR",
|
|
356
|
-
errorData.error?.message ?? `HTTP ${response.status}`
|
|
357
|
-
);
|
|
152
|
+
if (ctx.remoteChange.nodeId > (ctx.localChange?.nodeId ?? "")) {
|
|
153
|
+
return { action: "accept_remote" };
|
|
358
154
|
}
|
|
359
|
-
return
|
|
155
|
+
return { action: "keep_local" };
|
|
360
156
|
}
|
|
361
157
|
};
|
|
362
158
|
|
|
363
|
-
// src/
|
|
364
|
-
var
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
this.protocols = options?.protocols;
|
|
384
|
-
}
|
|
385
|
-
async query(sql, params) {
|
|
386
|
-
await this.ensureConnected();
|
|
387
|
-
const id = this.nextId();
|
|
388
|
-
const response = await this.request({
|
|
389
|
-
type: "query",
|
|
390
|
-
id,
|
|
391
|
-
sql,
|
|
392
|
-
params: encodeTaggedValues(params)
|
|
393
|
-
});
|
|
394
|
-
return { rows: decodeTaggedValues(response.rows ?? []) };
|
|
395
|
-
}
|
|
396
|
-
async execute(sql, params) {
|
|
397
|
-
await this.ensureConnected();
|
|
398
|
-
const id = this.nextId();
|
|
399
|
-
return this.request({
|
|
400
|
-
type: "execute",
|
|
401
|
-
id,
|
|
402
|
-
sql,
|
|
403
|
-
params: encodeTaggedValues(params)
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
async transaction(statements) {
|
|
407
|
-
await this.ensureConnected();
|
|
408
|
-
const id = this.nextId();
|
|
409
|
-
return this.request({
|
|
410
|
-
type: "transaction",
|
|
411
|
-
id,
|
|
412
|
-
statements: statements.map((stmt) => ({
|
|
413
|
-
sql: stmt.sql,
|
|
414
|
-
params: encodeTaggedValues(stmt.params)
|
|
415
|
-
}))
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
async batch(sql, paramsBatch, writeConcern) {
|
|
419
|
-
await this.ensureConnected();
|
|
420
|
-
const id = this.nextId();
|
|
421
|
-
return this.request({
|
|
422
|
-
type: "batch",
|
|
423
|
-
id,
|
|
424
|
-
sql,
|
|
425
|
-
paramsBatch: paramsBatch.map((entry) => encodeTaggedValues(entry)),
|
|
426
|
-
...writeConcern ? { writeConcern } : {}
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
async load(sql, paramsBatch, durability, checkpoint) {
|
|
430
|
-
await this.ensureConnected();
|
|
431
|
-
const id = this.nextId();
|
|
432
|
-
return this.request({
|
|
433
|
-
type: "load",
|
|
434
|
-
id,
|
|
435
|
-
sql,
|
|
436
|
-
paramsBatch: paramsBatch.map((entry) => encodeTaggedValues(entry)),
|
|
437
|
-
...durability ? { durability } : {},
|
|
438
|
-
...checkpoint !== void 0 ? { checkpoint } : {}
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
async subscribe(table, filter, callback, options) {
|
|
442
|
-
await this.ensureConnected();
|
|
443
|
-
const id = this.nextId();
|
|
444
|
-
this.activeSubscriptions.set(id, {
|
|
445
|
-
table,
|
|
446
|
-
filter,
|
|
447
|
-
callback,
|
|
448
|
-
onReset: options?.onReset,
|
|
449
|
-
lastSeq: void 0,
|
|
450
|
-
epoch: void 0
|
|
451
|
-
});
|
|
452
|
-
try {
|
|
453
|
-
const msg = {
|
|
454
|
-
type: "subscribe",
|
|
455
|
-
id,
|
|
456
|
-
table,
|
|
457
|
-
...filter ? { filter: encodeTaggedValues(filter) } : {}
|
|
458
|
-
};
|
|
459
|
-
await this.request(msg);
|
|
460
|
-
} catch (err) {
|
|
461
|
-
this.activeSubscriptions.delete(id);
|
|
462
|
-
throw err;
|
|
463
|
-
}
|
|
464
|
-
return {
|
|
465
|
-
unsubscribe: () => {
|
|
466
|
-
this.activeSubscriptions.delete(id);
|
|
467
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
468
|
-
this.ws.send(JSON.stringify({ type: "unsubscribe", id }));
|
|
469
|
-
}
|
|
159
|
+
// src/core/sync/conflict/field-merge.ts
|
|
160
|
+
var FieldMergeResolver = class {
|
|
161
|
+
getColumnVersions;
|
|
162
|
+
lww = new LWWResolver();
|
|
163
|
+
constructor(getColumnVersions) {
|
|
164
|
+
this.getColumnVersions = getColumnVersions;
|
|
165
|
+
}
|
|
166
|
+
async resolve(ctx) {
|
|
167
|
+
const columnVersions = await this.getColumnVersions(ctx.table, ctx.rowId);
|
|
168
|
+
if (columnVersions.size === 0) {
|
|
169
|
+
return this.lww.resolve(ctx);
|
|
170
|
+
}
|
|
171
|
+
const localData = ctx.localChange?.newData ?? ctx.localChange?.oldData ?? {};
|
|
172
|
+
const remoteData = ctx.remoteChange.newData ?? {};
|
|
173
|
+
const oldData = ctx.remoteChange.oldData ?? {};
|
|
174
|
+
const localChanged = /* @__PURE__ */ new Set();
|
|
175
|
+
const remoteChanged = /* @__PURE__ */ new Set();
|
|
176
|
+
for (const key of Object.keys(localData)) {
|
|
177
|
+
if (canonicaliseForChecksum(localData[key]) !== canonicaliseForChecksum(oldData[key])) {
|
|
178
|
+
localChanged.add(key);
|
|
470
179
|
}
|
|
471
|
-
};
|
|
472
|
-
}
|
|
473
|
-
close() {
|
|
474
|
-
this.closed = true;
|
|
475
|
-
this.cancelReconnect();
|
|
476
|
-
this.rejectAllPending(new RemoteError("TRANSPORT_ERROR", "Transport closed"));
|
|
477
|
-
this.activeSubscriptions.clear();
|
|
478
|
-
if (this.ws) {
|
|
479
|
-
this.ws.close(1e3, "Client closed");
|
|
480
|
-
this.ws = null;
|
|
481
|
-
}
|
|
482
|
-
this.connectPromise = null;
|
|
483
|
-
}
|
|
484
|
-
nextId() {
|
|
485
|
-
return `c_${++this.idCounter}_${Date.now()}`;
|
|
486
|
-
}
|
|
487
|
-
async ensureConnected() {
|
|
488
|
-
if (this.closed) {
|
|
489
|
-
throw new RemoteError("TRANSPORT_ERROR", "Transport is closed");
|
|
490
|
-
}
|
|
491
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
if (!this.connectPromise) {
|
|
495
|
-
this.connectPromise = this.connect().finally(() => {
|
|
496
|
-
this.connectPromise = null;
|
|
497
|
-
});
|
|
498
180
|
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
let settled = false;
|
|
504
|
-
const ws = this.protocols === void 0 ? new WebSocket(this.url) : new WebSocket(this.url, this.protocols);
|
|
505
|
-
const onOpen = () => {
|
|
506
|
-
settled = true;
|
|
507
|
-
this.ws = ws;
|
|
508
|
-
resolve();
|
|
509
|
-
};
|
|
510
|
-
const onError = () => {
|
|
511
|
-
if (!settled) {
|
|
512
|
-
settled = true;
|
|
513
|
-
reject(new RemoteError("CONNECTION_ERROR", `Failed to connect to ${this.url}`));
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
const onClose = (event) => {
|
|
517
|
-
ws.removeEventListener("open", onOpen);
|
|
518
|
-
ws.removeEventListener("error", onError);
|
|
519
|
-
if (!settled) {
|
|
520
|
-
settled = true;
|
|
521
|
-
reject(
|
|
522
|
-
new RemoteError("CONNECTION_ERROR", `Connection closed during handshake: ${event.code} ${event.reason}`)
|
|
523
|
-
);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
this.ws = null;
|
|
527
|
-
this.handleDisconnect();
|
|
528
|
-
};
|
|
529
|
-
const onMessage = (event) => {
|
|
530
|
-
this.handleMessage(String(event.data));
|
|
531
|
-
};
|
|
532
|
-
ws.addEventListener("open", onOpen);
|
|
533
|
-
ws.addEventListener("error", onError);
|
|
534
|
-
ws.addEventListener("close", onClose);
|
|
535
|
-
ws.addEventListener("message", onMessage);
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
handleMessage(raw) {
|
|
539
|
-
let msg;
|
|
540
|
-
try {
|
|
541
|
-
msg = JSON.parse(raw);
|
|
542
|
-
} catch {
|
|
543
|
-
return;
|
|
181
|
+
for (const key of Object.keys(remoteData)) {
|
|
182
|
+
if (canonicaliseForChecksum(remoteData[key]) !== canonicaliseForChecksum(oldData[key])) {
|
|
183
|
+
remoteChanged.add(key);
|
|
184
|
+
}
|
|
544
185
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
clearTimeout(pending.timer);
|
|
550
|
-
this.pendingRequests.delete(msg.id);
|
|
551
|
-
pending.resolve(msg.data);
|
|
552
|
-
}
|
|
553
|
-
break;
|
|
186
|
+
const overlapping = /* @__PURE__ */ new Set();
|
|
187
|
+
for (const key of remoteChanged) {
|
|
188
|
+
if (localChanged.has(key)) {
|
|
189
|
+
overlapping.add(key);
|
|
554
190
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
let baseline;
|
|
562
|
-
if (msg.seq !== void 0) {
|
|
563
|
-
try {
|
|
564
|
-
baseline = BigInt(msg.seq);
|
|
565
|
-
} catch {
|
|
566
|
-
baseline = void 0;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
if (msg.resync) {
|
|
570
|
-
sub.lastSeq = baseline;
|
|
571
|
-
try {
|
|
572
|
-
sub.onReset?.();
|
|
573
|
-
} catch {
|
|
574
|
-
}
|
|
575
|
-
} else if (sub.lastSeq === void 0 && baseline !== void 0) {
|
|
576
|
-
sub.lastSeq = baseline;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
const pending = this.pendingRequests.get(msg.id);
|
|
580
|
-
if (pending) {
|
|
581
|
-
clearTimeout(pending.timer);
|
|
582
|
-
this.pendingRequests.delete(msg.id);
|
|
583
|
-
pending.resolve(void 0);
|
|
584
|
-
}
|
|
585
|
-
break;
|
|
191
|
+
}
|
|
192
|
+
if (overlapping.size === 0 && (localChanged.size > 0 || remoteChanged.size > 0)) {
|
|
193
|
+
const merged2 = { ...localData };
|
|
194
|
+
for (const key of remoteChanged) {
|
|
195
|
+
merged2[key] = remoteData[key];
|
|
586
196
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
197
|
+
return { action: "merge", mergedData: merged2 };
|
|
198
|
+
}
|
|
199
|
+
const merged = { ...localData };
|
|
200
|
+
let anyRemoteWins = false;
|
|
201
|
+
for (const key of overlapping) {
|
|
202
|
+
const cv = columnVersions.get(key);
|
|
203
|
+
if (!cv) {
|
|
204
|
+
const rowLww = this.lww.resolve(ctx);
|
|
205
|
+
if (rowLww.action === "accept_remote") {
|
|
206
|
+
merged[key] = remoteData[key];
|
|
207
|
+
anyRemoteWins = true;
|
|
593
208
|
}
|
|
594
|
-
|
|
209
|
+
continue;
|
|
595
210
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
row: decodeTaggedValues(msg.event.row),
|
|
604
|
-
oldRow: msg.event.oldRow === void 0 ? void 0 : decodeTaggedValues(msg.event.oldRow),
|
|
605
|
-
seq: BigInt(msg.event.seq),
|
|
606
|
-
timestamp: msg.event.timestamp
|
|
607
|
-
};
|
|
608
|
-
if (sub.lastSeq === void 0 || event.seq > sub.lastSeq) {
|
|
609
|
-
sub.lastSeq = event.seq;
|
|
610
|
-
}
|
|
611
|
-
sub.callback(event);
|
|
612
|
-
} catch {
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
break;
|
|
211
|
+
const cmp = HLC.compare(ctx.remoteHlc, cv.hlc);
|
|
212
|
+
if (cmp > 0) {
|
|
213
|
+
merged[key] = remoteData[key];
|
|
214
|
+
anyRemoteWins = true;
|
|
215
|
+
} else if (cmp === 0 && ctx.remoteChange.nodeId > cv.nodeId) {
|
|
216
|
+
merged[key] = remoteData[key];
|
|
217
|
+
anyRemoteWins = true;
|
|
616
218
|
}
|
|
617
219
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
this.scheduleReconnect();
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
scheduleReconnect() {
|
|
626
|
-
if (this.reconnectTimer !== null || this.closed) {
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
630
|
-
this.reconnectTimer = null;
|
|
631
|
-
if (this.closed) return;
|
|
632
|
-
try {
|
|
633
|
-
await this.ensureConnected();
|
|
634
|
-
await this.resubscribeAll();
|
|
635
|
-
} catch {
|
|
636
|
-
if (!this.closed && this.activeSubscriptions.size > 0) {
|
|
637
|
-
this.scheduleReconnect();
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}, this.reconnectInterval);
|
|
641
|
-
}
|
|
642
|
-
async resubscribeAll() {
|
|
643
|
-
const entries = [...this.activeSubscriptions.entries()];
|
|
644
|
-
for (const [id, sub] of entries) {
|
|
645
|
-
if (this.closed) break;
|
|
646
|
-
try {
|
|
647
|
-
const msg = {
|
|
648
|
-
type: "subscribe",
|
|
649
|
-
id,
|
|
650
|
-
table: sub.table,
|
|
651
|
-
...sub.filter ? { filter: encodeTaggedValues(sub.filter) } : {},
|
|
652
|
-
...sub.lastSeq !== void 0 ? { sinceSeq: sub.lastSeq.toString() } : {},
|
|
653
|
-
...sub.epoch !== void 0 ? { epoch: sub.epoch } : {}
|
|
654
|
-
};
|
|
655
|
-
await this.request(msg);
|
|
656
|
-
} catch {
|
|
657
|
-
this.activeSubscriptions.delete(id);
|
|
220
|
+
for (const key of remoteChanged) {
|
|
221
|
+
if (!overlapping.has(key)) {
|
|
222
|
+
merged[key] = remoteData[key];
|
|
223
|
+
anyRemoteWins = true;
|
|
658
224
|
}
|
|
659
225
|
}
|
|
226
|
+
if (anyRemoteWins) {
|
|
227
|
+
return { action: "merge", mergedData: merged };
|
|
228
|
+
}
|
|
229
|
+
return { action: "keep_local" };
|
|
660
230
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
this.pendingRequests.delete(id);
|
|
670
|
-
reject(new RemoteError("TIMEOUT", `Request timed out after ${this.requestTimeout}ms`));
|
|
671
|
-
}, this.requestTimeout) : void 0;
|
|
672
|
-
this.pendingRequests.set(id, {
|
|
673
|
-
resolve,
|
|
674
|
-
reject,
|
|
675
|
-
timer
|
|
676
|
-
});
|
|
677
|
-
this.ws.send(JSON.stringify(msg));
|
|
678
|
-
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/core/sync/conflict/primary-wins.ts
|
|
234
|
+
var PrimaryWinsResolver = class {
|
|
235
|
+
primaryNodeId;
|
|
236
|
+
lww = new LWWResolver();
|
|
237
|
+
constructor(primaryNodeId) {
|
|
238
|
+
this.primaryNodeId = primaryNodeId;
|
|
679
239
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
pending.reject(error);
|
|
240
|
+
resolve(ctx) {
|
|
241
|
+
if (ctx.remoteChange.nodeId === this.primaryNodeId) {
|
|
242
|
+
return { action: "accept_remote" };
|
|
684
243
|
}
|
|
685
|
-
this.
|
|
686
|
-
|
|
687
|
-
cancelReconnect() {
|
|
688
|
-
if (this.reconnectTimer !== null) {
|
|
689
|
-
clearTimeout(this.reconnectTimer);
|
|
690
|
-
this.reconnectTimer = null;
|
|
244
|
+
if (ctx.localChange?.nodeId === this.primaryNodeId) {
|
|
245
|
+
return { action: "keep_local" };
|
|
691
246
|
}
|
|
247
|
+
return this.lww.resolve(ctx);
|
|
692
248
|
}
|
|
693
249
|
};
|
|
694
250
|
|
|
695
251
|
// src/client/client.ts
|
|
696
|
-
var
|
|
697
|
-
function
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
function clusterRoutingChanged(previous, next) {
|
|
709
|
-
return previous === void 0 || clusterRoutingFingerprint(previous) !== clusterRoutingFingerprint(next);
|
|
710
|
-
}
|
|
711
|
-
function isTopologyConfig(urlOrOpts) {
|
|
712
|
-
if (typeof urlOrOpts !== "object") {
|
|
713
|
-
return false;
|
|
714
|
-
}
|
|
715
|
-
return "primary" in urlOrOpts || "replicas" in urlOrOpts && Array.isArray(urlOrOpts.replicas) && urlOrOpts.replicas.length > 0 || "endpoints" in urlOrOpts && Array.isArray(urlOrOpts.endpoints) && urlOrOpts.endpoints.length > 0 || urlOrOpts.discovery === "coordinator";
|
|
716
|
-
}
|
|
717
|
-
function toBaseUrl(url) {
|
|
718
|
-
return normaliseEndpointUrl(url);
|
|
719
|
-
}
|
|
720
|
-
function toServerBaseUrl(url, databaseId) {
|
|
721
|
-
const base = toBaseUrl(url);
|
|
722
|
-
if (!databaseId) return base.replace(/\/db\/[^/]+$/i, "");
|
|
723
|
-
return base.replace(new RegExp(`/db/${escapeRegExp(encodeURIComponent(databaseId))}$`, "i"), "");
|
|
724
|
-
}
|
|
725
|
-
function toWsUrl(baseUrl) {
|
|
726
|
-
return baseUrl.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
|
|
727
|
-
}
|
|
728
|
-
function escapeRegExp(value) {
|
|
729
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
252
|
+
var TOPOLOGY_KEYS = ["endpoints", "primary", "replicas", "readPreference", "discovery", "readConcern"];
|
|
253
|
+
function normaliseClientUrl(url) {
|
|
254
|
+
const value = url;
|
|
255
|
+
if (typeof value === "string") return toBaseUrl(value);
|
|
256
|
+
const named = typeof value === "object" && value !== null ? TOPOLOGY_KEYS.find((key) => key in value) : void 0;
|
|
257
|
+
const subject = named === void 0 ? "Topology-aware routing" : `The '${named}' option`;
|
|
258
|
+
throw new RemoteError(
|
|
259
|
+
"INVALID_ARGUMENT",
|
|
260
|
+
`SirannonClient connects to a single server URL and never routes between nodes. ${subject} moved to the '@delali/sirannon-db/client/topology' entry point, which a browser bundle must not import.`
|
|
261
|
+
);
|
|
730
262
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
throw new TypeError(`Endpoint URL '${rawUrl}' is invalid`);
|
|
737
|
-
}
|
|
738
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
739
|
-
throw new TypeError(`Endpoint URL '${rawUrl}' must use http or https`);
|
|
740
|
-
}
|
|
741
|
-
if (parsed.username || parsed.password) {
|
|
742
|
-
throw new TypeError(`Endpoint URL '${rawUrl}' must not contain credentials`);
|
|
263
|
+
var SirannonClient = class extends DatabaseClient {
|
|
264
|
+
baseUrl;
|
|
265
|
+
constructor(url, options) {
|
|
266
|
+
super(options);
|
|
267
|
+
this.baseUrl = normaliseClientUrl(url);
|
|
743
268
|
}
|
|
744
|
-
|
|
745
|
-
|
|
269
|
+
createTransport(databaseId) {
|
|
270
|
+
return createEndpointTransport(this.settings, this.baseUrl, databaseId);
|
|
746
271
|
}
|
|
747
|
-
|
|
748
|
-
|
|
272
|
+
resolveServerUrl() {
|
|
273
|
+
return this.baseUrl;
|
|
749
274
|
}
|
|
750
|
-
|
|
751
|
-
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/core/sync/sha256.ts
|
|
278
|
+
var K = new Uint32Array([
|
|
279
|
+
1116352408,
|
|
280
|
+
1899447441,
|
|
281
|
+
3049323471,
|
|
282
|
+
3921009573,
|
|
283
|
+
961987163,
|
|
284
|
+
1508970993,
|
|
285
|
+
2453635748,
|
|
286
|
+
2870763221,
|
|
287
|
+
3624381080,
|
|
288
|
+
310598401,
|
|
289
|
+
607225278,
|
|
290
|
+
1426881987,
|
|
291
|
+
1925078388,
|
|
292
|
+
2162078206,
|
|
293
|
+
2614888103,
|
|
294
|
+
3248222580,
|
|
295
|
+
3835390401,
|
|
296
|
+
4022224774,
|
|
297
|
+
264347078,
|
|
298
|
+
604807628,
|
|
299
|
+
770255983,
|
|
300
|
+
1249150122,
|
|
301
|
+
1555081692,
|
|
302
|
+
1996064986,
|
|
303
|
+
2554220882,
|
|
304
|
+
2821834349,
|
|
305
|
+
2952996808,
|
|
306
|
+
3210313671,
|
|
307
|
+
3336571891,
|
|
308
|
+
3584528711,
|
|
309
|
+
113926993,
|
|
310
|
+
338241895,
|
|
311
|
+
666307205,
|
|
312
|
+
773529912,
|
|
313
|
+
1294757372,
|
|
314
|
+
1396182291,
|
|
315
|
+
1695183700,
|
|
316
|
+
1986661051,
|
|
317
|
+
2177026350,
|
|
318
|
+
2456956037,
|
|
319
|
+
2730485921,
|
|
320
|
+
2820302411,
|
|
321
|
+
3259730800,
|
|
322
|
+
3345764771,
|
|
323
|
+
3516065817,
|
|
324
|
+
3600352804,
|
|
325
|
+
4094571909,
|
|
326
|
+
275423344,
|
|
327
|
+
430227734,
|
|
328
|
+
506948616,
|
|
329
|
+
659060556,
|
|
330
|
+
883997877,
|
|
331
|
+
958139571,
|
|
332
|
+
1322822218,
|
|
333
|
+
1537002063,
|
|
334
|
+
1747873779,
|
|
335
|
+
1955562222,
|
|
336
|
+
2024104815,
|
|
337
|
+
2227730452,
|
|
338
|
+
2361852424,
|
|
339
|
+
2428436474,
|
|
340
|
+
2756734187,
|
|
341
|
+
3204031479,
|
|
342
|
+
3329325298
|
|
343
|
+
]);
|
|
344
|
+
function rotr(x, n) {
|
|
345
|
+
return x >>> n | x << 32 - n;
|
|
752
346
|
}
|
|
753
|
-
function
|
|
754
|
-
|
|
347
|
+
function sha256Hex(input) {
|
|
348
|
+
const data = new TextEncoder().encode(input);
|
|
349
|
+
const bitLength = data.length * 8;
|
|
350
|
+
const paddedLength = (data.length + 8 >> 6) + 1 << 6;
|
|
351
|
+
const padded = new Uint8Array(paddedLength);
|
|
352
|
+
padded.set(data);
|
|
353
|
+
padded[data.length] = 128;
|
|
354
|
+
const view = new DataView(padded.buffer);
|
|
355
|
+
view.setUint32(paddedLength - 8, Math.floor(bitLength / 4294967296), false);
|
|
356
|
+
view.setUint32(paddedLength - 4, bitLength >>> 0, false);
|
|
357
|
+
const state = new Uint32Array([
|
|
358
|
+
1779033703,
|
|
359
|
+
3144134277,
|
|
360
|
+
1013904242,
|
|
361
|
+
2773480762,
|
|
362
|
+
1359893119,
|
|
363
|
+
2600822924,
|
|
364
|
+
528734635,
|
|
365
|
+
1541459225
|
|
366
|
+
]);
|
|
367
|
+
const w = new Uint32Array(64);
|
|
368
|
+
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
369
|
+
for (let i = 0; i < 16; i++) {
|
|
370
|
+
w[i] = view.getUint32(offset + i * 4, false);
|
|
371
|
+
}
|
|
372
|
+
for (let i = 16; i < 64; i++) {
|
|
373
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ w[i - 15] >>> 3;
|
|
374
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ w[i - 2] >>> 10;
|
|
375
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 >>> 0;
|
|
376
|
+
}
|
|
377
|
+
let [a, b, c, d, e, f, g, h] = state;
|
|
378
|
+
for (let i = 0; i < 64; i++) {
|
|
379
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
380
|
+
const ch = e & f ^ ~e & g;
|
|
381
|
+
const temp1 = h + s1 + ch + K[i] + w[i] >>> 0;
|
|
382
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
383
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
384
|
+
const temp2 = s0 + maj >>> 0;
|
|
385
|
+
h = g;
|
|
386
|
+
g = f;
|
|
387
|
+
f = e;
|
|
388
|
+
e = d + temp1 >>> 0;
|
|
389
|
+
d = c;
|
|
390
|
+
c = b;
|
|
391
|
+
b = a;
|
|
392
|
+
a = temp1 + temp2 >>> 0;
|
|
393
|
+
}
|
|
394
|
+
state[0] = state[0] + a >>> 0;
|
|
395
|
+
state[1] = state[1] + b >>> 0;
|
|
396
|
+
state[2] = state[2] + c >>> 0;
|
|
397
|
+
state[3] = state[3] + d >>> 0;
|
|
398
|
+
state[4] = state[4] + e >>> 0;
|
|
399
|
+
state[5] = state[5] + f >>> 0;
|
|
400
|
+
state[6] = state[6] + g >>> 0;
|
|
401
|
+
state[7] = state[7] + h >>> 0;
|
|
402
|
+
}
|
|
403
|
+
return Array.from(state, (word) => word.toString(16).padStart(8, "0")).join("");
|
|
755
404
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
405
|
+
|
|
406
|
+
// src/core/sync/validators.ts
|
|
407
|
+
var IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
408
|
+
var SEQ_STRING_RE = /^\d{1,19}$/;
|
|
409
|
+
|
|
410
|
+
// src/client/snapshot-loader.ts
|
|
411
|
+
var DEFAULT_SNAPSHOT_PAGE_ROWS = 500;
|
|
412
|
+
function validateManifest(raw) {
|
|
413
|
+
const record = raw;
|
|
414
|
+
if (record === null || typeof record !== "object" || typeof record.startSeq !== "string" || !SEQ_STRING_RE.test(record.startSeq) || typeof record.epoch !== "string" || record.epoch.length === 0 || !Array.isArray(record.schema) || record.schema.some((ddl) => typeof ddl !== "string") || !Array.isArray(record.tables) || !Array.isArray(record.migrations)) {
|
|
415
|
+
throw new RemoteError("INVALID_RESPONSE", "Snapshot manifest is malformed");
|
|
759
416
|
}
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
throw new RemoteError("INVALID_RESPONSE", "Cluster metadata contains an invalid read concern");
|
|
417
|
+
for (const migration of record.migrations) {
|
|
418
|
+
if (typeof migration !== "object" || migration === null || !Number.isSafeInteger(migration.version) || typeof migration.name !== "string" || migration.checksum !== null && typeof migration.checksum !== "string") {
|
|
419
|
+
throw new RemoteError("INVALID_RESPONSE", "Snapshot manifest migration entry is malformed");
|
|
764
420
|
}
|
|
765
|
-
|
|
766
|
-
|
|
421
|
+
}
|
|
422
|
+
for (const table of record.tables) {
|
|
423
|
+
if (typeof table !== "object" || table === null || !IDENTIFIER_RE.test(String(table.name)) || typeof table.rowCount !== "number") {
|
|
424
|
+
throw new RemoteError("INVALID_RESPONSE", "Snapshot manifest table entry is malformed");
|
|
767
425
|
}
|
|
768
426
|
}
|
|
769
|
-
return
|
|
427
|
+
return record;
|
|
428
|
+
}
|
|
429
|
+
function validatePage(raw) {
|
|
430
|
+
const record = raw;
|
|
431
|
+
if (record === null || typeof record !== "object" || !Array.isArray(record.rows) || typeof record.checksum !== "string" || typeof record.done !== "boolean" || record.nextKey !== null && !Array.isArray(record.nextKey)) {
|
|
432
|
+
throw new RemoteError("INVALID_RESPONSE", "Snapshot page is malformed");
|
|
433
|
+
}
|
|
434
|
+
return record;
|
|
770
435
|
}
|
|
771
|
-
function
|
|
436
|
+
async function downloadDatabaseSnapshot(port, options) {
|
|
437
|
+
const baseUrl = toBaseUrl(options.url);
|
|
438
|
+
const encodedId = encodeURIComponent(options.databaseId);
|
|
439
|
+
const pageSize = options.pageSize ?? DEFAULT_SNAPSHOT_PAGE_ROWS;
|
|
440
|
+
const timeoutMs = options.requestTimeoutMs ?? DEFAULT_HTTP_REQUEST_TIMEOUT_MS;
|
|
441
|
+
const manifest = validateManifest(
|
|
442
|
+
await postJson(`${baseUrl}/db/${encodedId}/snapshot`, {}, options.headers, timeoutMs)
|
|
443
|
+
);
|
|
444
|
+
const tables = manifest.tables.map((table) => table.name);
|
|
445
|
+
const totalRows = manifest.tables.reduce((sum, table) => sum + table.rowCount, 0);
|
|
446
|
+
await port.beginSnapshotLoad(tables);
|
|
447
|
+
let loadedRows = 0;
|
|
772
448
|
try {
|
|
773
|
-
|
|
449
|
+
await port.applySnapshotSchema(manifest.schema);
|
|
450
|
+
for (const table of manifest.tables) {
|
|
451
|
+
let afterKey;
|
|
452
|
+
let tableLoadedRows = 0;
|
|
453
|
+
for (; ; ) {
|
|
454
|
+
const page = validatePage(
|
|
455
|
+
await postJson(
|
|
456
|
+
`${baseUrl}/db/${encodedId}/snapshot/page`,
|
|
457
|
+
{ table: table.name, ...afterKey !== void 0 ? { afterKey } : {}, limit: pageSize },
|
|
458
|
+
options.headers,
|
|
459
|
+
timeoutMs
|
|
460
|
+
)
|
|
461
|
+
);
|
|
462
|
+
if (page.rows.length > 0) {
|
|
463
|
+
const rows = decodeTaggedValues(page.rows);
|
|
464
|
+
if (sha256Hex(canonicaliseForChecksum(rows)) !== page.checksum) {
|
|
465
|
+
throw new RemoteError(
|
|
466
|
+
"SNAPSHOT_CHECKSUM_MISMATCH",
|
|
467
|
+
`Snapshot page checksum mismatch for table '${table.name}'`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
await port.loadSnapshotPage(table.name, rows);
|
|
471
|
+
tableLoadedRows += rows.length;
|
|
472
|
+
loadedRows += rows.length;
|
|
473
|
+
options.onProgress?.({
|
|
474
|
+
table: table.name,
|
|
475
|
+
tableLoadedRows,
|
|
476
|
+
tableTotalRows: table.rowCount,
|
|
477
|
+
loadedRows,
|
|
478
|
+
totalRows
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (page.done) break;
|
|
482
|
+
if (page.nextKey === null) {
|
|
483
|
+
throw new RemoteError("INVALID_RESPONSE", `Snapshot page for table '${table.name}' has no resume key`);
|
|
484
|
+
}
|
|
485
|
+
afterKey = page.nextKey;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
await port.replaceMigrationHistory(manifest.migrations);
|
|
489
|
+
const startSeq = BigInt(manifest.startSeq);
|
|
490
|
+
await port.setPullState(startSeq, manifest.epoch);
|
|
491
|
+
await port.endSnapshotLoad(tables);
|
|
492
|
+
return { startSeq, epoch: manifest.epoch, tables, loadedRows };
|
|
774
493
|
} catch (err) {
|
|
775
|
-
|
|
776
|
-
|
|
494
|
+
try {
|
|
495
|
+
await port.abortSnapshotLoad();
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
throw err;
|
|
777
499
|
}
|
|
778
500
|
}
|
|
779
|
-
function
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const record = data;
|
|
784
|
-
if (record.databaseId !== databaseId) {
|
|
785
|
-
throw new RemoteError("INVALID_RESPONSE", "Cluster metadata database id does not match the request");
|
|
501
|
+
function highestMigrationVersion(rows) {
|
|
502
|
+
let highest = 0;
|
|
503
|
+
for (const row of rows) {
|
|
504
|
+
if (row.version > highest) highest = row.version;
|
|
786
505
|
}
|
|
787
|
-
|
|
788
|
-
|
|
506
|
+
return highest;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/core/migrations/checksum.ts
|
|
510
|
+
var FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
511
|
+
var FNV_PRIME = 0x100000001b3n;
|
|
512
|
+
var MASK_64 = 0xffffffffffffffffn;
|
|
513
|
+
function migrationChecksum(sql) {
|
|
514
|
+
const bytes = new TextEncoder().encode(sql.replace(/\r\n?/g, "\n").trim());
|
|
515
|
+
let hash = FNV_OFFSET_BASIS;
|
|
516
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
517
|
+
hash = (hash ^ BigInt(bytes[i])) * FNV_PRIME & MASK_64;
|
|
518
|
+
}
|
|
519
|
+
return hash.toString(16).padStart(16, "0");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/core/migrations/types.ts
|
|
523
|
+
var MIGRATION_NAME_RE = /^\w+$/;
|
|
524
|
+
|
|
525
|
+
// src/core/migrations/baseline.ts
|
|
526
|
+
var SQLITE_USER_VERSION_MAX = 2147483647;
|
|
527
|
+
|
|
528
|
+
// src/server/sync-protocol.ts
|
|
529
|
+
function isValidSchemaVersion(candidate) {
|
|
530
|
+
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 && candidate <= SQLITE_USER_VERSION_MAX;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/client/migration-sync.ts
|
|
534
|
+
function validateMigrationList(raw) {
|
|
535
|
+
const record = raw;
|
|
536
|
+
if (record === null || typeof record !== "object" || !isValidSchemaVersion(record.serverVersion) || !Array.isArray(record.migrations)) {
|
|
537
|
+
throw new RemoteError("INVALID_RESPONSE", "Migration list response is malformed");
|
|
789
538
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (typeof
|
|
793
|
-
throw new RemoteError("INVALID_RESPONSE", "
|
|
539
|
+
for (const entry of record.migrations) {
|
|
540
|
+
const candidate = entry;
|
|
541
|
+
if (candidate === null || typeof candidate !== "object" || !isValidSchemaVersion(candidate.version) || candidate.version === 0 || typeof candidate.name !== "string" || !MIGRATION_NAME_RE.test(candidate.name) || candidate.checksum !== null && typeof candidate.checksum !== "string" || candidate.up !== void 0 && typeof candidate.up !== "string") {
|
|
542
|
+
throw new RemoteError("INVALID_RESPONSE", "Migration list entry is malformed");
|
|
794
543
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
544
|
+
}
|
|
545
|
+
return record;
|
|
546
|
+
}
|
|
547
|
+
async function fetchServerMigrations(options, after) {
|
|
548
|
+
const url = `${options.url}/db/${encodeURIComponent(options.databaseId)}/migrations`;
|
|
549
|
+
const body = { after };
|
|
550
|
+
return validateMigrationList(await postJson(url, body, options.headers, options.requestTimeoutMs));
|
|
551
|
+
}
|
|
552
|
+
function historiesDiverge(local, server, localVersion, serverVersion) {
|
|
553
|
+
const serverByVersion = new Map(server.map((entry) => [entry.version, entry]));
|
|
554
|
+
for (const row of local) {
|
|
555
|
+
if (row.version > serverVersion) continue;
|
|
556
|
+
const counterpart = serverByVersion.get(row.version);
|
|
557
|
+
if (counterpart === void 0) return true;
|
|
558
|
+
if (row.checksum !== null && counterpart.checksum !== null && row.checksum !== counterpart.checksum) {
|
|
559
|
+
return true;
|
|
798
560
|
}
|
|
799
|
-
currentPrimary = toDiscoveredServerBaseUrl(primary.endpoint, databaseId);
|
|
800
561
|
}
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
562
|
+
const localVersions = new Set(local.map((row) => row.version));
|
|
563
|
+
for (const entry of server) {
|
|
564
|
+
if (entry.version <= localVersion && !localVersions.has(entry.version)) return true;
|
|
804
565
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
if (
|
|
811
|
-
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
function toPendingMigrations(server, localVersion) {
|
|
569
|
+
const pending = [];
|
|
570
|
+
for (const entry of server) {
|
|
571
|
+
if (entry.version <= localVersion) continue;
|
|
572
|
+
if (entry.up === void 0 || entry.checksum === null) return null;
|
|
573
|
+
if (migrationChecksum(entry.up) !== entry.checksum) return null;
|
|
574
|
+
pending.push({ version: entry.version, name: entry.name, up: entry.up });
|
|
575
|
+
}
|
|
576
|
+
return pending;
|
|
577
|
+
}
|
|
578
|
+
async function syncDeviceMigrations(db, options) {
|
|
579
|
+
const local = await db.appliedMigrations();
|
|
580
|
+
const localVersion = highestMigrationVersion(local);
|
|
581
|
+
const list = await fetchServerMigrations(options, localVersion);
|
|
582
|
+
if (historiesDiverge(local, list.migrations, localVersion, list.serverVersion)) {
|
|
583
|
+
return { status: "resync-required", schemaVersion: localVersion, serverVersion: list.serverVersion };
|
|
584
|
+
}
|
|
585
|
+
if (localVersion > list.serverVersion) {
|
|
586
|
+
return { status: "ahead", schemaVersion: localVersion, serverVersion: list.serverVersion };
|
|
587
|
+
}
|
|
588
|
+
if (localVersion === list.serverVersion) {
|
|
589
|
+
return { status: "in-sync", schemaVersion: localVersion, serverVersion: list.serverVersion };
|
|
590
|
+
}
|
|
591
|
+
const pending = toPendingMigrations(list.migrations, localVersion);
|
|
592
|
+
if (pending === null || pending.length === 0) {
|
|
593
|
+
return { status: "resync-required", schemaVersion: localVersion, serverVersion: list.serverVersion };
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
await db.migrate(pending);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
if (err instanceof MigrationError && (err.code === "MIGRATION_CHECKSUM_MISMATCH" || err.code === "MIGRATION_BASELINE_GAP")) {
|
|
599
|
+
return { status: "resync-required", schemaVersion: localVersion, serverVersion: list.serverVersion };
|
|
812
600
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
};
|
|
817
|
-
});
|
|
601
|
+
throw err;
|
|
602
|
+
}
|
|
603
|
+
const schemaVersion = highestMigrationVersion(await db.appliedMigrations());
|
|
818
604
|
return {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
605
|
+
status: schemaVersion === list.serverVersion ? "migrated" : "resync-required",
|
|
606
|
+
schemaVersion,
|
|
607
|
+
serverVersion: list.serverVersion
|
|
822
608
|
};
|
|
823
609
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
this.
|
|
857
|
-
this.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
this.
|
|
869
|
-
this.
|
|
870
|
-
this.replicaUrls = [];
|
|
871
|
-
this.readPreference = "primary";
|
|
872
|
-
this.discovery = "static";
|
|
873
|
-
this.readConcern = void 0;
|
|
874
|
-
this.starterEndpoints = [];
|
|
875
|
-
this.baseUrl = toBaseUrl(urlOrOpts);
|
|
876
|
-
this.wsBaseUrl = toWsUrl(this.baseUrl);
|
|
877
|
-
this.transport = options?.transport ?? "websocket";
|
|
878
|
-
this.headers = options?.headers;
|
|
879
|
-
this.webSocketProtocols = options?.webSocketProtocols;
|
|
880
|
-
this.autoReconnect = options?.autoReconnect ?? true;
|
|
881
|
-
this.reconnectInterval = options?.reconnectInterval ?? 1e3;
|
|
882
|
-
this.requestTimeout = options?.requestTimeout;
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
database(id) {
|
|
886
|
-
if (this.closed) {
|
|
887
|
-
throw new Error("Client is closed");
|
|
888
|
-
}
|
|
889
|
-
const existing = this.databases.get(id);
|
|
890
|
-
if (existing) {
|
|
891
|
-
return existing;
|
|
892
|
-
}
|
|
893
|
-
const transport = this.createTransport(id);
|
|
894
|
-
const db = new RemoteDatabase(id, transport, () => {
|
|
895
|
-
this.databases.delete(id);
|
|
610
|
+
|
|
611
|
+
// src/client/sync-pull-stream.ts
|
|
612
|
+
var DEFAULT_IMMEDIATE_ACK_AFTER_CHANGES = 500;
|
|
613
|
+
var STAGE_BATCH_EVENTS = 500;
|
|
614
|
+
var PullStream = class {
|
|
615
|
+
constructor(config, hooks) {
|
|
616
|
+
this.config = config;
|
|
617
|
+
this.hooks = hooks;
|
|
618
|
+
}
|
|
619
|
+
transport = null;
|
|
620
|
+
subscriptions = [];
|
|
621
|
+
ackTimer = null;
|
|
622
|
+
deviceId = null;
|
|
623
|
+
lastAckedSeq = null;
|
|
624
|
+
ackBaseSeq = null;
|
|
625
|
+
inbox = [];
|
|
626
|
+
pumping = false;
|
|
627
|
+
failed = false;
|
|
628
|
+
serverWindow = null;
|
|
629
|
+
stagedSeq = null;
|
|
630
|
+
pullSeq = null;
|
|
631
|
+
pullEpoch;
|
|
632
|
+
stagedStream = false;
|
|
633
|
+
async open(deviceId, schemaVersion) {
|
|
634
|
+
this.deviceId = deviceId;
|
|
635
|
+
this.lastAckedSeq = null;
|
|
636
|
+
this.inbox = [];
|
|
637
|
+
this.failed = false;
|
|
638
|
+
let recoveryApplyError = null;
|
|
639
|
+
const port = this.hooks.port();
|
|
640
|
+
if (port !== null) {
|
|
641
|
+
const recovered = await port.recoverStagedPull(this.config.resolver, this.hooks.onChange);
|
|
642
|
+
this.stagedSeq = recovered.resumeSeq;
|
|
643
|
+
if (recovered.appliedSeq !== null && (this.pullSeq === null || recovered.appliedSeq > this.pullSeq)) {
|
|
644
|
+
this.pullSeq = recovered.appliedSeq;
|
|
645
|
+
}
|
|
646
|
+
recoveryApplyError = recovered.applyError;
|
|
647
|
+
if (recoveryApplyError !== null) {
|
|
648
|
+
await this.adoptRecordedCursor(port);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
this.ackBaseSeq = this.resumeWatermark();
|
|
652
|
+
const encodedId = encodeURIComponent(this.config.databaseId);
|
|
653
|
+
const transport = new WebSocketTransport(`${this.config.wsBaseUrl}/db/${encodedId}`, {
|
|
654
|
+
headers: this.config.headers,
|
|
655
|
+
requestTimeout: this.config.requestTimeout
|
|
896
656
|
});
|
|
897
|
-
this.
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
657
|
+
this.transport = transport;
|
|
658
|
+
const [firstTable] = this.config.tables;
|
|
659
|
+
if (firstTable !== void 0) {
|
|
660
|
+
const subscription = await transport.subscribe(firstTable, void 0, (event) => this.handlePullEvent(event), {
|
|
661
|
+
deviceId,
|
|
662
|
+
schemaVersion,
|
|
663
|
+
tables: this.config.tables,
|
|
664
|
+
sinceSeq: this.resumeWatermark() ?? void 0,
|
|
665
|
+
getResumeSeq: () => this.resumeWatermark() ?? void 0,
|
|
666
|
+
epoch: this.pullEpoch,
|
|
667
|
+
stagedStream: this.stagedStream,
|
|
668
|
+
onReset: () => this.hooks.onResyncRequired(),
|
|
669
|
+
onSubscribed: (info) => this.handleSubscribed(info)
|
|
670
|
+
});
|
|
671
|
+
this.subscriptions.push(subscription);
|
|
672
|
+
}
|
|
673
|
+
if (recoveryApplyError !== null) {
|
|
674
|
+
this.hooks.onApplyFailure(recoveryApplyError);
|
|
906
675
|
}
|
|
907
676
|
}
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
this.
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
autoReconnect: this.autoReconnect,
|
|
923
|
-
reconnectInterval: this.reconnectInterval,
|
|
924
|
-
protocols: this.webSocketProtocols,
|
|
925
|
-
requestTimeout: this.requestTimeout
|
|
926
|
-
});
|
|
677
|
+
teardown() {
|
|
678
|
+
if (this.ackTimer !== null) {
|
|
679
|
+
clearTimeout(this.ackTimer);
|
|
680
|
+
this.ackTimer = null;
|
|
681
|
+
}
|
|
682
|
+
for (const subscription of this.subscriptions) {
|
|
683
|
+
subscription.unsubscribe();
|
|
684
|
+
}
|
|
685
|
+
this.subscriptions = [];
|
|
686
|
+
if (this.transport) {
|
|
687
|
+
this.transport.close();
|
|
688
|
+
this.transport = null;
|
|
689
|
+
}
|
|
690
|
+
this.inbox = [];
|
|
927
691
|
}
|
|
928
|
-
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const routing = await this.ensureClusterRouting(databaseId);
|
|
936
|
-
const concern = readConcern ?? this.readConcern ?? "majority";
|
|
937
|
-
if (concern === "linearizable") {
|
|
938
|
-
if (routing.currentPrimary) return routing.currentPrimary;
|
|
939
|
-
throw new RemoteError("NO_SAFE_PRIMARY", "No current primary is available for linearizable reads");
|
|
940
|
-
}
|
|
941
|
-
const readable = routing.readEndpoints.filter((endpoint) => endpoint.readConcerns.includes(concern));
|
|
942
|
-
const preferredReadable = this.readPreference === "replica" && routing.currentPrimary ? readable.filter((endpoint) => endpoint.url !== routing.currentPrimary) : readable;
|
|
943
|
-
if (this.readPreference !== "primary" && preferredReadable.length > 0) {
|
|
944
|
-
if (this.readPreference === "nearest") {
|
|
945
|
-
return preferredReadable[0].url;
|
|
946
|
-
}
|
|
947
|
-
const idx = Math.floor(Math.random() * preferredReadable.length);
|
|
948
|
-
return preferredReadable[idx].url;
|
|
949
|
-
}
|
|
950
|
-
if (routing.currentPrimary) return routing.currentPrimary;
|
|
951
|
-
const localReadable = routing.readEndpoints.find((endpoint) => endpoint.readConcerns.includes("local"));
|
|
952
|
-
if (localReadable) return localReadable.url;
|
|
953
|
-
throw new RemoteError("ROUTING_ERROR", "No usable read endpoint is available");
|
|
954
|
-
}
|
|
955
|
-
if (this.readPreference === "primary") {
|
|
956
|
-
return this.primaryUrl ?? this.baseUrl;
|
|
957
|
-
}
|
|
958
|
-
const availableReplicas = this.replicaUrls.filter((r) => !this.removedReplicas.has(r));
|
|
959
|
-
if (this.readPreference === "replica") {
|
|
960
|
-
if (availableReplicas.length === 0) {
|
|
961
|
-
return this.primaryUrl ?? this.baseUrl;
|
|
962
|
-
}
|
|
963
|
-
const idx = Math.floor(Math.random() * availableReplicas.length);
|
|
964
|
-
return availableReplicas[idx];
|
|
965
|
-
}
|
|
966
|
-
if (this.readPreference === "nearest") {
|
|
967
|
-
await this.ensureLatencyMeasured();
|
|
968
|
-
const reachable = this.latencies.filter((l) => l.reachable && !this.removedReplicas.has(l.url));
|
|
969
|
-
if (reachable.length === 0) {
|
|
970
|
-
return this.primaryUrl ?? this.baseUrl;
|
|
971
|
-
}
|
|
972
|
-
reachable.sort((a, b) => a.latencyMs - b.latencyMs);
|
|
973
|
-
return reachable[0].url;
|
|
692
|
+
async persist() {
|
|
693
|
+
const port = this.hooks.port();
|
|
694
|
+
if (port === null || this.pullSeq === null) return;
|
|
695
|
+
try {
|
|
696
|
+
await port.setPullState(this.pullSeq, this.pullEpoch);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
this.hooks.recordError(err);
|
|
974
699
|
}
|
|
975
|
-
return this.primaryUrl ?? this.baseUrl;
|
|
976
700
|
}
|
|
977
|
-
async
|
|
978
|
-
|
|
979
|
-
const
|
|
980
|
-
if (
|
|
981
|
-
|
|
701
|
+
async adoptRecordedCursor(port) {
|
|
702
|
+
try {
|
|
703
|
+
const recorded = await port.getPullState();
|
|
704
|
+
if (recorded !== null && (this.pullSeq === null || recorded.seq > this.pullSeq)) {
|
|
705
|
+
this.pullSeq = recorded.seq;
|
|
982
706
|
}
|
|
983
|
-
|
|
707
|
+
} catch (err) {
|
|
708
|
+
this.hooks.recordError(err);
|
|
984
709
|
}
|
|
985
|
-
return this.primaryUrl ?? this.baseUrl;
|
|
986
|
-
}
|
|
987
|
-
_getReadConcern() {
|
|
988
|
-
return this.readConcern;
|
|
989
|
-
}
|
|
990
|
-
_usesCoordinatorDiscovery() {
|
|
991
|
-
return this.discovery === "coordinator";
|
|
992
710
|
}
|
|
993
|
-
|
|
994
|
-
this.
|
|
711
|
+
resumeWatermark() {
|
|
712
|
+
if (this.stagedSeq !== null && (this.pullSeq === null || this.stagedSeq > this.pullSeq)) {
|
|
713
|
+
return this.stagedSeq;
|
|
714
|
+
}
|
|
715
|
+
return this.pullSeq;
|
|
995
716
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
return;
|
|
717
|
+
handleSubscribed(info) {
|
|
718
|
+
if (info.maxUnacknowledgedChanges !== void 0 && info.maxUnacknowledgedChanges > 0) {
|
|
719
|
+
this.serverWindow = info.maxUnacknowledgedChanges;
|
|
1000
720
|
}
|
|
1001
|
-
if (
|
|
1002
|
-
|
|
721
|
+
if (info.resync) {
|
|
722
|
+
this.hooks.onResyncRequired();
|
|
1003
723
|
return;
|
|
1004
724
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
await this.latencyMeasuring;
|
|
1008
|
-
} finally {
|
|
1009
|
-
this.latencyMeasuring = null;
|
|
725
|
+
if (info.epoch !== void 0) {
|
|
726
|
+
this.pullEpoch = info.epoch;
|
|
1010
727
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const allEndpoints = [];
|
|
1014
|
-
if (this.primaryUrl) {
|
|
1015
|
-
allEndpoints.push(this.primaryUrl);
|
|
728
|
+
if (this.pullSeq === null && this.stagedSeq === null && info.seq !== void 0) {
|
|
729
|
+
this.pullSeq = info.seq;
|
|
1016
730
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
731
|
+
if (this.ackBaseSeq === null) {
|
|
732
|
+
this.ackBaseSeq = this.resumeWatermark();
|
|
1019
733
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
734
|
+
}
|
|
735
|
+
handlePullEvent(event) {
|
|
736
|
+
if (this.failed) return;
|
|
737
|
+
this.inbox.push(event);
|
|
738
|
+
void this.pump();
|
|
739
|
+
}
|
|
740
|
+
async pump() {
|
|
741
|
+
if (this.pumping) return;
|
|
742
|
+
this.pumping = true;
|
|
743
|
+
try {
|
|
744
|
+
while (this.inbox.length > 0 && !this.failed) {
|
|
745
|
+
const port = this.hooks.port();
|
|
746
|
+
if (port === null) {
|
|
747
|
+
this.inbox = [];
|
|
748
|
+
return;
|
|
1028
749
|
}
|
|
750
|
+
const batch = this.inbox.splice(0, STAGE_BATCH_EVENTS);
|
|
751
|
+
const closesTransaction = batch.some((event) => event.txEnd === true);
|
|
1029
752
|
try {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
return { url, latencyMs: Number.MAX_SAFE_INTEGER, reachable: false };
|
|
753
|
+
const staged = await port.stagePulledChanges(batch);
|
|
754
|
+
if (staged !== null && (this.stagedSeq === null || staged > this.stagedSeq)) {
|
|
755
|
+
this.stagedSeq = staged;
|
|
1034
756
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
this.latencies = results;
|
|
1044
|
-
this.latencyMeasuredAt = Date.now();
|
|
1045
|
-
}
|
|
1046
|
-
async _refreshClusterRouting(databaseId) {
|
|
1047
|
-
const candidates = this.clusterDiscoveryCandidates(databaseId);
|
|
1048
|
-
const encodedId = encodeURIComponent(databaseId);
|
|
1049
|
-
for (const endpoint of candidates) {
|
|
1050
|
-
const base = toServerBaseUrl(endpoint, databaseId);
|
|
1051
|
-
const controller = new AbortController();
|
|
1052
|
-
const timeout = setTimeout(() => controller.abort(), CLUSTER_DISCOVERY_FETCH_TIMEOUT_MS);
|
|
1053
|
-
const unrefable = timeout;
|
|
1054
|
-
unrefable.unref?.();
|
|
1055
|
-
let next = null;
|
|
1056
|
-
try {
|
|
1057
|
-
const response = await fetch(`${base}/db/${encodedId}/cluster`, {
|
|
1058
|
-
headers: this.headers,
|
|
1059
|
-
signal: controller.signal
|
|
1060
|
-
});
|
|
1061
|
-
if (!response.ok) {
|
|
1062
|
-
continue;
|
|
1063
|
-
}
|
|
1064
|
-
const data = await response.json();
|
|
1065
|
-
next = parseClusterRouting(data, databaseId);
|
|
1066
|
-
} catch (err) {
|
|
1067
|
-
if (err instanceof RemoteError && err.code === "INVALID_RESPONSE") {
|
|
1068
|
-
throw err;
|
|
1069
|
-
}
|
|
1070
|
-
} finally {
|
|
1071
|
-
clearTimeout(timeout);
|
|
1072
|
-
}
|
|
1073
|
-
if (!next) {
|
|
1074
|
-
continue;
|
|
1075
|
-
}
|
|
1076
|
-
const previous = this.clusterRouting.get(databaseId);
|
|
1077
|
-
this.clusterRouting.set(databaseId, next);
|
|
1078
|
-
if (clusterRoutingChanged(previous, next)) {
|
|
1079
|
-
try {
|
|
1080
|
-
await this.notifyClusterRoutingChanged(databaseId);
|
|
1081
|
-
} catch (err) {
|
|
1082
|
-
if (previous) {
|
|
1083
|
-
this.clusterRouting.set(databaseId, previous);
|
|
1084
|
-
} else {
|
|
1085
|
-
this.clusterRouting.delete(databaseId);
|
|
757
|
+
if (closesTransaction) {
|
|
758
|
+
const appliedThrough = await port.applyStagedPull(this.config.resolver, this.hooks.onChange);
|
|
759
|
+
if (appliedThrough !== null) {
|
|
760
|
+
if (this.pullSeq === null || appliedThrough > this.pullSeq) {
|
|
761
|
+
this.pullSeq = appliedThrough;
|
|
762
|
+
}
|
|
763
|
+
this.hooks.onApplySuccess();
|
|
764
|
+
}
|
|
1086
765
|
}
|
|
1087
|
-
|
|
766
|
+
} catch (err) {
|
|
767
|
+
this.failed = true;
|
|
768
|
+
this.inbox = [];
|
|
769
|
+
await this.adoptRecordedCursor(port);
|
|
770
|
+
this.hooks.onApplyFailure(err);
|
|
771
|
+
return;
|
|
1088
772
|
}
|
|
773
|
+
this.scheduleAckFlush();
|
|
774
|
+
}
|
|
775
|
+
} finally {
|
|
776
|
+
this.pumping = false;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
scheduleAckFlush() {
|
|
780
|
+
const configured = this.config.immediateAckAfterChanges ?? (this.serverWindow === null ? DEFAULT_IMMEDIATE_ACK_AFTER_CHANGES : Math.max(1, Math.floor(this.serverWindow / 2)));
|
|
781
|
+
const threshold = BigInt(configured);
|
|
782
|
+
const base = this.lastAckedSeq ?? this.ackBaseSeq;
|
|
783
|
+
const watermark = this.resumeWatermark();
|
|
784
|
+
const outstanding = watermark !== null && base !== null ? watermark - base : 0n;
|
|
785
|
+
if (outstanding > threshold) {
|
|
786
|
+
if (this.ackTimer !== null) {
|
|
787
|
+
clearTimeout(this.ackTimer);
|
|
788
|
+
this.ackTimer = null;
|
|
1089
789
|
}
|
|
790
|
+
void this.flushAck();
|
|
1090
791
|
return;
|
|
1091
792
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
793
|
+
if (this.ackTimer !== null) return;
|
|
794
|
+
this.ackTimer = setTimeout(() => {
|
|
795
|
+
this.ackTimer = null;
|
|
796
|
+
void this.flushAck();
|
|
797
|
+
}, this.config.ackIntervalMs);
|
|
798
|
+
unrefTimer(this.ackTimer);
|
|
799
|
+
}
|
|
800
|
+
async flushAck() {
|
|
801
|
+
if (!this.hooks.isRunning()) return;
|
|
802
|
+
const deviceId = this.deviceId;
|
|
803
|
+
const transport = this.transport;
|
|
804
|
+
const seq = this.resumeWatermark();
|
|
805
|
+
if (deviceId === null || transport === null || seq === null) return;
|
|
806
|
+
if (this.lastAckedSeq !== null && seq <= this.lastAckedSeq) return;
|
|
807
|
+
try {
|
|
808
|
+
await this.persist();
|
|
809
|
+
await transport.ack(deviceId, seq);
|
|
810
|
+
this.lastAckedSeq = seq;
|
|
811
|
+
} catch (err) {
|
|
812
|
+
this.hooks.recordError(err);
|
|
813
|
+
this.scheduleAckFlush();
|
|
1101
814
|
}
|
|
1102
|
-
return refreshed;
|
|
1103
|
-
}
|
|
1104
|
-
clusterDiscoveryCandidates(databaseId) {
|
|
1105
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
1106
|
-
for (const endpoint of this.starterEndpoints) candidates.add(endpoint);
|
|
1107
|
-
if (this.primaryUrl) candidates.add(this.primaryUrl);
|
|
1108
|
-
for (const endpoint of this.replicaUrls) candidates.add(endpoint);
|
|
1109
|
-
const existing = this.clusterRouting.get(databaseId);
|
|
1110
|
-
if (existing?.currentPrimary) candidates.add(existing.currentPrimary);
|
|
1111
|
-
for (const endpoint of existing?.readEndpoints ?? []) candidates.add(endpoint.url);
|
|
1112
|
-
if (this.baseUrl) candidates.add(this.baseUrl);
|
|
1113
|
-
return [...candidates];
|
|
1114
815
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// src/client/sync-push.ts
|
|
819
|
+
function encodeSyncBatch(batch) {
|
|
820
|
+
return {
|
|
821
|
+
sourceNodeId: batch.sourceNodeId,
|
|
822
|
+
batchId: batch.batchId,
|
|
823
|
+
fromSeq: batch.fromSeq.toString(),
|
|
824
|
+
toSeq: batch.toSeq.toString(),
|
|
825
|
+
hlcRange: { min: batch.hlcRange.min, max: batch.hlcRange.max },
|
|
826
|
+
changes: batch.changes.map((change) => ({
|
|
827
|
+
table: change.table,
|
|
828
|
+
operation: change.operation,
|
|
829
|
+
rowId: change.rowId,
|
|
830
|
+
primaryKey: encodeTaggedValues(change.primaryKey),
|
|
831
|
+
hlc: change.hlc,
|
|
832
|
+
txId: change.txId,
|
|
833
|
+
nodeId: change.nodeId,
|
|
834
|
+
newData: change.newData === null ? null : encodeTaggedValues(change.newData),
|
|
835
|
+
oldData: change.oldData === null ? null : encodeTaggedValues(change.oldData)
|
|
836
|
+
})),
|
|
837
|
+
checksum: batch.checksum
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
async function pushSyncBatch(baseUrl, databaseId, batch, headers, timeoutMs, schemaVersion) {
|
|
841
|
+
const url = `${baseUrl}/db/${encodeURIComponent(databaseId)}/changes`;
|
|
842
|
+
const body = {
|
|
843
|
+
batch: encodeSyncBatch(batch),
|
|
844
|
+
...schemaVersion !== void 0 ? { schemaVersion } : {}
|
|
845
|
+
};
|
|
846
|
+
const data = await postJson(url, body, headers, timeoutMs);
|
|
847
|
+
const result = data;
|
|
848
|
+
if (typeof result.applied !== "number" || typeof result.skipped !== "number" || typeof result.conflicts !== "number") {
|
|
849
|
+
throw new RemoteError("INVALID_RESPONSE", "Changes response must carry applied, skipped, and conflicts counts");
|
|
1119
850
|
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
851
|
+
return { applied: result.applied, skipped: result.skipped, conflicts: result.conflicts };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/client/sync-push-loop.ts
|
|
855
|
+
var PushLoop = class {
|
|
856
|
+
constructor(config, hooks) {
|
|
857
|
+
this.config = config;
|
|
858
|
+
this.hooks = hooks;
|
|
859
|
+
}
|
|
860
|
+
timer = null;
|
|
861
|
+
pushing = false;
|
|
862
|
+
consecutiveFailures = 0;
|
|
863
|
+
nextAttemptAt = 0;
|
|
864
|
+
cursor = 0n;
|
|
865
|
+
start() {
|
|
866
|
+
if (this.timer !== null) return;
|
|
867
|
+
this.timer = setInterval(() => {
|
|
868
|
+
void this.drain();
|
|
869
|
+
}, this.config.intervalMs);
|
|
870
|
+
unrefTimer(this.timer);
|
|
871
|
+
}
|
|
872
|
+
stop() {
|
|
873
|
+
if (this.timer === null) return;
|
|
874
|
+
clearInterval(this.timer);
|
|
875
|
+
this.timer = null;
|
|
1124
876
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
this.databaseId = databaseId;
|
|
1144
|
-
this.client = client;
|
|
1145
|
-
}
|
|
1146
|
-
async query(sql, params) {
|
|
1147
|
-
const readConcern = this.client._getReadConcern();
|
|
1148
|
-
const transport = await this.getReadTransport(readConcern);
|
|
1149
|
-
const endpointUsed = this.currentReadUrl;
|
|
1150
|
-
try {
|
|
1151
|
-
return await transport.query(sql, params, readConcern ? { level: readConcern } : void 0);
|
|
1152
|
-
} catch (err) {
|
|
1153
|
-
if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
|
|
1154
|
-
await this.client._refreshClusterRouting(this.databaseId);
|
|
1155
|
-
this.readTransport = null;
|
|
1156
|
-
this.currentReadUrl = "";
|
|
1157
|
-
const refreshed = await this.getReadTransport(readConcern);
|
|
1158
|
-
return refreshed.query(sql, params, readConcern ? { level: readConcern } : void 0);
|
|
1159
|
-
}
|
|
1160
|
-
const isTransportError = err instanceof Error && (err.name !== "RemoteError" || err.code === "CONNECTION_ERROR");
|
|
1161
|
-
const writeEndpoint = await this.client._getWriteEndpoint(this.databaseId);
|
|
1162
|
-
if (isTransportError && endpointUsed && endpointUsed !== writeEndpoint) {
|
|
1163
|
-
this.client._removeReplica(endpointUsed);
|
|
1164
|
-
if (this.currentReadUrl === endpointUsed) {
|
|
1165
|
-
this.readTransport = null;
|
|
1166
|
-
this.currentReadUrl = "";
|
|
1167
|
-
}
|
|
1168
|
-
const fallback = await this.getReadTransport();
|
|
1169
|
-
return fallback.query(sql, params);
|
|
877
|
+
/**
|
|
878
|
+
* Empties the outbox before a snapshot replaces the database, so local work
|
|
879
|
+
* reaches the server before the copy overwrites it. A schema refusal is
|
|
880
|
+
* reconciled once; a device that still cannot push returns rather than
|
|
881
|
+
* looping, leaving the snapshot to bring it back into line.
|
|
882
|
+
*/
|
|
883
|
+
async drainFully(port) {
|
|
884
|
+
let retriedAfterMigration = false;
|
|
885
|
+
for (; ; ) {
|
|
886
|
+
try {
|
|
887
|
+
if (!await this.pushNextBatch(port)) return;
|
|
888
|
+
} catch (err) {
|
|
889
|
+
if (!(err instanceof RemoteError) || err.code !== "MIGRATION_REQUIRED") throw err;
|
|
890
|
+
if (retriedAfterMigration) return;
|
|
891
|
+
retriedAfterMigration = true;
|
|
892
|
+
const status = await this.hooks.reconcileSchema().catch(() => "resync-required");
|
|
893
|
+
if (status === "ahead") throw err;
|
|
894
|
+
if (status === "resync-required") return;
|
|
1170
895
|
}
|
|
1171
|
-
throw err;
|
|
1172
896
|
}
|
|
1173
897
|
}
|
|
1174
|
-
async
|
|
1175
|
-
const
|
|
898
|
+
async drain() {
|
|
899
|
+
const port = this.hooks.port();
|
|
900
|
+
if (this.pushing || !this.hooks.isRunning() || port === null) return;
|
|
901
|
+
if (Date.now() < this.nextAttemptAt) return;
|
|
902
|
+
this.pushing = true;
|
|
1176
903
|
try {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
this.
|
|
1182
|
-
this.
|
|
904
|
+
while (this.hooks.isRunning()) {
|
|
905
|
+
const pushed = await this.pushNextBatch(port);
|
|
906
|
+
if (!pushed) break;
|
|
907
|
+
this.consecutiveFailures = 0;
|
|
908
|
+
this.nextAttemptAt = 0;
|
|
909
|
+
this.hooks.onDrained();
|
|
1183
910
|
}
|
|
1184
|
-
|
|
911
|
+
} catch (err) {
|
|
912
|
+
await this.handleFailure(err);
|
|
913
|
+
} finally {
|
|
914
|
+
this.pushing = false;
|
|
1185
915
|
}
|
|
1186
916
|
}
|
|
1187
|
-
async
|
|
1188
|
-
|
|
917
|
+
async handleFailure(err) {
|
|
918
|
+
this.hooks.recordError(err);
|
|
919
|
+
this.consecutiveFailures += 1;
|
|
920
|
+
const delay = Math.min(this.config.intervalMs * 2 ** this.consecutiveFailures, this.config.maxRetryDelayMs);
|
|
921
|
+
this.nextAttemptAt = Date.now() + delay;
|
|
922
|
+
if (!(err instanceof RemoteError) || err.code !== "MIGRATION_REQUIRED" || !this.hooks.isRunning()) return;
|
|
1189
923
|
try {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
this.writeTransport = null;
|
|
1195
|
-
this.currentWriteUrl = "";
|
|
924
|
+
const status = await this.hooks.reconcileSchema();
|
|
925
|
+
if (status === "migrated" || status === "in-sync") {
|
|
926
|
+
this.consecutiveFailures = 0;
|
|
927
|
+
this.nextAttemptAt = 0;
|
|
1196
928
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
929
|
+
} catch (reconcileErr) {
|
|
930
|
+
this.hooks.recordError(reconcileErr);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async pushNextBatch(port) {
|
|
934
|
+
const batch = await port.readOutboxBatch(this.cursor, this.config.batchSize);
|
|
935
|
+
if (batch === null) return false;
|
|
936
|
+
await pushSyncBatch(
|
|
937
|
+
this.config.baseUrl,
|
|
938
|
+
this.config.databaseId,
|
|
939
|
+
batch,
|
|
940
|
+
this.config.headers,
|
|
941
|
+
this.config.requestTimeout,
|
|
942
|
+
this.hooks.schemaVersion()
|
|
943
|
+
);
|
|
944
|
+
this.cursor = batch.toSeq;
|
|
945
|
+
await port.setPushCursor(batch.toSeq);
|
|
946
|
+
port.protectUnpushedChanges(batch.toSeq);
|
|
947
|
+
return true;
|
|
1199
948
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
// src/client/sync-resync-scheduler.ts
|
|
952
|
+
var ResyncScheduler = class {
|
|
953
|
+
constructor(config, hooks) {
|
|
954
|
+
this.config = config;
|
|
955
|
+
this.hooks = hooks;
|
|
956
|
+
}
|
|
957
|
+
required = false;
|
|
958
|
+
timer = null;
|
|
959
|
+
consecutiveFailures = 0;
|
|
960
|
+
get retryScheduled() {
|
|
961
|
+
return this.timer !== null;
|
|
962
|
+
}
|
|
963
|
+
markRequired() {
|
|
964
|
+
this.required = true;
|
|
965
|
+
void this.hooks.port()?.setResyncRequired(true).catch((err) => this.hooks.recordError(err));
|
|
1202
966
|
try {
|
|
1203
|
-
|
|
1204
|
-
} catch
|
|
1205
|
-
if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
|
|
1206
|
-
await this.client._refreshClusterRouting(this.databaseId);
|
|
1207
|
-
this.writeTransport = null;
|
|
1208
|
-
this.currentWriteUrl = "";
|
|
1209
|
-
}
|
|
1210
|
-
throw err;
|
|
967
|
+
this.config.onResyncRequired?.();
|
|
968
|
+
} catch {
|
|
1211
969
|
}
|
|
1212
970
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
971
|
+
schedule() {
|
|
972
|
+
if (this.config.autoResync === false) return;
|
|
973
|
+
if (this.timer !== null || this.hooks.isSnapshotting()) return;
|
|
974
|
+
const delay = this.consecutiveFailures === 0 ? 0 : Math.min(this.config.retryDelayMs * 2 ** (this.consecutiveFailures - 1), this.config.maxRetryDelayMs);
|
|
975
|
+
this.timer = setTimeout(() => {
|
|
976
|
+
this.timer = null;
|
|
977
|
+
void this.attempt();
|
|
978
|
+
}, delay);
|
|
979
|
+
unrefTimer(this.timer);
|
|
980
|
+
}
|
|
981
|
+
cancel() {
|
|
982
|
+
if (this.timer === null) return;
|
|
983
|
+
clearTimeout(this.timer);
|
|
984
|
+
this.timer = null;
|
|
985
|
+
}
|
|
986
|
+
recordSuccess() {
|
|
987
|
+
this.required = false;
|
|
988
|
+
this.consecutiveFailures = 0;
|
|
989
|
+
}
|
|
990
|
+
recordFailure() {
|
|
991
|
+
this.required = true;
|
|
992
|
+
this.consecutiveFailures += 1;
|
|
993
|
+
}
|
|
994
|
+
complete(outcome) {
|
|
1215
995
|
try {
|
|
1216
|
-
|
|
1217
|
-
} catch
|
|
1218
|
-
if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
|
|
1219
|
-
await this.client._refreshClusterRouting(this.databaseId);
|
|
1220
|
-
this.writeTransport = null;
|
|
1221
|
-
this.currentWriteUrl = "";
|
|
1222
|
-
}
|
|
1223
|
-
throw err;
|
|
996
|
+
this.config.onSnapshotComplete?.(outcome);
|
|
997
|
+
} catch {
|
|
1224
998
|
}
|
|
1225
999
|
}
|
|
1226
|
-
async
|
|
1000
|
+
async attempt() {
|
|
1001
|
+
if (!this.hooks.isRunning() || !this.required) return;
|
|
1227
1002
|
try {
|
|
1228
|
-
|
|
1229
|
-
} catch
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1003
|
+
await this.hooks.download();
|
|
1004
|
+
} catch {
|
|
1005
|
+
this.schedule();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// src/client/sync-controller.ts
|
|
1011
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
1012
|
+
var DEFAULT_PUSH_INTERVAL_MS = 1e3;
|
|
1013
|
+
var DEFAULT_ACK_INTERVAL_MS = 2e3;
|
|
1014
|
+
var DEFAULT_MAX_PUSH_RETRY_DELAY_MS = 3e4;
|
|
1015
|
+
var DEFAULT_SNAPSHOT_RETRY_DELAY_MS = 5e3;
|
|
1016
|
+
var DEFAULT_MAX_SNAPSHOT_RETRY_DELAY_MS = 3e5;
|
|
1017
|
+
var SyncController = class {
|
|
1018
|
+
constructor(db, options) {
|
|
1019
|
+
this.db = db;
|
|
1020
|
+
this.options = options;
|
|
1021
|
+
this.baseUrl = toBaseUrl(options.url);
|
|
1022
|
+
this.pushIntervalMs = options.pushIntervalMs ?? DEFAULT_PUSH_INTERVAL_MS;
|
|
1023
|
+
this.maxPushRetryDelayMs = options.maxPushRetryDelayMs ?? DEFAULT_MAX_PUSH_RETRY_DELAY_MS;
|
|
1024
|
+
this.push = new PushLoop(
|
|
1025
|
+
{
|
|
1026
|
+
baseUrl: this.baseUrl,
|
|
1027
|
+
databaseId: options.databaseId,
|
|
1028
|
+
headers: options.headers,
|
|
1029
|
+
requestTimeout: options.requestTimeout,
|
|
1030
|
+
batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
1031
|
+
intervalMs: this.pushIntervalMs,
|
|
1032
|
+
maxRetryDelayMs: this.maxPushRetryDelayMs
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
isRunning: () => this.state === "running",
|
|
1036
|
+
port: () => this.port,
|
|
1037
|
+
schemaVersion: () => this.schemaVersion ?? 0,
|
|
1038
|
+
reconcileSchema: () => this.reconcileSchema(),
|
|
1039
|
+
recordError: (err) => this.recordError(err),
|
|
1040
|
+
onDrained: () => {
|
|
1041
|
+
this.lastError = null;
|
|
1234
1042
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
this.pull = new PullStream(
|
|
1046
|
+
{
|
|
1047
|
+
wsBaseUrl: toWsUrl(this.baseUrl),
|
|
1048
|
+
databaseId: options.databaseId,
|
|
1049
|
+
tables: options.tables,
|
|
1050
|
+
headers: options.headers,
|
|
1051
|
+
ackIntervalMs: options.ackIntervalMs ?? DEFAULT_ACK_INTERVAL_MS,
|
|
1052
|
+
requestTimeout: options.requestTimeout,
|
|
1053
|
+
immediateAckAfterChanges: options.immediateAckAfterChanges,
|
|
1054
|
+
resolver: options.resolver
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
isRunning: () => this.state === "running",
|
|
1058
|
+
port: () => this.port,
|
|
1059
|
+
onChange: options.onChange,
|
|
1060
|
+
onResyncRequired: () => this.markResyncRequired(),
|
|
1061
|
+
onApplyFailure: (err) => this.handleApplyFailure(err),
|
|
1062
|
+
onApplySuccess: () => {
|
|
1063
|
+
this.consecutivePullFailures = 0;
|
|
1064
|
+
},
|
|
1065
|
+
recordError: (err) => this.recordError(err)
|
|
1066
|
+
}
|
|
1067
|
+
);
|
|
1068
|
+
this.resync = new ResyncScheduler(
|
|
1069
|
+
{
|
|
1070
|
+
autoResync: options.autoResync,
|
|
1071
|
+
retryDelayMs: options.snapshotRetryDelayMs ?? DEFAULT_SNAPSHOT_RETRY_DELAY_MS,
|
|
1072
|
+
maxRetryDelayMs: options.maxSnapshotRetryDelayMs ?? DEFAULT_MAX_SNAPSHOT_RETRY_DELAY_MS,
|
|
1073
|
+
onResyncRequired: options.onResyncRequired,
|
|
1074
|
+
onSnapshotComplete: options.onSnapshotComplete
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
isRunning: () => this.state === "running",
|
|
1078
|
+
isSnapshotting: () => this.state === "snapshotting",
|
|
1079
|
+
port: () => this.port,
|
|
1080
|
+
recordError: (err) => this.recordError(err),
|
|
1081
|
+
download: () => this.downloadSnapshot({
|
|
1082
|
+
pageSize: options.snapshotPageSize,
|
|
1083
|
+
onProgress: options.onSnapshotProgress
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
baseUrl;
|
|
1089
|
+
pushIntervalMs;
|
|
1090
|
+
maxPushRetryDelayMs;
|
|
1091
|
+
pull;
|
|
1092
|
+
push;
|
|
1093
|
+
resync;
|
|
1094
|
+
port = null;
|
|
1095
|
+
deviceId = null;
|
|
1096
|
+
capabilities = null;
|
|
1097
|
+
schemaVersion = null;
|
|
1098
|
+
state = "stopped";
|
|
1099
|
+
pullRetryTimer = null;
|
|
1100
|
+
consecutivePullFailures = 0;
|
|
1101
|
+
lastError = null;
|
|
1102
|
+
async start() {
|
|
1103
|
+
if (this.state === "running" || this.state === "starting") return;
|
|
1104
|
+
this.state = "starting";
|
|
1105
|
+
try {
|
|
1106
|
+
await this.verifyCapabilities();
|
|
1107
|
+
this.pull.stagedStream = this.capabilities?.includes(STAGED_STREAM_CAPABILITY) ?? false;
|
|
1108
|
+
this.port ??= this.db.deviceSync();
|
|
1109
|
+
this.deviceId = (await this.port.identity()).nodeId;
|
|
1110
|
+
this.push.cursor = await this.port.getPushCursor();
|
|
1111
|
+
this.port.protectUnpushedChanges(this.push.cursor);
|
|
1112
|
+
const pullState = await this.port.getPullState();
|
|
1113
|
+
this.pull.pullSeq = pullState?.seq ?? null;
|
|
1114
|
+
this.pull.pullEpoch = pullState?.epoch;
|
|
1115
|
+
if (await this.port.snapshotLoadPending() || await this.port.getResyncRequired()) {
|
|
1116
|
+
this.resync.markRequired();
|
|
1117
|
+
}
|
|
1118
|
+
if (!this.resync.required) {
|
|
1119
|
+
try {
|
|
1120
|
+
await this.reconcileSchema();
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
this.recordError(err);
|
|
1123
|
+
this.schemaVersion = await this.localSchemaVersion();
|
|
1238
1124
|
}
|
|
1239
|
-
return this.subscribeOnCurrentEndpoint(table, filter, callback, options);
|
|
1240
1125
|
}
|
|
1126
|
+
if (!this.resync.required) {
|
|
1127
|
+
await this.openPull();
|
|
1128
|
+
}
|
|
1129
|
+
this.state = "running";
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
this.teardownStream();
|
|
1132
|
+
this.state = "stopped";
|
|
1241
1133
|
throw err;
|
|
1242
1134
|
}
|
|
1135
|
+
this.push.start();
|
|
1136
|
+
void this.push.drain();
|
|
1137
|
+
if (this.resync.required) {
|
|
1138
|
+
this.resync.schedule();
|
|
1139
|
+
}
|
|
1243
1140
|
}
|
|
1244
|
-
|
|
1245
|
-
if (
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
close() {
|
|
1250
|
-
this.closed = true;
|
|
1251
|
-
const sameTransport = this.readTransport !== null && this.readTransport === this.writeTransport;
|
|
1252
|
-
const subscriptionIsRead = this.subscriptionTransport !== null && this.subscriptionTransport === this.readTransport;
|
|
1253
|
-
const subscriptionIsWrite = this.subscriptionTransport !== null && this.subscriptionTransport === this.writeTransport;
|
|
1254
|
-
for (const subscription of this.activeSubscriptions.values()) {
|
|
1255
|
-
subscription.active = false;
|
|
1256
|
-
subscription.remote = null;
|
|
1257
|
-
}
|
|
1258
|
-
this.activeSubscriptions.clear();
|
|
1259
|
-
if (this.readTransport) {
|
|
1260
|
-
this.readTransport.close();
|
|
1261
|
-
this.readTransport = null;
|
|
1262
|
-
}
|
|
1263
|
-
if (this.writeTransport && !sameTransport) {
|
|
1264
|
-
this.writeTransport.close();
|
|
1265
|
-
}
|
|
1266
|
-
this.writeTransport = null;
|
|
1267
|
-
if (this.subscriptionTransport && !subscriptionIsRead && !subscriptionIsWrite) {
|
|
1268
|
-
this.subscriptionTransport.close();
|
|
1269
|
-
}
|
|
1270
|
-
this.subscriptionTransport = null;
|
|
1271
|
-
this.currentSubscriptionUrl = "";
|
|
1272
|
-
this.client._unregisterTopologyTransport(this.databaseId, this);
|
|
1273
|
-
}
|
|
1274
|
-
async subscribeOnCurrentEndpoint(table, filter, callback, options) {
|
|
1275
|
-
return this.withSubscriptionOperation(async () => {
|
|
1276
|
-
const transport = await this.getSubscriptionTransport(this.client._getReadConcern());
|
|
1277
|
-
const remote = await transport.subscribe(table, filter, callback, options);
|
|
1278
|
-
const subscription = {
|
|
1279
|
-
id: ++this.nextSubscriptionId,
|
|
1280
|
-
table,
|
|
1281
|
-
filter,
|
|
1282
|
-
callback,
|
|
1283
|
-
onReset: options?.onReset,
|
|
1284
|
-
remote,
|
|
1285
|
-
active: true
|
|
1286
|
-
};
|
|
1287
|
-
this.activeSubscriptions.set(subscription.id, subscription);
|
|
1288
|
-
return this.createSubscriptionHandle(subscription);
|
|
1289
|
-
});
|
|
1141
|
+
pause() {
|
|
1142
|
+
if (this.state !== "running") return;
|
|
1143
|
+
this.teardownStream();
|
|
1144
|
+
this.state = "paused";
|
|
1145
|
+
void this.pull.persist();
|
|
1290
1146
|
}
|
|
1291
|
-
|
|
1147
|
+
async resume() {
|
|
1148
|
+
if (this.state !== "paused") return;
|
|
1149
|
+
this.state = "stopped";
|
|
1150
|
+
await this.start();
|
|
1151
|
+
}
|
|
1152
|
+
async stop() {
|
|
1153
|
+
if (this.state === "stopped") return;
|
|
1154
|
+
this.teardownStream();
|
|
1155
|
+
this.state = "stopped";
|
|
1156
|
+
await this.pull.persist();
|
|
1157
|
+
}
|
|
1158
|
+
async status() {
|
|
1159
|
+
const pendingPushCount = this.port ? await this.port.countOutboxPending(this.push.cursor) : 0;
|
|
1292
1160
|
return {
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1161
|
+
state: this.state,
|
|
1162
|
+
deviceId: this.deviceId,
|
|
1163
|
+
serverCapabilities: this.capabilities,
|
|
1164
|
+
schemaVersion: this.schemaVersion,
|
|
1165
|
+
pendingPushCount,
|
|
1166
|
+
lastPushedSeq: this.push.cursor,
|
|
1167
|
+
lastPulledSeq: this.pull.pullSeq,
|
|
1168
|
+
pushCaughtUp: pendingPushCount === 0,
|
|
1169
|
+
resyncRequired: this.resync.required,
|
|
1170
|
+
lastError: this.lastError
|
|
1301
1171
|
};
|
|
1302
1172
|
}
|
|
1303
|
-
|
|
1304
|
-
this.
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
const endpoint = await this.client._getReadEndpoint(this.databaseId, readConcern);
|
|
1309
|
-
this.assertOpen();
|
|
1310
|
-
if (this.subscriptionTransport && this.currentSubscriptionUrl === endpoint) {
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
const nextTransport = this.client._createTransportForEndpoint(endpoint, this.databaseId);
|
|
1314
|
-
const nextSubscriptions = /* @__PURE__ */ new Map();
|
|
1173
|
+
triggerPush() {
|
|
1174
|
+
void this.push.drain();
|
|
1175
|
+
}
|
|
1176
|
+
async verifyCapabilities() {
|
|
1177
|
+
if (this.capabilities !== null) return;
|
|
1315
1178
|
try {
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
if (!subscription.active) {
|
|
1322
|
-
remote.unsubscribe();
|
|
1323
|
-
continue;
|
|
1324
|
-
}
|
|
1325
|
-
nextSubscriptions.set(subscription.id, remote);
|
|
1326
|
-
}
|
|
1179
|
+
this.capabilities = await verifyDeviceSyncCapabilities({
|
|
1180
|
+
url: this.baseUrl,
|
|
1181
|
+
headers: this.options.headers,
|
|
1182
|
+
requestTimeoutMs: this.options.requestTimeout
|
|
1183
|
+
});
|
|
1327
1184
|
} catch (err) {
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
}
|
|
1331
|
-
nextTransport.close();
|
|
1332
|
-
throw toSubscriptionRoutingError(err, this.databaseId);
|
|
1333
|
-
}
|
|
1334
|
-
const oldTransport = this.subscriptionTransport;
|
|
1335
|
-
this.subscriptionTransport = nextTransport;
|
|
1336
|
-
this.currentSubscriptionUrl = endpoint;
|
|
1337
|
-
for (const subscription of subscriptions) {
|
|
1338
|
-
const nextRemote = nextSubscriptions.get(subscription.id);
|
|
1339
|
-
if (!nextRemote) continue;
|
|
1340
|
-
const previousRemote = subscription.remote;
|
|
1341
|
-
if (!subscription.active) {
|
|
1342
|
-
nextRemote.unsubscribe();
|
|
1343
|
-
continue;
|
|
1344
|
-
}
|
|
1345
|
-
subscription.remote = nextRemote;
|
|
1346
|
-
previousRemote?.unsubscribe();
|
|
1347
|
-
}
|
|
1348
|
-
if (oldTransport) {
|
|
1349
|
-
oldTransport.close();
|
|
1185
|
+
if (err instanceof RemoteError && err.code === "SYNC_UNSUPPORTED") throw err;
|
|
1186
|
+
this.recordError(err);
|
|
1350
1187
|
}
|
|
1351
1188
|
}
|
|
1352
|
-
async
|
|
1353
|
-
this.
|
|
1354
|
-
while (this.readTransportRequest) {
|
|
1355
|
-
await this.readTransportRequest.catch(() => void 0);
|
|
1356
|
-
this.assertOpen();
|
|
1357
|
-
}
|
|
1358
|
-
const request = this.resolveReadTransport(readConcern);
|
|
1359
|
-
this.readTransportRequest = request;
|
|
1360
|
-
try {
|
|
1361
|
-
return await request;
|
|
1362
|
-
} finally {
|
|
1363
|
-
if (this.readTransportRequest === request) {
|
|
1364
|
-
this.readTransportRequest = null;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1189
|
+
async localSchemaVersion() {
|
|
1190
|
+
return highestMigrationVersion(await this.db.appliedMigrations());
|
|
1367
1191
|
}
|
|
1368
|
-
async
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (
|
|
1379
|
-
|
|
1192
|
+
async reconcileSchema() {
|
|
1193
|
+
const result = await syncDeviceMigrations(this.db, {
|
|
1194
|
+
url: this.baseUrl,
|
|
1195
|
+
databaseId: this.options.databaseId,
|
|
1196
|
+
headers: this.options.headers,
|
|
1197
|
+
requestTimeoutMs: this.options.requestTimeout
|
|
1198
|
+
});
|
|
1199
|
+
this.schemaVersion = result.schemaVersion;
|
|
1200
|
+
if (result.status === "resync-required") {
|
|
1201
|
+
this.markResyncRequired();
|
|
1202
|
+
} else if (result.status === "ahead") {
|
|
1203
|
+
this.lastError = {
|
|
1204
|
+
code: "SCHEMA_AHEAD",
|
|
1205
|
+
message: `Device schema version ${result.schemaVersion} is ahead of server version ${result.serverVersion}`
|
|
1206
|
+
};
|
|
1380
1207
|
}
|
|
1381
|
-
return
|
|
1208
|
+
return result.status;
|
|
1382
1209
|
}
|
|
1383
|
-
|
|
1384
|
-
this.
|
|
1385
|
-
|
|
1386
|
-
await this.writeTransportRequest.catch(() => void 0);
|
|
1387
|
-
this.assertOpen();
|
|
1388
|
-
}
|
|
1389
|
-
const request = this.resolveWriteTransport();
|
|
1390
|
-
this.writeTransportRequest = request;
|
|
1391
|
-
try {
|
|
1392
|
-
return await request;
|
|
1393
|
-
} finally {
|
|
1394
|
-
if (this.writeTransportRequest === request) {
|
|
1395
|
-
this.writeTransportRequest = null;
|
|
1396
|
-
}
|
|
1397
|
-
}
|
|
1210
|
+
markResyncRequired() {
|
|
1211
|
+
this.resync.markRequired();
|
|
1212
|
+
this.resync.schedule();
|
|
1398
1213
|
}
|
|
1399
|
-
async
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
if (this.writeTransport && this.currentWriteUrl === endpoint) {
|
|
1403
|
-
return this.writeTransport;
|
|
1214
|
+
async downloadSnapshot(options) {
|
|
1215
|
+
if (this.state === "snapshotting") {
|
|
1216
|
+
throw new Error("A snapshot download is already in progress");
|
|
1404
1217
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
this.writeTransport = nextTransport;
|
|
1408
|
-
this.currentWriteUrl = endpoint;
|
|
1409
|
-
if (oldTransport) {
|
|
1410
|
-
oldTransport.close();
|
|
1218
|
+
if (this.state !== "running" && this.state !== "paused") {
|
|
1219
|
+
throw new Error("Snapshot download requires a started sync controller");
|
|
1411
1220
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
this.assertOpen();
|
|
1416
|
-
if (this.subscriptionTransport) {
|
|
1417
|
-
return this.subscriptionTransport;
|
|
1221
|
+
const port = this.port;
|
|
1222
|
+
if (port === null) {
|
|
1223
|
+
throw new Error("Snapshot download requires a started sync controller");
|
|
1418
1224
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1225
|
+
this.teardownStream();
|
|
1226
|
+
this.state = "snapshotting";
|
|
1227
|
+
try {
|
|
1228
|
+
await this.push.drainFully(port);
|
|
1229
|
+
await downloadDatabaseSnapshot(port, {
|
|
1230
|
+
url: this.baseUrl,
|
|
1231
|
+
databaseId: this.options.databaseId,
|
|
1232
|
+
headers: this.options.headers,
|
|
1233
|
+
pageSize: options?.pageSize,
|
|
1234
|
+
requestTimeoutMs: this.options.requestTimeout,
|
|
1235
|
+
onProgress: options?.onProgress ?? this.options.onSnapshotProgress
|
|
1236
|
+
});
|
|
1237
|
+
this.schemaVersion = await this.localSchemaVersion();
|
|
1238
|
+
await port.setResyncRequired(false);
|
|
1239
|
+
this.resync.recordSuccess();
|
|
1240
|
+
this.lastError = null;
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
const failure = describeError(err);
|
|
1243
|
+
this.lastError = failure;
|
|
1244
|
+
this.resync.recordFailure();
|
|
1245
|
+
const databaseUsable = await this.snapshotGateOpen(port);
|
|
1246
|
+
this.state = "stopped";
|
|
1247
|
+
try {
|
|
1248
|
+
await this.start();
|
|
1249
|
+
} catch {
|
|
1250
|
+
this.state = "paused";
|
|
1424
1251
|
}
|
|
1252
|
+
this.resync.complete({ ok: false, error: failure, databaseUsable, retrying: this.resync.retryScheduled });
|
|
1253
|
+
throw err;
|
|
1425
1254
|
}
|
|
1426
|
-
|
|
1427
|
-
this.subscriptionTransportRequest = request;
|
|
1255
|
+
this.state = "stopped";
|
|
1428
1256
|
try {
|
|
1429
|
-
|
|
1257
|
+
await this.start();
|
|
1430
1258
|
} finally {
|
|
1431
|
-
|
|
1432
|
-
this.subscriptionTransportRequest = null;
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
async resolveSubscriptionTransport(readConcern) {
|
|
1437
|
-
if (this.subscriptionTransport) {
|
|
1438
|
-
return this.subscriptionTransport;
|
|
1439
|
-
}
|
|
1440
|
-
const endpoint = await this.client._getReadEndpoint(this.databaseId, readConcern);
|
|
1441
|
-
this.assertOpen();
|
|
1442
|
-
if (this.subscriptionTransport) {
|
|
1443
|
-
return this.subscriptionTransport;
|
|
1444
|
-
}
|
|
1445
|
-
this.subscriptionTransport = this.client._createTransportForEndpoint(endpoint, this.databaseId);
|
|
1446
|
-
this.currentSubscriptionUrl = endpoint;
|
|
1447
|
-
return this.subscriptionTransport;
|
|
1448
|
-
}
|
|
1449
|
-
closeSubscriptionTransport() {
|
|
1450
|
-
if (this.subscriptionTransport) {
|
|
1451
|
-
this.subscriptionTransport.close();
|
|
1452
|
-
this.subscriptionTransport = null;
|
|
1259
|
+
this.resync.complete({ ok: true, error: null, databaseUsable: true, retrying: false });
|
|
1453
1260
|
}
|
|
1454
|
-
this.currentSubscriptionUrl = "";
|
|
1455
1261
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
await previous.catch(() => void 0);
|
|
1262
|
+
/**
|
|
1263
|
+
* Answers whether the local database serves reads and writes again. A failure
|
|
1264
|
+
* before the wipe begins leaves it intact, while one after it leaves every
|
|
1265
|
+
* statement refused with `SNAPSHOT_IN_PROGRESS` until a later copy succeeds,
|
|
1266
|
+
* so the application learns which of the two it is rather than assuming.
|
|
1267
|
+
*/
|
|
1268
|
+
async snapshotGateOpen(port) {
|
|
1464
1269
|
try {
|
|
1465
|
-
return await
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1270
|
+
return !await port.snapshotLoadPending();
|
|
1271
|
+
} catch {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
recordError(err) {
|
|
1276
|
+
this.lastError = describeError(err);
|
|
1277
|
+
}
|
|
1278
|
+
handleApplyFailure(err) {
|
|
1279
|
+
this.recordError(err);
|
|
1280
|
+
this.pull.teardown();
|
|
1281
|
+
const live = this.state === "running" || this.state === "starting";
|
|
1282
|
+
if (!live || this.pullRetryTimer !== null) return;
|
|
1283
|
+
const delay = Math.min(this.pushIntervalMs * 2 ** this.consecutivePullFailures, this.maxPushRetryDelayMs);
|
|
1284
|
+
this.consecutivePullFailures += 1;
|
|
1285
|
+
this.pullRetryTimer = setTimeout(() => {
|
|
1286
|
+
this.pullRetryTimer = null;
|
|
1287
|
+
void this.reopenPull();
|
|
1288
|
+
}, delay);
|
|
1289
|
+
unrefTimer(this.pullRetryTimer);
|
|
1290
|
+
}
|
|
1291
|
+
async reopenPull() {
|
|
1292
|
+
if (this.state !== "running" || this.deviceId === null || this.resync.required) return;
|
|
1293
|
+
try {
|
|
1294
|
+
await this.openPull();
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
this.handleApplyFailure(err);
|
|
1468
1297
|
}
|
|
1469
1298
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1299
|
+
/**
|
|
1300
|
+
* Opens the pull subscription, reconciling migrations when the server refuses
|
|
1301
|
+
* it because this device is behind. A device that only reads never pushes, so
|
|
1302
|
+
* the subscribe refusal is the sole point at which it can learn that the
|
|
1303
|
+
* server has migrated; without this it would retry the same refused
|
|
1304
|
+
* subscription forever and silently receive nothing.
|
|
1305
|
+
*/
|
|
1306
|
+
async openPull() {
|
|
1307
|
+
const deviceId = this.deviceId;
|
|
1308
|
+
if (deviceId === null) return;
|
|
1309
|
+
let refusal;
|
|
1310
|
+
try {
|
|
1311
|
+
await this.pull.open(deviceId, this.schemaVersion ?? 0);
|
|
1312
|
+
return;
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
if (!(err instanceof RemoteError) || err.code !== "MIGRATION_REQUIRED") throw err;
|
|
1315
|
+
this.recordError(err);
|
|
1316
|
+
refusal = err;
|
|
1317
|
+
}
|
|
1318
|
+
const status = await this.reconcileSchema();
|
|
1319
|
+
if (this.resync.required) return;
|
|
1320
|
+
if (status === "ahead") throw refusal;
|
|
1321
|
+
this.pull.teardown();
|
|
1322
|
+
this.lastError = null;
|
|
1323
|
+
await this.pull.open(deviceId, this.schemaVersion ?? 0);
|
|
1324
|
+
}
|
|
1325
|
+
teardownStream() {
|
|
1326
|
+
this.push.stop();
|
|
1327
|
+
this.resync.cancel();
|
|
1328
|
+
if (this.pullRetryTimer !== null) {
|
|
1329
|
+
clearTimeout(this.pullRetryTimer);
|
|
1330
|
+
this.pullRetryTimer = null;
|
|
1331
|
+
}
|
|
1332
|
+
this.pull.teardown();
|
|
1474
1333
|
}
|
|
1475
1334
|
};
|
|
1476
|
-
function
|
|
1477
|
-
const
|
|
1478
|
-
return
|
|
1479
|
-
"ROUTING_ERROR",
|
|
1480
|
-
`Could not re-establish active subscriptions on refreshed routing for database '${databaseId}': ${detail}`
|
|
1481
|
-
);
|
|
1482
|
-
}
|
|
1483
|
-
function shouldRefreshRouting(err) {
|
|
1484
|
-
if (!(err instanceof RemoteError)) {
|
|
1485
|
-
return false;
|
|
1486
|
-
}
|
|
1487
|
-
return err.code === "STALE_PRIMARY" || err.code === "AUTHORITY_LOST" || err.code === "COORDINATOR_UNAVAILABLE" || err.code === "NO_SAFE_PRIMARY" || err.code === "CONNECTION_ERROR";
|
|
1335
|
+
function describeError(err) {
|
|
1336
|
+
const code = err instanceof Error && "code" in err ? String(err.code) : "UNKNOWN_ERROR";
|
|
1337
|
+
return { code, message: err instanceof Error ? err.message : String(err) };
|
|
1488
1338
|
}
|
|
1489
1339
|
|
|
1490
|
-
export {
|
|
1340
|
+
export { FieldMergeResolver, LWWResolver, PrimaryWinsResolver, SirannonClient, SyncController, downloadDatabaseSnapshot, encodeSyncBatch, pushSyncBatch };
|