bun-sqlite-for-rxdb 1.0.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 (68) hide show
  1. package/.serena/project.yml +84 -0
  2. package/CHANGELOG.md +300 -0
  3. package/LICENSE +21 -0
  4. package/README.md +87 -0
  5. package/ROADMAP.md +532 -0
  6. package/benchmarks/benchmark.ts +145 -0
  7. package/benchmarks/case-insensitive-10runs.ts +156 -0
  8. package/benchmarks/fts5-1m-scale.ts +126 -0
  9. package/benchmarks/fts5-before-after.ts +104 -0
  10. package/benchmarks/indexed-benchmark.ts +141 -0
  11. package/benchmarks/new-operators-benchmark.ts +140 -0
  12. package/benchmarks/query-builder-benchmark.ts +88 -0
  13. package/benchmarks/query-builder-consistency.ts +109 -0
  14. package/benchmarks/raw-better-sqlite3-10m.ts +85 -0
  15. package/benchmarks/raw-better-sqlite3.ts +86 -0
  16. package/benchmarks/raw-bun-sqlite-10m.ts +85 -0
  17. package/benchmarks/raw-bun-sqlite.ts +86 -0
  18. package/benchmarks/regex-10runs-all.ts +216 -0
  19. package/benchmarks/regex-comparison-benchmark.ts +161 -0
  20. package/benchmarks/regex-real-comparison.ts +213 -0
  21. package/benchmarks/run-10x.sh +19 -0
  22. package/benchmarks/smart-regex-benchmark.ts +148 -0
  23. package/benchmarks/sql-vs-mingo-benchmark.ts +210 -0
  24. package/benchmarks/sql-vs-mingo-comparison.ts +175 -0
  25. package/benchmarks/text-vs-jsonb.ts +167 -0
  26. package/benchmarks/wal-benchmark.ts +112 -0
  27. package/docs/architectural-patterns.md +1336 -0
  28. package/docs/id1-testsuite-journey.md +839 -0
  29. package/docs/official-test-suite-setup.md +393 -0
  30. package/nul +0 -0
  31. package/package.json +44 -0
  32. package/src/changestream.test.ts +182 -0
  33. package/src/cleanup.test.ts +110 -0
  34. package/src/collection-isolation.test.ts +74 -0
  35. package/src/connection-pool.test.ts +102 -0
  36. package/src/connection-pool.ts +38 -0
  37. package/src/findDocumentsById.test.ts +122 -0
  38. package/src/index.ts +2 -0
  39. package/src/instance.ts +382 -0
  40. package/src/multi-instance-events.test.ts +204 -0
  41. package/src/query/and-operator.test.ts +39 -0
  42. package/src/query/builder.test.ts +96 -0
  43. package/src/query/builder.ts +154 -0
  44. package/src/query/elemMatch-operator.test.ts +24 -0
  45. package/src/query/exists-operator.test.ts +28 -0
  46. package/src/query/in-operators.test.ts +54 -0
  47. package/src/query/mod-operator.test.ts +22 -0
  48. package/src/query/nested-query.test.ts +198 -0
  49. package/src/query/not-operators.test.ts +49 -0
  50. package/src/query/operators.test.ts +70 -0
  51. package/src/query/operators.ts +185 -0
  52. package/src/query/or-operator.test.ts +68 -0
  53. package/src/query/regex-escaping-regression.test.ts +43 -0
  54. package/src/query/regex-operator.test.ts +44 -0
  55. package/src/query/schema-mapper.ts +27 -0
  56. package/src/query/size-operator.test.ts +22 -0
  57. package/src/query/smart-regex.ts +52 -0
  58. package/src/query/type-operator.test.ts +37 -0
  59. package/src/query-cache.test.ts +286 -0
  60. package/src/rxdb-helpers.test.ts +348 -0
  61. package/src/rxdb-helpers.ts +262 -0
  62. package/src/schema-version-isolation.test.ts +126 -0
  63. package/src/statement-manager.ts +69 -0
  64. package/src/storage.test.ts +589 -0
  65. package/src/storage.ts +21 -0
  66. package/src/types.ts +14 -0
  67. package/test/rxdb-test-suite.ts +27 -0
  68. package/tsconfig.json +31 -0
