@semics-tech/mongolite 1.3.0 → 1.4.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 (63) hide show
  1. package/README.md +79 -21
  2. package/dist/cjs/collection.js +8 -1
  3. package/dist/cjs/index.js +108 -1
  4. package/dist/cjs/mongo-client.js +18 -1
  5. package/dist/cjs/server.js +164 -0
  6. package/dist/cjs/sync/commands.js +126 -0
  7. package/dist/cjs/sync/diff.js +174 -0
  8. package/dist/cjs/sync/httpSink.js +159 -0
  9. package/dist/cjs/sync/index.js +40 -0
  10. package/dist/cjs/sync/mongoExecutor.js +306 -0
  11. package/dist/cjs/sync/mongoSink.js +161 -0
  12. package/dist/cjs/sync/outbox.js +358 -0
  13. package/dist/cjs/sync/protocol.js +177 -0
  14. package/dist/cjs/sync/replicator.js +747 -0
  15. package/dist/cjs/sync/shadow.js +178 -0
  16. package/dist/cjs/sync/types.js +2 -0
  17. package/dist/cloudflare.d.ts +1 -1
  18. package/dist/cloudflare.js.map +1 -1
  19. package/dist/collection.js +8 -1
  20. package/dist/collection.js.map +1 -1
  21. package/dist/index.d.ts +68 -2
  22. package/dist/index.js +96 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/mongo-client.d.ts +13 -0
  25. package/dist/mongo-client.js +18 -1
  26. package/dist/mongo-client.js.map +1 -1
  27. package/dist/server.d.ts +97 -0
  28. package/dist/server.js +158 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/sync/commands.d.ts +49 -0
  31. package/dist/sync/commands.js +118 -0
  32. package/dist/sync/commands.js.map +1 -0
  33. package/dist/sync/diff.d.ts +55 -0
  34. package/dist/sync/diff.js +169 -0
  35. package/dist/sync/diff.js.map +1 -0
  36. package/dist/sync/httpSink.d.ts +93 -0
  37. package/dist/sync/httpSink.js +155 -0
  38. package/dist/sync/httpSink.js.map +1 -0
  39. package/dist/sync/index.d.ts +17 -0
  40. package/dist/sync/index.js +10 -0
  41. package/dist/sync/index.js.map +1 -0
  42. package/dist/sync/mongoExecutor.d.ts +37 -0
  43. package/dist/sync/mongoExecutor.js +302 -0
  44. package/dist/sync/mongoExecutor.js.map +1 -0
  45. package/dist/sync/mongoSink.d.ts +109 -0
  46. package/dist/sync/mongoSink.js +123 -0
  47. package/dist/sync/mongoSink.js.map +1 -0
  48. package/dist/sync/outbox.d.ts +120 -0
  49. package/dist/sync/outbox.js +355 -0
  50. package/dist/sync/outbox.js.map +1 -0
  51. package/dist/sync/protocol.d.ts +93 -0
  52. package/dist/sync/protocol.js +168 -0
  53. package/dist/sync/protocol.js.map +1 -0
  54. package/dist/sync/replicator.d.ts +177 -0
  55. package/dist/sync/replicator.js +744 -0
  56. package/dist/sync/replicator.js.map +1 -0
  57. package/dist/sync/shadow.d.ts +54 -0
  58. package/dist/sync/shadow.js +173 -0
  59. package/dist/sync/shadow.js.map +1 -0
  60. package/dist/sync/types.d.ts +388 -0
  61. package/dist/sync/types.js +2 -0
  62. package/dist/sync/types.js.map +1 -0
  63. package/package.json +15 -1
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ /**
3
+ * A {@link SyncSink} that writes to a MongoDB deployment using the official
4
+ * `mongodb` driver.
5
+ *
6
+ * The driver is an **optional peer dependency**, imported dynamically the first
7
+ * time a connection is opened. Projects that never replicate upstream do not
8
+ * need it installed, and bundlers that never reach this module do not pull it in.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.MongoUpstreamSink = exports.UPDATED_AT_FIELD = exports.VERSION_FIELD = void 0;
45
+ const commands_js_1 = require("./commands.js");
46
+ const mongoExecutor_js_1 = require("./mongoExecutor.js");
47
+ var commands_js_2 = require("./commands.js");
48
+ Object.defineProperty(exports, "VERSION_FIELD", { enumerable: true, get: function () { return commands_js_2.VERSION_FIELD; } });
49
+ Object.defineProperty(exports, "UPDATED_AT_FIELD", { enumerable: true, get: function () { return commands_js_2.UPDATED_AT_FIELD; } });
50
+ class MongoUpstreamSink {
51
+ constructor(options) {
52
+ this.options = options;
53
+ this.name = 'mongodb';
54
+ this.client = null;
55
+ this.db = null;
56
+ this.driver = null;
57
+ if (!options.connectionString) {
58
+ throw new Error('MongoUpstreamSink requires a `connectionString`.');
59
+ }
60
+ this.idMapping = options.idMapping ?? 'auto';
61
+ this.versioning = options.versioning ?? true;
62
+ this.driver = options.driver ?? null;
63
+ }
64
+ async connect() {
65
+ if (this.client && this.db)
66
+ return;
67
+ const driver = await this.loadDriver();
68
+ const client = new driver.MongoClient(this.options.connectionString, this.clientOptions());
69
+ await client.connect();
70
+ this.client = client;
71
+ this.db = client.db(this.options.database);
72
+ }
73
+ async apply(operations) {
74
+ if (!this.db)
75
+ await this.connect();
76
+ const db = this.db;
77
+ if (!db)
78
+ throw new Error('MongoUpstreamSink is not connected.');
79
+ const writes = operations.map((op, index) => ({
80
+ index,
81
+ op,
82
+ command: (0, commands_js_1.buildWriteCommand)(op, this.commandOptions()),
83
+ }));
84
+ return (0, mongoExecutor_js_1.executeWrites)(db, writes, {
85
+ writeConcern: this.options.writeConcern ?? { w: 'majority' },
86
+ versioning: this.versioning,
87
+ idMapping: this.idMapping,
88
+ ObjectId: this.driver?.ObjectId,
89
+ });
90
+ }
91
+ /**
92
+ * Command-building settings. The driver's own `ObjectId` is used when available so
93
+ * BSON instances match the connection that will serialise them.
94
+ */
95
+ commandOptions() {
96
+ return {
97
+ idMapping: this.idMapping,
98
+ versioning: this.versioning,
99
+ ObjectId: this.driver?.ObjectId,
100
+ };
101
+ }
102
+ async fetch(collection, documentIds) {
103
+ if (documentIds.length === 0)
104
+ return [];
105
+ if (!this.db)
106
+ await this.connect();
107
+ const db = this.db;
108
+ if (!db)
109
+ throw new Error('MongoUpstreamSink is not connected.');
110
+ return (0, mongoExecutor_js_1.fetchDocuments)(db, collection, documentIds, this.idMapping, this.driver?.ObjectId);
111
+ }
112
+ async close() {
113
+ const client = this.client;
114
+ this.client = null;
115
+ this.db = null;
116
+ if (client)
117
+ await client.close();
118
+ }
119
+ // ---------------------------------------------------------------- internals
120
+ async loadDriver() {
121
+ if (this.driver)
122
+ return this.driver;
123
+ try {
124
+ // Indirect specifier: `mongodb` is optional, and a literal import would
125
+ // make it a hard build-time dependency for everyone compiling this package.
126
+ const specifier = 'mongodb';
127
+ const mod = (await Promise.resolve(`${specifier}`).then(s => __importStar(require(s))));
128
+ this.driver = mod;
129
+ return mod;
130
+ }
131
+ catch (err) {
132
+ throw new Error('MongoDB sync requires the `mongodb` driver. Install it with `npm install mongodb`, ' +
133
+ `or pass a \`driver\` explicitly. Original error: ${err instanceof Error ? err.message : String(err)}`);
134
+ }
135
+ }
136
+ clientOptions() {
137
+ const { authSource, authMechanism, authMechanismProperties, username, password, tls, tlsCAFile, tlsCertificateKeyFile, tlsCertificateKeyFilePassword, tlsCRLFile, tlsAllowInvalidHostnames, tlsAllowInvalidCertificates, driverOptions, } = this.options;
138
+ const auth = username !== undefined || password !== undefined ? { username, password } : undefined;
139
+ // Undefined keys are stripped so they never override a setting that the
140
+ // connection string already specified.
141
+ return stripUndefined({
142
+ authSource,
143
+ authMechanism,
144
+ authMechanismProperties,
145
+ auth,
146
+ tls,
147
+ tlsCAFile,
148
+ tlsCertificateKeyFile,
149
+ tlsCertificateKeyFilePassword,
150
+ tlsCRLFile,
151
+ tlsAllowInvalidHostnames,
152
+ tlsAllowInvalidCertificates,
153
+ ...driverOptions,
154
+ });
155
+ }
156
+ }
157
+ exports.MongoUpstreamSink = MongoUpstreamSink;
158
+ /** Drops unset keys so they never override what the connection string specified. */
159
+ function stripUndefined(value) {
160
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
161
+ }
@@ -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
+ }