local-lite 0.0.4 → 0.0.6

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.
Files changed (36) hide show
  1. package/dist/adapters/local/better-sqlite3.d.ts +4 -1
  2. package/dist/adapters/local/better-sqlite3.js +34 -14
  3. package/dist/adapters/local/expo-sqlite.d.ts +10 -2
  4. package/dist/adapters/local/expo-sqlite.js +36 -20
  5. package/dist/adapters/local/node-sqlite.d.ts +4 -1
  6. package/dist/adapters/local/node-sqlite.js +29 -12
  7. package/dist/adapters/remote/firestore.js +39 -32
  8. package/dist/adapters/remote/supabase.d.ts +7 -2
  9. package/dist/adapters/remote/supabase.js +192 -85
  10. package/dist/config/types.d.ts +1 -1
  11. package/dist/core/client.d.ts +52 -15
  12. package/dist/core/client.js +195 -208
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/outbox-store.d.ts +5 -0
  16. package/dist/core/outbox-store.js +29 -4
  17. package/dist/core/query-builder.d.ts +120 -0
  18. package/dist/core/query-builder.js +441 -0
  19. package/dist/core/query.js +28 -32
  20. package/dist/core/schema-manager.d.ts +4 -15
  21. package/dist/core/schema-manager.js +48 -103
  22. package/dist/core/sync-worker.js +7 -2
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/react/SyncInspector.js +0 -70
  26. package/dist/react/index.d.ts +39 -0
  27. package/dist/react/index.js +232 -34
  28. package/dist/react/status.d.ts +5 -0
  29. package/dist/react/status.js +20 -12
  30. package/dist/schema/generator.d.ts +49 -0
  31. package/dist/schema/generator.js +209 -0
  32. package/dist/schema/index.d.ts +1 -0
  33. package/dist/schema/index.js +17 -0
  34. package/dist/utils/uuid.d.ts +1 -1
  35. package/dist/utils/uuid.js +7 -16
  36. package/package.json +7 -2
@@ -10,6 +10,44 @@ const schema_manager_js_1 = require("./schema-manager.js");
10
10
  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
