@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.
Files changed (72) hide show
  1. package/README.md +117 -969
  2. package/dist/backup-scheduler/index.d.ts +1 -1
  3. package/dist/backup-scheduler/index.mjs +2 -2
  4. package/dist/baseline-Br77Fnhb.d.ts +6 -0
  5. package/dist/{change-tracker-CbmaMO-N.d.ts → change-tracker-DKRVUC3l.d.ts} +5 -8
  6. package/dist/{chunk-DJLX6CAE.mjs → chunk-4ISB7XMA.mjs} +2 -1
  7. package/dist/chunk-5NOIGN5Y.mjs +18 -0
  8. package/dist/chunk-67M7KAH6.mjs +90 -0
  9. package/dist/{chunk-GEZUUIKV.mjs → chunk-D7LAYTKN.mjs} +5 -2
  10. package/dist/{chunk-4IGMIJQK.mjs → chunk-EBJXPQQO.mjs} +59 -20
  11. package/dist/{chunk-BNUTBHHH.mjs → chunk-FHWTZFI4.mjs} +1 -1
  12. package/dist/chunk-GVCNMPOS.mjs +130 -0
  13. package/dist/chunk-H237TXZW.mjs +1407 -0
  14. package/dist/{chunk-HHRMRFFR.mjs → chunk-H6PIVVDN.mjs} +353 -142
  15. package/dist/chunk-HR5CWTLC.mjs +661 -0
  16. package/dist/chunk-JU64Y7HM.mjs +299 -0
  17. package/dist/{chunk-CW6S3WL5.mjs → chunk-JZGINXTN.mjs} +7 -5
  18. package/dist/{chunk-CJLYFDP5.mjs → chunk-LFZ37BSX.mjs} +1 -1
  19. package/dist/chunk-LNY2VVHE.mjs +18 -0
  20. package/dist/chunk-NVQS53NT.mjs +665 -0
  21. package/dist/{chunk-H5AB6NIR.mjs → chunk-O7SLN3GI.mjs} +2 -15
  22. package/dist/chunk-OQVZBEBY.mjs +283 -0
  23. package/dist/{chunk-VLTICJOD.mjs → chunk-PIKHN33N.mjs} +1 -1
  24. package/dist/chunk-TJF5GZSV.mjs +187 -0
  25. package/dist/chunk-TUD5CJ76.mjs +126 -0
  26. package/dist/{chunk-YPYVQJ4C.mjs → chunk-UC3SCMIN.mjs} +9 -1
  27. package/dist/client/index.d.ts +216 -321
  28. package/dist/client/index.mjs +1192 -1342
  29. package/dist/client/topology.d.ts +61 -0
  30. package/dist/client/topology.mjs +682 -0
  31. package/dist/client-base-CLWmH5Ln.d.ts +234 -0
  32. package/dist/codegen/cli.d.ts +2 -0
  33. package/dist/codegen/cli.mjs +11 -0
  34. package/dist/codegen/index.d.ts +30 -0
  35. package/dist/codegen/index.mjs +5 -0
  36. package/dist/core/index.d.ts +37 -16
  37. package/dist/core/index.mjs +2322 -298
  38. package/dist/core/writer-worker.mjs +28 -6
  39. package/dist/{database-DuGp0Rtr.d.ts → database-BY0L5Q2n.d.ts} +59 -14
  40. package/dist/driver/better-sqlite3.d.ts +1 -1
  41. package/dist/driver/better-sqlite3.mjs +6 -6
  42. package/dist/driver/node.d.ts +1 -1
  43. package/dist/driver/node.mjs +6 -6
  44. package/dist/driver/wa-sqlite.d.ts +1 -136
  45. package/dist/{errors-5Nf5ZAEC.d.ts → errors-Bw5MdNCu.d.ts} +5 -1
  46. package/dist/file-migrations/index.d.ts +7 -5
  47. package/dist/file-migrations/index.mjs +42 -37
  48. package/dist/operation-registry-9DcvxcE5.d.ts +45 -0
  49. package/dist/primary-wins-DPAm2AKG.d.ts +61 -0
  50. package/dist/protocol-6KrSq2Hy.d.ts +66 -0
  51. package/dist/react/index.d.ts +20 -0
  52. package/dist/react/index.mjs +137 -0
  53. package/dist/replication/coordinator/etcd.d.ts +5 -3
  54. package/dist/replication/coordinator/etcd.mjs +554 -462
  55. package/dist/replication/index.d.ts +51 -105
  56. package/dist/replication/index.mjs +1415 -2061
  57. package/dist/server/index.d.ts +111 -87
  58. package/dist/server/index.mjs +1917 -489
  59. package/dist/server-options-1JHu8pid.d.ts +191 -0
  60. package/dist/{sirannon-4SspRvP5.d.ts → sirannon-DaQSyhbJ.d.ts} +7 -2
  61. package/dist/transport/grpc.d.ts +7 -4
  62. package/dist/transport/grpc.mjs +191 -97
  63. package/dist/transport/memory.d.ts +5 -134
  64. package/dist/transport/memory.mjs +3 -1
  65. package/dist/{types-BsjobKbl.d.ts → types-B7gmEsZW.d.ts} +8 -61
  66. package/dist/{types-BEu1I_9_.d.ts → types-BsVabqSI.d.ts} +1 -1
  67. package/dist/types-CL6piSnD.d.ts +43 -0
  68. package/dist/types-C_D8IhpO.d.ts +60 -0
  69. package/dist/{types-D_hQW1hr.d.ts → types-zhnRXrsb.d.ts} +55 -165
  70. package/package.json +44 -6
  71. package/dist/chunk-TKGHYWQ6.mjs +0 -35
  72. package/dist/types-D4p4UyDK.d.ts +0 -26
