@semics-tech/mongolite 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +38 -0
- package/dist/adapters/node-sqlite.d.ts +61 -0
- package/dist/adapters/node-sqlite.js +196 -0
- package/dist/adapters/node-sqlite.js.map +1 -0
- package/dist/better-sqlite3.d.ts +49 -0
- package/dist/better-sqlite3.js +60 -0
- package/dist/better-sqlite3.js.map +1 -0
- package/dist/bin/mongolite-debug.js +0 -0
- package/dist/cjs/adapters/browser.js +106 -0
- package/dist/cjs/adapters/cloudflare.js +97 -0
- package/dist/cjs/adapters/node-sqlite.js +199 -0
- package/dist/cjs/better-sqlite3.js +80 -0
- package/dist/cjs/bin/mongolite-debug.js +152 -0
- package/dist/cjs/changeStream.js +341 -0
- package/dist/cjs/cloudflare.js +59 -0
- package/dist/cjs/collection.js +1709 -0
- package/dist/cjs/cursors/findCursor.js +760 -0
- package/dist/cjs/db.js +260 -0
- package/dist/cjs/debugger/query-debugger.js +297 -0
- package/dist/cjs/index.js +65 -0
- package/dist/cjs/mongo-client.js +58 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils/indexing.js +45 -0
- package/dist/cjs/utils/projection.js +101 -0
- package/dist/cjs/utils/queryOperators.js +142 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/package.json +20 -10
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChangeStream = void 0;
|
|
4
|
+
const events_1 = require("events");
|
|
5
|
+
class ChangeStream extends events_1.EventEmitter {
|
|
6
|
+
constructor(db, collectionName, options = {}) {
|
|
7
|
+
super();
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.collectionName = collectionName;
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.closed = false;
|
|
12
|
+
this.pollInterval = null;
|
|
13
|
+
this.lastProcessedId = 0;
|
|
14
|
+
this.buffer = [];
|
|
15
|
+
this.maxBufferSize = options.maxBufferSize || 1000;
|
|
16
|
+
this.setupChangeTracking();
|
|
17
|
+
this.startPolling();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Sets up the change tracking infrastructure
|
|
21
|
+
*/
|
|
22
|
+
async setupChangeTracking() {
|
|
23
|
+
await this.ensureChangeLogTable();
|
|
24
|
+
await this.setupTriggers();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates the change log table if it doesn't exist
|
|
28
|
+
*/
|
|
29
|
+
async ensureChangeLogTable() {
|
|
30
|
+
const createTableSQL = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS __mongolite_changes__ (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
34
|
+
operation_type TEXT NOT NULL,
|
|
35
|
+
collection_name TEXT NOT NULL,
|
|
36
|
+
document_id TEXT NOT NULL,
|
|
37
|
+
full_document TEXT,
|
|
38
|
+
full_document_before TEXT,
|
|
39
|
+
updated_fields TEXT,
|
|
40
|
+
removed_fields TEXT,
|
|
41
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
42
|
+
);
|
|
43
|
+
`;
|
|
44
|
+
const createIndexSQL = `
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_mongolite_changes_collection_timestamp
|
|
46
|
+
ON __mongolite_changes__ (collection_name, timestamp);
|
|
47
|
+
`;
|
|
48
|
+
await this.db.exec(createTableSQL);
|
|
49
|
+
await this.db.exec(createIndexSQL);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Sets up triggers for tracking changes on the collection
|
|
53
|
+
*/
|
|
54
|
+
async setupTriggers() {
|
|
55
|
+
const collectionName = this.collectionName;
|
|
56
|
+
// Drop existing triggers for this collection
|
|
57
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_insert_trigger"`);
|
|
58
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_update_trigger"`);
|
|
59
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_delete_trigger"`);
|
|
60
|
+
// Insert trigger
|
|
61
|
+
const insertTriggerSQL = `
|
|
62
|
+
CREATE TRIGGER "${collectionName}_insert_trigger"
|
|
63
|
+
AFTER INSERT ON "${collectionName}"
|
|
64
|
+
FOR EACH ROW
|
|
65
|
+
BEGIN
|
|
66
|
+
INSERT INTO __mongolite_changes__ (
|
|
67
|
+
operation_type, collection_name, document_id, full_document
|
|
68
|
+
) VALUES (
|
|
69
|
+
'insert', '${collectionName}', NEW._id, NEW.data
|
|
70
|
+
);
|
|
71
|
+
END;
|
|
72
|
+
`;
|
|
73
|
+
// Update trigger
|
|
74
|
+
const updateTriggerSQL = `
|
|
75
|
+
CREATE TRIGGER "${collectionName}_update_trigger"
|
|
76
|
+
AFTER UPDATE ON "${collectionName}"
|
|
77
|
+
FOR EACH ROW
|
|
78
|
+
BEGIN
|
|
79
|
+
INSERT INTO __mongolite_changes__ (
|
|
80
|
+
operation_type, collection_name, document_id,
|
|
81
|
+
full_document, full_document_before
|
|
82
|
+
) VALUES (
|
|
83
|
+
'update', '${collectionName}', NEW._id,
|
|
84
|
+
NEW.data, OLD.data
|
|
85
|
+
);
|
|
86
|
+
END;
|
|
87
|
+
`;
|
|
88
|
+
// Delete trigger
|
|
89
|
+
const deleteTriggerSQL = `
|
|
90
|
+
CREATE TRIGGER "${collectionName}_delete_trigger"
|
|
91
|
+
AFTER DELETE ON "${collectionName}"
|
|
92
|
+
FOR EACH ROW
|
|
93
|
+
BEGIN
|
|
94
|
+
INSERT INTO __mongolite_changes__ (
|
|
95
|
+
operation_type, collection_name, document_id, full_document_before
|
|
96
|
+
) VALUES (
|
|
97
|
+
'delete', '${collectionName}', OLD._id, OLD.data
|
|
98
|
+
);
|
|
99
|
+
END;
|
|
100
|
+
`;
|
|
101
|
+
await this.db.exec(insertTriggerSQL);
|
|
102
|
+
await this.db.exec(updateTriggerSQL);
|
|
103
|
+
await this.db.exec(deleteTriggerSQL);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Starts polling for new changes
|
|
107
|
+
*/
|
|
108
|
+
startPolling() {
|
|
109
|
+
if (this.closed)
|
|
110
|
+
return;
|
|
111
|
+
this.pollInterval = setInterval(async () => {
|
|
112
|
+
try {
|
|
113
|
+
await this.pollForChanges();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.emit('error', error);
|
|
117
|
+
}
|
|
118
|
+
}, 100); // Poll every 100ms
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Polls for new changes from the change log
|
|
122
|
+
*/
|
|
123
|
+
async pollForChanges() {
|
|
124
|
+
if (this.closed)
|
|
125
|
+
return;
|
|
126
|
+
const changes = await this.db.all(`SELECT * FROM __mongolite_changes__
|
|
127
|
+
WHERE collection_name = ? AND id > ?
|
|
128
|
+
ORDER BY id ASC LIMIT 100`, [this.collectionName, this.lastProcessedId]);
|
|
129
|
+
for (const change of changes) {
|
|
130
|
+
if (this.closed)
|
|
131
|
+
break;
|
|
132
|
+
const changeDoc = await this.transformChangeEvent(change);
|
|
133
|
+
if (this.passesFilter(changeDoc)) {
|
|
134
|
+
this.buffer.push(changeDoc);
|
|
135
|
+
// Emit the change event
|
|
136
|
+
this.emit('change', changeDoc);
|
|
137
|
+
// Clean buffer if it gets too large
|
|
138
|
+
if (this.buffer.length > this.maxBufferSize) {
|
|
139
|
+
this.buffer = this.buffer.slice(-this.maxBufferSize);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.lastProcessedId = change.id;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Transforms a raw change event from the database into a ChangeStreamDocument
|
|
147
|
+
*/
|
|
148
|
+
async transformChangeEvent(change) {
|
|
149
|
+
const fullDocument = change.full_document ? JSON.parse(change.full_document) : undefined;
|
|
150
|
+
const fullDocumentBefore = change.full_document_before
|
|
151
|
+
? JSON.parse(change.full_document_before)
|
|
152
|
+
: undefined;
|
|
153
|
+
let updateDescription;
|
|
154
|
+
if (change.operation_type === 'update' && fullDocument && fullDocumentBefore) {
|
|
155
|
+
updateDescription = this.computeUpdateDescription(fullDocumentBefore, fullDocument);
|
|
156
|
+
}
|
|
157
|
+
const changeDoc = {
|
|
158
|
+
_id: `${change.id}`, // Use the change log ID as the change stream document ID
|
|
159
|
+
operationType: change.operation_type,
|
|
160
|
+
clusterTime: new Date(change.timestamp),
|
|
161
|
+
documentKey: { _id: change.document_id },
|
|
162
|
+
ns: {
|
|
163
|
+
db: 'mongolite', // You could make this configurable
|
|
164
|
+
coll: change.collection_name,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
// Add full document based on options
|
|
168
|
+
if (this.shouldIncludeFullDocument(change.operation_type)) {
|
|
169
|
+
changeDoc.fullDocument = fullDocument
|
|
170
|
+
? { _id: change.document_id, ...fullDocument }
|
|
171
|
+
: undefined;
|
|
172
|
+
}
|
|
173
|
+
// Add full document before change based on options
|
|
174
|
+
if (this.shouldIncludeFullDocumentBefore(change.operation_type)) {
|
|
175
|
+
changeDoc.fullDocumentBeforeChange = fullDocumentBefore
|
|
176
|
+
? { _id: change.document_id, ...fullDocumentBefore }
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
if (updateDescription) {
|
|
180
|
+
changeDoc.updateDescription = updateDescription;
|
|
181
|
+
}
|
|
182
|
+
return changeDoc;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Computes the update description by comparing before and after documents
|
|
186
|
+
*/
|
|
187
|
+
computeUpdateDescription(before, after) {
|
|
188
|
+
const updatedFields = {};
|
|
189
|
+
const removedFields = [];
|
|
190
|
+
// Type guard to ensure we have objects to compare
|
|
191
|
+
if (typeof before !== 'object' ||
|
|
192
|
+
before === null ||
|
|
193
|
+
typeof after !== 'object' ||
|
|
194
|
+
after === null) {
|
|
195
|
+
return { updatedFields, removedFields };
|
|
196
|
+
}
|
|
197
|
+
// Simple comparison - this could be made more sophisticated
|
|
198
|
+
const beforeObj = before;
|
|
199
|
+
const afterObj = after;
|
|
200
|
+
const allKeys = new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]);
|
|
201
|
+
for (const key of allKeys) {
|
|
202
|
+
if (!(key in afterObj)) {
|
|
203
|
+
removedFields.push(key);
|
|
204
|
+
}
|
|
205
|
+
else if (!(key in beforeObj) ||
|
|
206
|
+
JSON.stringify(beforeObj[key]) !== JSON.stringify(afterObj[key])) {
|
|
207
|
+
updatedFields[key] = afterObj[key];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { updatedFields, removedFields };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Determines if the full document should be included
|
|
214
|
+
*/
|
|
215
|
+
shouldIncludeFullDocument(operationType) {
|
|
216
|
+
const option = this.options.fullDocument || 'default';
|
|
217
|
+
switch (option) {
|
|
218
|
+
case 'default':
|
|
219
|
+
return operationType === 'insert';
|
|
220
|
+
case 'updateLookup':
|
|
221
|
+
case 'whenAvailable':
|
|
222
|
+
case 'required':
|
|
223
|
+
return (operationType === 'insert' || operationType === 'update' || operationType === 'replace');
|
|
224
|
+
default:
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Determines if the full document before change should be included
|
|
230
|
+
*/
|
|
231
|
+
shouldIncludeFullDocumentBefore(operationType) {
|
|
232
|
+
const option = this.options.fullDocumentBeforeChange || 'off';
|
|
233
|
+
if (option === 'off')
|
|
234
|
+
return false;
|
|
235
|
+
return operationType === 'update' || operationType === 'replace' || operationType === 'delete';
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Checks if a change document passes the filter
|
|
239
|
+
*/
|
|
240
|
+
passesFilter(changeDoc) {
|
|
241
|
+
if (!this.options.filter)
|
|
242
|
+
return true;
|
|
243
|
+
// Simple filter implementation - could be enhanced
|
|
244
|
+
// For now, just check basic equality filters
|
|
245
|
+
for (const [key, value] of Object.entries(this.options.filter)) {
|
|
246
|
+
const docValue = changeDoc[key];
|
|
247
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
248
|
+
// Handle operators like $in, $eq, etc.
|
|
249
|
+
// This is a simplified implementation
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
else if (docValue !== value) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns an async iterator for the change stream
|
|
260
|
+
*/
|
|
261
|
+
async *[Symbol.asyncIterator]() {
|
|
262
|
+
while (!this.closed) {
|
|
263
|
+
if (this.buffer.length > 0) {
|
|
264
|
+
yield this.buffer.shift();
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Wait for next change
|
|
268
|
+
await new Promise((resolve) => {
|
|
269
|
+
const onChange = () => {
|
|
270
|
+
this.removeListener('change', onChange);
|
|
271
|
+
this.removeListener('close', onClose);
|
|
272
|
+
resolve();
|
|
273
|
+
};
|
|
274
|
+
const onClose = () => {
|
|
275
|
+
this.removeListener('change', onChange);
|
|
276
|
+
this.removeListener('close', onClose);
|
|
277
|
+
resolve();
|
|
278
|
+
};
|
|
279
|
+
this.once('change', onChange);
|
|
280
|
+
this.once('close', onClose);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Closes the change stream
|
|
287
|
+
*/
|
|
288
|
+
close() {
|
|
289
|
+
if (this.closed)
|
|
290
|
+
return;
|
|
291
|
+
this.closed = true;
|
|
292
|
+
if (this.pollInterval) {
|
|
293
|
+
clearInterval(this.pollInterval);
|
|
294
|
+
this.pollInterval = null;
|
|
295
|
+
}
|
|
296
|
+
this.emit('close');
|
|
297
|
+
this.removeAllListeners();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Returns the next change document
|
|
301
|
+
*/
|
|
302
|
+
async next() {
|
|
303
|
+
if (this.closed) {
|
|
304
|
+
return { value: {}, done: true };
|
|
305
|
+
}
|
|
306
|
+
if (this.buffer.length > 0) {
|
|
307
|
+
return { value: this.buffer.shift(), done: false };
|
|
308
|
+
}
|
|
309
|
+
// Wait for next change
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const onChange = (changeDoc) => {
|
|
312
|
+
this.removeListener('change', onChange);
|
|
313
|
+
this.removeListener('close', onClose);
|
|
314
|
+
resolve({ value: changeDoc, done: false });
|
|
315
|
+
};
|
|
316
|
+
const onClose = () => {
|
|
317
|
+
this.removeListener('change', onChange);
|
|
318
|
+
this.removeListener('close', onClose);
|
|
319
|
+
resolve({ value: {}, done: true });
|
|
320
|
+
};
|
|
321
|
+
this.once('change', onChange);
|
|
322
|
+
this.once('close', onClose);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Cleanup triggers when the change stream is destroyed
|
|
327
|
+
*/
|
|
328
|
+
async cleanup() {
|
|
329
|
+
const collectionName = this.collectionName;
|
|
330
|
+
try {
|
|
331
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_insert_trigger"`);
|
|
332
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_update_trigger"`);
|
|
333
|
+
await this.db.exec(`DROP TRIGGER IF EXISTS "${collectionName}_delete_trigger"`);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
// Ignore errors during cleanup
|
|
337
|
+
console.warn('Error during change stream cleanup:', error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
exports.ChangeStream = ChangeStream;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare-safe entry point for MongoLite.
|
|
4
|
+
*
|
|
5
|
+
* Import from `@semics-tech/mongolite/cloudflare` inside Cloudflare Workers / Durable Objects.
|
|
6
|
+
* This module does **not** import `better-sqlite3`, so it bundles cleanly with `wrangler`.
|
|
7
|
+
*
|
|
8
|
+
* ## Requirements
|
|
9
|
+
*
|
|
10
|
+
* - Enable **Node.js compatibility** in your Worker configuration (e.g. `nodejs_compat`
|
|
11
|
+
* flag in `wrangler.toml`/`wrangler.jsonc`). The `ChangeStream` class depends on
|
|
12
|
+
* Node's built-in `events` module, which requires this flag even if you don't use
|
|
13
|
+
* change streams directly.
|
|
14
|
+
*
|
|
15
|
+
* ## Usage
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { DurableObject } from 'cloudflare:workers';
|
|
19
|
+
* import { MongoLite, CloudflareDurableObjectAdapter } from '@semics-tech/mongolite/cloudflare';
|
|
20
|
+
*
|
|
21
|
+
* export class MyDurableObject extends DurableObject {
|
|
22
|
+
* private client: MongoLite;
|
|
23
|
+
*
|
|
24
|
+
* constructor(ctx: DurableObjectState, env: Env) {
|
|
25
|
+
* super(ctx, env);
|
|
26
|
+
* this.client = new MongoLite(new CloudflareDurableObjectAdapter(ctx.storage.sql));
|
|
27
|
+
* ctx.blockConcurrencyWhile(() => this.client.collection('users').ensureTable());
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
33
|
+
if (k2 === undefined) k2 = k;
|
|
34
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
35
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
36
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
37
|
+
}
|
|
38
|
+
Object.defineProperty(o, k2, desc);
|
|
39
|
+
}) : (function(o, m, k, k2) {
|
|
40
|
+
if (k2 === undefined) k2 = k;
|
|
41
|
+
o[k2] = m[k];
|
|
42
|
+
}));
|
|
43
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
44
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
45
|
+
};
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.ChangeStream = exports.MongoLiteCollection = exports.CloudflareDurableObjectAdapter = exports.MongoLite = void 0;
|
|
48
|
+
// Core MongoLite class — accepts any IDatabaseAdapter, no better-sqlite3 dependency.
|
|
49
|
+
var mongo_client_js_1 = require("./mongo-client.js");
|
|
50
|
+
Object.defineProperty(exports, "MongoLite", { enumerable: true, get: function () { return mongo_client_js_1.MongoLite; } });
|
|
51
|
+
// Cloudflare-specific adapter
|
|
52
|
+
var cloudflare_js_1 = require("./adapters/cloudflare.js");
|
|
53
|
+
Object.defineProperty(exports, "CloudflareDurableObjectAdapter", { enumerable: true, get: function () { return cloudflare_js_1.CloudflareDurableObjectAdapter; } });
|
|
54
|
+
var collection_js_1 = require("./collection.js");
|
|
55
|
+
Object.defineProperty(exports, "MongoLiteCollection", { enumerable: true, get: function () { return collection_js_1.MongoLiteCollection; } });
|
|
56
|
+
__exportStar(require("./types.js"), exports);
|
|
57
|
+
// Change streams (requires nodejs_compat in Workers)
|
|
58
|
+
var changeStream_js_1 = require("./changeStream.js");
|
|
59
|
+
Object.defineProperty(exports, "ChangeStream", { enumerable: true, get: function () { return changeStream_js_1.ChangeStream; } });
|