@tallyui/storage-sqlite 0.2.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/dist/index.d.ts +23 -0
- package/dist/index.js +401 -0
- package/package.json +33 -0
- package/src/index.ts +2 -0
- package/src/integration.test.ts +169 -0
- package/src/mango-to-sql.test.ts +239 -0
- package/src/mango-to-sql.ts +274 -0
- package/src/mock-sqlite.ts +566 -0
- package/src/rx-storage-sqlite.ts +24 -0
- package/src/storage-instance.test.ts +265 -0
- package/src/storage-instance.ts +333 -0
- package/src/types.ts +9 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { RxStorage } from 'rxdb';
|
|
2
|
+
|
|
3
|
+
interface SQLiteDatabase {
|
|
4
|
+
execSync(source: string): void;
|
|
5
|
+
getAllSync<T>(source: string, params?: any[]): T[];
|
|
6
|
+
runSync(source: string, params?: any[]): {
|
|
7
|
+
changes: number;
|
|
8
|
+
lastInsertRowId: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
interface SQLiteStorageSettings {
|
|
12
|
+
database: SQLiteDatabase;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SQLiteStorageInternals {
|
|
16
|
+
database: SQLiteDatabase;
|
|
17
|
+
tableName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RxStorageSQLite = RxStorage<SQLiteStorageInternals, SQLiteStorageSettings>;
|
|
21
|
+
declare function getRxStorageSQLite(database: SQLiteDatabase): RxStorageSQLite;
|
|
22
|
+
|
|
23
|
+
export { type SQLiteDatabase, type SQLiteStorageSettings, getRxStorageSQLite };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// src/rx-storage-sqlite.ts
|
|
2
|
+
import { ensureRxStorageInstanceParamsAreCorrect } from "rxdb";
|
|
3
|
+
import { RXDB_VERSION } from "rxdb/plugins/utils";
|
|
4
|
+
|
|
5
|
+
// src/storage-instance.ts
|
|
6
|
+
import { categorizeBulkWriteRows, getQueryMatcher, getSortComparator } from "rxdb";
|
|
7
|
+
import { now, ensureNotFalsy } from "rxdb/plugins/utils";
|
|
8
|
+
import { Subject } from "rxjs";
|
|
9
|
+
|
|
10
|
+
// src/mango-to-sql.ts
|
|
11
|
+
function mangoQueryToSQL(query) {
|
|
12
|
+
const params = [];
|
|
13
|
+
const conditions = [];
|
|
14
|
+
conditions.push(`json_extract(data, '$._deleted') = 0`);
|
|
15
|
+
if (query.selector && Object.keys(query.selector).length > 0) {
|
|
16
|
+
const selectorSql = selectorToSQL(query.selector, params);
|
|
17
|
+
if (selectorSql) {
|
|
18
|
+
conditions.push(selectorSql);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
22
|
+
let orderBy = "";
|
|
23
|
+
if (query.sort && query.sort.length > 0) {
|
|
24
|
+
const orderParts = query.sort.map((sortPart) => {
|
|
25
|
+
const entries = Object.entries(sortPart);
|
|
26
|
+
if (entries.length === 0) return "";
|
|
27
|
+
const [field, direction] = entries[0];
|
|
28
|
+
const jsonPath = fieldToJsonExtract(field);
|
|
29
|
+
return `${jsonPath} ${direction === "desc" ? "DESC" : "ASC"}`;
|
|
30
|
+
}).filter(Boolean);
|
|
31
|
+
if (orderParts.length > 0) {
|
|
32
|
+
orderBy = `ORDER BY ${orderParts.join(", ")}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const limitParams = [];
|
|
36
|
+
let limit = "";
|
|
37
|
+
if (query.limit !== void 0 && query.limit !== null) {
|
|
38
|
+
limit = "LIMIT ?";
|
|
39
|
+
limitParams.push(query.limit);
|
|
40
|
+
}
|
|
41
|
+
if (query.skip && query.skip > 0) {
|
|
42
|
+
if (!limit) {
|
|
43
|
+
limit = "LIMIT -1";
|
|
44
|
+
}
|
|
45
|
+
limit += " OFFSET ?";
|
|
46
|
+
limitParams.push(query.skip);
|
|
47
|
+
}
|
|
48
|
+
return { where, params, orderBy, limit, limitParams };
|
|
49
|
+
}
|
|
50
|
+
function buildQuerySQL(tableName, query) {
|
|
51
|
+
const result = mangoQueryToSQL(query);
|
|
52
|
+
const allParams = [...result.params, ...result.limitParams];
|
|
53
|
+
const parts = [
|
|
54
|
+
`SELECT data FROM "${tableName}"`,
|
|
55
|
+
result.where,
|
|
56
|
+
result.orderBy,
|
|
57
|
+
result.limit
|
|
58
|
+
].filter(Boolean);
|
|
59
|
+
return { sql: parts.join(" "), params: allParams };
|
|
60
|
+
}
|
|
61
|
+
function buildCountSQL(tableName, query) {
|
|
62
|
+
const result = mangoQueryToSQL(query);
|
|
63
|
+
const parts = [
|
|
64
|
+
`SELECT COUNT(*) as count FROM "${tableName}"`,
|
|
65
|
+
result.where
|
|
66
|
+
].filter(Boolean);
|
|
67
|
+
return { sql: parts.join(" "), params: result.params };
|
|
68
|
+
}
|
|
69
|
+
function fieldToJsonExtract(field) {
|
|
70
|
+
return `json_extract(data, '$.${field}')`;
|
|
71
|
+
}
|
|
72
|
+
function selectorToSQL(selector, params) {
|
|
73
|
+
const conditions = [];
|
|
74
|
+
for (const [key, value] of Object.entries(selector)) {
|
|
75
|
+
if (key === "$and") {
|
|
76
|
+
const andConditions = value.map(
|
|
77
|
+
(sub) => selectorToSQL(sub, params)
|
|
78
|
+
).filter(Boolean);
|
|
79
|
+
if (andConditions.length > 0) {
|
|
80
|
+
conditions.push(`(${andConditions.join(" AND ")})`);
|
|
81
|
+
}
|
|
82
|
+
} else if (key === "$or") {
|
|
83
|
+
const orConditions = value.map(
|
|
84
|
+
(sub) => selectorToSQL(sub, params)
|
|
85
|
+
).filter(Boolean);
|
|
86
|
+
if (orConditions.length > 0) {
|
|
87
|
+
conditions.push(`(${orConditions.join(" OR ")})`);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
const fieldSql = fieldConditionToSQL(key, value, params);
|
|
91
|
+
if (fieldSql) {
|
|
92
|
+
conditions.push(fieldSql);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return conditions.join(" AND ");
|
|
97
|
+
}
|
|
98
|
+
function fieldConditionToSQL(field, value, params) {
|
|
99
|
+
const jsonPath = fieldToJsonExtract(field);
|
|
100
|
+
if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) {
|
|
101
|
+
params.push(value);
|
|
102
|
+
return `${jsonPath} = ?`;
|
|
103
|
+
}
|
|
104
|
+
const conditions = [];
|
|
105
|
+
for (const [op, opValue] of Object.entries(value)) {
|
|
106
|
+
switch (op) {
|
|
107
|
+
case "$eq":
|
|
108
|
+
params.push(opValue);
|
|
109
|
+
conditions.push(`${jsonPath} = ?`);
|
|
110
|
+
break;
|
|
111
|
+
case "$ne":
|
|
112
|
+
params.push(opValue);
|
|
113
|
+
conditions.push(`${jsonPath} != ?`);
|
|
114
|
+
break;
|
|
115
|
+
case "$gt":
|
|
116
|
+
params.push(opValue);
|
|
117
|
+
conditions.push(`${jsonPath} > ?`);
|
|
118
|
+
break;
|
|
119
|
+
case "$gte":
|
|
120
|
+
params.push(opValue);
|
|
121
|
+
conditions.push(`${jsonPath} >= ?`);
|
|
122
|
+
break;
|
|
123
|
+
case "$lt":
|
|
124
|
+
params.push(opValue);
|
|
125
|
+
conditions.push(`${jsonPath} < ?`);
|
|
126
|
+
break;
|
|
127
|
+
case "$lte":
|
|
128
|
+
params.push(opValue);
|
|
129
|
+
conditions.push(`${jsonPath} <= ?`);
|
|
130
|
+
break;
|
|
131
|
+
case "$in": {
|
|
132
|
+
const arr = opValue;
|
|
133
|
+
if (arr.length === 0) {
|
|
134
|
+
conditions.push("0");
|
|
135
|
+
} else {
|
|
136
|
+
const placeholders = arr.map(() => "?").join(", ");
|
|
137
|
+
params.push(...arr);
|
|
138
|
+
conditions.push(`${jsonPath} IN (${placeholders})`);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "$nin": {
|
|
143
|
+
const arr = opValue;
|
|
144
|
+
if (arr.length === 0) {
|
|
145
|
+
} else {
|
|
146
|
+
const placeholders = arr.map(() => "?").join(", ");
|
|
147
|
+
params.push(...arr);
|
|
148
|
+
conditions.push(`${jsonPath} NOT IN (${placeholders})`);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
case "$regex": {
|
|
153
|
+
const regexStr = typeof opValue === "string" ? opValue : opValue.source;
|
|
154
|
+
const likePattern = regexToLike(regexStr);
|
|
155
|
+
params.push(likePattern);
|
|
156
|
+
conditions.push(`${jsonPath} LIKE ?`);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return conditions.join(" AND ");
|
|
164
|
+
}
|
|
165
|
+
function regexToLike(regex) {
|
|
166
|
+
let pattern = regex;
|
|
167
|
+
let prefix = "%";
|
|
168
|
+
let suffix = "%";
|
|
169
|
+
if (pattern.startsWith("^")) {
|
|
170
|
+
prefix = "";
|
|
171
|
+
pattern = pattern.slice(1);
|
|
172
|
+
}
|
|
173
|
+
if (pattern.endsWith("$")) {
|
|
174
|
+
suffix = "";
|
|
175
|
+
pattern = pattern.slice(0, -1);
|
|
176
|
+
}
|
|
177
|
+
pattern = pattern.replace(/\.\*/g, "%");
|
|
178
|
+
pattern = pattern.replace(/\./g, "_");
|
|
179
|
+
return `${prefix}${pattern}${suffix}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/storage-instance.ts
|
|
183
|
+
function sanitizeTableName(databaseName, collectionName, schemaVersion) {
|
|
184
|
+
return `${databaseName}_${collectionName}_v${schemaVersion}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
185
|
+
}
|
|
186
|
+
var RxStorageInstanceSQLite = class {
|
|
187
|
+
constructor(storage, params, database) {
|
|
188
|
+
this.storage = storage;
|
|
189
|
+
this.database = database;
|
|
190
|
+
this.databaseName = params.databaseName;
|
|
191
|
+
this.collectionName = params.collectionName;
|
|
192
|
+
this.schema = params.schema;
|
|
193
|
+
this.options = params.options;
|
|
194
|
+
this.primaryPath = typeof params.schema.primaryKey === "string" ? params.schema.primaryKey : params.schema.primaryKey.key;
|
|
195
|
+
const tableName = sanitizeTableName(params.databaseName, params.collectionName, params.schema.version);
|
|
196
|
+
this.internals = { database, tableName };
|
|
197
|
+
this.createTable();
|
|
198
|
+
}
|
|
199
|
+
databaseName;
|
|
200
|
+
collectionName;
|
|
201
|
+
schema;
|
|
202
|
+
internals;
|
|
203
|
+
options;
|
|
204
|
+
primaryPath;
|
|
205
|
+
changes$ = new Subject();
|
|
206
|
+
closed = false;
|
|
207
|
+
/**
|
|
208
|
+
* Create the SQLite table for this collection if it does not exist.
|
|
209
|
+
* Stores documents as JSON with indexed columns for _deleted, _rev, _meta.lwt and the primary key.
|
|
210
|
+
*/
|
|
211
|
+
createTable() {
|
|
212
|
+
this.database.execSync(
|
|
213
|
+
`CREATE TABLE IF NOT EXISTS "${this.internals.tableName}" ( id TEXT PRIMARY KEY NOT NULL, data TEXT NOT NULL, _deleted INTEGER NOT NULL DEFAULT 0, _meta_lwt REAL NOT NULL DEFAULT 0)`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Read documents from SQLite by their primary keys.
|
|
218
|
+
* Returns a Map<primaryKey, RxDocumentData> for use with categorizeBulkWriteRows.
|
|
219
|
+
*/
|
|
220
|
+
getDocsByIdMap(ids) {
|
|
221
|
+
const map = /* @__PURE__ */ new Map();
|
|
222
|
+
if (ids.length === 0) return map;
|
|
223
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
224
|
+
const rows = this.database.getAllSync(
|
|
225
|
+
`SELECT id, data FROM "${this.internals.tableName}" WHERE id IN (${placeholders})`,
|
|
226
|
+
ids
|
|
227
|
+
);
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
const doc = JSON.parse(row.data);
|
|
230
|
+
map.set(row.id, doc);
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
}
|
|
234
|
+
async bulkWrite(documentWrites, context) {
|
|
235
|
+
if (this.closed) {
|
|
236
|
+
throw new Error("RxStorageInstanceSQLite: storage is closed");
|
|
237
|
+
}
|
|
238
|
+
const ids = documentWrites.map(
|
|
239
|
+
(row) => row.document[this.primaryPath]
|
|
240
|
+
);
|
|
241
|
+
const docsInDb = this.getDocsByIdMap(ids);
|
|
242
|
+
const categorized = categorizeBulkWriteRows(
|
|
243
|
+
this,
|
|
244
|
+
this.primaryPath,
|
|
245
|
+
docsInDb,
|
|
246
|
+
documentWrites,
|
|
247
|
+
context
|
|
248
|
+
);
|
|
249
|
+
const error = categorized.errors;
|
|
250
|
+
for (const row of categorized.bulkInsertDocs) {
|
|
251
|
+
const doc = row.document;
|
|
252
|
+
const docId = doc[this.primaryPath];
|
|
253
|
+
const json = JSON.stringify(doc);
|
|
254
|
+
this.database.runSync(
|
|
255
|
+
`INSERT OR REPLACE INTO "${this.internals.tableName}" (id, data, _deleted, _meta_lwt) VALUES (?, ?, ?, ?)`,
|
|
256
|
+
[docId, json, doc._deleted ? 1 : 0, doc._meta.lwt]
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
for (const row of categorized.bulkUpdateDocs) {
|
|
260
|
+
const doc = row.document;
|
|
261
|
+
const docId = doc[this.primaryPath];
|
|
262
|
+
const json = JSON.stringify(doc);
|
|
263
|
+
this.database.runSync(
|
|
264
|
+
`UPDATE "${this.internals.tableName}" SET data = ?, _deleted = ?, _meta_lwt = ? WHERE id = ?`,
|
|
265
|
+
[json, doc._deleted ? 1 : 0, doc._meta.lwt, docId]
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (categorized.eventBulk.events.length > 0) {
|
|
269
|
+
const lastState = ensureNotFalsy(categorized.newestRow).document;
|
|
270
|
+
categorized.eventBulk.checkpoint = {
|
|
271
|
+
id: lastState[this.primaryPath],
|
|
272
|
+
lwt: lastState._meta.lwt
|
|
273
|
+
};
|
|
274
|
+
this.changes$.next(categorized.eventBulk);
|
|
275
|
+
}
|
|
276
|
+
return { error };
|
|
277
|
+
}
|
|
278
|
+
async findDocumentsById(ids, withDeleted) {
|
|
279
|
+
if (ids.length === 0) return [];
|
|
280
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
281
|
+
let sql = `SELECT data FROM "${this.internals.tableName}" WHERE id IN (${placeholders})`;
|
|
282
|
+
if (!withDeleted) {
|
|
283
|
+
sql += " AND _deleted = 0";
|
|
284
|
+
}
|
|
285
|
+
const rows = this.database.getAllSync(sql, ids);
|
|
286
|
+
return rows.map((row) => JSON.parse(row.data));
|
|
287
|
+
}
|
|
288
|
+
async query(preparedQuery) {
|
|
289
|
+
const { query } = preparedQuery;
|
|
290
|
+
try {
|
|
291
|
+
const { sql, params } = buildQuerySQL(this.internals.tableName, query);
|
|
292
|
+
const rows = this.database.getAllSync(sql, params);
|
|
293
|
+
const documents = rows.map(
|
|
294
|
+
(row) => JSON.parse(row.data)
|
|
295
|
+
);
|
|
296
|
+
return { documents };
|
|
297
|
+
} catch {
|
|
298
|
+
return this.queryInMemory(query);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async count(preparedQuery) {
|
|
302
|
+
const { query } = preparedQuery;
|
|
303
|
+
try {
|
|
304
|
+
const { sql, params } = buildCountSQL(this.internals.tableName, query);
|
|
305
|
+
const rows = this.database.getAllSync(sql, params);
|
|
306
|
+
if (rows.length > 0 && typeof rows[0].count === "number") {
|
|
307
|
+
return { count: rows[0].count, mode: "fast" };
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
const result = await this.queryInMemory(query);
|
|
312
|
+
return { count: result.documents.length, mode: "slow" };
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* In-memory query fallback using RxDB's built-in query matcher and sort comparator.
|
|
316
|
+
* Used when the Mango-to-SQL translation fails (e.g., unsupported operators).
|
|
317
|
+
*/
|
|
318
|
+
async queryInMemory(query) {
|
|
319
|
+
const rows = this.database.getAllSync(
|
|
320
|
+
`SELECT data FROM "${this.internals.tableName}" WHERE _deleted = 0`
|
|
321
|
+
);
|
|
322
|
+
let documents = rows.map(
|
|
323
|
+
(row) => JSON.parse(row.data)
|
|
324
|
+
);
|
|
325
|
+
const queryMatcher = getQueryMatcher(this.schema, query);
|
|
326
|
+
documents = documents.filter((doc) => queryMatcher(doc));
|
|
327
|
+
const sortComparator = getSortComparator(this.schema, query);
|
|
328
|
+
documents.sort(sortComparator);
|
|
329
|
+
const skip = query.skip ?? 0;
|
|
330
|
+
const limit = query.limit ?? Infinity;
|
|
331
|
+
documents = documents.slice(skip, skip + limit);
|
|
332
|
+
return { documents };
|
|
333
|
+
}
|
|
334
|
+
getAttachmentData(_documentId, _attachmentId, _digest) {
|
|
335
|
+
throw new Error("Attachments not supported by SQLite storage");
|
|
336
|
+
}
|
|
337
|
+
async getChangedDocumentsSince(limit, checkpoint) {
|
|
338
|
+
let sql;
|
|
339
|
+
let params;
|
|
340
|
+
if (checkpoint) {
|
|
341
|
+
sql = `SELECT data FROM "${this.internals.tableName}" WHERE (_meta_lwt > ?) OR (_meta_lwt = ? AND id > ?) ORDER BY _meta_lwt ASC, id ASC LIMIT ?`;
|
|
342
|
+
params = [checkpoint.lwt, checkpoint.lwt, checkpoint.id, limit];
|
|
343
|
+
} else {
|
|
344
|
+
sql = `SELECT data FROM "${this.internals.tableName}" ORDER BY _meta_lwt ASC, id ASC LIMIT ?`;
|
|
345
|
+
params = [limit];
|
|
346
|
+
}
|
|
347
|
+
const rows = this.database.getAllSync(sql, params);
|
|
348
|
+
const documents = rows.map(
|
|
349
|
+
(row) => JSON.parse(row.data)
|
|
350
|
+
);
|
|
351
|
+
const lastDoc = documents.length > 0 ? documents[documents.length - 1] : void 0;
|
|
352
|
+
const newCheckpoint = lastDoc ? {
|
|
353
|
+
id: lastDoc[this.primaryPath],
|
|
354
|
+
lwt: lastDoc._meta.lwt
|
|
355
|
+
} : checkpoint ?? { id: "", lwt: 0 };
|
|
356
|
+
return {
|
|
357
|
+
documents,
|
|
358
|
+
checkpoint: newCheckpoint
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
changeStream() {
|
|
362
|
+
return this.changes$.asObservable();
|
|
363
|
+
}
|
|
364
|
+
async cleanup(minimumDeletedTime) {
|
|
365
|
+
const maxDeletionTime = now() - minimumDeletedTime;
|
|
366
|
+
this.database.runSync(
|
|
367
|
+
`DELETE FROM "${this.internals.tableName}" WHERE _deleted = 1 AND _meta_lwt < ?`,
|
|
368
|
+
[maxDeletionTime]
|
|
369
|
+
);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
async close() {
|
|
373
|
+
if (this.closed) return;
|
|
374
|
+
this.closed = true;
|
|
375
|
+
this.changes$.complete();
|
|
376
|
+
}
|
|
377
|
+
async remove() {
|
|
378
|
+
this.database.execSync(`DROP TABLE IF EXISTS "${this.internals.tableName}"`);
|
|
379
|
+
await this.close();
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
async function createSQLiteStorageInstance(storage, params, database) {
|
|
383
|
+
const instance = new RxStorageInstanceSQLite(storage, params, database);
|
|
384
|
+
return instance;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/rx-storage-sqlite.ts
|
|
388
|
+
function getRxStorageSQLite(database) {
|
|
389
|
+
const storage = {
|
|
390
|
+
name: "sqlite",
|
|
391
|
+
rxdbVersion: RXDB_VERSION,
|
|
392
|
+
createStorageInstance(params) {
|
|
393
|
+
ensureRxStorageInstanceParamsAreCorrect(params);
|
|
394
|
+
return createSQLiteStorageInstance(storage, params, database);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
return storage;
|
|
398
|
+
}
|
|
399
|
+
export {
|
|
400
|
+
getRxStorageSQLite
|
|
401
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tallyui/storage-sqlite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "RxDB storage adapter for SQLite via expo-sqlite",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"source": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"source": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"rxdb": "16.21.1",
|
|
23
|
+
"rxjs": "7.8.2"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"expo-sqlite": ">=15.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: RxDB -> SQLite storage round-trip.
|
|
3
|
+
*
|
|
4
|
+
* Creates a real RxDB database backed by the SQLite storage adapter
|
|
5
|
+
* (using the mock in-memory SQLite) and proves that documents can
|
|
6
|
+
* be inserted, queried, and updated through the full RxDB pipeline.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { createRxDatabase, addRxPlugin, type RxDatabase, type RxCollection } from 'rxdb';
|
|
10
|
+
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
|
|
11
|
+
import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
|
|
12
|
+
import { createMockSQLiteDatabase } from './mock-sqlite';
|
|
13
|
+
import { getRxStorageSQLite } from './rx-storage-sqlite';
|
|
14
|
+
|
|
15
|
+
// Enable dev mode for better error messages in tests
|
|
16
|
+
addRxPlugin(RxDBDevModePlugin);
|
|
17
|
+
|
|
18
|
+
interface HeroDocType {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
power: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type HeroCollection = RxCollection<HeroDocType>;
|
|
25
|
+
type HeroDatabase = RxDatabase<{ heroes: HeroCollection }>;
|
|
26
|
+
|
|
27
|
+
const heroSchema = {
|
|
28
|
+
version: 0,
|
|
29
|
+
primaryKey: 'id',
|
|
30
|
+
type: 'object' as const,
|
|
31
|
+
properties: {
|
|
32
|
+
id: { type: 'string' as const, maxLength: 100 },
|
|
33
|
+
name: { type: 'string' as const },
|
|
34
|
+
power: { type: 'number' as const },
|
|
35
|
+
},
|
|
36
|
+
required: ['id', 'name', 'power'] as const,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('RxDB + SQLite storage integration', () => {
|
|
40
|
+
let db: HeroDatabase;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
const mockDb = createMockSQLiteDatabase();
|
|
44
|
+
const baseStorage = getRxStorageSQLite(mockDb);
|
|
45
|
+
// Wrap with ajv validator to satisfy dev-mode requirements
|
|
46
|
+
const storage = wrappedValidateAjvStorage({ storage: baseStorage });
|
|
47
|
+
|
|
48
|
+
db = await createRxDatabase<{ heroes: HeroCollection }>({
|
|
49
|
+
name: 'integration_test_' + Date.now(),
|
|
50
|
+
storage,
|
|
51
|
+
multiInstance: false,
|
|
52
|
+
ignoreDuplicate: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await db.addCollections({
|
|
56
|
+
heroes: { schema: heroSchema },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
if (db) {
|
|
62
|
+
await db.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should insert a document and query it back', async () => {
|
|
67
|
+
// Insert
|
|
68
|
+
const doc = await db.heroes.insert({
|
|
69
|
+
id: 'hero1',
|
|
70
|
+
name: 'Superman',
|
|
71
|
+
power: 100,
|
|
72
|
+
});
|
|
73
|
+
expect(doc.id).toBe('hero1');
|
|
74
|
+
expect(doc.name).toBe('Superman');
|
|
75
|
+
|
|
76
|
+
// Query back
|
|
77
|
+
const found = await db.heroes.findOne('hero1').exec();
|
|
78
|
+
expect(found).not.toBeNull();
|
|
79
|
+
expect(found!.name).toBe('Superman');
|
|
80
|
+
expect(found!.power).toBe(100);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should insert multiple documents and find them all', async () => {
|
|
84
|
+
await db.heroes.bulkInsert([
|
|
85
|
+
{ id: 'h1', name: 'Batman', power: 80 },
|
|
86
|
+
{ id: 'h2', name: 'Wonder Woman', power: 95 },
|
|
87
|
+
{ id: 'h3', name: 'Flash', power: 90 },
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const all = await db.heroes.find().exec();
|
|
91
|
+
expect(all).toHaveLength(3);
|
|
92
|
+
|
|
93
|
+
const names = all.map((d) => d.name).sort();
|
|
94
|
+
expect(names).toEqual(['Batman', 'Flash', 'Wonder Woman']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should update a document', async () => {
|
|
98
|
+
const doc = await db.heroes.insert({
|
|
99
|
+
id: 'hero1',
|
|
100
|
+
name: 'Superman',
|
|
101
|
+
power: 100,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Update using RxDB's patch method
|
|
105
|
+
await doc.patch({ power: 150 });
|
|
106
|
+
|
|
107
|
+
// Query back the updated version
|
|
108
|
+
const updated = await db.heroes.findOne('hero1').exec();
|
|
109
|
+
expect(updated).not.toBeNull();
|
|
110
|
+
expect(updated!.power).toBe(150);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should delete a document', async () => {
|
|
114
|
+
const doc = await db.heroes.insert({
|
|
115
|
+
id: 'hero1',
|
|
116
|
+
name: 'Superman',
|
|
117
|
+
power: 100,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await doc.remove();
|
|
121
|
+
|
|
122
|
+
const found = await db.heroes.findOne('hero1').exec();
|
|
123
|
+
expect(found).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should support querying by selector', async () => {
|
|
127
|
+
await db.heroes.bulkInsert([
|
|
128
|
+
{ id: 'h1', name: 'Batman', power: 80 },
|
|
129
|
+
{ id: 'h2', name: 'Superman', power: 100 },
|
|
130
|
+
{ id: 'h3', name: 'Flash', power: 90 },
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// Query for heroes with power > 85
|
|
134
|
+
// Use in-memory fallback since mock doesn't support json_extract
|
|
135
|
+
const powerful = await db.heroes.find({
|
|
136
|
+
selector: { power: { $gt: 85 } },
|
|
137
|
+
}).exec();
|
|
138
|
+
|
|
139
|
+
expect(powerful).toHaveLength(2);
|
|
140
|
+
const names = powerful.map((d) => d.name).sort();
|
|
141
|
+
expect(names).toEqual(['Flash', 'Superman']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should handle the full create-read-update-delete cycle', async () => {
|
|
145
|
+
// Create
|
|
146
|
+
const doc = await db.heroes.insert({
|
|
147
|
+
id: 'lifecycle',
|
|
148
|
+
name: 'Temp Hero',
|
|
149
|
+
power: 50,
|
|
150
|
+
});
|
|
151
|
+
expect(doc.id).toBe('lifecycle');
|
|
152
|
+
|
|
153
|
+
// Read
|
|
154
|
+
let found = await db.heroes.findOne('lifecycle').exec();
|
|
155
|
+
expect(found).not.toBeNull();
|
|
156
|
+
expect(found!.name).toBe('Temp Hero');
|
|
157
|
+
|
|
158
|
+
// Update
|
|
159
|
+
await found!.patch({ name: 'Updated Hero', power: 75 });
|
|
160
|
+
found = await db.heroes.findOne('lifecycle').exec();
|
|
161
|
+
expect(found!.name).toBe('Updated Hero');
|
|
162
|
+
expect(found!.power).toBe(75);
|
|
163
|
+
|
|
164
|
+
// Delete
|
|
165
|
+
await found!.remove();
|
|
166
|
+
found = await db.heroes.findOne('lifecycle').exec();
|
|
167
|
+
expect(found).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|