local-lite 0.0.3 → 0.0.5
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/README.md +421 -362
- package/dist/adapters/local/better-sqlite3.d.ts +2 -0
- package/dist/adapters/local/better-sqlite3.js +46 -25
- package/dist/adapters/local/expo-sqlite.d.ts +5 -1
- package/dist/adapters/local/expo-sqlite.js +40 -7
- package/dist/adapters/local/index.d.ts +1 -0
- package/dist/adapters/local/node-sqlite.d.ts +2 -0
- package/dist/adapters/local/node-sqlite.js +45 -22
- package/dist/adapters/local/op-sqlite.d.ts +1 -0
- package/dist/adapters/local/op-sqlite.js +6 -0
- package/dist/adapters/local/sql-js.d.ts +1 -0
- package/dist/adapters/local/sql-js.js +5 -0
- package/dist/adapters/network/index.js +33 -0
- package/dist/adapters/remote/convex.d.ts +2 -2
- package/dist/adapters/remote/convex.js +8 -12
- package/dist/adapters/remote/demo.d.ts +1 -1
- package/dist/adapters/remote/demo.js +3 -0
- package/dist/adapters/remote/firestore.d.ts +3 -2
- package/dist/adapters/remote/firestore.js +69 -46
- package/dist/adapters/remote/index.d.ts +6 -4
- package/dist/adapters/remote/supabase.d.ts +34 -6
- package/dist/adapters/remote/supabase.js +340 -79
- package/dist/config/index.js +7 -10
- package/dist/config/resolve-local.js +12 -1
- package/dist/config/resolve-remote.js +33 -28
- package/dist/config/types.d.ts +30 -5
- package/dist/core/client.d.ts +133 -5
- package/dist/core/client.js +431 -46
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +30 -0
- package/dist/core/outbox-store.d.ts +8 -4
- package/dist/core/outbox-store.js +84 -65
- package/dist/core/query-builder.d.ts +119 -0
- package/dist/core/query-builder.js +372 -0
- package/dist/core/query.d.ts +8 -0
- package/dist/core/query.js +37 -0
- package/dist/core/schema-manager.d.ts +9 -0
- package/dist/core/schema-manager.js +103 -0
- package/dist/core/sync-worker.d.ts +38 -1
- package/dist/core/sync-worker.js +351 -104
- package/dist/core/table-manager.js +26 -29
- package/dist/core/types.d.ts +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/react/BenchmarkModal.d.ts +32 -0
- package/dist/react/BenchmarkModal.js +777 -0
- package/dist/react/SyncInspector.d.ts +31 -0
- package/dist/react/SyncInspector.js +585 -0
- package/dist/react/index.d.ts +89 -18
- package/dist/react/index.js +450 -105
- package/dist/react/runner.d.ts +7 -0
- package/dist/react/runner.js +42 -0
- package/dist/react/status.d.ts +24 -0
- package/dist/react/status.js +123 -0
- package/dist/react/theme.d.ts +18 -0
- package/dist/react/theme.js +46 -0
- package/dist/schema/generator.d.ts +49 -0
- package/dist/schema/generator.js +209 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +17 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +20 -0
- package/package.json +143 -133
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.QueryBuilder = void 0;
|
|
4
|
+
exports.formatColumnIdentifier = formatColumnIdentifier;
|
|
5
|
+
/**
|
|
6
|
+
* Escapes an identifier or compound identifier (e.g. 'table.column' or 'table.col as alias').
|
|
7
|
+
*/
|
|
8
|
+
function formatColumnIdentifier(col) {
|
|
9
|
+
const trimmed = col.trim();
|
|
10
|
+
if (trimmed === '*')
|
|
11
|
+
return '*';
|
|
12
|
+
// Handle "expression AS alias"
|
|
13
|
+
const asMatch = trimmed.match(/^(.+?)\s+(?:AS|as)\s+([A-Za-z0-9_"]+)$/i);
|
|
14
|
+
if (asMatch) {
|
|
15
|
+
const [, expr, rawAlias] = asMatch;
|
|
16
|
+
const unquotedAlias = rawAlias.startsWith('"') && rawAlias.endsWith('"')
|
|
17
|
+
? rawAlias.slice(1, -1)
|
|
18
|
+
: rawAlias;
|
|
19
|
+
const cleanAlias = `"${unquotedAlias.replace(/"/g, '""')}"`;
|
|
20
|
+
if (expr.includes('(') && expr.endsWith(')')) {
|
|
21
|
+
return `${expr} AS ${cleanAlias}`;
|
|
22
|
+
}
|
|
23
|
+
return `${formatColumnIdentifier(expr)} AS ${cleanAlias}`;
|
|
24
|
+
}
|
|
25
|
+
// Handle function calls without alias (e.g. COUNT(*))
|
|
26
|
+
if (trimmed.includes('(') && trimmed.endsWith(')')) {
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
// Handle "table.column"
|
|
30
|
+
if (trimmed.includes('.')) {
|
|
31
|
+
return trimmed
|
|
32
|
+
.split('.')
|
|
33
|
+
.map((part) => `"${part.replace(/"/g, '""')}"`)
|
|
34
|
+
.join('.');
|
|
35
|
+
}
|
|
36
|
+
return `"${trimmed.replace(/"/g, '""')}"`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Fluent, type-safe SQL Query Builder for local-first SQLite operations.
|
|
40
|
+
* Allows writing expressive JavaScript code that compiles and executes high-performance SQL under the hood.
|
|
41
|
+
*/
|
|
42
|
+
class QueryBuilder {
|
|
43
|
+
client;
|
|
44
|
+
tableName;
|
|
45
|
+
selectCols = ['*'];
|
|
46
|
+
joins = [];
|
|
47
|
+
whereConditions = {};
|
|
48
|
+
orderColumn;
|
|
49
|
+
orderDirection = 'ASC';
|
|
50
|
+
limitCount;
|
|
51
|
+
offsetCount;
|
|
52
|
+
groupByCols = [];
|
|
53
|
+
includeDeleted = false;
|
|
54
|
+
constructor(client, tableName) {
|
|
55
|
+
this.client = client;
|
|
56
|
+
this.tableName = tableName;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Specifies which columns to select.
|
|
60
|
+
* Supports column aliases (e.g. ['id', 'contracts.rent_amount AS rent', 'houses.house_number']).
|
|
61
|
+
*/
|
|
62
|
+
select(...columns) {
|
|
63
|
+
const flat = columns.flat();
|
|
64
|
+
if (flat.length > 0) {
|
|
65
|
+
this.selectCols = flat;
|
|
66
|
+
}
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Inner joins another SQLite table on a matching column expression.
|
|
71
|
+
*/
|
|
72
|
+
join(table, firstCol, secondCol) {
|
|
73
|
+
this.joins.push({ type: 'INNER', table, firstCol, secondCol });
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Left joins another SQLite table on a matching column expression.
|
|
78
|
+
*/
|
|
79
|
+
leftJoin(table, firstCol, secondCol) {
|
|
80
|
+
this.joins.push({ type: 'LEFT', table, firstCol, secondCol });
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Adds WHERE criteria to filter results.
|
|
85
|
+
* Supports:
|
|
86
|
+
* - where({ status: 'ACTIVE' })
|
|
87
|
+
* - where('status', 'ACTIVE')
|
|
88
|
+
* - where('end_date', '>=', today)
|
|
89
|
+
*/
|
|
90
|
+
where(columnOrFilter, operatorOrValue, value) {
|
|
91
|
+
if (typeof columnOrFilter === 'string') {
|
|
92
|
+
if (value !== undefined) {
|
|
93
|
+
const rawOp = String(operatorOrValue).toLowerCase().trim();
|
|
94
|
+
const opKey = rawOp === '>=' ? '$gte' :
|
|
95
|
+
rawOp === '<=' ? '$lte' :
|
|
96
|
+
rawOp === '>' ? '$gt' :
|
|
97
|
+
rawOp === '<' ? '$lt' :
|
|
98
|
+
rawOp === '!=' || rawOp === '<>' ? '$ne' :
|
|
99
|
+
rawOp === '=' || rawOp === '==' ? '$eq' :
|
|
100
|
+
rawOp === 'like' ? '$contains' : '$eq';
|
|
101
|
+
const existing = typeof this.whereConditions[columnOrFilter] === 'object' &&
|
|
102
|
+
this.whereConditions[columnOrFilter] !== null
|
|
103
|
+
? this.whereConditions[columnOrFilter]
|
|
104
|
+
: {};
|
|
105
|
+
this.whereConditions[columnOrFilter] = { ...existing, [opKey]: value };
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
this.whereConditions[columnOrFilter] = operatorOrValue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (columnOrFilter && typeof columnOrFilter === 'object') {
|
|
112
|
+
this.whereConditions = { ...this.whereConditions, ...columnOrFilter };
|
|
113
|
+
}
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Orders query results by column and direction.
|
|
118
|
+
*/
|
|
119
|
+
orderBy(column, direction = 'ASC') {
|
|
120
|
+
this.orderColumn = column;
|
|
121
|
+
this.orderDirection = direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
122
|
+
return this;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Groups rows by one or more columns for aggregation.
|
|
126
|
+
*/
|
|
127
|
+
groupBy(...columns) {
|
|
128
|
+
this.groupByCols = columns.flat();
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Limits the number of returned records.
|
|
133
|
+
*/
|
|
134
|
+
limit(count) {
|
|
135
|
+
this.limitCount = count;
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Offsets the starting record position.
|
|
140
|
+
*/
|
|
141
|
+
offset(count) {
|
|
142
|
+
this.offsetCount = count;
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Includes soft-deleted rows in results.
|
|
147
|
+
*/
|
|
148
|
+
withDeleted(include = true) {
|
|
149
|
+
this.includeDeleted = include;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Applies standard QueryOptions (select, where, orderBy, limit, offset, includeDeleted) to this query.
|
|
154
|
+
*/
|
|
155
|
+
applyOptions(options) {
|
|
156
|
+
if (!options)
|
|
157
|
+
return this;
|
|
158
|
+
if (options.select)
|
|
159
|
+
this.select(options.select);
|
|
160
|
+
if (options.where && typeof options.where === 'object')
|
|
161
|
+
this.where(options.where);
|
|
162
|
+
if (options.orderBy)
|
|
163
|
+
this.orderBy(options.orderBy, options.order || 'ASC');
|
|
164
|
+
if (typeof options.limit === 'number')
|
|
165
|
+
this.limit(options.limit);
|
|
166
|
+
if (typeof options.offset === 'number')
|
|
167
|
+
this.offset(options.offset);
|
|
168
|
+
if (options.includeDeleted)
|
|
169
|
+
this.withDeleted(true);
|
|
170
|
+
return this;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Compiles the current builder state into an executable SQL string and parameter list.
|
|
174
|
+
*/
|
|
175
|
+
toSQL() {
|
|
176
|
+
const params = [];
|
|
177
|
+
const projections = this.selectCols.map(formatColumnIdentifier).join(', ');
|
|
178
|
+
const fromTable = `"${this.tableName.replace(/"/g, '""')}"`;
|
|
179
|
+
let sql = `SELECT ${projections} FROM ${fromTable}`;
|
|
180
|
+
// Append JOINs
|
|
181
|
+
for (const j of this.joins) {
|
|
182
|
+
const joinTable = `"${j.table.replace(/"/g, '""')}"`;
|
|
183
|
+
const col1 = formatColumnIdentifier(j.firstCol);
|
|
184
|
+
const col2 = formatColumnIdentifier(j.secondCol);
|
|
185
|
+
sql += ` ${j.type} JOIN ${joinTable} ON ${col1} = ${col2}`;
|
|
186
|
+
}
|
|
187
|
+
// Build WHERE clauses
|
|
188
|
+
const whereClauses = [];
|
|
189
|
+
if (this.whereConditions && typeof this.whereConditions === 'object') {
|
|
190
|
+
for (const [col, val] of Object.entries(this.whereConditions)) {
|
|
191
|
+
if (col === '$or' || col === '$and')
|
|
192
|
+
continue;
|
|
193
|
+
const safeCol = formatColumnIdentifier(col);
|
|
194
|
+
if (val === null) {
|
|
195
|
+
whereClauses.push(`${safeCol} IS NULL`);
|
|
196
|
+
}
|
|
197
|
+
else if (typeof val === 'boolean') {
|
|
198
|
+
whereClauses.push(`${safeCol} = ?`);
|
|
199
|
+
params.push(val ? 1 : 0);
|
|
200
|
+
}
|
|
201
|
+
else if (typeof val === 'string' || typeof val === 'number') {
|
|
202
|
+
whereClauses.push(`${safeCol} = ?`);
|
|
203
|
+
params.push(val);
|
|
204
|
+
}
|
|
205
|
+
else if (typeof val === 'object' && !Array.isArray(val)) {
|
|
206
|
+
const ops = val;
|
|
207
|
+
const eqVal = ops.$eq !== undefined ? ops.$eq : ops.eq;
|
|
208
|
+
if (eqVal !== undefined) {
|
|
209
|
+
whereClauses.push(`${safeCol} = ?`);
|
|
210
|
+
params.push(typeof eqVal === 'boolean' ? (eqVal ? 1 : 0) : eqVal);
|
|
211
|
+
}
|
|
212
|
+
const neVal = ops.$ne !== undefined ? ops.$ne : ops.ne;
|
|
213
|
+
if (neVal !== undefined) {
|
|
214
|
+
whereClauses.push(`${safeCol} != ?`);
|
|
215
|
+
params.push(typeof neVal === 'boolean' ? (neVal ? 1 : 0) : neVal);
|
|
216
|
+
}
|
|
217
|
+
const gtVal = ops.$gt !== undefined ? ops.$gt : ops.gt;
|
|
218
|
+
if (gtVal !== undefined) {
|
|
219
|
+
whereClauses.push(`${safeCol} > ?`);
|
|
220
|
+
params.push(gtVal);
|
|
221
|
+
}
|
|
222
|
+
const gteVal = ops.$gte !== undefined ? ops.$gte : ops.gte;
|
|
223
|
+
if (gteVal !== undefined) {
|
|
224
|
+
whereClauses.push(`${safeCol} >= ?`);
|
|
225
|
+
params.push(gteVal);
|
|
226
|
+
}
|
|
227
|
+
const ltVal = ops.$lt !== undefined ? ops.$lt : ops.lt;
|
|
228
|
+
if (ltVal !== undefined) {
|
|
229
|
+
whereClauses.push(`${safeCol} < ?`);
|
|
230
|
+
params.push(ltVal);
|
|
231
|
+
}
|
|
232
|
+
const lteVal = ops.$lte !== undefined ? ops.$lte : ops.lte;
|
|
233
|
+
if (lteVal !== undefined) {
|
|
234
|
+
whereClauses.push(`${safeCol} <= ?`);
|
|
235
|
+
params.push(lteVal);
|
|
236
|
+
}
|
|
237
|
+
const inVal = ops.$in !== undefined ? ops.$in : ops.in;
|
|
238
|
+
if (Array.isArray(inVal) && inVal.length > 0) {
|
|
239
|
+
const placeholders = inVal.map(() => '?').join(', ');
|
|
240
|
+
whereClauses.push(`${safeCol} IN (${placeholders})`);
|
|
241
|
+
params.push(...inVal);
|
|
242
|
+
}
|
|
243
|
+
const ninVal = ops.$nin !== undefined ? ops.$nin : ops.nin;
|
|
244
|
+
if (Array.isArray(ninVal) && ninVal.length > 0) {
|
|
245
|
+
const placeholders = ninVal.map(() => '?').join(', ');
|
|
246
|
+
whereClauses.push(`${safeCol} NOT IN (${placeholders})`);
|
|
247
|
+
params.push(...ninVal);
|
|
248
|
+
}
|
|
249
|
+
const containsVal = ops.$contains !== undefined ? ops.$contains : ops.contains;
|
|
250
|
+
if (containsVal !== undefined) {
|
|
251
|
+
whereClauses.push(`${safeCol} LIKE ?`);
|
|
252
|
+
params.push(`%${containsVal}%`);
|
|
253
|
+
}
|
|
254
|
+
const nullVal = ops.$null !== undefined ? ops.$null : ops.null;
|
|
255
|
+
if (nullVal !== undefined) {
|
|
256
|
+
whereClauses.push(nullVal ? `${safeCol} IS NULL` : `${safeCol} IS NOT NULL`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Push down soft-deletion check on primary table if deleted column exists and not explicitly overridden
|
|
262
|
+
const schemaManager = 'schemaManager' in this.client
|
|
263
|
+
? this.client.schemaManager
|
|
264
|
+
: this.client.parent?.schemaManager;
|
|
265
|
+
if (!this.includeDeleted && schemaManager?.hasColumn(this.tableName, 'deleted')) {
|
|
266
|
+
const delCol = this.tableName.includes('.')
|
|
267
|
+
? `${formatColumnIdentifier(this.tableName)}."deleted"`
|
|
268
|
+
: `"${this.tableName.replace(/"/g, '""')}"."deleted"`;
|
|
269
|
+
whereClauses.push(`(${delCol} IS NULL OR (${delCol} != 1 AND ${delCol} != 'true'))`);
|
|
270
|
+
}
|
|
271
|
+
if (whereClauses.length > 0) {
|
|
272
|
+
sql += ` WHERE ${whereClauses.join(' AND ')}`;
|
|
273
|
+
}
|
|
274
|
+
// GROUP BY
|
|
275
|
+
if (this.groupByCols.length > 0) {
|
|
276
|
+
sql += ` GROUP BY ${this.groupByCols.map(formatColumnIdentifier).join(', ')}`;
|
|
277
|
+
}
|
|
278
|
+
// ORDER BY
|
|
279
|
+
if (this.orderColumn) {
|
|
280
|
+
sql += ` ORDER BY ${formatColumnIdentifier(this.orderColumn)} ${this.orderDirection}`;
|
|
281
|
+
}
|
|
282
|
+
// LIMIT & OFFSET
|
|
283
|
+
if (typeof this.limitCount === 'number' && this.limitCount > 0) {
|
|
284
|
+
sql += ` LIMIT ?`;
|
|
285
|
+
params.push(this.limitCount);
|
|
286
|
+
if (typeof this.offsetCount === 'number' && this.offsetCount > 0) {
|
|
287
|
+
sql += ` OFFSET ?`;
|
|
288
|
+
params.push(this.offsetCount);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { sql, params };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Executes the compiled SQL query against SQLite and returns rows.
|
|
295
|
+
*/
|
|
296
|
+
async execute() {
|
|
297
|
+
const schemaManager = 'schemaManager' in this.client
|
|
298
|
+
? this.client.schemaManager
|
|
299
|
+
: this.client.parent?.schemaManager;
|
|
300
|
+
const db = 'db' in this.client ? this.client.db : this.client.tx;
|
|
301
|
+
if (schemaManager && db) {
|
|
302
|
+
await schemaManager.ensureTableIntrospected(db, this.tableName);
|
|
303
|
+
for (const j of this.joins) {
|
|
304
|
+
await schemaManager.ensureTableIntrospected(db, j.table);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const { sql, params } = this.toSQL();
|
|
308
|
+
try {
|
|
309
|
+
const rawRows = await this.client.query(sql, params);
|
|
310
|
+
const hydrator = 'hydrateRow' in this.client
|
|
311
|
+
? this.client.hydrateRow.bind(this.client)
|
|
312
|
+
: this.client.parent?.hydrateRow?.bind(this.client.parent);
|
|
313
|
+
if (hydrator && rawRows && rawRows.length > 0) {
|
|
314
|
+
return rawRows.map((r) => hydrator(this.tableName, r));
|
|
315
|
+
}
|
|
316
|
+
return rawRows;
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
const msg = String(err?.message || err || '');
|
|
320
|
+
if (msg.includes('no such table') || msg.includes('no such column')) {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Returns the first matching record or null if not found.
|
|
328
|
+
*/
|
|
329
|
+
async first() {
|
|
330
|
+
const originalLimit = this.limitCount;
|
|
331
|
+
this.limitCount = 1;
|
|
332
|
+
const rows = await this.execute();
|
|
333
|
+
this.limitCount = originalLimit;
|
|
334
|
+
return rows.length > 0 ? rows[0] : null;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Executes a COUNT(*) in SQLite and returns the numeric count directly.
|
|
338
|
+
*/
|
|
339
|
+
async count() {
|
|
340
|
+
const originalSelect = this.selectCols;
|
|
341
|
+
this.selectCols = ['COUNT(*) AS _count'];
|
|
342
|
+
const rows = await this.execute();
|
|
343
|
+
this.selectCols = originalSelect;
|
|
344
|
+
const countVal = rows[0]?._count;
|
|
345
|
+
return typeof countVal === 'number' ? countVal : Number(countVal) || 0;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Executes a SUM(column) in SQLite and returns the numeric total directly.
|
|
349
|
+
*/
|
|
350
|
+
async sum(column) {
|
|
351
|
+
const originalSelect = this.selectCols;
|
|
352
|
+
const safeCol = formatColumnIdentifier(column);
|
|
353
|
+
this.selectCols = [`COALESCE(SUM(${safeCol}), 0) AS _sum`];
|
|
354
|
+
const rows = await this.execute();
|
|
355
|
+
this.selectCols = originalSelect;
|
|
356
|
+
const sumVal = rows[0]?._sum;
|
|
357
|
+
return typeof sumVal === 'number' ? sumVal : Number(sumVal) || 0;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Executes an AVG(column) in SQLite and returns the average directly.
|
|
361
|
+
*/
|
|
362
|
+
async avg(column) {
|
|
363
|
+
const originalSelect = this.selectCols;
|
|
364
|
+
const safeCol = formatColumnIdentifier(column);
|
|
365
|
+
this.selectCols = [`AVG(${safeCol}) AS _avg`];
|
|
366
|
+
const rows = await this.execute();
|
|
367
|
+
this.selectCols = originalSelect;
|
|
368
|
+
const avgVal = rows[0]?._avg;
|
|
369
|
+
return avgVal === null || avgVal === undefined ? null : Number(avgVal) || 0;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
exports.QueryBuilder = QueryBuilder;
|
package/dist/core/query.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export declare function matchesField(fieldValue: any, filter: FieldFilter): bool
|
|
|
7
7
|
* Evaluates whether a given row matches a WhereFilter.
|
|
8
8
|
*/
|
|
9
9
|
export declare function matchesWhere(row: Record<string, any>, where?: WhereFilter): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Checks whether a row is considered soft-deleted.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isDeletedRecord(row: any): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Checks if a where filter explicitly specifies a condition on the 'deleted' column.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasExplicitDeletedFilter(where?: WhereFilter): boolean;
|
|
10
18
|
/**
|
|
11
19
|
* Applies in-memory filtering, sorting, offset, and limit to an array of rows.
|
|
12
20
|
*/
|
package/dist/core/query.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.matchesField = matchesField;
|
|
4
4
|
exports.matchesWhere = matchesWhere;
|
|
5
|
+
exports.isDeletedRecord = isDeletedRecord;
|
|
6
|
+
exports.hasExplicitDeletedFilter = hasExplicitDeletedFilter;
|
|
5
7
|
exports.applyQueryOptions = applyQueryOptions;
|
|
6
8
|
/**
|
|
7
9
|
* Checks whether a single field value matches a field filter condition.
|
|
@@ -106,6 +108,23 @@ function matchesWhere(row, where) {
|
|
|
106
108
|
}
|
|
107
109
|
return true;
|
|
108
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Checks whether a row is considered soft-deleted.
|
|
113
|
+
*/
|
|
114
|
+
function isDeletedRecord(row) {
|
|
115
|
+
if (!row || typeof row !== 'object')
|
|
116
|
+
return false;
|
|
117
|
+
const d = row.deleted;
|
|
118
|
+
return d === 1 || d === true || d === '1' || d === 'true';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Checks if a where filter explicitly specifies a condition on the 'deleted' column.
|
|
122
|
+
*/
|
|
123
|
+
function hasExplicitDeletedFilter(where) {
|
|
124
|
+
if (!where || typeof where !== 'object' || Array.isArray(where))
|
|
125
|
+
return false;
|
|
126
|
+
return 'deleted' in where;
|
|
127
|
+
}
|
|
109
128
|
/**
|
|
110
129
|
* Applies in-memory filtering, sorting, offset, and limit to an array of rows.
|
|
111
130
|
*/
|
|
@@ -113,6 +132,11 @@ function applyQueryOptions(rows, options) {
|
|
|
113
132
|
if (!rows || rows.length === 0)
|
|
114
133
|
return [];
|
|
115
134
|
let result = [...rows];
|
|
135
|
+
// 0. Automatic soft-delete filtering: omit deleted records unless includeDeleted is true
|
|
136
|
+
// or the where filter explicitly targets 'deleted'.
|
|
137
|
+
if (!options?.includeDeleted && !hasExplicitDeletedFilter(options?.where)) {
|
|
138
|
+
result = result.filter((row) => !isDeletedRecord(row));
|
|
139
|
+
}
|
|
116
140
|
// 1. Where filtering
|
|
117
141
|
if (options?.where) {
|
|
118
142
|
result = result.filter((row) => matchesWhere(row, options.where));
|
|
@@ -146,5 +170,18 @@ function applyQueryOptions(rows, options) {
|
|
|
146
170
|
if (options?.limit !== undefined && options.limit > 0) {
|
|
147
171
|
result = result.slice(0, options.limit);
|
|
148
172
|
}
|
|
173
|
+
// 4. Column Projection
|
|
174
|
+
if (options?.select && Array.isArray(options.select) && options.select.length > 0) {
|
|
175
|
+
const fields = options.select;
|
|
176
|
+
result = result.map((row) => {
|
|
177
|
+
const projected = {};
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
if (field in row) {
|
|
180
|
+
projected[field] = row[field];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return projected;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
149
186
|
return result;
|
|
150
187
|
}
|
|
@@ -23,10 +23,19 @@ export declare function formatValueForStorage(value: any): any;
|
|
|
23
23
|
export declare class SchemaManager {
|
|
24
24
|
private knownTables;
|
|
25
25
|
private knownColumns;
|
|
26
|
+
private knownIndexes;
|
|
26
27
|
/**
|
|
27
28
|
* Resets the schema cache (useful for testing or full database resets).
|
|
28
29
|
*/
|
|
29
30
|
clearCache(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Checks whether a column is known to exist on the specified table.
|
|
33
|
+
*/
|
|
34
|
+
hasColumn(tableName: string, column: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Introspects table columns via PRAGMA table_info if not previously cached.
|
|
37
|
+
*/
|
|
38
|
+
ensureTableIntrospected(db: DBExecutable, tableName: string): Promise<void>;
|
|
30
39
|
/**
|
|
31
40
|
* Ensures that the target table exists in SQLite with the specified primary key.
|
|
32
41
|
*/
|
|
@@ -58,12 +58,75 @@ function formatValueForStorage(value) {
|
|
|
58
58
|
class SchemaManager {
|
|
59
59
|
knownTables = new Set();
|
|
60
60
|
knownColumns = new Set();
|
|
61
|
+
knownIndexes = new Set();
|
|
61
62
|
/**
|
|
62
63
|
* Resets the schema cache (useful for testing or full database resets).
|
|
63
64
|
*/
|
|
64
65
|
clearCache() {
|
|
65
66
|
this.knownTables.clear();
|
|
66
67
|
this.knownColumns.clear();
|
|
68
|
+
this.knownIndexes.clear();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Checks whether a column is known to exist on the specified table.
|
|
72
|
+
*/
|
|
73
|
+
hasColumn(tableName, column) {
|
|
74
|
+
return this.knownColumns.has(`${tableName}:${column.toLowerCase()}`);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Introspects table columns via PRAGMA table_info if not previously cached.
|
|
78
|
+
*/
|
|
79
|
+
async ensureTableIntrospected(db, tableName) {
|
|
80
|
+
const cacheKey = `${tableName}:__introspected__`;
|
|
81
|
+
if (this.knownColumns.has(cacheKey))
|
|
82
|
+
return;
|
|
83
|
+
this.knownColumns.add(cacheKey);
|
|
84
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
85
|
+
try {
|
|
86
|
+
const tableInfo = await db.execute(`PRAGMA table_info(${safeTable});`);
|
|
87
|
+
if (Array.isArray(tableInfo)) {
|
|
88
|
+
for (const col of tableInfo) {
|
|
89
|
+
if (col && col.name) {
|
|
90
|
+
this.knownColumns.add(`${tableName}:${String(col.name).toLowerCase()}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Auto-create high performance single and composite indexes for foreign keys (_id) and soft-deletes (deleted)
|
|
95
|
+
const hasDeleted = this.knownColumns.has(`${tableName}:deleted`);
|
|
96
|
+
for (const entry of this.knownColumns) {
|
|
97
|
+
if (!entry.startsWith(`${tableName}:`))
|
|
98
|
+
continue;
|
|
99
|
+
const colName = entry.slice(tableName.length + 1);
|
|
100
|
+
if (colName.endsWith('_id') && colName !== 'id') {
|
|
101
|
+
const fkIndexKey = `${tableName}:${colName}`;
|
|
102
|
+
if (!this.knownIndexes.has(fkIndexKey)) {
|
|
103
|
+
this.knownIndexes.add(fkIndexKey);
|
|
104
|
+
try {
|
|
105
|
+
const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${colName}`);
|
|
106
|
+
const safeFk = (0, sql_js_1.escapeIdentifier)(colName);
|
|
107
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk});`);
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
}
|
|
111
|
+
if (hasDeleted) {
|
|
112
|
+
const compositeKey = `${tableName}:${colName}_deleted`;
|
|
113
|
+
if (!this.knownIndexes.has(compositeKey)) {
|
|
114
|
+
this.knownIndexes.add(compositeKey);
|
|
115
|
+
try {
|
|
116
|
+
const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${colName}_del`);
|
|
117
|
+
const safeFk = (0, sql_js_1.escapeIdentifier)(colName);
|
|
118
|
+
const safeDel = (0, sql_js_1.escapeIdentifier)('deleted');
|
|
119
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk}, ${safeDel});`);
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Ignore
|
|
129
|
+
}
|
|
67
130
|
}
|
|
68
131
|
/**
|
|
69
132
|
* Ensures that the target table exists in SQLite with the specified primary key.
|
|
@@ -137,6 +200,46 @@ class SchemaManager {
|
|
|
137
200
|
}
|
|
138
201
|
this.knownColumns.add(cacheKey);
|
|
139
202
|
}
|
|
203
|
+
// Auto-index foreign keys and common query columns
|
|
204
|
+
const lowerKey = key.toLowerCase();
|
|
205
|
+
if (lowerKey.endsWith('_id') ||
|
|
206
|
+
lowerKey === 'deleted' ||
|
|
207
|
+
lowerKey === 'created_at' ||
|
|
208
|
+
lowerKey === 'updated_at') {
|
|
209
|
+
const indexKey = `${tableName}:${lowerKey}`;
|
|
210
|
+
if (!this.knownIndexes.has(indexKey)) {
|
|
211
|
+
this.knownIndexes.add(indexKey);
|
|
212
|
+
try {
|
|
213
|
+
const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${key}`);
|
|
214
|
+
const safeCol = (0, sql_js_1.escapeIdentifier)(key);
|
|
215
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeCol});`);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Ignore if indexes are not supported by adapter or already exist
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Auto-create high performance composite index for foreign keys with soft-deletion
|
|
225
|
+
if (this.knownColumns.has(`${tableName}:deleted`)) {
|
|
226
|
+
for (const key of keys) {
|
|
227
|
+
const lowerKey = key.toLowerCase();
|
|
228
|
+
if (lowerKey.endsWith('_id') && lowerKey !== 'id') {
|
|
229
|
+
const compositeKey = `${tableName}:${lowerKey}_deleted`;
|
|
230
|
+
if (!this.knownIndexes.has(compositeKey)) {
|
|
231
|
+
this.knownIndexes.add(compositeKey);
|
|
232
|
+
try {
|
|
233
|
+
const idxName = (0, sql_js_1.escapeIdentifier)(`idx_${tableName}_${lowerKey}_del`);
|
|
234
|
+
const safeFk = (0, sql_js_1.escapeIdentifier)(key);
|
|
235
|
+
const safeDel = (0, sql_js_1.escapeIdentifier)('deleted');
|
|
236
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS ${idxName} ON ${safeTable} (${safeFk}, ${safeDel});`);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Ignore
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
140
243
|
}
|
|
141
244
|
}
|
|
142
245
|
// Format all values for SQLite storage
|
|
@@ -16,6 +16,9 @@ export interface SyncWorkerOptions {
|
|
|
16
16
|
primaryKey?: string;
|
|
17
17
|
autoSchema?: boolean;
|
|
18
18
|
schemaManager?: SchemaManager;
|
|
19
|
+
autoSync?: boolean;
|
|
20
|
+
syncScope?: Record<string, any> | (() => Record<string, any> | Promise<Record<string, any>>);
|
|
21
|
+
autoPauseOnBackground?: boolean;
|
|
19
22
|
}
|
|
20
23
|
export declare class SyncWorker {
|
|
21
24
|
private db;
|
|
@@ -26,20 +29,29 @@ export declare class SyncWorker {
|
|
|
26
29
|
private primaryKey;
|
|
27
30
|
private autoSchema;
|
|
28
31
|
private schemaManager?;
|
|
32
|
+
private syncScope?;
|
|
29
33
|
logger: SyncLogger;
|
|
30
34
|
retryConfig: RetryConfig;
|
|
31
35
|
private pushIntervalMs;
|
|
32
36
|
private pullIntervalMs;
|
|
33
37
|
private maxRetries;
|
|
38
|
+
private autoPauseOnBackground;
|
|
39
|
+
private isPaused;
|
|
40
|
+
private lifecycleUnsubscribe?;
|
|
34
41
|
private pushIntervalTimer?;
|
|
35
42
|
private pullIntervalTimer?;
|
|
36
43
|
private pushDebounceTimer?;
|
|
37
44
|
private networkUnsubscribe?;
|
|
45
|
+
private remoteUnsubscribe?;
|
|
46
|
+
private subscriptionGeneration;
|
|
47
|
+
private automaticSync;
|
|
38
48
|
private isPushing;
|
|
39
49
|
private isPulling;
|
|
40
50
|
private lastPulledTimestamp;
|
|
41
51
|
private cursorLoaded;
|
|
42
52
|
private deltaQueue;
|
|
53
|
+
private statusRun?;
|
|
54
|
+
private statusRerunRequested;
|
|
43
55
|
private totalPushedOperations;
|
|
44
56
|
private totalPulledDeltas;
|
|
45
57
|
private totalPushCycles;
|
|
@@ -59,11 +71,34 @@ export declare class SyncWorker {
|
|
|
59
71
|
* Stops all active intervals, timers, and listeners.
|
|
60
72
|
*/
|
|
61
73
|
stop(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Pauses recurring interval timers and active sync loops to conserve battery and data.
|
|
76
|
+
*/
|
|
77
|
+
pause(): void;
|
|
78
|
+
/**
|
|
79
|
+
* Resumes recurring interval timers and triggers an immediate catch-up push and pull.
|
|
80
|
+
*/
|
|
81
|
+
resume(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Returns whether the sync worker is currently suspended due to app backgrounding.
|
|
84
|
+
*/
|
|
85
|
+
get isBackgroundPaused(): boolean;
|
|
86
|
+
private isAppBackgrounded;
|
|
87
|
+
private setupLifecycleListener;
|
|
62
88
|
/**
|
|
63
89
|
* Schedules an immediate push with a short debounce.
|
|
64
90
|
*/
|
|
65
91
|
schedulePush(delayMs?: number): void;
|
|
92
|
+
/**
|
|
93
|
+
* Dynamically sets or updates the active sync scope (e.g. switching active businessId or userId).
|
|
94
|
+
*/
|
|
95
|
+
setSyncScope(scope?: Record<string, any> | (() => Record<string, any> | Promise<Record<string, any>>)): void;
|
|
66
96
|
private isOnline;
|
|
97
|
+
/**
|
|
98
|
+
* Emits a `sync:status` snapshot. Calls are coalesced: while one snapshot is being
|
|
99
|
+
* computed, further requests collapse into a single trailing run that reflects the
|
|
100
|
+
* latest state, so bursts of push/pull transitions cost at most two COUNT queries.
|
|
101
|
+
*/
|
|
67
102
|
private emitStatus;
|
|
68
103
|
/**
|
|
69
104
|
* Pushes pending outbox records to the remote database.
|
|
@@ -78,7 +113,9 @@ export declare class SyncWorker {
|
|
|
78
113
|
* Applies incoming server delta changes to local tables with conflict resolution.
|
|
79
114
|
* Serializes execution in a queue to prevent concurrent SQLite transactions.
|
|
80
115
|
*/
|
|
81
|
-
applyDeltasLocally(deltas: DeltaChanges
|
|
116
|
+
applyDeltasLocally(deltas: DeltaChanges, options?: {
|
|
117
|
+
advanceCursor?: boolean;
|
|
118
|
+
}): Promise<void>;
|
|
82
119
|
private processDeltasLocally;
|
|
83
120
|
getLastPulledTimestamp(): Promise<number>;
|
|
84
121
|
resetSyncCursor(): Promise<void>;
|