@semics-tech/mongolite 1.3.0 → 1.4.1

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 (67) hide show
  1. package/README.md +80 -21
  2. package/dist/cjs/collection.js +9 -6
  3. package/dist/cjs/cursors/findCursor.js +6 -4
  4. package/dist/cjs/index.js +108 -1
  5. package/dist/cjs/mongo-client.js +18 -1
  6. package/dist/cjs/server.js +164 -0
  7. package/dist/cjs/sync/commands.js +126 -0
  8. package/dist/cjs/sync/diff.js +174 -0
  9. package/dist/cjs/sync/httpSink.js +159 -0
  10. package/dist/cjs/sync/index.js +40 -0
  11. package/dist/cjs/sync/mongoExecutor.js +306 -0
  12. package/dist/cjs/sync/mongoSink.js +161 -0
  13. package/dist/cjs/sync/outbox.js +358 -0
  14. package/dist/cjs/sync/protocol.js +177 -0
  15. package/dist/cjs/sync/replicator.js +747 -0
  16. package/dist/cjs/sync/shadow.js +178 -0
  17. package/dist/cjs/sync/types.js +2 -0
  18. package/dist/cloudflare.d.ts +1 -1
  19. package/dist/cloudflare.js.map +1 -1
  20. package/dist/collection.js +9 -6
  21. package/dist/collection.js.map +1 -1
  22. package/dist/cursors/findCursor.d.ts +2 -1
  23. package/dist/cursors/findCursor.js +6 -4
  24. package/dist/cursors/findCursor.js.map +1 -1
  25. package/dist/index.d.ts +68 -2
  26. package/dist/index.js +96 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/mongo-client.d.ts +13 -0
  29. package/dist/mongo-client.js +18 -1
  30. package/dist/mongo-client.js.map +1 -1
  31. package/dist/server.d.ts +97 -0
  32. package/dist/server.js +158 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/sync/commands.d.ts +49 -0
  35. package/dist/sync/commands.js +118 -0
  36. package/dist/sync/commands.js.map +1 -0
  37. package/dist/sync/diff.d.ts +55 -0
  38. package/dist/sync/diff.js +169 -0
  39. package/dist/sync/diff.js.map +1 -0
  40. package/dist/sync/httpSink.d.ts +93 -0
  41. package/dist/sync/httpSink.js +155 -0
  42. package/dist/sync/httpSink.js.map +1 -0
  43. package/dist/sync/index.d.ts +17 -0
  44. package/dist/sync/index.js +10 -0
  45. package/dist/sync/index.js.map +1 -0
  46. package/dist/sync/mongoExecutor.d.ts +37 -0
  47. package/dist/sync/mongoExecutor.js +302 -0
  48. package/dist/sync/mongoExecutor.js.map +1 -0
  49. package/dist/sync/mongoSink.d.ts +109 -0
  50. package/dist/sync/mongoSink.js +123 -0
  51. package/dist/sync/mongoSink.js.map +1 -0
  52. package/dist/sync/outbox.d.ts +120 -0
  53. package/dist/sync/outbox.js +355 -0
  54. package/dist/sync/outbox.js.map +1 -0
  55. package/dist/sync/protocol.d.ts +93 -0
  56. package/dist/sync/protocol.js +168 -0
  57. package/dist/sync/protocol.js.map +1 -0
  58. package/dist/sync/replicator.d.ts +177 -0
  59. package/dist/sync/replicator.js +744 -0
  60. package/dist/sync/replicator.js.map +1 -0
  61. package/dist/sync/shadow.d.ts +54 -0
  62. package/dist/sync/shadow.js +173 -0
  63. package/dist/sync/shadow.js.map +1 -0
  64. package/dist/sync/types.d.ts +388 -0
  65. package/dist/sync/types.js +2 -0
  66. package/dist/sync/types.js.map +1 -0
  67. package/package.json +16 -2
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyncOutbox = exports.SYNC_OBJECT_PREFIX = exports.DEAD_LETTER_TABLE = exports.STATE_TABLE = exports.OUTBOX_TABLE = void 0;
4
+ exports.OUTBOX_TABLE = '__mongolite_sync_outbox__';
5
+ exports.STATE_TABLE = '__mongolite_sync_state__';
6
+ exports.DEAD_LETTER_TABLE = '__mongolite_sync_deadletter__';
7
+ /** Prefix shared by every table and trigger this module creates. */
8
+ exports.SYNC_OBJECT_PREFIX = '__mongolite_sync';
9
+ /** Quotes a SQL identifier, escaping any embedded double quotes. */
10
+ function quoteIdent(name) {
11
+ return `"${name.replace(/"/g, '""')}"`;
12
+ }
13
+ /** Quotes a SQL string literal, escaping any embedded single quotes. */
14
+ function quoteLiteral(value) {
15
+ return `'${value.replace(/'/g, "''")}'`;
16
+ }
17
+ /** Millisecond epoch as a SQL expression — portable across SQLite builds. */
18
+ const NOW_MS_SQL = `CAST((julianday('now') - 2440587.5) * 86400000.0 AS INTEGER)`;
19
+ function triggerName(collection, operation) {
20
+ return `${exports.SYNC_OBJECT_PREFIX}_${operation}__${collection}`;
21
+ }
22
+ /**
23
+ * Owns the outbox tables, the capture triggers, and every replicator's
24
+ * checkpoint. A single outbox serves any number of replicators: each keeps its
25
+ * own checkpoint row, and rows are pruned only once *every* replicator has
26
+ * passed them.
27
+ */
28
+ class SyncOutbox {
29
+ constructor(db) {
30
+ this.db = db;
31
+ this.schemaReady = null;
32
+ this.installedTriggers = new Set();
33
+ }
34
+ /** Creates the outbox, checkpoint and dead-letter tables. Idempotent. */
35
+ async ensureSchema() {
36
+ if (!this.schemaReady) {
37
+ this.schemaReady = this.createSchema().catch((err) => {
38
+ // Let a later call retry rather than caching the failure forever.
39
+ this.schemaReady = null;
40
+ throw err;
41
+ });
42
+ }
43
+ return this.schemaReady;
44
+ }
45
+ async createSchema() {
46
+ await this.db.exec(`
47
+ CREATE TABLE IF NOT EXISTS ${exports.OUTBOX_TABLE} (
48
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49
+ collection_name TEXT NOT NULL,
50
+ document_id TEXT NOT NULL,
51
+ operation TEXT NOT NULL,
52
+ full_document TEXT,
53
+ captured_at INTEGER NOT NULL
54
+ );
55
+ `);
56
+ await this.db.exec(`
57
+ CREATE INDEX IF NOT EXISTS ${exports.SYNC_OBJECT_PREFIX}_outbox_key
58
+ ON ${exports.OUTBOX_TABLE} (collection_name, document_id, id);
59
+ `);
60
+ await this.db.exec(`
61
+ CREATE TABLE IF NOT EXISTS ${exports.STATE_TABLE} (
62
+ name TEXT PRIMARY KEY,
63
+ checkpoint INTEGER NOT NULL DEFAULT 0,
64
+ backfilled TEXT NOT NULL DEFAULT '[]',
65
+ updated_at INTEGER NOT NULL DEFAULT 0
66
+ );
67
+ `);
68
+ await this.db.exec(`
69
+ CREATE TABLE IF NOT EXISTS ${exports.DEAD_LETTER_TABLE} (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ replicator TEXT NOT NULL,
72
+ collection_name TEXT NOT NULL,
73
+ document_id TEXT NOT NULL,
74
+ operation TEXT NOT NULL,
75
+ full_document TEXT,
76
+ error TEXT NOT NULL,
77
+ failed_at INTEGER NOT NULL
78
+ );
79
+ `);
80
+ }
81
+ /**
82
+ * Installs the insert/update/delete capture triggers for a collection.
83
+ *
84
+ * Triggers deliberately outlive the replicator that created them: changes made
85
+ * while replication is stopped still need to be captured, or restarting would
86
+ * silently skip them. Use {@link SyncOutbox.removeTriggers} to opt out.
87
+ */
88
+ async ensureTriggers(collection) {
89
+ if (this.installedTriggers.has(collection))
90
+ return;
91
+ await this.ensureSchema();
92
+ const table = quoteIdent(collection);
93
+ const literal = quoteLiteral(collection);
94
+ const insert = (op, row, doc) => `
95
+ CREATE TRIGGER IF NOT EXISTS ${quoteIdent(triggerName(collection, op))}
96
+ AFTER ${op === 'insert' ? 'INSERT' : op === 'update' ? 'UPDATE' : 'DELETE'} ON ${table}
97
+ FOR EACH ROW
98
+ BEGIN
99
+ INSERT INTO ${exports.OUTBOX_TABLE}
100
+ (collection_name, document_id, operation, full_document, captured_at)
101
+ VALUES
102
+ (${literal}, ${row}._id, ${quoteLiteral(op)}, ${doc}, ${NOW_MS_SQL});
103
+ END;
104
+ `;
105
+ await this.db.exec(insert('insert', 'NEW', 'NEW.data'));
106
+ await this.db.exec(insert('update', 'NEW', 'NEW.data'));
107
+ await this.db.exec(insert('delete', 'OLD', 'NULL'));
108
+ this.installedTriggers.add(collection);
109
+ }
110
+ /**
111
+ * Removes the capture triggers for a collection. Changes to it stop being
112
+ * recorded — existing queued rows are untouched.
113
+ */
114
+ async removeTriggers(collection) {
115
+ for (const op of ['insert', 'update', 'delete']) {
116
+ await this.db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(triggerName(collection, op))}`);
117
+ }
118
+ this.installedTriggers.delete(collection);
119
+ }
120
+ /** Lists user collections, excluding MongoLite's own bookkeeping tables. */
121
+ async listCollections() {
122
+ const rows = await this.db.all(`SELECT name FROM sqlite_master
123
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__mongolite%'
124
+ ORDER BY name`);
125
+ return rows.map((row) => row.name);
126
+ }
127
+ /**
128
+ * Reconciles the in-memory "already installed" cache with what is actually in
129
+ * the schema.
130
+ *
131
+ * Dropping a table drops its triggers with it, so a collection that was
132
+ * dropped and recreated would otherwise be silently uncaptured — the cache
133
+ * would insist its triggers were still there.
134
+ */
135
+ async refreshTriggerCache() {
136
+ const rows = await this.db.all(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE '${exports.SYNC_OBJECT_PREFIX}\\_%' ESCAPE '\\'`);
137
+ const present = new Set(rows.map((row) => row.name));
138
+ for (const collection of [...this.installedTriggers]) {
139
+ const complete = ['insert', 'update', 'delete'].every((op) => present.has(triggerName(collection, op)));
140
+ if (!complete)
141
+ this.installedTriggers.delete(collection);
142
+ }
143
+ }
144
+ /** The newest outbox id, or 0 when the log is empty. */
145
+ async currentSequence() {
146
+ const row = await this.db.get(`SELECT MAX(id) AS seq FROM ${exports.OUTBOX_TABLE}`);
147
+ return row?.seq ?? 0;
148
+ }
149
+ /** Reads the persisted checkpoint for a replicator, creating its state row if absent. */
150
+ async loadState(name) {
151
+ await this.ensureSchema();
152
+ const row = await this.db.get(`SELECT checkpoint, backfilled FROM ${exports.STATE_TABLE} WHERE name = ?`, [name]);
153
+ if (row) {
154
+ let backfilled = [];
155
+ try {
156
+ const parsed = JSON.parse(row.backfilled);
157
+ if (Array.isArray(parsed))
158
+ backfilled = parsed.filter((v) => typeof v === 'string');
159
+ }
160
+ catch {
161
+ // Corrupt bookkeeping should not wedge replication; treat as "nothing backfilled".
162
+ }
163
+ return { checkpoint: Number(row.checkpoint) || 0, backfilled };
164
+ }
165
+ // A brand new replicator starts from the current tail. Anything already in
166
+ // the collections is handled by the backfill, not by replaying the log.
167
+ const checkpoint = await this.currentSequence();
168
+ await this.db.run(`INSERT INTO ${exports.STATE_TABLE} (name, checkpoint, backfilled, updated_at)
169
+ VALUES (?, ?, '[]', ${NOW_MS_SQL})`, [name, checkpoint]);
170
+ return { checkpoint, backfilled: [] };
171
+ }
172
+ /**
173
+ * Advances a replicator's checkpoint.
174
+ *
175
+ * This is the acknowledgement, and it is a single atomic statement: a crash
176
+ * either leaves the old checkpoint (the batch is replayed, which is safe
177
+ * because upstream writes are idempotent) or the new one.
178
+ */
179
+ async saveCheckpoint(name, checkpoint) {
180
+ await this.db.run(`UPDATE ${exports.STATE_TABLE} SET checkpoint = ?, updated_at = ${NOW_MS_SQL} WHERE name = ?`, [checkpoint, name]);
181
+ }
182
+ /** Records that a collection's existing contents have been backfilled. */
183
+ async markBackfilled(name, collections) {
184
+ await this.db.run(`UPDATE ${exports.STATE_TABLE} SET backfilled = ?, updated_at = ${NOW_MS_SQL} WHERE name = ?`, [JSON.stringify(collections), name]);
185
+ }
186
+ /** Forgets a replicator, releasing the outbox rows its checkpoint was pinning. */
187
+ async unregister(name) {
188
+ await this.ensureSchema();
189
+ await this.db.run(`DELETE FROM ${exports.STATE_TABLE} WHERE name = ?`, [name]);
190
+ }
191
+ /** Reads the next batch of changes after `checkpoint`, oldest first. */
192
+ async readBatch(checkpoint, limit, collections) {
193
+ const params = [checkpoint];
194
+ let filter = '';
195
+ if (collections) {
196
+ if (collections.length === 0)
197
+ return [];
198
+ filter = ` AND collection_name IN (${collections.map(() => '?').join(', ')})`;
199
+ params.push(...collections);
200
+ }
201
+ params.push(limit);
202
+ const rows = await this.db.all(`SELECT id, collection_name, document_id, operation, full_document, captured_at
203
+ FROM ${exports.OUTBOX_TABLE}
204
+ WHERE id > ?${filter}
205
+ ORDER BY id ASC
206
+ LIMIT ?`, params);
207
+ return rows.map((row) => ({
208
+ id: Number(row.id),
209
+ collection: row.collection_name,
210
+ documentId: row.document_id,
211
+ operation: row.operation,
212
+ document: parseDocument(row.full_document, row.document_id),
213
+ capturedAt: Number(row.captured_at),
214
+ }));
215
+ }
216
+ /** Count of rows a replicator still has to apply. */
217
+ async pendingCount(checkpoint, collections) {
218
+ const params = [checkpoint];
219
+ let filter = '';
220
+ if (collections) {
221
+ if (collections.length === 0)
222
+ return 0;
223
+ filter = ` AND collection_name IN (${collections.map(() => '?').join(', ')})`;
224
+ params.push(...collections);
225
+ }
226
+ const row = await this.db.get(`SELECT COUNT(*) AS total FROM ${exports.OUTBOX_TABLE} WHERE id > ?${filter}`, params);
227
+ return Number(row?.total ?? 0);
228
+ }
229
+ /**
230
+ * Deletes rows every registered replicator has already applied.
231
+ * @returns Number of rows removed.
232
+ */
233
+ async prune() {
234
+ const row = await this.db.get(`SELECT MIN(checkpoint) AS floor FROM ${exports.STATE_TABLE}`);
235
+ const floor = row?.floor;
236
+ if (floor === null || floor === undefined)
237
+ return 0;
238
+ const result = await this.db.run(`DELETE FROM ${exports.OUTBOX_TABLE} WHERE id <= ?`, [floor]);
239
+ return result.changes;
240
+ }
241
+ /**
242
+ * Drops superseded revisions, keeping only the newest row per document.
243
+ *
244
+ * Safe because replication carries whole documents rather than deltas: the
245
+ * newest row alone reproduces the same upstream state. Rows at or below the
246
+ * slowest replicator's checkpoint are left to {@link SyncOutbox.prune}.
247
+ *
248
+ * @returns Number of rows removed.
249
+ */
250
+ async compact() {
251
+ const result = await this.db.run(`DELETE FROM ${exports.OUTBOX_TABLE}
252
+ WHERE id NOT IN (
253
+ SELECT MAX(id) FROM ${exports.OUTBOX_TABLE} GROUP BY collection_name, document_id
254
+ )
255
+ AND id > COALESCE((SELECT MIN(checkpoint) FROM ${exports.STATE_TABLE}), 0)`);
256
+ return result.changes;
257
+ }
258
+ /**
259
+ * Appends a change back onto the log so it is retried on a later drain.
260
+ *
261
+ * Used when a conflict is resolved in favour of the local version: the shadow has
262
+ * since been refreshed, so the replayed push diffs against current upstream state and
263
+ * carries the version it actually lost to.
264
+ */
265
+ async requeue(collection, documentId, document) {
266
+ await this.ensureSchema();
267
+ // `_id` lives in its own column locally and is re-attached on read, so it is
268
+ // stripped here to match what the capture triggers store.
269
+ let payload = null;
270
+ if (document) {
271
+ const rest = { ...document };
272
+ delete rest._id;
273
+ payload = JSON.stringify(rest);
274
+ }
275
+ await this.db.run(`INSERT INTO ${exports.OUTBOX_TABLE}
276
+ (collection_name, document_id, operation, full_document, captured_at)
277
+ VALUES (?, ?, ?, ?, ${NOW_MS_SQL})`, [collection, documentId, document ? 'update' : 'delete', payload]);
278
+ }
279
+ /** Moves a permanently-rejected change out of the stream so it cannot stall replication. */
280
+ async deadLetter(replicator, record, error) {
281
+ await this.db.run(`INSERT INTO ${exports.DEAD_LETTER_TABLE}
282
+ (replicator, collection_name, document_id, operation, full_document, error, failed_at)
283
+ VALUES (?, ?, ?, ?, ?, ?, ${NOW_MS_SQL})`, [
284
+ replicator,
285
+ record.collection,
286
+ record.documentId,
287
+ record.operation,
288
+ record.document === null || record.document === undefined
289
+ ? null
290
+ : JSON.stringify(record.document),
291
+ error,
292
+ ]);
293
+ }
294
+ /** Reads dead-lettered changes, newest first. */
295
+ async listDeadLetters(replicator, limit = 100) {
296
+ await this.ensureSchema();
297
+ const where = replicator ? 'WHERE replicator = ?' : '';
298
+ const params = replicator ? [replicator, limit] : [limit];
299
+ const rows = await this.db.all(`SELECT id, collection_name, document_id, operation, full_document, error, failed_at
300
+ FROM ${exports.DEAD_LETTER_TABLE} ${where} ORDER BY id DESC LIMIT ?`, params);
301
+ return rows.map((row) => ({
302
+ id: Number(row.id),
303
+ collection: row.collection_name,
304
+ documentId: row.document_id,
305
+ operation: row.operation,
306
+ document: parseDocument(row.full_document, row.document_id),
307
+ error: row.error,
308
+ failedAt: Number(row.failed_at),
309
+ }));
310
+ }
311
+ /** Removes dead-lettered rows, e.g. after replaying them by hand. */
312
+ async clearDeadLetters(replicator) {
313
+ await this.ensureSchema();
314
+ const result = replicator
315
+ ? await this.db.run(`DELETE FROM ${exports.DEAD_LETTER_TABLE} WHERE replicator = ?`, [replicator])
316
+ : await this.db.run(`DELETE FROM ${exports.DEAD_LETTER_TABLE}`);
317
+ return result.changes;
318
+ }
319
+ /**
320
+ * Streams a collection's current contents in `_id` order, for the initial
321
+ * backfill. Keyset pagination keeps memory flat on large collections.
322
+ */
323
+ async *scanCollection(collection, chunkSize) {
324
+ // `null` on the first pass so an empty-string `_id` is not skipped.
325
+ let after = null;
326
+ for (;;) {
327
+ const rows = await this.db.all(`SELECT _id, data FROM ${quoteIdent(collection)}
328
+ ${after === null ? '' : 'WHERE _id > ?'}
329
+ ORDER BY _id ASC LIMIT ?`, after === null ? [chunkSize] : [after, chunkSize]);
330
+ if (rows.length === 0)
331
+ return;
332
+ yield rows.map((row) => ({
333
+ documentId: row._id,
334
+ document: parseDocument(row.data, row._id),
335
+ }));
336
+ after = rows[rows.length - 1]._id;
337
+ }
338
+ }
339
+ }
340
+ exports.SyncOutbox = SyncOutbox;
341
+ /**
342
+ * Parses a stored document, re-attaching the `_id` that lives in its own column.
343
+ * Returns `null` for deletes and for rows whose JSON is unreadable — the caller
344
+ * dead-letters the latter rather than crashing the replicator.
345
+ */
346
+ function parseDocument(json, documentId) {
347
+ if (json === null || json === undefined)
348
+ return null;
349
+ try {
350
+ const parsed = JSON.parse(json);
351
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
352
+ return null;
353
+ return { _id: documentId, ...parsed };
354
+ }
355
+ catch {
356
+ return null;
357
+ }
358
+ }
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SyncProtocolError = exports.SYNC_CONTENT_TYPE = exports.SYNC_PROTOCOL_VERSION = void 0;
4
+ exports.encodeBody = encodeBody;
5
+ exports.decodeBody = decodeBody;
6
+ exports.toWireOperation = toWireOperation;
7
+ exports.fromWireOperation = fromWireOperation;
8
+ exports.parseApplyRequest = parseApplyRequest;
9
+ exports.parseFetchRequest = parseFetchRequest;
10
+ /**
11
+ * The wire format shared by the HTTP sink and the receiver that applies its messages.
12
+ *
13
+ * Both halves live in this package on purpose: a client and a receiver that disagree
14
+ * about the protocol lose data quietly, so they are versioned and released together.
15
+ *
16
+ * Bodies are **relaxed Extended JSON**, not plain JSON. Plain JSON would flatten
17
+ * `ObjectId` to a string and `Date` to an ISO string in transit — degrading exactly the
18
+ * BSON types the sync design goes to some trouble to preserve, and doing it invisibly.
19
+ *
20
+ * Relaxed rather than canonical, because canonical encodes every number as `$numberInt`
21
+ * or `$numberDouble` and revives it as a BSON wrapper object. A local `age: 31` would
22
+ * then be written upstream as an `Int32` over HTTP but as a double over a direct
23
+ * connection — the same change producing different stored types depending on transport.
24
+ * Relaxed keeps plain numbers plain while still preserving `Date`, `ObjectId`, `Binary`
25
+ * and `Decimal128`. The cost is that 64-bit integers beyond 2^53 lose precision, which
26
+ * the local store could not represent anyway.
27
+ */
28
+ const bson_1 = require("bson");
29
+ /**
30
+ * Protocol version, sent with every request.
31
+ *
32
+ * A receiver rejects a version it does not understand rather than guessing, so an
33
+ * upgraded client talking to an old server fails loudly instead of silently writing
34
+ * something unintended.
35
+ */
36
+ exports.SYNC_PROTOCOL_VERSION = 1;
37
+ /** Content type used for request and response bodies. */
38
+ exports.SYNC_CONTENT_TYPE = 'application/json';
39
+ /** Raised when a payload is malformed or speaks a version we do not implement. */
40
+ class SyncProtocolError extends Error {
41
+ constructor(message,
42
+ /** Suggested HTTP status for a receiver to return. */
43
+ status = 400) {
44
+ super(message);
45
+ this.status = status;
46
+ this.name = 'SyncProtocolError';
47
+ }
48
+ }
49
+ exports.SyncProtocolError = SyncProtocolError;
50
+ /** Serialises a body to relaxed Extended JSON. */
51
+ function encodeBody(value) {
52
+ return bson_1.EJSON.stringify(value, { relaxed: true });
53
+ }
54
+ /** Parses a relaxed Extended JSON body back into BSON-typed values. */
55
+ function decodeBody(raw) {
56
+ if (typeof raw !== 'string') {
57
+ // Some frameworks parse JSON before we see it. Re-encoding is the only way to get
58
+ // Extended JSON revived into real BSON types.
59
+ return bson_1.EJSON.parse(JSON.stringify(raw), { relaxed: true });
60
+ }
61
+ try {
62
+ return bson_1.EJSON.parse(raw, { relaxed: true });
63
+ }
64
+ catch (err) {
65
+ throw new SyncProtocolError(`Malformed sync payload: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ }
68
+ /** Converts an internal operation into its wire form. */
69
+ function toWireOperation(op, command) {
70
+ const wire = {
71
+ collection: op.collection,
72
+ documentId: op.documentId,
73
+ type: op.type,
74
+ };
75
+ if (op.baseVersion !== undefined)
76
+ wire.baseVersion = op.baseVersion;
77
+ if (op.diff)
78
+ wire.diff = op.diff;
79
+ if (op.document)
80
+ wire.document = op.document;
81
+ if (command)
82
+ wire.command = command;
83
+ return wire;
84
+ }
85
+ /**
86
+ * Validates a wire operation and converts it back into an internal one.
87
+ *
88
+ * Everything reaching a receiver is untrusted input, so this checks shape rather than
89
+ * assuming it. `index` and `sourceCollection` are reconstructed locally — a client does
90
+ * not get to choose them.
91
+ */
92
+ function fromWireOperation(value, index) {
93
+ if (typeof value !== 'object' || value === null) {
94
+ throw new SyncProtocolError(`operations[${index}] is not an object`);
95
+ }
96
+ const wire = value;
97
+ if (typeof wire.collection !== 'string' || wire.collection.length === 0) {
98
+ throw new SyncProtocolError(`operations[${index}].collection must be a non-empty string`);
99
+ }
100
+ if (typeof wire.documentId !== 'string' || wire.documentId.length === 0) {
101
+ throw new SyncProtocolError(`operations[${index}].documentId must be a non-empty string`);
102
+ }
103
+ if (wire.type !== 'upsert' && wire.type !== 'delete') {
104
+ throw new SyncProtocolError(`operations[${index}].type must be "upsert" or "delete"`);
105
+ }
106
+ if (wire.baseVersion !== undefined &&
107
+ wire.baseVersion !== null &&
108
+ typeof wire.baseVersion !== 'number') {
109
+ throw new SyncProtocolError(`operations[${index}].baseVersion must be a number or null`);
110
+ }
111
+ const op = {
112
+ collection: wire.collection,
113
+ sourceCollection: wire.collection,
114
+ documentId: wire.documentId,
115
+ type: wire.type,
116
+ outboxId: index,
117
+ baseVersion: wire.baseVersion ?? null,
118
+ };
119
+ if (isPlainRecord(wire.document))
120
+ op.document = wire.document;
121
+ if (wire.diff !== undefined) {
122
+ if (!isPlainRecord(wire.diff)) {
123
+ throw new SyncProtocolError(`operations[${index}].diff must be an object`);
124
+ }
125
+ const set = wire.diff.set;
126
+ const unset = wire.diff.unset;
127
+ if (!isPlainRecord(set) || !Array.isArray(unset) || unset.some((p) => typeof p !== 'string')) {
128
+ throw new SyncProtocolError(`operations[${index}].diff must be { set: object, unset: string[] }`);
129
+ }
130
+ op.diff = { set, unset: unset };
131
+ }
132
+ if (op.type === 'upsert' && !op.diff && !op.document) {
133
+ throw new SyncProtocolError(`operations[${index}] must carry a diff or a document`);
134
+ }
135
+ return op;
136
+ }
137
+ /** Validates the envelope of an apply request and returns its operations. */
138
+ function parseApplyRequest(body) {
139
+ const request = requireEnvelope(body);
140
+ const operations = request.operations;
141
+ if (!Array.isArray(operations)) {
142
+ throw new SyncProtocolError('`operations` must be an array');
143
+ }
144
+ return {
145
+ replicator: typeof request.replicator === 'string' ? request.replicator : 'unknown',
146
+ operations: operations.map((value, index) => fromWireOperation(value, index)),
147
+ };
148
+ }
149
+ /** Validates the envelope of a fetch request. */
150
+ function parseFetchRequest(body) {
151
+ const request = requireEnvelope(body);
152
+ if (typeof request.collection !== 'string' || request.collection.length === 0) {
153
+ throw new SyncProtocolError('`collection` must be a non-empty string');
154
+ }
155
+ if (!Array.isArray(request.documentIds) ||
156
+ request.documentIds.some((id) => typeof id !== 'string')) {
157
+ throw new SyncProtocolError('`documentIds` must be an array of strings');
158
+ }
159
+ return { collection: request.collection, documentIds: request.documentIds };
160
+ }
161
+ function requireEnvelope(body) {
162
+ if (!isPlainRecord(body)) {
163
+ throw new SyncProtocolError('Request body must be an object');
164
+ }
165
+ // Coerced rather than compared strictly: a proxy that re-encodes the body could hand
166
+ // us the version as a string or a BSON number wrapper, and refusing on that would be
167
+ // a confusing failure for something entirely cosmetic.
168
+ if (Number(body.protocol) !== exports.SYNC_PROTOCOL_VERSION) {
169
+ throw new SyncProtocolError(`Unsupported sync protocol ${String(body.protocol)}; this receiver implements ${exports.SYNC_PROTOCOL_VERSION}`,
170
+ // 426 tells the client the fix is to upgrade, not to retry.
171
+ 426);
172
+ }
173
+ return body;
174
+ }
175
+ function isPlainRecord(value) {
176
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
177
+ }