mongolite-ts 0.4.0 → 0.6.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.
@@ -0,0 +1,338 @@
1
+ import { EventEmitter } from 'events';
2
+ export class ChangeStream extends EventEmitter {
3
+ constructor(db, collectionName, options = {}) {
4
+ super();
5
+ this.db = db;
6
+ this.collectionName = collectionName;
7
+ this.options = options;
8
+ this.closed = false;
9
+ this.pollInterval = null;
10
+ this.lastProcessedId = 0;
11
+ this.buffer = [];
12
+ this.maxBufferSize = options.maxBufferSize || 1000;
13
+ this.setupChangeTracking();
14
+ this.startPolling();
15
+ }
16
+ /**
17
+ * Sets up the change tracking infrastructure
18
+ */
19
+ async setupChangeTracking() {
20
+ await this.ensureChangeLogTable();
21
+ await this.setupTriggers();
22
+ }
23
+ /**
24
+ * Creates the change log table if it doesn't exist
25
+ */
26
+ async ensureChangeLogTable() {
27
+ const createTableSQL = `
28
+ CREATE TABLE IF NOT EXISTS __mongolite_changes__ (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
31
+ operation_type TEXT NOT NULL,
32
+ collection_name TEXT NOT NULL,
33
+ document_id TEXT NOT NULL,
34
+ full_document TEXT,
35
+ full_document_before TEXT,
36
+ updated_fields TEXT,
37
+ removed_fields TEXT,
38
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
39
+ );
40
+ `;
41
+ const createIndexSQL = `
42
+ CREATE INDEX IF NOT EXISTS idx_mongolite_changes_collection_timestamp
43
+ ON __mongolite_changes__ (collection_name, timestamp);
44
+ `;
45
+ await this.db.exec(createTableSQL);
46
+ await this.db.exec(createIndexSQL);
47
+ }
48
+ /**
49
+ * Sets up triggers for tracking changes on the collection
50
+ */
51
+ async setupTriggers() {
52
+ const collectionName = this.collectionName;
53
+ // Drop existing triggers for this collection
54
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_insert_trigger"`);
55
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_update_trigger"`);
56
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_delete_trigger"`);
57
+ // Insert trigger
58
+ const insertTriggerSQL = `
59
+ CREATE TRIGGER "${collectionName}_insert_trigger"
60
+ AFTER INSERT ON "${collectionName}"
61
+ FOR EACH ROW
62
+ BEGIN
63
+ INSERT INTO __mongolite_changes__ (
64
+ operation_type, collection_name, document_id, full_document
65
+ ) VALUES (
66
+ 'insert', '${collectionName}', NEW._id, NEW.data
67
+ );
68
+ END;
69
+ `;
70
+ // Update trigger
71
+ const updateTriggerSQL = `
72
+ CREATE TRIGGER "${collectionName}_update_trigger"
73
+ AFTER UPDATE ON "${collectionName}"
74
+ FOR EACH ROW
75
+ BEGIN
76
+ INSERT INTO __mongolite_changes__ (
77
+ operation_type, collection_name, document_id,
78
+ full_document, full_document_before
79
+ ) VALUES (
80
+ 'update', '${collectionName}', NEW._id,
81
+ NEW.data, OLD.data
82
+ );
83
+ END;
84
+ `;
85
+ // Delete trigger
86
+ const deleteTriggerSQL = `
87
+ CREATE TRIGGER "${collectionName}_delete_trigger"
88
+ AFTER DELETE ON "${collectionName}"
89
+ FOR EACH ROW
90
+ BEGIN
91
+ INSERT INTO __mongolite_changes__ (
92
+ operation_type, collection_name, document_id, full_document_before
93
+ ) VALUES (
94
+ 'delete', '${collectionName}', OLD._id, OLD.data
95
+ );
96
+ END;
97
+ `;
98
+ await this.db.exec(insertTriggerSQL);
99
+ await this.db.exec(updateTriggerSQL);
100
+ await this.db.exec(deleteTriggerSQL);
101
+ }
102
+ /**
103
+ * Starts polling for new changes
104
+ */
105
+ startPolling() {
106
+ if (this.closed)
107
+ return;
108
+ this.pollInterval = setInterval(async () => {
109
+ try {
110
+ await this.pollForChanges();
111
+ }
112
+ catch (error) {
113
+ this.emit('error', error);
114
+ }
115
+ }, 100); // Poll every 100ms
116
+ }
117
+ /**
118
+ * Polls for new changes from the change log
119
+ */
120
+ async pollForChanges() {
121
+ if (this.closed)
122
+ return;
123
+ const changes = await this.db.all(`SELECT * FROM __mongolite_changes__
124
+ WHERE collection_name = ? AND id > ?
125
+ ORDER BY id ASC LIMIT 100`, [this.collectionName, this.lastProcessedId]);
126
+ for (const change of changes) {
127
+ if (this.closed)
128
+ break;
129
+ const changeDoc = await this.transformChangeEvent(change);
130
+ if (this.passesFilter(changeDoc)) {
131
+ this.buffer.push(changeDoc);
132
+ // Emit the change event
133
+ this.emit('change', changeDoc);
134
+ // Clean buffer if it gets too large
135
+ if (this.buffer.length > this.maxBufferSize) {
136
+ this.buffer = this.buffer.slice(-this.maxBufferSize);
137
+ }
138
+ }
139
+ this.lastProcessedId = change.id;
140
+ }
141
+ }
142
+ /**
143
+ * Transforms a raw change event from the database into a ChangeStreamDocument
144
+ */
145
+ async transformChangeEvent(change) {
146
+ const fullDocument = change.full_document ? JSON.parse(change.full_document) : undefined;
147
+ const fullDocumentBefore = change.full_document_before
148
+ ? JSON.parse(change.full_document_before)
149
+ : undefined;
150
+ let updateDescription;
151
+ if (change.operation_type === 'update' && fullDocument && fullDocumentBefore) {
152
+ updateDescription = this.computeUpdateDescription(fullDocumentBefore, fullDocument);
153
+ }
154
+ const changeDoc = {
155
+ _id: `${change.id}`, // Use the change log ID as the change stream document ID
156
+ operationType: change.operation_type,
157
+ clusterTime: new Date(change.timestamp),
158
+ documentKey: { _id: change.document_id },
159
+ ns: {
160
+ db: 'mongolite', // You could make this configurable
161
+ coll: change.collection_name,
162
+ },
163
+ };
164
+ // Add full document based on options
165
+ if (this.shouldIncludeFullDocument(change.operation_type)) {
166
+ changeDoc.fullDocument = fullDocument
167
+ ? { _id: change.document_id, ...fullDocument }
168
+ : undefined;
169
+ }
170
+ // Add full document before change based on options
171
+ if (this.shouldIncludeFullDocumentBefore(change.operation_type)) {
172
+ changeDoc.fullDocumentBeforeChange = fullDocumentBefore
173
+ ? { _id: change.document_id, ...fullDocumentBefore }
174
+ : undefined;
175
+ }
176
+ if (updateDescription) {
177
+ changeDoc.updateDescription = updateDescription;
178
+ }
179
+ return changeDoc;
180
+ }
181
+ /**
182
+ * Computes the update description by comparing before and after documents
183
+ */
184
+ computeUpdateDescription(before, after) {
185
+ const updatedFields = {};
186
+ const removedFields = [];
187
+ // Type guard to ensure we have objects to compare
188
+ if (typeof before !== 'object' ||
189
+ before === null ||
190
+ typeof after !== 'object' ||
191
+ after === null) {
192
+ return { updatedFields, removedFields };
193
+ }
194
+ // Simple comparison - this could be made more sophisticated
195
+ const beforeObj = before;
196
+ const afterObj = after;
197
+ const allKeys = new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]);
198
+ for (const key of allKeys) {
199
+ if (!(key in afterObj)) {
200
+ removedFields.push(key);
201
+ }
202
+ else if (!(key in beforeObj) ||
203
+ JSON.stringify(beforeObj[key]) !== JSON.stringify(afterObj[key])) {
204
+ updatedFields[key] = afterObj[key];
205
+ }
206
+ }
207
+ return { updatedFields, removedFields };
208
+ }
209
+ /**
210
+ * Determines if the full document should be included
211
+ */
212
+ shouldIncludeFullDocument(operationType) {
213
+ const option = this.options.fullDocument || 'default';
214
+ switch (option) {
215
+ case 'default':
216
+ return operationType === 'insert';
217
+ case 'updateLookup':
218
+ case 'whenAvailable':
219
+ case 'required':
220
+ return (operationType === 'insert' || operationType === 'update' || operationType === 'replace');
221
+ default:
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * Determines if the full document before change should be included
227
+ */
228
+ shouldIncludeFullDocumentBefore(operationType) {
229
+ const option = this.options.fullDocumentBeforeChange || 'off';
230
+ if (option === 'off')
231
+ return false;
232
+ return operationType === 'update' || operationType === 'replace' || operationType === 'delete';
233
+ }
234
+ /**
235
+ * Checks if a change document passes the filter
236
+ */
237
+ passesFilter(changeDoc) {
238
+ if (!this.options.filter)
239
+ return true;
240
+ // Simple filter implementation - could be enhanced
241
+ // For now, just check basic equality filters
242
+ for (const [key, value] of Object.entries(this.options.filter)) {
243
+ const docValue = changeDoc[key];
244
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
245
+ // Handle operators like $in, $eq, etc.
246
+ // This is a simplified implementation
247
+ continue;
248
+ }
249
+ else if (docValue !== value) {
250
+ return false;
251
+ }
252
+ }
253
+ return true;
254
+ }
255
+ /**
256
+ * Returns an async iterator for the change stream
257
+ */
258
+ async *[Symbol.asyncIterator]() {
259
+ while (!this.closed) {
260
+ if (this.buffer.length > 0) {
261
+ yield this.buffer.shift();
262
+ }
263
+ else {
264
+ // Wait for next change
265
+ await new Promise((resolve) => {
266
+ const onChange = () => {
267
+ this.removeListener('change', onChange);
268
+ this.removeListener('close', onClose);
269
+ resolve();
270
+ };
271
+ const onClose = () => {
272
+ this.removeListener('change', onChange);
273
+ this.removeListener('close', onClose);
274
+ resolve();
275
+ };
276
+ this.once('change', onChange);
277
+ this.once('close', onClose);
278
+ });
279
+ }
280
+ }
281
+ }
282
+ /**
283
+ * Closes the change stream
284
+ */
285
+ close() {
286
+ if (this.closed)
287
+ return;
288
+ this.closed = true;
289
+ if (this.pollInterval) {
290
+ clearInterval(this.pollInterval);
291
+ this.pollInterval = null;
292
+ }
293
+ this.emit('close');
294
+ this.removeAllListeners();
295
+ }
296
+ /**
297
+ * Returns the next change document
298
+ */
299
+ async next() {
300
+ if (this.closed) {
301
+ return { value: {}, done: true };
302
+ }
303
+ if (this.buffer.length > 0) {
304
+ return { value: this.buffer.shift(), done: false };
305
+ }
306
+ // Wait for next change
307
+ return new Promise((resolve) => {
308
+ const onChange = (changeDoc) => {
309
+ this.removeListener('change', onChange);
310
+ this.removeListener('close', onClose);
311
+ resolve({ value: changeDoc, done: false });
312
+ };
313
+ const onClose = () => {
314
+ this.removeListener('change', onChange);
315
+ this.removeListener('close', onClose);
316
+ resolve({ value: {}, done: true });
317
+ };
318
+ this.once('change', onChange);
319
+ this.once('close', onClose);
320
+ });
321
+ }
322
+ /**
323
+ * Cleanup triggers when the change stream is destroyed
324
+ */
325
+ async cleanup() {
326
+ const collectionName = this.collectionName;
327
+ try {
328
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_insert_trigger"`);
329
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_update_trigger"`);
330
+ await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_delete_trigger"`);
331
+ }
332
+ catch (error) {
333
+ // Ignore errors during cleanup
334
+ console.warn('Error during change stream cleanup:', error);
335
+ }
336
+ }
337
+ }
338
+ //# sourceMappingURL=changeStream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changeStream.js","sourceRoot":"","sources":["../src/changeStream.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { SQLiteDB } from './db.js';
2
2
  import { DocumentWithId, Filter, UpdateFilter, InsertOneResult, UpdateResult, DeleteResult, Projection, IndexSpecification, CreateIndexOptions, CreateIndexResult, DropIndexResult, IndexInfo } from './types.js';
