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.
@@ -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
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
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
- if (!ids || ids.length === 0)
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
- const rows = await this.from(tableName).applyOptions(options).execute();
115
- return (0, query_js_1.applyQueryOptions)(rows, options);
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 = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
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
- else {
236
- await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
237
- return existing.id;
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
- else if (operation === 'UPDATE') {
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
- else if (operation === 'INSERT') {
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
- id: outboxId,
254
- tableName,
255
- rowId,
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
- const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
364
- if (outboxId) {
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
- const rows = await this.from(tableName).applyOptions(options).execute();
633
- return (0, query_js_1.applyQueryOptions)(rows, options);
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
- // Primary-key lookup in SQLite instead of loading the whole table into JS.
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
- if (!ids || ids.length === 0)
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 failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
220
- const count = failedRecords?.length || 0;
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 failedRecords = await db.execute(`SELECT id FROM sync_outbox WHERE status = 'FAILED'`);
243
- const count = failedRecords?.length || 0;
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
- 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
- }
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