local-lite 0.0.4 → 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/dist/adapters/remote/supabase.d.ts +2 -2
- package/dist/config/types.d.ts +1 -1
- package/dist/core/client.d.ts +52 -7
- package/dist/core/client.js +125 -122
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/query-builder.d.ts +119 -0
- package/dist/core/query-builder.js +372 -0
- package/dist/core/schema-manager.js +51 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/react/SyncInspector.js +0 -70
- package/dist/react/index.d.ts +39 -0
- package/dist/react/index.js +189 -4
- 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/package.json +7 -2
|
@@ -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;
|
|
@@ -91,6 +91,38 @@ class SchemaManager {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
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
|
+
}
|
|
94
126
|
}
|
|
95
127
|
catch {
|
|
96
128
|
// Ignore
|
|
@@ -171,10 +203,9 @@ class SchemaManager {
|
|
|
171
203
|
// Auto-index foreign keys and common query columns
|
|
172
204
|
const lowerKey = key.toLowerCase();
|
|
173
205
|
if (lowerKey.endsWith('_id') ||
|
|
174
|
-
lowerKey === 'business_id' ||
|
|
175
206
|
lowerKey === 'deleted' ||
|
|
176
207
|
lowerKey === 'created_at' ||
|
|
177
|
-
lowerKey === '
|
|
208
|
+
lowerKey === 'updated_at') {
|
|
178
209
|
const indexKey = `${tableName}:${lowerKey}`;
|
|
179
210
|
if (!this.knownIndexes.has(indexKey)) {
|
|
180
211
|
this.knownIndexes.add(indexKey);
|
|
@@ -190,20 +221,24 @@ class SchemaManager {
|
|
|
190
221
|
}
|
|
191
222
|
}
|
|
192
223
|
}
|
|
193
|
-
// Auto-create high performance composite index for
|
|
194
|
-
if (this.knownColumns.has(`${tableName}:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
}
|
|
207
242
|
}
|
|
208
243
|
}
|
|
209
244
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from './core/outbox-store.js';
|
|
|
7
7
|
export * from './core/meta-store.js';
|
|
8
8
|
export * from './core/table-manager.js';
|
|
9
9
|
export * from './core/schema-manager.js';
|
|
10
|
+
export * from './core/query-builder.js';
|
|
10
11
|
export * from './reactive/event-emitter.js';
|
|
11
12
|
export * from './adapters/local/index.js';
|
|
12
13
|
export * from './adapters/remote/index.js';
|
|
@@ -16,3 +17,4 @@ export type * from './react/index.js';
|
|
|
16
17
|
export * from './utils/uuid.js';
|
|
17
18
|
export * from './utils/sql.js';
|
|
18
19
|
export * from './utils/logger.js';
|
|
20
|
+
export * from './schema/index.js';
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ __exportStar(require("./core/outbox-store.js"), exports);
|
|
|
23
23
|
__exportStar(require("./core/meta-store.js"), exports);
|
|
24
24
|
__exportStar(require("./core/table-manager.js"), exports);
|
|
25
25
|
__exportStar(require("./core/schema-manager.js"), exports);
|
|
26
|
+
__exportStar(require("./core/query-builder.js"), exports);
|
|
26
27
|
__exportStar(require("./reactive/event-emitter.js"), exports);
|
|
27
28
|
__exportStar(require("./adapters/local/index.js"), exports);
|
|
28
29
|
__exportStar(require("./adapters/remote/index.js"), exports);
|
|
@@ -31,3 +32,4 @@ __exportStar(require("./config/index.js"), exports);
|
|
|
31
32
|
__exportStar(require("./utils/uuid.js"), exports);
|
|
32
33
|
__exportStar(require("./utils/sql.js"), exports);
|
|
33
34
|
__exportStar(require("./utils/logger.js"), exports);
|
|
35
|
+
__exportStar(require("./schema/index.js"), exports);
|
|
@@ -92,37 +92,6 @@ function SyncInspector({ client, position = 'bottom-right', variant = 'pill', in
|
|
|
92
92
|
numColor: metrics?.outboxFailedCount ? '#EF4444' : '#10B981',
|
|
93
93
|
},
|
|
94
94
|
], [status.pendingCount, metrics?.outboxFailedCount, currentTheme.text]);
|
|
95
|
-
// Shared Connection & Telemetry Rows
|
|
96
|
-
const telemetryItems = (0, react_1.useMemo)(() => [
|
|
97
|
-
{
|
|
98
|
-
id: 'status',
|
|
99
|
-
label: 'Connection Status',
|
|
100
|
-
value: status.isOnline ? 'Online (Connected)' : 'Offline',
|
|
101
|
-
color: statusColor,
|
|
102
|
-
hasDot: true,
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
id: 'sync-state',
|
|
106
|
-
label: 'Sync State',
|
|
107
|
-
value: status.isSyncing ? 'Syncing...' : 'Idle',
|
|
108
|
-
color: status.isSyncing ? '#F59E0B' : currentTheme.textMuted,
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
id: 'last-synced',
|
|
112
|
-
label: 'Last Synchronized',
|
|
113
|
-
value: status.lastSyncedAt ? new Date(status.lastSyncedAt).toLocaleTimeString() : 'Never',
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
id: 'push-duration',
|
|
117
|
-
label: 'Last Push Duration',
|
|
118
|
-
value: `${metrics?.lastPushDurationMs || 0} ms`,
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
id: 'pull-duration',
|
|
122
|
-
label: 'Last Pull Duration',
|
|
123
|
-
value: `${metrics?.lastPullDurationMs || 0} ms`,
|
|
124
|
-
},
|
|
125
|
-
], [status.isOnline, status.isSyncing, status.lastSyncedAt, statusColor, metrics?.lastPushDurationMs, metrics?.lastPullDurationMs, currentTheme.textMuted]);
|
|
126
95
|
// Shared Action Buttons
|
|
127
96
|
const actionButtons = (0, react_1.useMemo)(() => [
|
|
128
97
|
{ id: 'push', label: '🚀 Push Changes', onPress: handlePush, bg: '#3B82F6', color: '#FFFFFF' },
|
|
@@ -185,19 +154,6 @@ function SyncInspector({ client, position = 'bottom-right', variant = 'pill', in
|
|
|
185
154
|
backgroundColor: currentTheme.card,
|
|
186
155
|
borderColor: currentTheme.border,
|
|
187
156
|
},
|
|
188
|
-
], children: [(0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.infoCardTitle, { color: currentTheme.textMuted }], children: "CONNECTION & TELEMETRY" }), telemetryItems.map((item, idx) => ((0, jsx_runtime_1.jsxs)(View, { style: [
|
|
189
|
-
rnStyles.infoRow,
|
|
190
|
-
{ borderBottomColor: currentTheme.border },
|
|
191
|
-
idx === telemetryItems.length - 1 && { borderBottomWidth: 0 },
|
|
192
|
-
], children: [(0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.infoKey, { color: currentTheme.textMuted }], children: item.label }), (0, jsx_runtime_1.jsxs)(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 6 }, children: [item.hasDot && (0, jsx_runtime_1.jsx)(View, { style: [rnStyles.dotSmall, { backgroundColor: item.color }] }), (0, jsx_runtime_1.jsx)(Text, { style: [
|
|
193
|
-
rnStyles.infoVal,
|
|
194
|
-
item.color ? { color: item.color, fontWeight: '700' } : { color: currentTheme.text },
|
|
195
|
-
], children: item.value })] })] }, item.id)))] }), (0, jsx_runtime_1.jsxs)(View, { style: [
|
|
196
|
-
rnStyles.infoCard,
|
|
197
|
-
{
|
|
198
|
-
backgroundColor: currentTheme.card,
|
|
199
|
-
borderColor: currentTheme.border,
|
|
200
|
-
},
|
|
201
157
|
], children: [(0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.infoCardTitle, { color: currentTheme.textMuted }], children: "ENGINE ACTIONS" }), (0, jsx_runtime_1.jsx)(View, { style: rnStyles.btnRow, children: actionButtons.slice(0, 2).map((btn) => ((0, jsx_runtime_1.jsx)(TouchableOpacity, { onPress: btn.onPress, style: [rnStyles.actionBtn, { backgroundColor: btn.bg }], activeOpacity: 0.8, children: (0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.actionBtnText, { color: btn.color }], children: btn.label }) }, btn.id))) }), (0, jsx_runtime_1.jsx)(View, { style: [rnStyles.btnRow, { marginTop: 8 }], children: actionButtons.slice(2, 4).map((btn) => ((0, jsx_runtime_1.jsx)(TouchableOpacity, { onPress: btn.onPress, style: [rnStyles.actionBtn, { backgroundColor: btn.bg }], activeOpacity: 0.8, children: (0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.actionBtnText, { color: btn.color }], children: btn.label }) }, btn.id))) })] }), (metrics?.lastPushError || metrics?.lastPullError) && ((0, jsx_runtime_1.jsxs)(View, { style: [rnStyles.infoCard, { borderColor: '#EF444480', backgroundColor: '#7F1D1D20' }], children: [(0, jsx_runtime_1.jsx)(Text, { style: [rnStyles.infoCardTitle, { color: '#F87171' }], children: "SYNC ENGINE ERRORS" }), metrics?.lastPushError && ((0, jsx_runtime_1.jsxs)(View, { style: { marginBottom: 6 }, children: [(0, jsx_runtime_1.jsx)(Text, { style: { fontSize: 11, color: '#FCA5A5', fontWeight: '700' }, children: "Last Push Error:" }), (0, jsx_runtime_1.jsx)(Text, { style: { fontSize: 12, color: '#FECACA' }, children: String(metrics.lastPushError) })] })), metrics?.lastPullError && ((0, jsx_runtime_1.jsxs)(View, { children: [(0, jsx_runtime_1.jsx)(Text, { style: { fontSize: 11, color: '#FCA5A5', fontWeight: '700' }, children: "Last Pull Error:" }), (0, jsx_runtime_1.jsx)(Text, { style: { fontSize: 12, color: '#FECACA' }, children: String(metrics.lastPullError) })] }))] })), status.pendingRecords && status.pendingRecords.length > 0 && ((0, jsx_runtime_1.jsxs)(View, { style: [
|
|
202
158
|
rnStyles.infoCard,
|
|
203
159
|
{
|
|
@@ -324,32 +280,6 @@ function SyncInspector({ client, position = 'bottom-right', variant = 'pill', in
|
|
|
324
280
|
border: `1px solid ${currentTheme.border}`,
|
|
325
281
|
borderRadius: 12,
|
|
326
282
|
padding: 16,
|
|
327
|
-
}, children: [(0, jsx_runtime_1.jsx)("div", { style: {
|
|
328
|
-
fontSize: 11,
|
|
329
|
-
fontWeight: 700,
|
|
330
|
-
color: currentTheme.textMuted,
|
|
331
|
-
letterSpacing: '0.8px',
|
|
332
|
-
marginBottom: 12,
|
|
333
|
-
}, children: "CONNECTION & TELEMETRY" }), telemetryItems.map((item, idx) => ((0, jsx_runtime_1.jsxs)("div", { style: {
|
|
334
|
-
display: 'flex',
|
|
335
|
-
justifyContent: 'space-between',
|
|
336
|
-
alignItems: 'center',
|
|
337
|
-
padding: '8px 0',
|
|
338
|
-
borderBottom: idx === telemetryItems.length - 1 ? 'none' : `1px solid ${currentTheme.border}`,
|
|
339
|
-
}, children: [(0, jsx_runtime_1.jsx)("span", { style: { color: currentTheme.textMuted, fontSize: 13 }, children: item.label }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [item.hasDot && ((0, jsx_runtime_1.jsx)("span", { style: {
|
|
340
|
-
width: 8,
|
|
341
|
-
height: 8,
|
|
342
|
-
borderRadius: 4,
|
|
343
|
-
backgroundColor: item.color,
|
|
344
|
-
} })), (0, jsx_runtime_1.jsx)("span", { style: {
|
|
345
|
-
color: item.color || currentTheme.text,
|
|
346
|
-
fontSize: 13,
|
|
347
|
-
fontWeight: item.color ? 700 : 600,
|
|
348
|
-
}, children: item.value })] })] }, item.id)))] }), (0, jsx_runtime_1.jsxs)("div", { style: {
|
|
349
|
-
backgroundColor: currentTheme.card,
|
|
350
|
-
border: `1px solid ${currentTheme.border}`,
|
|
351
|
-
borderRadius: 12,
|
|
352
|
-
padding: 16,
|
|
353
283
|
}, children: [(0, jsx_runtime_1.jsx)("div", { style: {
|
|
354
284
|
fontSize: 11,
|
|
355
285
|
fontWeight: 700,
|
package/dist/react/index.d.ts
CHANGED
|
@@ -28,6 +28,45 @@ export declare function useLiveQuery<T = any>(client: LocalFirstClient | null |
|
|
|
28
28
|
refetch: () => Promise<void>;
|
|
29
29
|
loading: boolean;
|
|
30
30
|
};
|
|
31
|
+
export type MultiQuerySpec<T = any> = string | (QueryOptions & {
|
|
32
|
+
table?: string;
|
|
33
|
+
}) | LiveQueryFn<T>;
|
|
34
|
+
export type MultiQueryInput = Array<string | (QueryOptions & {
|
|
35
|
+
table: string;
|
|
36
|
+
})> | Record<string, MultiQuerySpec>;
|
|
37
|
+
export interface UseLiveQueriesResult<T extends Record<string, any[]>> {
|
|
38
|
+
data: T;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
refetch: () => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reactive multi-table live query hook that loads data from multiple tables concurrently.
|
|
44
|
+
*
|
|
45
|
+
* Benefits:
|
|
46
|
+
* - Parallel fetching: queries SQLite tables concurrently via Promise.all.
|
|
47
|
+
* - Single atomic React commit: coalesces multiple table results into a single state update,
|
|
48
|
+
* preventing cascading re-renders and UI freezing.
|
|
49
|
+
* - Multi-table event coordination: listens to mutations across all requested tables,
|
|
50
|
+
* coalescing updates so downstream calculations only run once.
|
|
51
|
+
* - Zero-latency cache read-through: returns instant cached rows on mount.
|
|
52
|
+
*
|
|
53
|
+
* Usage with dictionary:
|
|
54
|
+
* ```ts
|
|
55
|
+
* const { data, loading } = useLiveQueries(client, {
|
|
56
|
+
* projects: 'projects',
|
|
57
|
+
* tasks: { where: { project_id: '123' }, orderBy: 'created_at' },
|
|
58
|
+
* tags: { table: 'labels' }
|
|
59
|
+
* });
|
|
60
|
+
* // data.projects, data.tasks, data.tags
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Usage with array:
|
|
64
|
+
* ```ts
|
|
65
|
+
* const { data, loading } = useLiveQueries(client, ['projects', 'tasks', 'tags']);
|
|
66
|
+
* // data.projects, data.tasks, data.tags
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function useLiveQueries<T extends Record<string, any[]> = Record<string, any[]>>(client: LocalFirstClient | null | undefined, queries: MultiQueryInput): UseLiveQueriesResult<T>;
|
|
31
70
|
/**
|
|
32
71
|
* Reactive hook to query a single record by primary key with 0ms cache read-through.
|
|
33
72
|
* Automatically re-renders only when this specific row or table changes.
|