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.
- package/.serena/project.yml +84 -0
- package/CHANGELOG.md +300 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/ROADMAP.md +532 -0
- package/benchmarks/benchmark.ts +145 -0
- package/benchmarks/case-insensitive-10runs.ts +156 -0
- package/benchmarks/fts5-1m-scale.ts +126 -0
- package/benchmarks/fts5-before-after.ts +104 -0
- package/benchmarks/indexed-benchmark.ts +141 -0
- package/benchmarks/new-operators-benchmark.ts +140 -0
- package/benchmarks/query-builder-benchmark.ts +88 -0
- package/benchmarks/query-builder-consistency.ts +109 -0
- package/benchmarks/raw-better-sqlite3-10m.ts +85 -0
- package/benchmarks/raw-better-sqlite3.ts +86 -0
- package/benchmarks/raw-bun-sqlite-10m.ts +85 -0
- package/benchmarks/raw-bun-sqlite.ts +86 -0
- package/benchmarks/regex-10runs-all.ts +216 -0
- package/benchmarks/regex-comparison-benchmark.ts +161 -0
- package/benchmarks/regex-real-comparison.ts +213 -0
- package/benchmarks/run-10x.sh +19 -0
- package/benchmarks/smart-regex-benchmark.ts +148 -0
- package/benchmarks/sql-vs-mingo-benchmark.ts +210 -0
- package/benchmarks/sql-vs-mingo-comparison.ts +175 -0
- package/benchmarks/text-vs-jsonb.ts +167 -0
- package/benchmarks/wal-benchmark.ts +112 -0
- package/docs/architectural-patterns.md +1336 -0
- package/docs/id1-testsuite-journey.md +839 -0
- package/docs/official-test-suite-setup.md +393 -0
- package/nul +0 -0
- package/package.json +44 -0
- package/src/changestream.test.ts +182 -0
- package/src/cleanup.test.ts +110 -0
- package/src/collection-isolation.test.ts +74 -0
- package/src/connection-pool.test.ts +102 -0
- package/src/connection-pool.ts +38 -0
- package/src/findDocumentsById.test.ts +122 -0
- package/src/index.ts +2 -0
- package/src/instance.ts +382 -0
- package/src/multi-instance-events.test.ts +204 -0
- package/src/query/and-operator.test.ts +39 -0
- package/src/query/builder.test.ts +96 -0
- package/src/query/builder.ts +154 -0
- package/src/query/elemMatch-operator.test.ts +24 -0
- package/src/query/exists-operator.test.ts +28 -0
- package/src/query/in-operators.test.ts +54 -0
- package/src/query/mod-operator.test.ts +22 -0
- package/src/query/nested-query.test.ts +198 -0
- package/src/query/not-operators.test.ts +49 -0
- package/src/query/operators.test.ts +70 -0
- package/src/query/operators.ts +185 -0
- package/src/query/or-operator.test.ts +68 -0
- package/src/query/regex-escaping-regression.test.ts +43 -0
- package/src/query/regex-operator.test.ts +44 -0
- package/src/query/schema-mapper.ts +27 -0
- package/src/query/size-operator.test.ts +22 -0
- package/src/query/smart-regex.ts +52 -0
- package/src/query/type-operator.test.ts +37 -0
- package/src/query-cache.test.ts +286 -0
- package/src/rxdb-helpers.test.ts +348 -0
- package/src/rxdb-helpers.ts +262 -0
- package/src/schema-version-isolation.test.ts +126 -0
- package/src/statement-manager.ts +69 -0
- package/src/storage.test.ts +589 -0
- package/src/storage.ts +21 -0
- package/src/types.ts +14 -0
- package/test/rxdb-test-suite.ts +27 -0
- package/tsconfig.json +31 -0
package/src/instance.ts
ADDED
|
@@ -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
|
+
});
|