@@ -0,0 +1,382 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { Subject, Observable } from 'rxjs';
3
+ import type {
4
+ RxStorageInstance,
5
+ RxStorageInstanceCreationParams,
6
+ BulkWriteRow,
7
+ RxDocumentData,
8
+ RxStorageBulkWriteResponse,
9
+ RxStorageQueryResult,
10
+ RxStorageCountResult,
11
+ EventBulk,
12
+ RxStorageChangeEvent,
13
+ RxStorageWriteError,
14
+ PreparedQuery,
15
+ RxJsonSchema,
16
+ MangoQuerySelector,
17
+ MangoQuerySortPart,
18
+ RxStorageDefaultCheckpoint
19
+ } from 'rxdb';
20
+ import type { BunSQLiteStorageSettings, BunSQLiteInternals } from './types';
21
+ import { buildWhereClause } from './query/builder';
22
+ import { categorizeBulkWriteRows, ensureRxStorageInstanceParamsAreCorrect } from './rxdb-helpers';
23
+ import { StatementManager } from './statement-manager';
24
+ import { getDatabase, releaseDatabase } from './connection-pool';
25
+
26
+ export class BunSQLiteStorageInstance<RxDocType> implements RxStorageInstance<RxDocType, BunSQLiteInternals, BunSQLiteStorageSettings> {
27
+ private db: Database;
28
+ private stmtManager: StatementManager;
29
+ private changeStream$ = new Subject<EventBulk<RxStorageChangeEvent<RxDocumentData<RxDocType>>, RxStorageDefaultCheckpoint>>();
30
+ public readonly databaseName: string;
31
+ public readonly collectionName: string;
32
+ public readonly schema: Readonly<RxJsonSchema<RxDocumentData<RxDocType>>>;
33
+ public readonly internals: Readonly<BunSQLiteInternals>;
34
+ public readonly options: Readonly<BunSQLiteStorageSettings>;
35
+ private primaryPath: string;
36
+ private tableName: string;
37
+ public closed?: Promise<void>;
38
+
39
+ constructor(
40
+ params: RxStorageInstanceCreationParams<RxDocType, BunSQLiteStorageSettings>,
41
+ settings: BunSQLiteStorageSettings = {}
42
+ ) {
43
+ ensureRxStorageInstanceParamsAreCorrect(params);
44
+
45
+ this.databaseName = params.databaseName;
46
+ this.collectionName = params.collectionName;
47
+ this.schema = params.schema;
48
+ this.options = params.options;
49
+ const primaryKey = params.schema.primaryKey;
50
+ this.primaryPath = typeof primaryKey === 'string' ? primaryKey : primaryKey.key;
51
+ this.tableName = `${params.collectionName}_v${params.schema.version}`;
52
+
53
+ const filename = settings.filename || ':memory:';
54
+ this.db = getDatabase(this.databaseName, filename);
55
+ this.stmtManager = new StatementManager(this.db);
56
+
57
+ this.internals = {
58
+ db: this.db,
59
+ primaryPath: this.primaryPath
60
+ };
61
+
62
+ this.initTable(filename);
63
+ }
64
+
65
+ private initTable(filename: string) {
66
+ if (filename !== ':memory:') {
67
+ this.db.run("PRAGMA journal_mode = WAL");
68
+ this.db.run("PRAGMA synchronous = NORMAL");
69
+ }
70
+
71
+ this.db.run(`
72
+ CREATE TABLE IF NOT EXISTS "${this.tableName}" (
73
+ id TEXT PRIMARY KEY NOT NULL,
74
+ data BLOB NOT NULL,
75
+ deleted INTEGER NOT NULL DEFAULT 0,
76
+ rev TEXT NOT NULL,
77
+ mtime_ms REAL NOT NULL
78
+ )
79
+ `);
80
+
81
+ this.db.run(`CREATE INDEX IF NOT EXISTS "idx_${this.tableName}_deleted_id" ON "${this.tableName}"(deleted, id)`);
82
+ this.db.run(`CREATE INDEX IF NOT EXISTS "idx_${this.tableName}_mtime_ms_id" ON "${this.tableName}"(mtime_ms, id)`);
83
+
84
+ this.db.run(`CREATE INDEX IF NOT EXISTS "idx_${this.tableName}_age" ON "${this.tableName}"(json_extract(data, '$.age'))`);
85
+ this.db.run(`CREATE INDEX IF NOT EXISTS "idx_${this.tableName}_status" ON "${this.tableName}"(json_extract(data, '$.status'))`);
86
+ this.db.run(`CREATE INDEX IF NOT EXISTS "idx_${this.tableName}_email" ON "${this.tableName}"(json_extract(data, '$.email'))`);
87
+
88
+ this.db.run(`
89
+ CREATE TABLE IF NOT EXISTS "${this.tableName}_attachments" (
90
+ id TEXT PRIMARY KEY NOT NULL,
91
+ data TEXT NOT NULL,
92
+ digest TEXT NOT NULL
93
+ )
94
+ `);
95
+ }
96
+
97
+ async bulkWrite(
98
+ documentWrites: BulkWriteRow<RxDocType>[],
99
+ context: string
100
+ ): Promise<RxStorageBulkWriteResponse<RxDocType>> {
101
+ if (documentWrites.length === 0) {
102
+ return { error: [] };
103
+ }
104
+
105
+ const ids = documentWrites.map(w => (w.document as RxDocumentData<RxDocType>)[this.primaryPath as keyof RxDocumentData<RxDocType>] as string);
106
+ const docsInDb = await this.findDocumentsById(ids, true);
107
+ const docsInDbMap = new Map(docsInDb.map(d => [d[this.primaryPath as keyof RxDocumentData<RxDocType>] as string, d]));
108
+
109
+ const categorized = categorizeBulkWriteRows(
110
+ this,
111
+ this.primaryPath as any,
112
+ docsInDbMap,
113
+ documentWrites,
114
+ context
115
+ );
116
+
117
+ const insertQuery = `INSERT INTO "${this.tableName}" (id, data, deleted, rev, mtime_ms) VALUES (?, jsonb(?), ?, ?, ?)`;
118
+ const updateQuery = `UPDATE "${this.tableName}" SET data = jsonb(?), deleted = ?, rev = ?, mtime_ms = ? WHERE id = ?`;
119
+
120
+ for (const row of categorized.bulkInsertDocs) {
121
+ const doc = row.document;
122
+ const id = doc[this.primaryPath as keyof RxDocumentData<RxDocType>] as string;
123
+ try {
124
+ this.stmtManager.run({ query: insertQuery, params: [id, JSON.stringify(doc), doc._deleted ? 1 : 0, doc._rev, doc._meta.lwt] });
125
+ } catch (err: any) {
126
+ if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
127
+ const documentInDb = docsInDbMap.get(id);
128
+ categorized.errors.push({
129
+ isError: true,
130
+ status: 409,
131
+ documentId: id,
132
+ writeRow: row,
133
+ documentInDb: documentInDb || doc
134
+ });
135
+ } else {
136
+ throw err;
137
+ }
138
+ }
139
+ }
140
+
141
+ for (const row of categorized.bulkUpdateDocs) {
142
+ const doc = row.document;
143
+ const id = doc[this.primaryPath as keyof RxDocumentData<RxDocType>] as string;
144
+ this.stmtManager.run({ query: updateQuery, params: [JSON.stringify(doc), doc._deleted ? 1 : 0, doc._rev, doc._meta.lwt, id] });
145
+ }
146
+
147
+ const insertAttQuery = `INSERT OR REPLACE INTO "${this.tableName}_attachments" (id, data, digest) VALUES (?, ?, ?)`;
148
+ const deleteAttQuery = `DELETE FROM "${this.tableName}_attachments" WHERE id = ?`;
149
+
150
+ for (const att of [...categorized.attachmentsAdd, ...categorized.attachmentsUpdate]) {
151
+ this.stmtManager.run({
152
+ query: insertAttQuery,
153
+ params: [
154
+ this.attachmentMapKey(att.documentId, att.attachmentId),
155
+ att.attachmentData.data,
156
+ att.digest
157
+ ]
158
+ });
159
+ }
160
+
161
+ for (const att of categorized.attachmentsRemove) {
162
+ this.stmtManager.run({
163
+ query: deleteAttQuery,
164
+ params: [this.attachmentMapKey(att.documentId, att.attachmentId)]
165
+ });
166
+ }
167
+
168
+ const failedDocIds = new Set(categorized.errors.map(e => e.documentId));
169
+ categorized.eventBulk.events = categorized.eventBulk.events.filter(
170
+ event => !failedDocIds.has(event.documentId)
171
+ );
172
+
173
+ if (categorized.eventBulk.events.length > 0 && categorized.newestRow) {
174
+ const lastState = categorized.newestRow.document;
175
+ categorized.eventBulk.checkpoint = {
176
+ id: lastState[this.primaryPath as keyof typeof lastState] as string,
177
+ lwt: lastState._meta.lwt
178
+ };
179
+ this.changeStream$.next(categorized.eventBulk);
180
+ }
181
+
182
+ return { error: categorized.errors };
183
+ }
184
+
185
+ async findDocumentsById(ids: string[], withDeleted: boolean): Promise<RxDocumentData<RxDocType>[]> {
186
+ if (ids.length === 0) return [];
187
+
188
+ const placeholders = ids.map(() => '?').join(',');
189
+
190
+ const whereClause = withDeleted
191
+ ? `WHERE id IN (${placeholders})`
192
+ : `WHERE id IN (${placeholders}) AND deleted = 0`;
193
+
194
+ const query = `SELECT json(data) as data FROM "${this.tableName}" ${whereClause}`;
195
+ const rows = this.stmtManager.all({ query, params: ids }) as Array<{ data: string }>;
196
+ return rows.map(row => JSON.parse(row.data) as RxDocumentData<RxDocType>);
197
+ }
198
+
199
+ async query(preparedQuery: PreparedQuery<RxDocType>): Promise<RxStorageQueryResult<RxDocType>> {
200
+ try {
201
+ const { sql: whereClause, args } = buildWhereClause(preparedQuery.query.selector, this.schema);
202
+
203
+ const sql = `
204
+ SELECT json(data) as data FROM "${this.tableName}"
205
+ WHERE (${whereClause})
206
+ ORDER BY id
207
+ `;
208
+
209
+ if (process.env.DEBUG_QUERIES) {
210
+ const explainSql = `EXPLAIN QUERY PLAN ${sql}`;
211
+ const plan = this.stmtManager.all({ query: explainSql, params: args });
212
+ console.log('[DEBUG_QUERIES] Query plan:', JSON.stringify(plan, null, 2));
213
+ console.log('[DEBUG_QUERIES] SQL:', sql);
214
+ console.log('[DEBUG_QUERIES] Args:', args);
215
+ }
216
+
217
+ const rows = this.stmtManager.all({ query: sql, params: args }) as Array<{ data: string }>;
218
+ let documents = rows.map(row => JSON.parse(row.data) as RxDocumentData<RxDocType>);
219
+
220
+ if (preparedQuery.query.sort && preparedQuery.query.sort.length > 0) {
221
+ documents = this.sortDocuments(documents, preparedQuery.query.sort);
222
+ }
223
+
224
+ if (preparedQuery.query.skip) {
225
+ documents = documents.slice(preparedQuery.query.skip);
226
+ }
227
+
228
+ if (preparedQuery.query.limit) {
229
+ documents = documents.slice(0, preparedQuery.query.limit);
230
+ }
231
+
232
+ return { documents };
233
+ } catch (err) {
234
+ const query = `SELECT json(data) as data FROM "${this.tableName}"`;
235
+ const rows = this.stmtManager.all({ query, params: [] }) as Array<{ data: string }>;
236
+ let documents = rows.map(row => JSON.parse(row.data) as RxDocumentData<RxDocType>);
237
+
238
+ documents = documents.filter(doc => this.matchesSelector(doc, preparedQuery.query.selector));
239
+
240
+ if (preparedQuery.query.sort && preparedQuery.query.sort.length > 0) {
241
+ documents = this.sortDocuments(documents, preparedQuery.query.sort);
242
+ }
243
+
244
+ if (preparedQuery.query.skip) {
245
+ documents = documents.slice(preparedQuery.query.skip);
246
+ }
247
+
248
+ if (preparedQuery.query.limit) {
249
+ documents = documents.slice(0, preparedQuery.query.limit);
250
+ }
251
+
252
+ return { documents };
253
+ }
254
+ }
255
+
256
+ private matchesSelector(doc: RxDocumentData<RxDocType>, selector: MangoQuerySelector<RxDocumentData<RxDocType>>): boolean {
257
+ for (const [key, value] of Object.entries(selector)) {
258
+ const docValue = this.getNestedValue(doc, key);
259
+
260
+ if (typeof value === 'object' && value !== null) {
261
+ for (const [op, opValue] of Object.entries(value)) {
262
+ if (op === '$eq' && docValue !== opValue) return false;
263
+ if (op === '$ne' && docValue === opValue) return false;
264
+ if (op === '$gt' && !((docValue as number) > (opValue as number))) return false;
265
+ if (op === '$gte' && !((docValue as number) >= (opValue as number))) return false;
266
+ if (op === '$lt' && !((docValue as number) < (opValue as number))) return false;
267
+ if (op === '$lte' && !((docValue as number) <= (opValue as number))) return false;
268
+ }
269
+ } else {
270
+ if (docValue !== value) return false;
271
+ }
272
+ }
273
+ return true;
274
+ }
275
+
276
+ private sortDocuments(docs: RxDocumentData<RxDocType>[], sort: MangoQuerySortPart<RxDocType>[]): RxDocumentData<RxDocType>[] {
277
+ return docs.sort((a, b) => {
278
+ for (const sortField of sort) {
279
+ const [key, direction] = Object.entries(sortField)[0];
280
+ const aVal = this.getNestedValue(a, key) as number | string;
281
+ const bVal = this.getNestedValue(b, key) as number | string;
282
+
283
+ if (aVal < bVal) return direction === 'asc' ? -1 : 1;
284
+ if (aVal > bVal) return direction === 'asc' ? 1 : -1;
285
+ }
286
+ return 0;
287
+ });
288
+ }
289
+
290
+ private getNestedValue(obj: RxDocumentData<RxDocType>, path: string): unknown {
291
+ return path.split('.').reduce((current, key) => (current as Record<string, unknown>)?.[key], obj as unknown);
292
+ }
293
+
294
+ async count(preparedQuery: PreparedQuery<RxDocType>): Promise<RxStorageCountResult> {
295
+ const result = await this.query(preparedQuery);
296
+ return {
297
+ count: result.documents.length,
298
+ mode: 'fast'
299
+ };
300
+ }
301
+
302
+ changeStream(): Observable<EventBulk<RxStorageChangeEvent<RxDocumentData<RxDocType>>, RxStorageDefaultCheckpoint>> {
303
+ return this.changeStream$.asObservable();
304
+ }
305
+
306
+ async cleanup(minimumDeletedTime: number): Promise<boolean> {
307
+ let query: string;
308
+ let params: unknown[];
309
+
310
+ // RxDB contract: minimumDeletedTime is a DURATION (milliseconds), not a timestamp
311
+ // Calculation: currentTime - minimumDeletedTime = cutoffTimestamp
312
+ // When minimumDeletedTime = 0: now() - 0 = now() → delete ALL deleted documents
313
+ // This matches official Dexie implementation: const maxDeletionTime = now() - minimumDeletedTime
314
+ if (minimumDeletedTime === 0) {
315
+ query = `DELETE FROM "${this.tableName}" WHERE deleted = 1`;
316
+ params = [];
317
+ } else {
318
+ query = `DELETE FROM "${this.tableName}" WHERE deleted = 1 AND mtime_ms < ?`;
319
+ params = [minimumDeletedTime];
320
+ }
321
+
322
+ const result = this.stmtManager.run({ query, params });
323
+ return result.changes === 0;
324
+ }
325
+
326
+ async close(): Promise<void> {
327
+ if (this.closed) return this.closed;
328
+ this.closed = (async () => {
329
+ this.changeStream$.complete();
330
+ this.stmtManager.close();
331
+ releaseDatabase(this.databaseName);
332
+ })();
333
+ return this.closed;
334
+ }
335
+
336
+ async remove(): Promise<void> {
337
+ if (this.closed) throw new Error('already closed');
338
+ try {
339
+ this.db.run(`DROP TABLE IF EXISTS "${this.tableName}"`);
340
+ } catch {}
341
+ return this.close();
342
+ }
343
+
344
+ // Gate 2: Helper function
345
+ private attachmentMapKey(documentId: string, attachmentId: string): string {
346
+ return documentId + '||' + attachmentId;
347
+ }
348
+
349
+ // Gate 3: getAttachmentData with digest validation
350
+ async getAttachmentData(documentId: string, attachmentId: string, digest: string): Promise<string> {
351
+ const key = this.attachmentMapKey(documentId, attachmentId);
352
+ const result = this.db.query(
353
+ `SELECT data, digest FROM "${this.tableName}_attachments" WHERE id = ?`
354
+ ).get(key) as { data: string; digest: string } | undefined;
355
+
356
+ if (!result || result.digest !== digest) {
357
+ throw new Error('attachment does not exist: ' + key);
358
+ }
359
+
360
+ return result.data;
361
+ }
362
+
363
+ async getChangedDocumentsSince(limit: number, checkpoint?: { id: string; lwt: number }) {
364
+ const checkpointLwt = checkpoint?.lwt ?? 0;
365
+ const checkpointId = checkpoint?.id ?? '';
366
+
367
+ const sql = `
368
+ SELECT json(data) as data FROM "${this.tableName}"
369
+ WHERE (mtime_ms > ? OR (mtime_ms = ? AND id > ?))
370
+ ORDER BY mtime_ms ASC, id ASC
371
+ LIMIT ?
372
+ `;
373
+
374
+ const rows = this.stmtManager.all({ query: sql, params: [checkpointLwt, checkpointLwt, checkpointId, limit] }) as Array<{ data: string }>;
375
+ const documents = rows.map(row => JSON.parse(row.data) as RxDocumentData<RxDocType>);
376
+
377
+ const lastDoc = documents[documents.length - 1];
378
+ const newCheckpoint = lastDoc ? { id: (lastDoc as any)[this.primaryPath] as string, lwt: lastDoc._meta.lwt } : checkpoint ?? null;
379
+
380
+ return { documents, checkpoint: newCheckpoint };
381
+ }
382
+ }
@@ -0,0 +1,204 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import { createRxDatabase, addRxPlugin } from 'rxdb';
3
+ import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
4
+ import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
5
+ import { getRxStorageBunSQLite } from './index';
6
+ import type { RxDatabase } from 'rxdb';
7
+
8
+ addRxPlugin(RxDBDevModePlugin);
9
+
10
+ describe('Multi-Instance Event Propagation', () => {
11
+ const databases: RxDatabase[] = [];
12
+
13
+ afterEach(async () => {
14
+ for (const db of databases) {
15
+ await db.remove();
16
+ }
17
+ databases.length = 0;
18
+ });
19
+
20
+ test('events should propagate from instance A to instance B', async () => {
21
+ const dbName = 'testdb-' + Date.now();
22
+
23
+ const db1 = await createRxDatabase({
24
+ name: dbName,
25
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
26
+ multiInstance: true,
27
+ ignoreDuplicate: true
28
+ });
29
+
30
+ const db2 = await createRxDatabase({
31
+ name: dbName,
32
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
33
+ multiInstance: true,
34
+ ignoreDuplicate: true
35
+ });
36
+
37
+ databases.push(db1, db2);
38
+
39
+ await db1.addCollections({
40
+ users: {
41
+ schema: {
42
+ version: 0,
43
+ primaryKey: 'id',
44
+ type: 'object',
45
+ properties: {
46
+ id: { type: 'string', maxLength: 100 },
47
+ name: { type: 'string' }
48
+ },
49
+ required: ['id', 'name']
50
+ }
51
+ }
52
+ });
53
+
54
+ await db2.addCollections({
55
+ users: {
56
+ schema: {
57
+ version: 0,
58
+ primaryKey: 'id',
59
+ type: 'object',
60
+ properties: {
61
+ id: { type: 'string', maxLength: 100 },
62
+ name: { type: 'string' }
63
+ },
64
+ required: ['id', 'name']
65
+ }
66
+ }
67
+ });
68
+
69
+ let eventsReceived = 0;
70
+ db2.users.$.subscribe(() => eventsReceived++);
71
+
72
+ await new Promise(resolve => setTimeout(resolve, 50));
73
+
74
+ await db1.users.insert({ id: 'user1', name: 'Alice' });
75
+
76
+ await new Promise(resolve => setTimeout(resolve, 200));
77
+
78
+ expect(eventsReceived).toBeGreaterThan(0);
79
+ });
80
+
81
+ test('events should propagate bidirectionally', async () => {
82
+ const dbName = 'testdb-' + Date.now();
83
+
84
+ const db1 = await createRxDatabase({
85
+ name: dbName,
86
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
87
+ multiInstance: true,
88
+ ignoreDuplicate: true
89
+ });
90
+
91
+ const db2 = await createRxDatabase({
92
+ name: dbName,
93
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
94
+ multiInstance: true,
95
+ ignoreDuplicate: true
96
+ });
97
+
98
+ databases.push(db1, db2);
99
+
100
+ await db1.addCollections({
101
+ users: {
102
+ schema: {
103
+ version: 0,
104
+ primaryKey: 'id',
105
+ type: 'object',
106
+ properties: {
107
+ id: { type: 'string', maxLength: 100 },
108
+ name: { type: 'string' }
109
+ },
110
+ required: ['id', 'name']
111
+ }
112
+ }
113
+ });
114
+
115
+ await db2.addCollections({
116
+ users: {
117
+ schema: {
118
+ version: 0,
119
+ primaryKey: 'id',
120
+ type: 'object',
121
+ properties: {
122
+ id: { type: 'string', maxLength: 100 },
123
+ name: { type: 'string' }
124
+ },
125
+ required: ['id', 'name']
126
+ }
127
+ }
128
+ });
129
+
130
+ let events1 = 0;
131
+ let events2 = 0;
132
+ db1.users.$.subscribe(() => events1++);
133
+ db2.users.$.subscribe(() => events2++);
134
+
135
+ await new Promise(resolve => setTimeout(resolve, 50));
136
+
137
+ await db1.users.insert({ id: 'user1', name: 'Alice' });
138
+ await db2.users.insert({ id: 'user2', name: 'Bob' });
139
+
140
+ await new Promise(resolve => setTimeout(resolve, 200));
141
+
142
+ expect(events1).toBeGreaterThan(1);
143
+ expect(events2).toBeGreaterThan(1);
144
+ });
145
+
146
+ test('instances with different databaseNames should NOT share events', async () => {
147
+ const db1 = await createRxDatabase({
148
+ name: 'testdb1-' + Date.now(),
149
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
150
+ multiInstance: true,
151
+ ignoreDuplicate: true
152
+ });
153
+
154
+ const db2 = await createRxDatabase({
155
+ name: 'testdb2-' + Date.now(),
156
+ storage: wrappedValidateAjvStorage({ storage: getRxStorageBunSQLite() }),
157
+ multiInstance: true,
158
+ ignoreDuplicate: true
159
+ });
160
+
161
+ databases.push(db1, db2);
162
+
163
+ await db1.addCollections({
164
+ users: {
165
+ schema: {
166
+ version: 0,
167
+ primaryKey: 'id',
168
+ type: 'object',
169
+ properties: {
170
+ id: { type: 'string', maxLength: 100 },
171
+ name: { type: 'string' }
172
+ },
173
+ required: ['id', 'name']
174
+ }
175
+ }
176
+ });
177
+
178
+ await db2.addCollections({
179
+ users: {
180
+ schema: {
181
+ version: 0,
182
+ primaryKey: 'id',
183
+ type: 'object',
184
+ properties: {
185
+ id: { type: 'string', maxLength: 100 },
186
+ name: { type: 'string' }
187
+ },
188
+ required: ['id', 'name']
189
+ }
190
+ }
191
+ });
192
+
193
+ let eventsReceived = 0;
194
+ db2.users.$.subscribe(() => eventsReceived++);
195
+
196
+ await new Promise(resolve => setTimeout(resolve, 50));
197
+
198
+ await db1.users.insert({ id: 'user1', name: 'Alice' });
199
+
200
+ await new Promise(resolve => setTimeout(resolve, 200));
201
+
202
+ expect(eventsReceived).toBe(0);
203
+ });
204
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { buildWhereClause } from './builder';
3
+ import type { RxJsonSchema } from 'rxdb';
4
+
5
+ const testSchema: RxJsonSchema<any> = {
6
+ version: 0,
7
+ primaryKey: 'id',
8
+ type: 'object',
9
+ properties: {
10
+ id: { type: 'string' },
11
+ age: { type: 'number' },
12
+ status: { type: 'string' }
13
+ }
14
+ };
15
+
16
+ describe('$and operator', () => {
17
+ it('explicit $and works same as implicit', () => {
18
+ const implicit = buildWhereClause({ age: 25, status: 'active' }, testSchema);
19
+ const explicit = buildWhereClause({ $and: [{ age: 25 }, { status: 'active' }] }, testSchema);
20
+
21
+ expect(explicit.sql).toBe('json_extract(data, \'$.age\') = ? AND json_extract(data, \'$.status\') = ?');
22
+ expect(explicit.args).toEqual([25, 'active']);
23
+ });
24
+
25
+ it('handles nested $and with operators', () => {
26
+ const result = buildWhereClause({
27
+ $and: [
28
+ { age: { $gt: 18 } },
29
+ { age: { $lt: 65 } },
30
+ { status: 'active' }
31
+ ]
32
+ }, testSchema);
33
+
34
+ expect(result.sql).toContain('json_extract(data, \'$.age\') > ?');
35
+ expect(result.sql).toContain('json_extract(data, \'$.age\') < ?');
36
+ expect(result.sql).toContain('json_extract(data, \'$.status\') = ?');
37
+ expect(result.args).toEqual([18, 65, 'active']);
38
+ });
39
+ });