+ const query_builder_js_1 = require("./query-builder.js");
14
+ async function executeFindById(db, tableName, id, primaryKey, hydrateRow, includeDeleted) {
15
+ const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
16
+ const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
17
+ try {
18
+ const rows = await db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
19
+ if (!rows || rows.length === 0)
20
+ return null;
21
+ const row = hydrateRow(tableName, rows[0]);
22
+ return !includeDeleted && (0, query_js_1.isDeletedRecord)(row) ? null : row;
23
+ }
24
+ catch (err) {
25
+ const msg = String(err?.message || err || '');
26
+ if (msg.includes('no such table') || msg.includes('no such column'))
27
+ return null;
28
+ throw err;
29
+ }
30
+ }
31
+ async function executeFindByIds(db, tableName, ids, primaryKey, hydrateRow, includeDeleted) {
32
+ if (!ids || ids.length === 0)
33
+ return [];
34
+ const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
35
+ const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
36
+ const placeholders = ids.map(() => '?').join(', ');
37
+ try {
38
+ const rows = await db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
39
+ if (!rows || rows.length === 0)
40
+ return [];
41
+ const hydrated = rows.map((r) => hydrateRow(tableName, r));
42
+ return !includeDeleted ? hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r)) : hydrated;
43
+ }
44
+ catch (err) {
45
+ const msg = String(err?.message || err || '');
46
+ if (msg.includes('no such table') || msg.includes('no such column'))
47
+ return [];
48
+ throw err;
49
+ }
50
+ }
13
51
  class TransactionalClient {
14
52
  parent;
15
53
  tx;
@@ -73,44 +111,20 @@ class TransactionalClient {
73
111
  await this.mutate(tableName, rowId, 'DELETE', null, options);
74
112
  }
75
113
  async findById(tableName, id, options) {
76
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
77
- const safePk = (0, sql_js_1.escapeIdentifier)(this.parent.getPrimaryKey());
78
- try {
79
- const rows = await this.tx.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
80
- if (!rows || rows.length === 0)
81
- return null;
82
- const row = this.parent.hydrateRow(tableName, rows[0]);
83
- if (!options?.includeDeleted && (0, query_js_1.isDeletedRecord)(row)) {
84
- return null;
85
- }
86
- return row;
87
- }
88
- catch {
89
- return null;
90
- }
114
+ return executeFindById(this.tx, tableName, id, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
91
115
  }
92
116
  async findByIds(tableName, ids, options) {
93
- if (!ids || ids.length === 0)
94
- return [];
95
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
96
- const safePk = (0, sql_js_1.escapeIdentifier)(this.parent.getPrimaryKey());
97
- const placeholders = ids.map(() => '?').join(', ');
98
- try {
99
- const rows = await this.tx.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
100
- if (!rows || rows.length === 0)
101
- return [];
102
- const hydrated = rows.map((r) => this.parent.hydrateRow(tableName, r));
103
- if (!options?.includeDeleted) {
104
- return hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r));
105
- }
106
- return hydrated;
107
- }
108
- catch {
109
- return [];
110
- }
117
+ return executeFindByIds(this.tx, tableName, ids, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
111
118
  }
112
119
  async findMany(tableName, options) {
113
- return this.parent.findManyInternal(this.tx, tableName, options);
120
+ const rows = await this.from(tableName).applyOptions(options).execute();
121
+ return (0, query_js_1.applyQueryOptions)(rows, options);
122
+ }
123
+ async query(sql, params = []) {
124
+ return (await this.tx.execute(sql, params));
125
+ }
126
+ from(tableName) {
127
+ return new query_builder_js_1.QueryBuilder(this, tableName);
114
128
  }
115
129
  get rawTx() {
116
130
  return this.tx;
@@ -266,6 +280,15 @@ class LocalFirstClient {
266
280
  await this.initialization;
267
281
  }
268
282
  async initializeStorage() {
283
+ try {
284
+ await this.db.execute('PRAGMA journal_mode = WAL;');
285
+ await this.db.execute('PRAGMA synchronous = NORMAL;');
286
+ await this.db.execute('PRAGMA busy_timeout = 5000;');
287
+ await this.db.execute('PRAGMA cache_size = -64000;');
288
+ }
289
+ catch {
290
+ // Ignored for drivers, mock DBs, or in-memory modes that do not support PRAGMAs
291
+ }
269
292
  await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
270
293
  await meta_store_js_1.MetaStore.ensureSchema(this.db);
271
294
  await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
@@ -336,6 +359,7 @@ class LocalFirstClient {
336
359
  const generatedIds = [];
337
360
  const queuedEvents = [];
338
361
  const queuedOutboxIds = [];
362
+ const pendingOutboxOps = [];
339
363
  const executeInTx = async (tx) => {
340
364
  for (const m of mutations) {
341
365
  const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
@@ -352,8 +376,22 @@ class LocalFirstClient {
352
376
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
353
377
  if (!skipOutbox) {
354
378
  const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
355
- const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
356
- if (outboxId) {
379
+ if (this.coalesceOutbox && m.operation !== 'INSERT') {
380
+ const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
381
+ if (outboxId) {
382
+ queuedOutboxIds.push(outboxId);
383
+ }
384
+ }
385
+ else {
386
+ const outboxId = (0, uuid_js_1.generateUUID)();
387
+ pendingOutboxOps.push({
388
+ id: outboxId,
389
+ tableName: m.tableName,
390
+ rowId,
391
+ operation: m.operation,
392
+ payload,
393
+ timestamp,
394
+ });
357
395
  queuedOutboxIds.push(outboxId);
358
396
  }
359
397
  }
@@ -367,6 +405,9 @@ class LocalFirstClient {
367
405
  },
368
406
  });
369
407
  }
408
+ if (pendingOutboxOps.length > 0) {
409
+ await outbox_store_js_1.OutboxStore.insertRecords(tx, pendingOutboxOps);
410
+ }
370
411
  };
371
412
  if (options.tx) {
372
413
  await executeInTx(options.tx);
@@ -492,142 +533,137 @@ class LocalFirstClient {
492
533
  * Pushes simple equality where clauses down to SQLite for high performance, with memory fallback.
493
534
  * Gracefully returns [] if table or columns have not been populated yet on empty schemas.
494
535
  *
536
+ /**
537
+ * Executes a raw relational SQL query with parameters directly against SQLite.
538
+ * Enables complex multi-table JOINs, GROUP BY aggregations (COUNT, SUM, AVG), CTEs, and window functions.
539
+ */
540
+ async query(sql, params = []) {
541
+ return (await this.db.execute(sql, params));
542
+ }
543
+ /**
544
+ * Returns a fluent SQL QueryBuilder for the specified table.
545
+ * Allows writing clean JavaScript method chains (select, join, leftJoin, where, orderBy, groupBy)
546
+ * that automatically compile into optimized native SQLite queries.
547
+ *
495
548
  * Example:
496
- * const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
497
- * const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
549
+ * const activeRentals = await client
550
+ * .from('contracts')
551
+ * .join('houses', 'contracts.house_id', 'houses.id')
552
+ * .leftJoin('tenants', 'contracts.tenant_id', 'tenants.id')
553
+ * .where({ 'contracts.business_id': bizId, 'contracts.status': 'ACTIVE' })
554
+ * .select('contracts.id', 'contracts.rent_amount', 'houses.house_number', 'tenants.name')
555
+ * .execute();
498
556
  */
499
- async findMany(tableName, options) {
500
- return this.findManyInternal(this.db, tableName, options);
557
+ from(tableName) {
558
+ return new query_builder_js_1.QueryBuilder(this, tableName);
501
559
  }
502
560
  /**
503
- * Internal findMany implementation shared between LocalFirstClient and TransactionalClient.
561
+ * Executes a native SQLite COUNT(*) query and returns the count directly.
504
562
  */
505
- async findManyInternal(db, tableName, options) {
506
- await this.schemaManager.ensureTableIntrospected(db, tableName);
507
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
508
- let rows;
509
- const projection = options?.select && Array.isArray(options.select) && options.select.length > 0
510
- ? options.select.map(sql_js_1.escapeIdentifier).join(', ')
511
- : '*';
512
- let sql = `SELECT ${projection} FROM ${safeTable}`;
513
- const params = [];
514
- const whereClauses = [];
515
- // Automatically push down soft-delete filter if table has deleted column and not explicitly overridden
516
- const hasDeletedClause = options?.where && typeof options.where === 'object' && (0, query_js_1.hasExplicitDeletedFilter)(options.where);
517
- if (!options?.includeDeleted && !hasDeletedClause && this.schemaManager.hasColumn(tableName, 'deleted')) {
518
- const safeDel = (0, sql_js_1.escapeIdentifier)('deleted');
519
- whereClauses.push(`(${safeDel} IS NULL OR (${safeDel} != 1 AND ${safeDel} != 'true'))`);
520
- }
521
- // Push down where conditions to SQLite
522
- if (options?.where && typeof options.where === 'object' && !Array.isArray(options.where)) {
523
- for (const [col, filter] of Object.entries(options.where)) {
524
- if (col === '$or' || col === '$and')
525
- continue;
526
- const safeCol = (0, sql_js_1.escapeIdentifier)(col);
527
- if (filter === null) {
528
- whereClauses.push(`${safeCol} IS NULL`);
529
- }
530
- else if (typeof filter === 'boolean') {
531
- whereClauses.push(`${safeCol} = ?`);
532
- params.push(filter ? 1 : 0);
533
- }
534
- else if (typeof filter === 'string' || typeof filter === 'number') {
535
- whereClauses.push(`${safeCol} = ?`);
536
- params.push(filter);
537
- }
538
- else if (typeof filter === 'object' && !Array.isArray(filter)) {
539
- const ops = filter;
540
- if (ops.$eq !== undefined) {
541
- whereClauses.push(`${safeCol} = ?`);
542
- params.push(typeof ops.$eq === 'boolean' ? (ops.$eq ? 1 : 0) : ops.$eq);
543
- }
544
- if (ops.$ne !== undefined) {
545
- whereClauses.push(`${safeCol} != ?`);
546
- params.push(typeof ops.$ne === 'boolean' ? (ops.$ne ? 1 : 0) : ops.$ne);
547
- }
548
- if (ops.$gt !== undefined) {
549
- whereClauses.push(`${safeCol} > ?`);
550
- params.push(ops.$gt);
551
- }
552
- if (ops.$gte !== undefined) {
553
- whereClauses.push(`${safeCol} >= ?`);
554
- params.push(ops.$gte);
555
- }
556
- if (ops.$lt !== undefined) {
557
- whereClauses.push(`${safeCol} < ?`);
558
- params.push(ops.$lt);
559
- }
560
- if (ops.$lte !== undefined) {
561
- whereClauses.push(`${safeCol} <= ?`);
562
- params.push(ops.$lte);
563
- }
564
- if (Array.isArray(ops.$in) && ops.$in.length > 0) {
565
- const placeholders = ops.$in.map(() => '?').join(', ');
566
- whereClauses.push(`${safeCol} IN (${placeholders})`);
567
- params.push(...ops.$in.map((v) => (typeof v === 'boolean' ? (v ? 1 : 0) : v)));
568
- }
569
- if (Array.isArray(ops.$nin) && ops.$nin.length > 0) {
570
- const placeholders = ops.$nin.map(() => '?').join(', ');
571
- whereClauses.push(`${safeCol} NOT IN (${placeholders})`);
572
- params.push(...ops.$nin.map((v) => (typeof v === 'boolean' ? (v ? 1 : 0) : v)));
573
- }
574
- if (ops.$null !== undefined) {
575
- whereClauses.push(ops.$null ? `${safeCol} IS NULL` : `${safeCol} IS NOT NULL`);
576
- }
577
- if (ops.$contains !== undefined) {
578
- whereClauses.push(`${safeCol} LIKE ?`);
579
- params.push(`%${ops.$contains}%`);
580
- }
581
- if (ops.$startsWith !== undefined) {
582
- whereClauses.push(`${safeCol} LIKE ?`);
583
- params.push(`${ops.$startsWith}%`);
584
- }
585
- if (ops.$endsWith !== undefined) {
586
- whereClauses.push(`${safeCol} LIKE ?`);
587
- params.push(`%${ops.$endsWith}`);
588
- }
589
- }
563
+ async count(tableName, options) {
564
+ const qb = this.from(tableName);
565
+ if (options?.where)
566
+ qb.where(options.where);
567
+ return qb.count();
568
+ }
569
+ /**
570
+ * Executes a native SQLite SUM(column) query and returns the sum directly.
571
+ */
572
+ async sum(tableName, column, options) {
573
+ const qb = this.from(tableName);
574
+ if (options?.where)
575
+ qb.where(options.where);
576
+ return qb.sum(column);
577
+ }
578
+ /**
579
+ * Executes a native SQLite AVG(column) query and returns the average directly.
580
+ */
581
+ async avg(tableName, column, options) {
582
+ const qb = this.from(tableName);
583
+ if (options?.where)
584
+ qb.where(options.where);
585
+ return qb.avg(column);
586
+ }
587
+ /**
588
+ * Executes native multi-metric SQLite aggregation in a single query.
589
+ *
590
+ * Example:
591
+ * const stats = await client.aggregate('receipts', {
592
+ * where: { business_id: bizId },
593
+ * count: true,
594
+ * sum: ['amount', 'refunded_amount'],
595
+ * avg: ['amount']
596
+ * });
597
+ */
598
+ async aggregate(tableName, spec) {
599
+ const projections = [];
600
+ if (spec.count) {
601
+ projections.push('COUNT(*) AS "_count"');
602
+ }
603
+ if (spec.sum) {
604
+ for (const col of spec.sum) {
605
+ projections.push(`COALESCE(SUM("${col.replace(/"/g, '""')}"), 0) AS "sum_${col}"`);
606
+ }
607
+ }
608
+ if (spec.avg) {
609
+ for (const col of spec.avg) {
610
+ projections.push(`AVG("${col.replace(/"/g, '""')}") AS "avg_${col}"`);
590
611
  }
591
- if (whereClauses.length > 0) {
592
- sql += ` WHERE ${whereClauses.join(' AND ')}`;
612
+ }
613
+ if (spec.min) {
614
+ for (const col of spec.min) {
615
+ projections.push(`MIN("${col.replace(/"/g, '""')}") AS "min_${col}"`);
593
616
  }
594
617
  }
595
- // Push down ORDER BY
596
- if (options?.orderBy && typeof options.orderBy === 'string') {
597
- const safeCol = (0, sql_js_1.escapeIdentifier)(options.orderBy);
598
- const dir = options.order && String(options.order).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
599
- sql += ` ORDER BY ${safeCol} ${dir}`;
600
- }
601
- // Push down LIMIT & OFFSET
602
- if (typeof options?.limit === 'number' && options.limit > 0) {
603
- sql += ` LIMIT ?`;
604
- params.push(options.limit);
605
- if (typeof options.offset === 'number' && options.offset > 0) {
606
- sql += ` OFFSET ?`;
607
- params.push(options.offset);
618
+ if (spec.max) {
619
+ for (const col of spec.max) {
620
+ projections.push(`MAX("${col.replace(/"/g, '""')}") AS "max_${col}"`);
608
621
  }
609
622
  }
610
- try {
611
- rows = await db.execute(sql, params);
623
+ if (projections.length === 0) {
624
+ return {};
612
625
  }
613
- catch (err) {
614
- const msg = String(err?.message || err || '');
615
- if (msg.includes('no such table') || msg.includes('no such column')) {
616
- return [];
626
+ const qb = this.from(tableName).select(...projections);
627
+ if (spec.where) {
628
+ qb.where(spec.where);
629
+ }
630
+ const rows = await qb.execute();
631
+ const row = rows[0] || {};
632
+ const result = {};
633
+ if (spec.count) {
634
+ result.count = typeof row._count === 'number' ? row._count : Number(row._count) || 0;
635
+ }
636
+ if (spec.sum) {
637
+ result.sum = {};
638
+ for (const col of spec.sum) {
639
+ const val = row[`sum_${col}`];
640
+ result.sum[col] = typeof val === 'number' ? val : Number(val) || 0;
617
641
  }
618
- // Fallback to select * if pushdown syntax differs
619
- try {
620
- rows = await db.execute(`SELECT * FROM ${safeTable}`);
642
+ }
643
+ if (spec.avg) {
644
+ result.avg = {};
645
+ for (const col of spec.avg) {
646
+ const val = row[`avg_${col}`];
647
+ result.avg[col] = val === null || val === undefined ? null : Number(val) || 0;
621
648
  }
622
- catch {
623
- return [];
649
+ }
650
+ if (spec.min) {
651
+ result.min = {};
652
+ for (const col of spec.min) {
653
+ result.min[col] = row[`min_${col}`];
624
654
  }
625
655
  }
626
- const hasJsonConfig = Boolean(this.jsonColumns[tableName] || this.autoHydrateJSON);
627
- const hydratedRows = hasJsonConfig
628
- ? rows.map((r) => this.hydrateRow(tableName, r))
629
- : rows;
630
- return (0, query_js_1.applyQueryOptions)(hydratedRows, options);
656
+ if (spec.max) {
657
+ result.max = {};
658
+ for (const col of spec.max) {
659
+ result.max[col] = row[`max_${col}`];
660
+ }
661
+ }
662
+ return result;
663
+ }
664
+ async findMany(tableName, options) {
665
+ const rows = await this.from(tableName).applyOptions(options).execute();
666
+ return (0, query_js_1.applyQueryOptions)(rows, options);
631
667
  }
632
668
  /**
633
669
  * Finds a single record by primary key.
@@ -636,59 +672,10 @@ class LocalFirstClient {
636
672
  * const tenant = await client.findById('tenants', 'ten-1');
637
673
  */
638
674
  async findById(tableName, id, options) {
639
- // Primary-key lookup in SQLite instead of loading the whole table into JS.
640
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
641
- const safePk = (0, sql_js_1.escapeIdentifier)(this.primaryKey);
642
- try {
643
- const rows = await this.db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} = ? LIMIT 1`, [id]);
644
- if (!rows || rows.length === 0)
645
- return null;
646
- const row = this.hydrateRow(tableName, rows[0]);
647
- if (!options?.includeDeleted && (0, query_js_1.isDeletedRecord)(row)) {
648
- return null;
649
- }
650
- return row;
651
- }
652
- catch (err) {
653
- const msg = String(err?.message || err || '');
654
- if (msg.includes('no such table') || msg.includes('no such column')) {
655
- return null;
656
- }
657
- throw err;
658
- }
675
+ return executeFindById(this.db, tableName, id, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
659
676
  }
660
- /**
661
- * Finds multiple records by primary keys in a single batched SQL query.
662
- * Eliminates the N-roundtrip bridge overhead of multiple findById calls.
663
- * Automatically omits soft-deleted records unless includeDeleted: true.
664
- *
665
- * Example:
666
- * const tenants = await client.findByIds('tenants', ['ten-1', 'ten-2', 'ten-3']);
667
- */
668
677
  async findByIds(tableName, ids, options) {
669
- if (!ids || ids.length === 0)
670
- return [];
671
- const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
672
- const safePk = (0, sql_js_1.escapeIdentifier)(this.primaryKey);
673
- const placeholders = ids.map(() => '?').join(', ');
674
- try {
675
- const rows = await this.db.execute(`SELECT * FROM ${safeTable} WHERE ${safePk} IN (${placeholders})`, ids);
676
- if (!rows || rows.length === 0)
677
- return [];
678
- const hasJsonConfig = Boolean(this.jsonColumns[tableName] || this.autoHydrateJSON);
679
- const hydrated = hasJsonConfig ? rows.map((r) => this.hydrateRow(tableName, r)) : rows;
680
- if (!options?.includeDeleted) {
681
- return hydrated.filter((r) => !(0, query_js_1.isDeletedRecord)(r));
682
- }
683
- return hydrated;
684
- }
685
- catch (err) {
686
- const msg = String(err?.message || err || '');
687
- if (msg.includes('no such table') || msg.includes('no such column')) {
688
- return [];
689
- }
690
- throw err;
691
- }
678
+ return executeFindByIds(this.db, tableName, ids, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
692
679
  }
693
680
  /**
694
681
  * Tracks an existing mutation into sync_outbox without modifying the table.
@@ -7,6 +7,7 @@ export * from './outbox-store.js';
7
7
  export * from './meta-store.js';
8
8
  export * from './table-manager.js';
9
9
  export * from './schema-manager.js';
10
+ export * from './query-builder.js';
10
11
  export * from '../reactive/event-emitter.js';
11
12
  export * from '../adapters/network/index.js';
12
13
  export type { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
@@ -24,6 +24,7 @@ __exportStar(require("./outbox-store.js"), exports);
24
24
  __exportStar(require("./meta-store.js"), exports);
25
25
  __exportStar(require("./table-manager.js"), exports);
26
26
  __exportStar(require("./schema-manager.js"), exports);
27
+ __exportStar(require("./query-builder.js"), exports);
27
28
  __exportStar(require("../reactive/event-emitter.js"), exports);
28
29
  __exportStar(require("../adapters/network/index.js"), exports);
29
30
  __exportStar(require("../utils/logger.js"), exports);
@@ -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
  }