@@ -1,1490 +1,1340 @@
1
- // src/client/subscription.ts
2
- var RemoteSubscriptionBuilderImpl = class {
3
- constructor(table, transport) {
4
- this.table = table;
5
- this.transport = transport;
6
- }
7
- conditions = {};
8
- filter(conditions) {
9
- this.conditions = { ...this.conditions, ...conditions };
10
- return this;
11
- }
12
- subscribe(callback, options) {
13
- const filter = Object.keys(this.conditions).length > 0 ? this.conditions : void 0;
14
- return this.transport.subscribe(this.table, filter, callback, options);
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/client/types.ts
19
- var RemoteError = class extends Error {
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
- // src/client/database-proxy.ts
29
- var DEFAULT_LOAD_BATCH_SIZE = 1e3;
30
- function isAsyncIterable(value) {
31
- return typeof value[Symbol.asyncIterator] === "function";
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
- * Run the same statement once per parameter set as a single atomic
73
- * transaction that commits with one fsync. Returns one result per
74
- * parameter set, in order. Use this for a burst of same-shape writes
75
- * (an import, a bulk insert) that must all commit or all roll back.
76
- */
77
- async batch(sql, paramsBatch, writeConcern) {
78
- const response = await this.transport.batch(sql, paramsBatch, writeConcern);
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
- * Load a whole dataset through the same statement, batching it into requests
83
- * for you and paying the one fsyncing WAL checkpoint once, after the final
84
- * batch. The configured durability is restored after every batch, so an
85
- * import that stops partway never leaves the writer at the relaxed level.
86
- * Prefer this over {@link load} for anything larger than a single request:
87
- * it runs the finalize itself, so there is no checkpoint flag to forget.
88
- *
89
- * Accepts a synchronous or asynchronous iterable of parameter sets, so rows
90
- * can stream from a file or the network without being held in memory at
91
- * once. Returns the total rows loaded and changes applied.
92
- *
93
- * ```ts
94
- * const summary = await db.loadAll(
95
- * 'INSERT INTO events (id, payload) VALUES (?, ?)',
96
- * rowStream,
97
- * { batchSize: 5000, durability: 'off' },
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
- for (const row of rows) {
127
- batch.push(row);
128
- if (batch.length >= batchSize) await rotate();
84
+ this.logical += 1;
85
+ if (this.logical > MAX_LOGICAL) {
86
+ throw new ReplicationError("HLC logical counter overflow");
129
87
  }
130
88
  }
131
- if (batch.length > 0) {
132
- if (previous !== null) await send(previous, false);
133
- await send(batch, true);
134
- } else if (previous !== null) {
135
- await send(previous, true);
136
- }
137
- return total;
138
- }
139
- /**
140
- * Load one batch of rows through the same statement with writer durability
141
- * relaxed for the duration, then restored before this resolves. This is the
142
- * low-level primitive; prefer {@link loadAll} for a dataset that spans more
143
- * than one request, since it runs the finalize itself rather than relying on
144
- * a `checkpoint` flag.
145
- *
146
- * Returns the total rows loaded and changes applied. When splitting a dataset
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
- return { [INT_TAG]: value.toString() };
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
- const out = {};
240
- for (const k of keys) {
241
- out[k] = decodeTaggedValues(obj[k]);
242
- }
243
- return out;
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
- return hex.toUpperCase();
257
- }
258
- function decodeHexBytes(hex) {
259
- if (hex.length % 2 !== 0 || !HEX_BYTES_RE.test(hex)) {
260
- throw new Error("Invalid CDC BLOB hex payload");
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
- bytes[i / 2] = byte;
272
- }
273
- return bytes;
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
- async query(sql, params, readConcern) {
289
- const response = await this.post("/query", {
290
- sql,
291
- params: encodeTaggedValues(params),
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
- async post(path, body) {
329
- if (this.closed) {
330
- throw new RemoteError("TRANSPORT_ERROR", "Transport is closed");
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
- const url = `${this.baseUrl}${path}`;
333
- let response;
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
- let data;
347
- try {
348
- data = await response.json();
349
- } catch {
350
- throw new RemoteError("INVALID_RESPONSE", `Server returned non-JSON response (HTTP ${response.status})`);
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 (!response.ok) {
353
- const errorData = data;
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 data;
155
+ return { action: "keep_local" };
360
156
  }
361
157
  };
362
158
 
363
- // src/client/transport/ws.ts
364
- var DEFAULT_REQUEST_TIMEOUT = 3e4;
365
- var WebSocketTransport = class {
366
- ws = null;
367
- url;
368
- autoReconnect;
369
- reconnectInterval;
370
- requestTimeout;
371
- protocols;
372
- pendingRequests = /* @__PURE__ */ new Map();
373
- activeSubscriptions = /* @__PURE__ */ new Map();
374
- idCounter = 0;
375
- closed = false;
376
- connectPromise = null;
377
- reconnectTimer = null;
378
- constructor(url, options) {
379
- this.url = url;
380
- this.autoReconnect = options?.autoReconnect ?? true;
381
- this.reconnectInterval = options?.reconnectInterval ?? 1e3;
382
- this.requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
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
- return this.connectPromise;
500
- }
501
- connect() {
502
- return new Promise((resolve, reject) => {
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
- switch (msg.type) {
546
- case "result": {
547
- const pending = this.pendingRequests.get(msg.id);
548
- if (pending) {
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
- case "subscribed": {
556
- const sub = this.activeSubscriptions.get(msg.id);
557
- if (sub) {
558
- if (msg.epoch !== void 0) {
559
- sub.epoch = msg.epoch;
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
- case "error": {
588
- const pending = this.pendingRequests.get(msg.id);
589
- if (pending) {
590
- clearTimeout(pending.timer);
591
- this.pendingRequests.delete(msg.id);
592
- pending.reject(new RemoteError(msg.error.code, msg.error.message));
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
- break;
209
+ continue;
595
210
  }
596
- case "change": {
597
- const sub = this.activeSubscriptions.get(msg.id);
598
- if (sub) {
599
- try {
600
- const event = {
601
- type: msg.event.type,
602
- table: msg.event.table,
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
- handleDisconnect() {
620
- this.rejectAllPending(new RemoteError("CONNECTION_ERROR", "WebSocket disconnected"));
621
- if (this.autoReconnect && !this.closed && this.activeSubscriptions.size > 0) {
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
- request(msg) {
662
- return new Promise((resolve, reject) => {
663
- const { id } = msg;
664
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
665
- reject(new RemoteError("CONNECTION_ERROR", "WebSocket is not connected"));
666
- return;
667
- }
668
- const timer = this.requestTimeout > 0 ? setTimeout(() => {
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
- rejectAllPending(error) {
681
- for (const [, pending] of this.pendingRequests) {
682
- clearTimeout(pending.timer);
683
- pending.reject(error);
240
+ resolve(ctx) {
241
+ if (ctx.remoteChange.nodeId === this.primaryNodeId) {
242
+ return { action: "accept_remote" };
684
243
  }
685
- this.pendingRequests.clear();
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 CLUSTER_DISCOVERY_FETCH_TIMEOUT_MS = 2e3;
697
- function clusterRoutingFingerprint(state) {
698
- const readEndpoints = state.readEndpoints.map((endpoint) => ({
699
- url: endpoint.url,
700
- readConcerns: [...endpoint.readConcerns].sort()
701
- })).sort((left, right) => left.url.localeCompare(right.url));
702
- return JSON.stringify({
703
- currentPrimary: state.currentPrimary,
704
- primaryTerm: state.primaryTerm,
705
- readEndpoints
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
- function normaliseEndpointUrl(rawUrl) {
732
- let parsed;
733
- try {
734
- parsed = new URL(rawUrl);
735
- } catch {
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
- if (parsed.hash) {
745
- throw new TypeError(`Endpoint URL '${rawUrl}' must not contain a fragment`);
269
+ createTransport(databaseId) {
270
+ return createEndpointTransport(this.settings, this.baseUrl, databaseId);
746
271
  }
747
- if (parsed.search) {
748
- throw new TypeError(`Endpoint URL '${rawUrl}' must not contain a query string`);
272
+ resolveServerUrl() {
273
+ return this.baseUrl;
749
274
  }
750
- parsed.pathname = parsed.pathname.replace(/\/+$/, "");
751
- return parsed.toString().replace(/\/$/, "");
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 isReadConcernLevel(value) {
754
- return value === "local" || value === "majority" || value === "linearizable";
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
- function parseDiscoveredReadConcerns(value) {
757
- if (!Array.isArray(value)) {
758
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata readConcerns must be an array");
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 concerns = [];
761
- for (const concern of value) {
762
- if (!isReadConcernLevel(concern)) {
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
- if (!concerns.includes(concern)) {
766
- concerns.push(concern);
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 concerns;
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 toDiscoveredServerBaseUrl(endpoint, databaseId) {
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
- return toServerBaseUrl(endpoint, databaseId);
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
- const message = err instanceof Error ? err.message : String(err);
776
- throw new RemoteError("INVALID_RESPONSE", `Cluster metadata contains an unsafe endpoint: ${message}`);
494
+ try {
495
+ await port.abortSnapshotLoad();
496
+ } catch {
497
+ }
498
+ throw err;
777
499
  }
778
500
  }
779
- function parseClusterRouting(data, databaseId) {
780
- if (typeof data !== "object" || data === null || Array.isArray(data)) {
781
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata must be an object");
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
- if (record.primaryTerm !== void 0 && typeof record.primaryTerm !== "string") {
788
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata primaryTerm must be a string");
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
- let currentPrimary = null;
791
- if (record.currentPrimary !== void 0 && record.currentPrimary !== null) {
792
- if (typeof record.currentPrimary !== "object" || Array.isArray(record.currentPrimary)) {
793
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata currentPrimary must be an object or null");
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
- const primary = record.currentPrimary;
796
- if (typeof primary.endpoint !== "string") {
797
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata currentPrimary.endpoint must be a string");
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 readEndpointsRaw = record.readEndpoints;
802
- if (readEndpointsRaw !== void 0 && !Array.isArray(readEndpointsRaw)) {
803
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata readEndpoints must be an array");
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
- const readEndpoints = (readEndpointsRaw ?? []).map((endpointInfo) => {
806
- if (typeof endpointInfo !== "object" || endpointInfo === null || Array.isArray(endpointInfo)) {
807
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata read endpoint must be an object");
808
- }
809
- const endpoint = endpointInfo;
810
- if (typeof endpoint.endpoint !== "string") {
811
- throw new RemoteError("INVALID_RESPONSE", "Cluster metadata read endpoint URL must be a string");
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
- return {
814
- url: toDiscoveredServerBaseUrl(endpoint.endpoint, databaseId),
815
- readConcerns: parseDiscoveredReadConcerns(endpoint.readConcerns)
816
- };
817
- });
601
+ throw err;
602
+ }
603
+ const schemaVersion = highestMigrationVersion(await db.appliedMigrations());
818
604
  return {
819
- currentPrimary,
820
- primaryTerm: record.primaryTerm ?? null,
821
- readEndpoints
605
+ status: schemaVersion === list.serverVersion ? "migrated" : "resync-required",
606
+ schemaVersion,
607
+ serverVersion: list.serverVersion
822
608
  };
823
609
  }
824
- var SirannonClient = class {
825
- baseUrl;
826
- wsBaseUrl;
827
- transport;
828
- headers;
829
- webSocketProtocols;
830
- autoReconnect;
831
- reconnectInterval;
832
- requestTimeout;
833
- databases = /* @__PURE__ */ new Map();
834
- closed = false;
835
- topologyEnabled;
836
- primaryUrl;
837
- replicaUrls;
838
- readPreference;
839
- discovery;
840
- readConcern;
841
- starterEndpoints;
842
- clusterRouting = /* @__PURE__ */ new Map();
843
- topologyTransports = /* @__PURE__ */ new Map();
844
- latencies = [];
845
- latencyMeasuredAt = 0;
846
- latencyMeasuring = null;
847
- LATENCY_TTL_MS = 6e4;
848
- removedReplicas = /* @__PURE__ */ new Set();
849
- constructor(urlOrOpts, options) {
850
- if (isTopologyConfig(urlOrOpts)) {
851
- const topoOpts = urlOrOpts;
852
- this.topologyEnabled = true;
853
- this.primaryUrl = topoOpts.primary ? toBaseUrl(topoOpts.primary) : void 0;
854
- this.replicaUrls = (topoOpts.replicas ?? []).map(toBaseUrl);
855
- this.readPreference = topoOpts.readPreference ?? "primary";
856
- this.discovery = topoOpts.discovery ?? "static";
857
- this.readConcern = topoOpts.readConcern;
858
- this.starterEndpoints = (topoOpts.endpoints ?? []).map(toBaseUrl);
859
- this.baseUrl = this.primaryUrl ?? this.replicaUrls[0] ?? this.starterEndpoints[0] ?? "";
860
- this.wsBaseUrl = toWsUrl(this.baseUrl);
861
- this.transport = topoOpts.transport ?? "websocket";
862
- this.headers = topoOpts.headers;
863
- this.webSocketProtocols = topoOpts.webSocketProtocols;
864
- this.autoReconnect = topoOpts.autoReconnect ?? true;
865
- this.reconnectInterval = topoOpts.reconnectInterval ?? 1e3;
866
- this.requestTimeout = topoOpts.requestTimeout;
867
- } else {
868
- this.topologyEnabled = false;
869
- this.primaryUrl = void 0;
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.databases.set(id, db);
898
- return db;
899
- }
900
- close() {
901
- this.closed = true;
902
- const openDatabases = [...this.databases.values()];
903
- this.databases.clear();
904
- for (const db of openDatabases) {
905
- db.close();
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
- createTransport(databaseId) {
909
- if (!this.topologyEnabled) {
910
- return this.createTransportForUrl(this.baseUrl, this.wsBaseUrl, databaseId);
911
- }
912
- const transport = new TopologyAwareTransport(databaseId, this);
913
- this.topologyTransports.set(databaseId, transport);
914
- return transport;
915
- }
916
- createTransportForUrl(baseUrl, wsBaseUrl, databaseId) {
917
- const encodedId = encodeURIComponent(databaseId);
918
- if (this.transport === "http") {
919
- return new HttpTransport(`${baseUrl}/db/${encodedId}`, this.headers);
920
- }
921
- return new WebSocketTransport(`${wsBaseUrl}/db/${encodedId}`, {
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
- _createTransportForEndpoint(url, databaseId) {
929
- const base = toBaseUrl(url);
930
- const ws = toWsUrl(base);
931
- return this.createTransportForUrl(base, ws, databaseId);
932
- }
933
- async _getReadEndpoint(databaseId, readConcern) {
934
- if (this.discovery === "coordinator" && databaseId) {
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 _getWriteEndpoint(databaseId) {
978
- if (this.discovery === "coordinator" && databaseId) {
979
- const routing = await this.ensureClusterRouting(databaseId);
980
- if (!routing.currentPrimary) {
981
- throw new RemoteError("NO_SAFE_PRIMARY", "No current primary is available");
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
- return routing.currentPrimary;
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
- _removeReplica(url) {
994
- this.removedReplicas.add(url);
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
- async ensureLatencyMeasured() {
997
- const now = Date.now();
998
- if (this.latencies.length > 0 && now - this.latencyMeasuredAt < this.LATENCY_TTL_MS) {
999
- return;
717
+ handleSubscribed(info) {
718
+ if (info.maxUnacknowledgedChanges !== void 0 && info.maxUnacknowledgedChanges > 0) {
719
+ this.serverWindow = info.maxUnacknowledgedChanges;
1000
720
  }
1001
- if (this.latencyMeasuring) {
1002
- await this.latencyMeasuring;
721
+ if (info.resync) {
722
+ this.hooks.onResyncRequired();
1003
723
  return;
1004
724
  }
1005
- this.latencyMeasuring = this.measureLatencies();
1006
- try {
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
- async measureLatencies() {
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
- for (const r of this.replicaUrls) {
1018
- allEndpoints.push(r);
731
+ if (this.ackBaseSeq === null) {
732
+ this.ackBaseSeq = this.resumeWatermark();
1019
733
  }
1020
- const results = await Promise.all(
1021
- allEndpoints.map(async (url) => {
1022
- const start = performance.now();
1023
- const controller = new AbortController();
1024
- const timeout = setTimeout(() => controller.abort(), 5e3);
1025
- const unrefable = timeout;
1026
- if (typeof unrefable.unref === "function") {
1027
- unrefable.unref();
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 init = { signal: controller.signal };
1031
- const response = await fetch(`${url}/health`, init);
1032
- if (!response.ok) {
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
- return { url, latencyMs: performance.now() - start, reachable: true };
1036
- } catch {
1037
- return { url, latencyMs: Number.MAX_SAFE_INTEGER, reachable: false };
1038
- } finally {
1039
- clearTimeout(timeout);
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
- throw err;
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
- throw new RemoteError("ROUTING_ERROR", `Could not discover cluster routing for database '${databaseId}'`);
1093
- }
1094
- async ensureClusterRouting(databaseId) {
1095
- const existing = this.clusterRouting.get(databaseId);
1096
- if (existing) return existing;
1097
- await this._refreshClusterRouting(databaseId);
1098
- const refreshed = this.clusterRouting.get(databaseId);
1099
- if (!refreshed) {
1100
- throw new RemoteError("ROUTING_ERROR", `Could not discover cluster routing for database '${databaseId}'`);
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
- _unregisterTopologyTransport(databaseId, transport) {
1116
- if (this.topologyTransports.get(databaseId) === transport) {
1117
- this.topologyTransports.delete(databaseId);
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
- async notifyClusterRoutingChanged(databaseId) {
1121
- const transport = this.topologyTransports.get(databaseId);
1122
- if (!transport) return;
1123
- await transport._handleClusterRoutingChanged();
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
- var TopologyAwareTransport = class {
1127
- databaseId;
1128
- client;
1129
- closed = false;
1130
- readTransport = null;
1131
- writeTransport = null;
1132
- subscriptionTransport = null;
1133
- readTransportRequest = null;
1134
- writeTransportRequest = null;
1135
- subscriptionTransportRequest = null;
1136
- subscriptionOperation = Promise.resolve();
1137
- activeSubscriptions = /* @__PURE__ */ new Map();
1138
- nextSubscriptionId = 0;
1139
- currentReadUrl = "";
1140
- currentWriteUrl = "";
1141
- currentSubscriptionUrl = "";
1142
- constructor(databaseId, client) {
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 execute(sql, params) {
1175
- const transport = await this.getWriteTransport();
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
- return await transport.execute(sql, params);
1178
- } catch (err) {
1179
- if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
1180
- await this.client._refreshClusterRouting(this.databaseId);
1181
- this.writeTransport = null;
1182
- this.currentWriteUrl = "";
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
- throw err;
911
+ } catch (err) {
912
+ await this.handleFailure(err);
913
+ } finally {
914
+ this.pushing = false;
1185
915
  }
1186
916
  }
1187
- async transaction(statements) {
1188
- const transport = await this.getWriteTransport();
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
- return await transport.transaction(statements);
1191
- } catch (err) {
1192
- if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
1193
- await this.client._refreshClusterRouting(this.databaseId);
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
- throw err;
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
- async batch(sql, paramsBatch, writeConcern) {
1201
- const transport = await this.getWriteTransport();
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
- return await transport.batch(sql, paramsBatch, writeConcern);
1204
- } catch (err) {
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
- async load(sql, paramsBatch, durability, checkpoint) {
1214
- const transport = await this.getWriteTransport();
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
- return await transport.load(sql, paramsBatch, durability, checkpoint);
1217
- } catch (err) {
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 subscribe(table, filter, callback, options) {
1000
+ async attempt() {
1001
+ if (!this.hooks.isRunning() || !this.required) return;
1227
1002
  try {
1228
- return await this.subscribeOnCurrentEndpoint(table, filter, callback, options);
1229
- } catch (err) {
1230
- if (this.client._usesCoordinatorDiscovery() && shouldRefreshRouting(err)) {
1231
- const hadActiveSubscriptions = this.activeSubscriptions.size > 0;
1232
- if (!hadActiveSubscriptions) {
1233
- this.closeSubscriptionTransport();
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
- await this.client._refreshClusterRouting(this.databaseId);
1236
- if (!hadActiveSubscriptions) {
1237
- this.closeSubscriptionTransport();
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
- async _handleClusterRoutingChanged() {
1245
- if (!this.client._usesCoordinatorDiscovery()) return;
1246
- if (this.activeSubscriptions.size === 0) return;
1247
- await this.withSubscriptionOperation(() => this.migrateSubscriptionsToCurrentEndpoint());
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
- createSubscriptionHandle(subscription) {
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
- unsubscribe: () => {
1294
- if (!subscription.active) return;
1295
- subscription.active = false;
1296
- this.activeSubscriptions.delete(subscription.id);
1297
- const remote = subscription.remote;
1298
- subscription.remote = null;
1299
- remote?.unsubscribe();
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
- async migrateSubscriptionsToCurrentEndpoint() {
1304
- this.assertOpen();
1305
- const subscriptions = [...this.activeSubscriptions.values()].filter((subscription) => subscription.active);
1306
- if (subscriptions.length === 0) return;
1307
- const readConcern = this.client._getReadConcern();
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
- for (const subscription of subscriptions) {
1317
- if (!subscription.active) continue;
1318
- const remote = await nextTransport.subscribe(subscription.table, subscription.filter, subscription.callback, {
1319
- onReset: subscription.onReset
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
- for (const remote of nextSubscriptions.values()) {
1329
- remote.unsubscribe();
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 getReadTransport(readConcern) {
1353
- this.assertOpen();
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 resolveReadTransport(readConcern) {
1369
- const endpoint = await this.client._getReadEndpoint(this.databaseId, readConcern);
1370
- this.assertOpen();
1371
- if (this.readTransport && this.currentReadUrl === endpoint) {
1372
- return this.readTransport;
1373
- }
1374
- const nextTransport = this.client._createTransportForEndpoint(endpoint, this.databaseId);
1375
- const oldTransport = this.readTransport;
1376
- this.readTransport = nextTransport;
1377
- this.currentReadUrl = endpoint;
1378
- if (oldTransport) {
1379
- oldTransport.close();
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 this.readTransport;
1208
+ return result.status;
1382
1209
  }
1383
- async getWriteTransport() {
1384
- this.assertOpen();
1385
- while (this.writeTransportRequest) {
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 resolveWriteTransport() {
1400
- const endpoint = await this.client._getWriteEndpoint(this.databaseId);
1401
- this.assertOpen();
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
- const nextTransport = this.client._createTransportForEndpoint(endpoint, this.databaseId);
1406
- const oldTransport = this.writeTransport;
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
- return this.writeTransport;
1413
- }
1414
- async getSubscriptionTransport(readConcern) {
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
- while (this.subscriptionTransportRequest) {
1420
- await this.subscriptionTransportRequest.catch(() => void 0);
1421
- this.assertOpen();
1422
- if (this.subscriptionTransport) {
1423
- return this.subscriptionTransport;
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
- const request = this.resolveSubscriptionTransport(readConcern);
1427
- this.subscriptionTransportRequest = request;
1255
+ this.state = "stopped";
1428
1256
  try {
1429
- return await request;
1257
+ await this.start();
1430
1258
  } finally {
1431
- if (this.subscriptionTransportRequest === request) {
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
- async withSubscriptionOperation(operation) {
1457
- const previous = this.subscriptionOperation;
1458
- let release = () => {
1459
- };
1460
- this.subscriptionOperation = new Promise((resolve) => {
1461
- release = resolve;
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 operation();
1466
- } finally {
1467
- release();
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
- assertOpen() {
1471
- if (this.closed) {
1472
- throw new RemoteError("TRANSPORT_ERROR", "Transport is closed");
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 toSubscriptionRoutingError(err, databaseId) {
1477
- const detail = err instanceof Error ? err.message : String(err);
1478
- return new RemoteError(
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 { HttpTransport, RemoteDatabase, RemoteError, RemoteSubscriptionBuilderImpl, SirannonClient, WebSocketTransport };
1340
+ export { FieldMergeResolver, LWWResolver, PrimaryWinsResolver, SirannonClient, SyncController, downloadDatabaseSnapshot, encodeSyncBatch, pushSyncBatch };