3
3
  import { FindCursor } from './cursors/findCursor.js';
4
+ import { ChangeStream, ChangeStreamOptions } from './changeStream.js';
4
5
  /**
5
6
  * MongoLiteCollection provides methods to interact with a specific SQLite table
6
7
  * as if it were a MongoDB collection.
@@ -147,6 +148,31 @@ export declare class MongoLiteCollection<T extends DocumentWithId> {
147
148
  * @returns {Promise<number>} The count of matching documents.
148
149
  */
149
150
  countDocuments(filter?: Filter<T>): Promise<number>;
151
+ /**
152
+ * Opens a change stream to watch for changes on this collection.
153
+ * Returns a ChangeStream that emits events when documents are inserted, updated, or deleted.
154
+ *
155
+ * @param options Options for the change stream
156
+ * @returns A ChangeStream instance
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * const changeStream = collection.watch();
161
+ *
162
+ * changeStream.on('change', (change) => {
163
+ * console.log('Change detected:', change);
164
+ * });
165
+ *
166
+ * // Or use async iteration
167
+ * for await (const change of changeStream) {
168
+ * console.log('Change detected:', change);
169
+ * }
170
+ *
171
+ * // Close the change stream when done
172
+ * changeStream.close();
173
+ * ```
174
+ */
175
+ watch(options?: ChangeStreamOptions<T>): ChangeStream<T>;
150
176
  /**
151
177
  * Retries an operation with exponential backoff when SQLITE_BUSY errors occur.
152
178
  * @param operation The operation function to retry.
@@ -1,6 +1,138 @@
1
1
  import { ObjectId } from 'bson';
2
2
  import { FindCursor } from './cursors/findCursor.js';
3
3
  import { extractRawIndexColumns } from './utils/indexing.js';
4
+ import { ChangeStream } from './changeStream.js';
5
+ /**
6
+ * Safely stringifies JSON data with validation and error handling.
7
+ * Prevents storing malformed JSON that could cause parsing issues later.
8
+ * @param data The data to stringify
9
+ * @param context Optional context for error messages
10
+ * @returns The JSON string
11
+ * @throws Error if the data cannot be safely stringified
12
+ */
13
+ function safeJsonStringify(data, context) {
14
+ try {
15
+ // First, validate that the data is JSON-serializable by attempting to stringify it
16
+ const jsonString = JSON.stringify(data);
17
+ // Verify that the stringified data can be parsed back (round-trip test)
18
+ try {
19
+ const parsed = JSON.parse(jsonString);
20
+ // Basic validation that the round-trip preserved the data structure
21
+ if (typeof data === 'object' && data !== null && typeof parsed !== 'object') {
22
+ throw new Error('Round-trip JSON validation failed: type mismatch');
23
+ }
24
+ }
25
+ catch (parseError) {
26
+ throw new Error(`Round-trip JSON validation failed: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}`);
27
+ }
28
+ return jsonString;
29
+ }
30
+ catch (error) {
31
+ const contextMsg = context ? ` in ${context}` : '';
32
+ if (error instanceof Error) {
33
+ throw new Error(`Failed to safely stringify JSON data${contextMsg}: ${error.message}`);
34
+ }
35
+ throw new Error(`Failed to safely stringify JSON data${contextMsg}: Unknown error`);
36
+ }
37
+ }
38
+ /**
39
+ * Safely parses JSON data with fallback mechanisms for malformed JSON.
40
+ * @param jsonString The JSON string to parse
41
+ * @param context Optional context for error messages and recovery
42
+ * @returns The parsed object or a fallback object
43
+ */
44
+ function safeJsonParse(jsonString, context) {
45
+ if (!jsonString || typeof jsonString !== 'string') {
46
+ console.warn(`Invalid JSON string${context ? ` in ${context}` : ''}: not a string or empty`);
47
+ return {};
48
+ }
49
+ try {
50
+ return JSON.parse(jsonString);
51
+ }
52
+ catch (error) {
53
+ // Log the error for debugging
54
+ console.error(`JSON parse error${context ? ` in ${context}` : ''}: ${error instanceof Error ? error.message : 'Unknown error'}`);
55
+ console.error(`Malformed JSON string: ${jsonString.substring(0, 500)}${jsonString.length > 500 ? '...' : ''}`);
56
+ // Attempt to recover from common JSON corruption issues
57
+ try {
58
+ // Try to fix common escaping issues
59
+ let fixedJson = jsonString;
60
+ // Fix double-escaped quotes
61
+ fixedJson = fixedJson.replace(/\\"/g, '"');
62
+ // Fix improperly escaped backslashes
63
+ fixedJson = fixedJson.replace(/\\\\/g, '\\');
64
+ // Try parsing the fixed JSON
65
+ const recovered = JSON.parse(fixedJson);
66
+ console.warn(`Successfully recovered malformed JSON${context ? ` in ${context}` : ''}`);
67
+ return recovered;
68
+ }
69
+ catch (recoveryError) {
70
+ console.error(`JSON recovery failed${context ? ` in ${context}` : ''}: ${recoveryError instanceof Error ? recoveryError.message : 'Unknown error'}`);
71
+ // Last resort: return an empty object with a special marker
72
+ return {
73
+ __mongoLiteCorrupted: true,
74
+ __originalData: jsonString,
75
+ __error: error instanceof Error ? error.message : 'Unknown JSON parse error',
76
+ };
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Validates that a document is safe to store (no functions, circular references, etc.)
82
+ * @param doc The document to validate
83
+ * @param path Current path for error reporting
84
+ * @throws Error if the document contains invalid data
85
+ */
86
+ function validateDocumentForStorage(doc, path = 'root') {
87
+ if (doc === null || doc === undefined) {
88
+ return;
89
+ }
90
+ if (typeof doc === 'function') {
91
+ throw new Error(`Document validation failed: Functions are not allowed in documents (found at ${path})`);
92
+ }
93
+ if (typeof doc === 'symbol') {
94
+ throw new Error(`Document validation failed: Symbols are not allowed in documents (found at ${path})`);
95
+ }
96
+ if (typeof doc === 'bigint') {
97
+ throw new Error(`Document validation failed: BigInt values are not supported, use regular numbers or strings (found at ${path})`);
98
+ }
99
+ if (doc instanceof RegExp) {
100
+ throw new Error(`Document validation failed: RegExp objects are not supported in documents (found at ${path})`);
101
+ }
102
+ if (Array.isArray(doc)) {
103
+ doc.forEach((item, index) => {
104
+ validateDocumentForStorage(item, `${path}[${index}]`);
105
+ });
106
+ }
107
+ else if (typeof doc === 'object') {
108
+ // Check for circular references by maintaining a Set of visited objects
109
+ const visited = new Set();
110
+ function checkCircular(obj, currentPath) {
111
+ if (visited.has(obj)) {
112
+ throw new Error(`Document validation failed: Circular reference detected at ${currentPath}`);
113
+ }
114
+ visited.add(obj);
115
+ for (const [key, value] of Object.entries(obj)) {
116
+ if (typeof value === 'object' && value !== null) {
117
+ checkCircular(value, `${currentPath}.${key}`);
118
+ }
119
+ }
120
+ visited.delete(obj);
121
+ }
122
+ try {
123
+ checkCircular(doc, path);
124
+ }
125
+ catch (error) {
126
+ if (error instanceof Error && error.message.includes('Circular reference')) {
127
+ throw error;
128
+ }
129
+ }
130
+ // Validate each property
131
+ for (const [key, value] of Object.entries(doc)) {
132
+ validateDocumentForStorage(value, `${path}.${key}`);
133
+ }
134
+ }
135
+ }
4
136
  /**
5
137
  * MongoLiteCollection provides methods to interact with a specific SQLite table
6
138
  * as if it were a MongoDB collection.
@@ -54,7 +186,14 @@ export class MongoLiteCollection {
54
186
  const docId = doc._id || new ObjectId().toString();
55
187
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
56
188
  const { _id, ...dataToStore } = { ...doc, _id: docId }; // Ensure _id is part of the internal structure
57
- const jsonData = JSON.stringify(dataToStore);
189
+ // Validate the document before storing
190
+ try {
191
+ validateDocumentForStorage(dataToStore);
192
+ }
193
+ catch (error) {
194
+ throw new Error(`Cannot insert document: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
195
+ }
196
+ const jsonData = safeJsonStringify(dataToStore, `insertOne for document ${docId}`);
58
197
  const sql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
59
198
  try {
60
199
  await this.db.run(sql, [docId, jsonData]);
@@ -103,13 +242,20 @@ export class MongoLiteCollection {
103
242
  async insertBatch(docs) {
104
243
  const results = [];
105
244
  // Prepare all documents and their data upfront
106
- const preparedDocs = docs.map(doc => {
245
+ const preparedDocs = docs.map((doc) => {
107
246
  const docId = doc._id || new ObjectId().toString();
108
247
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
109
248
  const { _id, ...dataToStore } = { ...doc, _id: docId };
249
+ // Validate the document before storing
250
+ try {
251
+ validateDocumentForStorage(dataToStore);
252
+ }
253
+ catch (error) {
254
+ throw new Error(`Cannot insert document ${docId}: ${error instanceof Error ? error.message : 'Unknown validation error'}`);
255
+ }
110
256
  return {
111
257
  id: docId,
112
- data: JSON.stringify(dataToStore)
258
+ data: safeJsonStringify(dataToStore, `insertBatch for document ${docId}`),
113
259
  };
114
260
  });
115
261
  const insertBatchOperation = async () => {
@@ -139,7 +285,7 @@ export class MongoLiteCollection {
139
285
  if (error.code === 'SQLITE_CONSTRAINT') {
140
286
  // Find which document caused the constraint violation
141
287
  const errorMessage = error.message;
142
- const duplicateId = preparedDocs.find(doc => errorMessage.includes(doc.id))?.id || 'unknown';
288
+ const duplicateId = preparedDocs.find((doc) => errorMessage.includes(doc.id))?.id || 'unknown';
143
289
  throw new Error(`Duplicate _id: ${duplicateId}`);
144
290
  }
145
291
  throw error;
@@ -211,7 +357,7 @@ export class MongoLiteCollection {
211
357
  const newDocId = update.$set?._id || new ObjectId().toString();
212
358
  const newDoc = { _id: newDocId, ...update.$set }; // Assuming $set is used for upsert
213
359
  // Ensure _id is set
214
- const jsonData = JSON.stringify(newDoc);
360
+ const jsonData = safeJsonStringify(newDoc, `upsert for document ${newDocId}`);
215
361
  const insertSql = `INSERT INTO "${this.name}" (_id, data) VALUES (?, ?)`;
216
362
  try {
217
363
  await this.db.run(insertSql, [newDocId, jsonData]);
@@ -233,7 +379,7 @@ export class MongoLiteCollection {
233
379
  if (!rowToUpdate) {
234
380
  return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
235
381
  }
236
- const currentDoc = JSON.parse(rowToUpdate.data);
382
+ const currentDoc = safeJsonParse(rowToUpdate.data, `updateOne for document ${rowToUpdate._id}`);
237
383
  let modified = false;
238
384
  // Process update operators
239
385
  for (const operator in update) {
@@ -322,7 +468,10 @@ export class MongoLiteCollection {
322
468
  if (modified) {
323
469
  // Update the document in SQLite
324
470
  const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
325
- const updateParams = [JSON.stringify(currentDoc), rowToUpdate._id];
471
+ const updateParams = [
472
+ safeJsonStringify(currentDoc, `updateOne for document ${rowToUpdate._id}`),
473
+ rowToUpdate._id,
474
+ ];
326
475
  try {
327
476
  await this.db.run(updateSql, updateParams);
328
477
  }
@@ -366,7 +515,7 @@ export class MongoLiteCollection {
366
515
  let modifiedCount = 0;
367
516
  const updatedIds = [];
368
517
  for (const rowToUpdate of rowsToUpdate) {
369
- const currentDoc = JSON.parse(rowToUpdate.data);
518
+ const currentDoc = safeJsonParse(rowToUpdate.data, `updateMany for document ${rowToUpdate._id}`);
370
519
  let modified = false;
371
520
  // Process update operators
372
521
  for (const operator in update) {
@@ -458,7 +607,10 @@ export class MongoLiteCollection {
458
607
  if (modified) {
459
608
  // Update the document in SQLite
460
609
  const updateSql = `UPDATE "${this.name}" SET data = ? WHERE _id = ?`;
461
- const updateParams = [JSON.stringify(currentDoc), rowToUpdate._id];
610
+ const updateParams = [
611
+ safeJsonStringify(currentDoc, `updateMany for document ${rowToUpdate._id}`),
612
+ rowToUpdate._id,
613
+ ];
462
614
  try {
463
615
  await this.db.run(updateSql, updateParams);
464
616
  modifiedCount++;
@@ -760,6 +912,33 @@ export class MongoLiteCollection {
760
912
  const result = await this.db.get(countSql, paramsForCount);
761
913
  return result?.count || 0;
762
914
  }
915
+ /**
916
+ * Opens a change stream to watch for changes on this collection.
917
+ * Returns a ChangeStream that emits events when documents are inserted, updated, or deleted.
918
+ *
919
+ * @param options Options for the change stream
920
+ * @returns A ChangeStream instance
921
+ *
922
+ * @example
923
+ * ```typescript
924
+ * const changeStream = collection.watch();
925
+ *
926
+ * changeStream.on('change', (change) => {
927
+ * console.log('Change detected:', change);
928
+ * });
929
+ *
930
+ * // Or use async iteration
931
+ * for await (const change of changeStream) {
932
+ * console.log('Change detected:', change);
933
+ * }
934
+ *
935
+ * // Close the change stream when done
936
+ * changeStream.close();
937
+ * ```
938
+ */
939
+ watch(options = {}) {
940
+ return new ChangeStream(this.db, this.name, options);
941
+ }
763
942
  /**
764
943
  * Retries an operation with exponential backoff when SQLITE_BUSY errors occur.
765
944
  * @param operation The operation function to retry.