local-lite 0.0.5 → 0.0.7
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/local/better-sqlite3.d.ts +5 -1
- package/dist/adapters/local/better-sqlite3.js +35 -14
- package/dist/adapters/local/expo-sqlite.d.ts +11 -2
- package/dist/adapters/local/expo-sqlite.js +37 -20
- package/dist/adapters/local/index.d.ts +2 -0
- package/dist/adapters/local/libsql.d.ts +1 -0
- package/dist/adapters/local/libsql.js +1 -0
- package/dist/adapters/local/node-sqlite.d.ts +5 -1
- package/dist/adapters/local/node-sqlite.js +30 -12
- package/dist/adapters/local/op-sqlite.d.ts +1 -0
- package/dist/adapters/local/op-sqlite.js +1 -0
- package/dist/adapters/local/sql-js.d.ts +1 -0
- package/dist/adapters/local/sql-js.js +1 -0
- package/dist/adapters/remote/firestore.js +39 -32
- package/dist/adapters/remote/supabase.d.ts +5 -0
- package/dist/adapters/remote/supabase.js +192 -85
- package/dist/core/client.d.ts +1 -9
- package/dist/core/client.js +123 -105
- package/dist/core/outbox-store.d.ts +5 -0
- package/dist/core/outbox-store.js +29 -4
- package/dist/core/query-builder.d.ts +1 -0
- package/dist/core/query-builder.js +140 -71
- package/dist/core/query.js +28 -32
- package/dist/core/schema-manager.d.ts +4 -15
- package/dist/core/schema-manager.js +45 -135
- package/dist/core/sync-worker.js +7 -2
- package/dist/react/index.js +123 -57
- package/dist/react/status.d.ts +5 -0
- package/dist/react/status.js +20 -12
- package/dist/utils/uuid.d.ts +1 -1
- package/dist/utils/uuid.js +7 -16
- package/package.json +5 -4
package/dist/core/client.js
CHANGED
|
@@ -11,6 +11,55 @@ const uuid_js_1 = require("../utils/uuid.js");
|
|
|
11
11
|
const sql_js_1 = require("../utils/sql.js");
|
|
12
12
|
const query_js_1 = require("./query.js");
|
|
13
13
|
const query_builder_js_1 = require("./query-builder.js");
|
|
14
|
+
function hasFunctionFilter(value) {
|
|
15
|
+
if (typeof value === 'function')
|
|
16
|
+
return true;
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.some(hasFunctionFilter);
|
|
19
|
+
if (value && typeof value === 'object')
|
|
20
|
+
return Object.values(value).some(hasFunctionFilter);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
function shouldUseClientSideQuery(options) {
|
|
24
|
+
return hasFunctionFilter(options?.where);
|
|
25
|
+
}
|
|
26
|
+
async function executeFindById(db, tableName, id, primaryKey, hydrateRow, includeDeleted) {
|
|
27
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
28
|
+
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
29
|
+
try {
|
|
30
|
+
const rows = await db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
|
|
31
|
+
if (!rows || rows.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
const row = hydrateRow(tableName, rows[0]);
|
|
34
|
+
return !includeDeleted && (0, query_js_1.isDeletedRecord)(row) ? null : row;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const msg = String(err?.message || err || '');
|
|
38
|
+
if (msg.includes('no such table') || msg.includes('no such column'))
|
|
39
|
+
return null;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function executeFindByIds(db, tableName, ids, primaryKey, hydrateRow, includeDeleted) {
|
|
44
|
+
if (!ids || ids.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
47
|
+
const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
|
|
48
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
49
|
+
try {
|
|
50
|
+
const rows = await db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
|
|
51
|
+
if (!rows || rows.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
const hydrated = rows.map((r) => hydrateRow(tableName, r));
|
|
54
|
+
return !includeDeleted ? hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r)) : hydrated;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const msg = String(err?.message || err || '');
|
|
58
|
+
if (msg.includes('no such table') || msg.includes('no such column'))
|
|
59
|
+
return [];
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
14
63
|
class TransactionalClient {
|
|
15
64
|
parent;
|
|
16
65
|
tx;
|
|
@@ -74,45 +123,17 @@ class TransactionalClient {
|
|
|
74
123
|
await this.mutate(tableName, rowId, 'DELETE', null, options);
|
|
75
124
|
}
|
|
76
125
|
async findById(tableName, id, options) {
|
|
77
|
-
|
|
78
|
-
const safePk = (0, sql_js_1.escapeIdentifier)(this.parent.getPrimaryKey());
|
|
79
|
-
try {
|
|
80
|
-
const rows = await this.tx.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
|
|
81
|
-
if (!rows || rows.length === 0)
|
|
82
|
-
return null;
|
|
83
|
-
const row = this.parent.hydrateRow(tableName, rows[0]);
|
|
84
|
-
if (!options?.includeDeleted && (0, query_js_1.isDeletedRecord)(row)) {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
return row;
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
126
|
+
return executeFindById(this.tx, tableName, id, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
|
|
92
127
|
}
|
|
93
128
|
async findByIds(tableName, ids, options) {
|
|
94
|
-
|
|
95
|
-
return [];
|
|
96
|
-
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
97
|
-
const safePk = (0, sql_js_1.escapeIdentifier)(this.parent.getPrimaryKey());
|
|
98
|
-
const placeholders = ids.map(() => '?').join(', ');
|
|
99
|
-
try {
|
|
100
|
-
const rows = await this.tx.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
|
|
101
|
-
if (!rows || rows.length === 0)
|
|
102
|
-
return [];
|
|
103
|
-
const hydrated = rows.map((r) => this.parent.hydrateRow(tableName, r));
|
|
104
|
-
if (!options?.includeDeleted) {
|
|
105
|
-
return hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r));
|
|
106
|
-
}
|
|
107
|
-
return hydrated;
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return [];
|
|
111
|
-
}
|
|
129
|
+
return executeFindByIds(this.tx, tableName, ids, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
|
|
112
130
|
}
|
|
113
131
|
async findMany(tableName, options) {
|
|
114
|
-
|
|
115
|
-
|
|
132
|
+
if (!this.parent.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
|
|
133
|
+
const rows = await this.from(tableName).execute();
|
|
134
|
+
return (0, query_js_1.applyQueryOptions)(rows, options);
|
|
135
|
+
}
|
|
136
|
+
return this.from(tableName).applyOptions(options).execute();
|
|
116
137
|
}
|
|
117
138
|
async query(sql, params = []) {
|
|
118
139
|
return (await this.tx.execute(sql, params));
|
|
@@ -222,40 +243,52 @@ class LocalFirstClient {
|
|
|
222
243
|
* Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
|
|
223
244
|
* Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
|
|
224
245
|
*/
|
|
225
|
-
async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp) {
|
|
246
|
+
async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp, pendingOutboxMap) {
|
|
247
|
+
const pendingKey = tableName + ':' + rowId;
|
|
226
248
|
if (this.coalesceOutbox) {
|
|
227
|
-
const existing =
|
|
249
|
+
const existing = pendingOutboxMap
|
|
250
|
+
? pendingOutboxMap.get(pendingKey)
|
|
251
|
+
: await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
|
|
228
252
|
if (existing && existing.status === 'PENDING') {
|
|
229
253
|
if (operation === 'DELETE') {
|
|
230
254
|
if (existing.operation === 'INSERT') {
|
|
231
|
-
// Created offline and deleted offline before ever being pushed to server: clean up outbox completely
|
|
232
255
|
await outbox_store_js_1.OutboxStore.deleteRecord(tx, existing.id);
|
|
256
|
+
pendingOutboxMap?.delete(pendingKey);
|
|
233
257
|
return undefined;
|
|
234
258
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
259
|
+
await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
|
|
260
|
+
if (pendingOutboxMap) {
|
|
261
|
+
existing.operation = 'DELETE';
|
|
262
|
+
existing.payload = null;
|
|
263
|
+
existing.timestamp = timestamp;
|
|
238
264
|
}
|
|
265
|
+
return existing.id;
|
|
239
266
|
}
|
|
240
|
-
|
|
267
|
+
if (operation === 'UPDATE') {
|
|
241
268
|
const merged = { ...(existing.payload || {}), ...(payload || {}) };
|
|
242
269
|
await outbox_store_js_1.OutboxStore.updatePayload(tx, existing.id, merged, timestamp);
|
|
270
|
+
if (pendingOutboxMap) {
|
|
271
|
+
existing.payload = merged;
|
|
272
|
+
existing.timestamp = timestamp;
|
|
273
|
+
}
|
|
243
274
|
return existing.id;
|
|
244
275
|
}
|
|
245
|
-
|
|
276
|
+
if (operation === 'INSERT') {
|
|
246
277
|
await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'INSERT', payload, timestamp);
|
|
278
|
+
if (pendingOutboxMap) {
|
|
279
|
+
existing.operation = 'INSERT';
|
|
280
|
+
existing.payload = payload;
|
|
281
|
+
existing.timestamp = timestamp;
|
|
282
|
+
}
|
|
247
283
|
return existing.id;
|
|
248
284
|
}
|
|
249
285
|
}
|
|
250
286
|
}
|
|
251
287
|
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
252
|
-
await outbox_store_js_1.OutboxStore.insertRecord(tx, {
|
|
253
|
-
|
|
254
|
-
tableName,
|
|
255
|
-
|
|
256
|
-
operation,
|
|
257
|
-
payload,
|
|
258
|
-
timestamp,
|
|
288
|
+
await outbox_store_js_1.OutboxStore.insertRecord(tx, { id: outboxId, tableName, rowId, operation, payload, timestamp });
|
|
289
|
+
pendingOutboxMap?.set(pendingKey, {
|
|
290
|
+
id: outboxId, tableName, rowId, operation, payload, timestamp,
|
|
291
|
+
status: 'PENDING', retryCount: 0, lastError: null,
|
|
259
292
|
});
|
|
260
293
|
return outboxId;
|
|
261
294
|
}
|
|
@@ -274,6 +307,15 @@ class LocalFirstClient {
|
|
|
274
307
|
await this.initialization;
|
|
275
308
|
}
|
|
276
309
|
async initializeStorage() {
|
|
310
|
+
try {
|
|
311
|
+
await this.db.execute('PRAGMA journal_mode = WAL;');
|
|
312
|
+
await this.db.execute('PRAGMA synchronous = NORMAL;');
|
|
313
|
+
await this.db.execute('PRAGMA busy_timeout = 5000;');
|
|
314
|
+
await this.db.execute('PRAGMA cache_size = -64000;');
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Ignored for drivers, mock DBs, or in-memory modes that do not support PRAGMAs
|
|
318
|
+
}
|
|
277
319
|
await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
|
|
278
320
|
await meta_store_js_1.MetaStore.ensureSchema(this.db);
|
|
279
321
|
await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
|
|
@@ -344,7 +386,12 @@ class LocalFirstClient {
|
|
|
344
386
|
const generatedIds = [];
|
|
345
387
|
const queuedEvents = [];
|
|
346
388
|
const queuedOutboxIds = [];
|
|
389
|
+
const pendingOutboxOps = [];
|
|
390
|
+
let pendingOutboxMap;
|
|
347
391
|
const executeInTx = async (tx) => {
|
|
392
|
+
if (this.coalesceOutbox && mutations.length > 1) {
|
|
393
|
+
pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
|
|
394
|
+
}
|
|
348
395
|
for (const m of mutations) {
|
|
349
396
|
const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
|
|
350
397
|
const payload = m.payload ?? null;
|
|
@@ -360,8 +407,22 @@ class LocalFirstClient {
|
|
|
360
407
|
const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
|
|
361
408
|
if (!skipOutbox) {
|
|
362
409
|
const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
|
|
363
|
-
|
|
364
|
-
|
|
410
|
+
if (this.coalesceOutbox && m.operation !== 'INSERT') {
|
|
411
|
+
const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp, pendingOutboxMap);
|
|
412
|
+
if (outboxId) {
|
|
413
|
+
queuedOutboxIds.push(outboxId);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const outboxId = (0, uuid_js_1.generateUUID)();
|
|
418
|
+
pendingOutboxOps.push({
|
|
419
|
+
id: outboxId,
|
|
420
|
+
tableName: m.tableName,
|
|
421
|
+
rowId,
|
|
422
|
+
operation: m.operation,
|
|
423
|
+
payload,
|
|
424
|
+
timestamp,
|
|
425
|
+
});
|
|
365
426
|
queuedOutboxIds.push(outboxId);
|
|
366
427
|
}
|
|
367
428
|
}
|
|
@@ -375,6 +436,9 @@ class LocalFirstClient {
|
|
|
375
436
|
},
|
|
376
437
|
});
|
|
377
438
|
}
|
|
439
|
+
if (pendingOutboxOps.length > 0) {
|
|
440
|
+
await outbox_store_js_1.OutboxStore.insertRecords(tx, pendingOutboxOps);
|
|
441
|
+
}
|
|
378
442
|
};
|
|
379
443
|
if (options.tx) {
|
|
380
444
|
await executeInTx(options.tx);
|
|
@@ -629,8 +693,11 @@ class LocalFirstClient {
|
|
|
629
693
|
return result;
|
|
630
694
|
}
|
|
631
695
|
async findMany(tableName, options) {
|
|
632
|
-
|
|
633
|
-
|
|
696
|
+
if (!this.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
|
|
697
|
+
const rows = await this.from(tableName).execute();
|
|
698
|
+
return (0, query_js_1.applyQueryOptions)(rows, options);
|
|
699
|
+
}
|
|
700
|
+
return this.from(tableName).applyOptions(options).execute();
|
|
634
701
|
}
|
|
635
702
|
/**
|
|
636
703
|
* Finds a single record by primary key.
|
|
@@ -639,59 +706,10 @@ class LocalFirstClient {
|
|
|
639
706
|
* const tenant = await client.findById('tenants', 'ten-1');
|
|
640
707
|
*/
|
|
641
708
|
async findById(tableName, id, options) {
|
|
642
|
-
|
|
643
|
-
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
644
|
-
const safePk = (0, sql_js_1.escapeIdentifier)(this.primaryKey);
|
|
645
|
-
try {
|
|
646
|
-
const rows = await this.db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
|
|
647
|
-
if (!rows || rows.length === 0)
|
|
648
|
-
return null;
|
|
649
|
-
const row = this.hydrateRow(tableName, rows[0]);
|
|
650
|
-
if (!options?.includeDeleted && (0, query_js_1.isDeletedRecord)(row)) {
|
|
651
|
-
return null;
|
|
652
|
-
}
|
|
653
|
-
return row;
|
|
654
|
-
}
|
|
655
|
-
catch (err) {
|
|
656
|
-
const msg = String(err?.message || err || '');
|
|
657
|
-
if (msg.includes('no such table') || msg.includes('no such column')) {
|
|
658
|
-
return null;
|
|
659
|
-
}
|
|
660
|
-
throw err;
|
|
661
|
-
}
|
|
709
|
+
return executeFindById(this.db, tableName, id, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
|
|
662
710
|
}
|
|
663
|
-
/**
|
|
664
|
-
* Finds multiple records by primary keys in a single batched SQL query.
|
|
665
|
-
* Eliminates the N-roundtrip bridge overhead of multiple findById calls.
|
|
666
|
-
* Automatically omits soft-deleted records unless includeDeleted: true.
|
|
667
|
-
*
|
|
668
|
-
* Example:
|
|
669
|
-
* const tenants = await client.findByIds('tenants', ['ten-1', 'ten-2', 'ten-3']);
|
|
670
|
-
*/
|
|
671
711
|
async findByIds(tableName, ids, options) {
|
|
672
|
-
|
|
673
|
-
return [];
|
|
674
|
-
const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
|
|
675
|
-
const safePk = (0, sql_js_1.escapeIdentifier)(this.primaryKey);
|
|
676
|
-
const placeholders = ids.map(() => '?').join(', ');
|
|
677
|
-
try {
|
|
678
|
-
const rows = await this.db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
|
|
679
|
-
if (!rows || rows.length === 0)
|
|
680
|
-
return [];
|
|
681
|
-
const hasJsonConfig = Boolean(this.jsonColumns[tableName] || this.autoHydrateJSON);
|
|
682
|
-
const hydrated = hasJsonConfig ? rows.map((r) => this.hydrateRow(tableName, r)) : rows;
|
|
683
|
-
if (!options?.includeDeleted) {
|
|
684
|
-
return hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r));
|
|
685
|
-
}
|
|
686
|
-
return hydrated;
|
|
687
|
-
}
|
|
688
|
-
catch (err) {
|
|
689
|
-
const msg = String(err?.message || err || '');
|
|
690
|
-
if (msg.includes('no such table') || msg.includes('no such column')) {
|
|
691
|
-
return [];
|
|
692
|
-
}
|
|
693
|
-
throw err;
|
|
694
|
-
}
|
|
712
|
+
return executeFindByIds(this.db, tableName, ids, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
|
|
695
713
|
}
|
|
696
714
|
/**
|
|
697
715
|
* Tracks an existing mutation into sync_outbox without modifying the table.
|
|
@@ -21,6 +21,11 @@ export declare class OutboxStore {
|
|
|
21
21
|
* Finds the latest pending outbox record for a given table and row.
|
|
22
22
|
*/
|
|
23
23
|
static findPendingByRow(tx: LocalTransaction, tableName: string, rowId: string): Promise<OutboxRecord | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* Retrieves active (non-FAILED) pending outbox records indexed by `${table_name}:${row_id}`.
|
|
26
|
+
* Enables O(1) in-memory conflict lookup during delta ingestion, eliminating N+1 queries.
|
|
27
|
+
*/
|
|
28
|
+
static getActivePendingMap(tx: LocalTransaction): Promise<Map<string, OutboxRecord>>;
|
|
24
29
|
/**
|
|
25
30
|
* Marks a batch of outbox IDs as 'PROCESSING'.
|
|
26
31
|
*/
|
|
@@ -36,6 +36,7 @@ class OutboxStore {
|
|
|
36
36
|
// Indexes for the hot paths: pending scans (push), per-row conflict lookups (pull),
|
|
37
37
|
// and status counts (telemetry). Executed separately: many drivers prepare one statement.
|
|
38
38
|
await db.execute(`CREATE INDEX IF NOT EXISTS idx_sync_outbox_status_ts ON sync_outbox (status, timestamp);`);
|
|
39
|
+
await db.execute(`CREATE INDEX IF NOT EXISTS idx_sync_outbox_status_retry ON sync_outbox (status, retry_count, timestamp);`);
|
|
39
40
|
await db.execute(`CREATE INDEX IF NOT EXISTS idx_sync_outbox_row ON sync_outbox (table_name, row_id, timestamp);`);
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
@@ -107,6 +108,30 @@ class OutboxStore {
|
|
|
107
108
|
return undefined;
|
|
108
109
|
return mapOutboxRow(rows[0]);
|
|
109
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Retrieves active (non-FAILED) pending outbox records indexed by `${table_name}:${row_id}`.
|
|
113
|
+
* Enables O(1) in-memory conflict lookup during delta ingestion, eliminating N+1 queries.
|
|
114
|
+
*/
|
|
115
|
+
static async getActivePendingMap(tx) {
|
|
116
|
+
const map = new Map();
|
|
117
|
+
try {
|
|
118
|
+
const rows = await tx.execute(`SELECT * FROM sync_outbox WHERE status != 'FAILED' ORDER BY timestamp DESC`);
|
|
119
|
+
if (!rows || rows.length === 0)
|
|
120
|
+
return map;
|
|
121
|
+
for (const r of rows) {
|
|
122
|
+
const record = mapOutboxRow(r);
|
|
123
|
+
const key = `${record.tableName}:${record.rowId}`;
|
|
124
|
+
// Preserve the latest record for each table_name:row_id
|
|
125
|
+
if (!map.has(key)) {
|
|
126
|
+
map.set(key, record);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return map;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
110
135
|
/**
|
|
111
136
|
* Marks a batch of outbox IDs as 'PROCESSING'.
|
|
112
137
|
*/
|
|
@@ -216,8 +241,8 @@ class OutboxStore {
|
|
|
216
241
|
return ids.length;
|
|
217
242
|
}
|
|
218
243
|
else {
|
|
219
|
-
const
|
|
220
|
-
const count =
|
|
244
|
+
const rows = await db.execute(`SELECT COUNT(*) AS count FROM sync_outbox WHERE status = 'FAILED'`);
|
|
245
|
+
const count = Number(rows?.[0]?.count ?? (Array.isArray(rows) ? rows.length : 0));
|
|
221
246
|
if (count > 0) {
|
|
222
247
|
await db.execute(`UPDATE sync_outbox SET status = 'PENDING', retry_count = 0, last_error = NULL WHERE status = 'FAILED'`);
|
|
223
248
|
}
|
|
@@ -239,8 +264,8 @@ class OutboxStore {
|
|
|
239
264
|
return ids.length;
|
|
240
265
|
}
|
|
241
266
|
else {
|
|
242
|
-
const
|
|
243
|
-
const count =
|
|
267
|
+
const rows = await db.execute(`SELECT COUNT(*) AS count FROM sync_outbox WHERE status = 'FAILED'`);
|
|
268
|
+
const count = Number(rows?.[0]?.count ?? (Array.isArray(rows) ? rows.length : 0));
|
|
244
269
|
if (count > 0) {
|
|
245
270
|
await db.execute(`DELETE FROM sync_outbox WHERE status = 'FAILED'`);
|
|
246
271
|
}
|
|
@@ -90,6 +90,7 @@ export declare class QueryBuilder<T = any> {
|
|
|
90
90
|
applyOptions(options?: QueryOptions): this;
|
|
91
91
|
/**
|
|
92
92
|
* Compiles the current builder state into an executable SQL string and parameter list.
|
|
93
|
+
* Uses module-scoped compileComparison / compileWhereConditions helpers (defined once).
|
|
93
94
|
*/
|
|
94
95
|
toSQL(): {
|
|
95
96
|
sql: string;
|
|
@@ -35,6 +35,143 @@ function formatColumnIdentifier(col) {
|
|
|
35
35
|
}
|
|
36
36
|
return `"${trimmed.replace(/"/g, '""')}"`;
|
|
37
37
|
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Module-scoped SQL compilation helpers (defined once, reused on every query)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Compiles a single column comparison value into one or more SQL fragments.
|
|
43
|
+
* Supports null, boolean, scalar, and operator-object forms ($eq, $gt, $in, …).
|
|
44
|
+
*/
|
|
45
|
+
function compileComparison(safeCol, val, params) {
|
|
46
|
+
if (val === null) {
|
|
47
|
+
return `${safeCol} IS NULL`;
|
|
48
|
+
}
|
|
49
|
+
if (typeof val === 'boolean') {
|
|
50
|
+
params.push(val ? 1 : 0);
|
|
51
|
+
return `${safeCol} = ?`;
|
|
52
|
+
}
|
|
53
|
+
if (typeof val === 'string' || typeof val === 'number') {
|
|
54
|
+
params.push(val);
|
|
55
|
+
return `${safeCol} = ?`;
|
|
56
|
+
}
|
|
57
|
+
if (typeof val === 'object' && !Array.isArray(val)) {
|
|
58
|
+
const ops = val;
|
|
59
|
+
const subClauses = [];
|
|
60
|
+
const eqVal = ops.$eq !== undefined ? ops.$eq : ops.eq;
|
|
61
|
+
if (eqVal !== undefined) {
|
|
62
|
+
subClauses.push(`${safeCol} = ?`);
|
|
63
|
+
params.push(typeof eqVal === 'boolean' ? (eqVal ? 1 : 0) : eqVal);
|
|
64
|
+
}
|
|
65
|
+
const neVal = ops.$ne !== undefined ? ops.$ne : ops.ne;
|
|
66
|
+
if (neVal !== undefined) {
|
|
67
|
+
subClauses.push(`${safeCol} != ?`);
|
|
68
|
+
params.push(typeof neVal === 'boolean' ? (neVal ? 1 : 0) : neVal);
|
|
69
|
+
}
|
|
70
|
+
const gtVal = ops.$gt !== undefined ? ops.$gt : ops.gt;
|
|
71
|
+
if (gtVal !== undefined) {
|
|
72
|
+
subClauses.push(`${safeCol} > ?`);
|
|
73
|
+
params.push(gtVal);
|
|
74
|
+
}
|
|
75
|
+
const gteVal = ops.$gte !== undefined ? ops.$gte : ops.gte;
|
|
76
|
+
if (gteVal !== undefined) {
|
|
77
|
+
subClauses.push(`${safeCol} >= ?`);
|
|
78
|
+
params.push(gteVal);
|
|
79
|
+
}
|
|
80
|
+
const ltVal = ops.$lt !== undefined ? ops.$lt : ops.lt;
|
|
81
|
+
if (ltVal !== undefined) {
|
|
82
|
+
subClauses.push(`${safeCol} < ?`);
|
|
83
|
+
params.push(ltVal);
|
|
84
|
+
}
|
|
85
|
+
const lteVal = ops.$lte !== undefined ? ops.$lte : ops.lte;
|
|
86
|
+
if (lteVal !== undefined) {
|
|
87
|
+
subClauses.push(`${safeCol} <= ?`);
|
|
88
|
+
params.push(lteVal);
|
|
89
|
+
}
|
|
90
|
+
const inVal = ops.$in !== undefined ? ops.$in : ops.in;
|
|
91
|
+
if (Array.isArray(inVal) && inVal.length > 0) {
|
|
92
|
+
const placeholders = inVal.map(() => '?').join(', ');
|
|
93
|
+
subClauses.push(`${safeCol} IN (${placeholders})`);
|
|
94
|
+
params.push(...inVal);
|
|
95
|
+
}
|
|
96
|
+
const ninVal = ops.$nin !== undefined ? ops.$nin : ops.nin;
|
|
97
|
+
if (Array.isArray(ninVal) && ninVal.length > 0) {
|
|
98
|
+
const placeholders = ninVal.map(() => '?').join(', ');
|
|
99
|
+
subClauses.push(`${safeCol} NOT IN (${placeholders})`);
|
|
100
|
+
params.push(...ninVal);
|
|
101
|
+
}
|
|
102
|
+
const containsVal = ops.$contains !== undefined ? ops.$contains : ops.contains;
|
|
103
|
+
if (containsVal !== undefined) {
|
|
104
|
+
subClauses.push(`${safeCol} LIKE ?`);
|
|
105
|
+
params.push(`%${containsVal}%`);
|
|
106
|
+
}
|
|
107
|
+
const startsWithVal = ops.$startsWith !== undefined ? ops.$startsWith : ops.startsWith;
|
|
108
|
+
if (startsWithVal !== undefined) {
|
|
109
|
+
subClauses.push(`${safeCol} LIKE ?`);
|
|
110
|
+
params.push(`${startsWithVal}%`);
|
|
111
|
+
}
|
|
112
|
+
const endsWithVal = ops.$endsWith !== undefined ? ops.$endsWith : ops.endsWith;
|
|
113
|
+
if (endsWithVal !== undefined) {
|
|
114
|
+
subClauses.push(`${safeCol} LIKE ?`);
|
|
115
|
+
params.push(`%${endsWithVal}`);
|
|
116
|
+
}
|
|
117
|
+
const nullVal = ops.$null !== undefined ? ops.$null : ops.null;
|
|
118
|
+
if (nullVal !== undefined) {
|
|
119
|
+
subClauses.push(nullVal ? `${safeCol} IS NULL` : `${safeCol} IS NOT NULL`);
|
|
120
|
+
}
|
|
121
|
+
if (subClauses.length > 0) {
|
|
122
|
+
return subClauses.join(' AND ');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Recursively compiles a where-conditions object (including $or / $and) into SQL clauses.
|
|
129
|
+
*/
|
|
130
|
+
function compileWhereConditions(where, params) {
|
|
131
|
+
const clauses = [];
|
|
132
|
+
// Handle $or — compile each branch, join with OR
|
|
133
|
+
if (Array.isArray(where.$or) && where.$or.length > 0) {
|
|
134
|
+
const orClauses = [];
|
|
135
|
+
for (const sub of where.$or) {
|
|
136
|
+
if (typeof sub === 'object' && sub !== null) {
|
|
137
|
+
const subList = compileWhereConditions(sub, params);
|
|
138
|
+
if (subList.length > 0) {
|
|
139
|
+
orClauses.push(`(${subList.join(' AND ')})`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (orClauses.length > 0) {
|
|
144
|
+
clauses.push(`(${orClauses.join(' OR ')})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Handle $and — compile each branch, join with AND
|
|
148
|
+
if (Array.isArray(where.$and) && where.$and.length > 0) {
|
|
149
|
+
const andClauses = [];
|
|
150
|
+
for (const sub of where.$and) {
|
|
151
|
+
if (typeof sub === 'object' && sub !== null) {
|
|
152
|
+
const subList = compileWhereConditions(sub, params);
|
|
153
|
+
if (subList.length > 0) {
|
|
154
|
+
andClauses.push(`(${subList.join(' AND ')})`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (andClauses.length > 0) {
|
|
159
|
+
clauses.push(`(${andClauses.join(' AND ')})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Handle regular column conditions
|
|
163
|
+
for (const [col, val] of Object.entries(where)) {
|
|
164
|
+
if (col === '$or' || col === '$and')
|
|
165
|
+
continue;
|
|
166
|
+
const safeCol = formatColumnIdentifier(col);
|
|
167
|
+
const clause = compileComparison(safeCol, val, params);
|
|
168
|
+
if (clause) {
|
|
169
|
+
clauses.push(clause);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return clauses;
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
38
175
|
/**
|
|
39
176
|
* Fluent, type-safe SQL Query Builder for local-first SQLite operations.
|
|
40
177
|
* Allows writing expressive JavaScript code that compiles and executes high-performance SQL under the hood.
|
|
@@ -171,6 +308,7 @@ class QueryBuilder {
|
|
|
171
308
|
}
|
|
172
309
|
/**
|
|
173
310
|
* Compiles the current builder state into an executable SQL string and parameter list.
|
|
311
|
+
* Uses module-scoped compileComparison / compileWhereConditions helpers (defined once).
|
|
174
312
|
*/
|
|
175
313
|
toSQL() {
|
|
176
314
|
const params = [];
|
|
@@ -184,79 +322,10 @@ class QueryBuilder {
|
|
|
184
322
|
const col2 = formatColumnIdentifier(j.secondCol);
|
|
185
323
|
sql += ` ${j.type} JOIN ${joinTable} ON ${col1} = ${col2}`;
|
|
186
324
|
}
|
|
187
|
-
// Build WHERE clauses
|
|
325
|
+
// Build WHERE clauses (delegates to module-scoped helpers)
|
|
188
326
|
const whereClauses = [];
|
|
189
327
|
if (this.whereConditions && typeof this.whereConditions === 'object') {
|
|
190
|
-
|
|
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
|
-
}
|
|
328
|
+
whereClauses.push(...compileWhereConditions(this.whereConditions, params));
|
|
260
329
|
}
|
|
261
330
|
// Push down soft-deletion check on primary table if deleted column exists and not explicitly overridden
|
|
262
331
|
const schemaManager = 'schemaManager' in this.client
|