local-lite 0.0.5 → 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.
@@ -137,20 +137,9 @@ class SupabaseRemoteAdapter {
137
137
  batch.push(operations[i]);
138
138
  i++;
139
139
  }
140
- const buildRowData = (item) => {
141
- const row = {
142
- ...(item.payload || {}),
143
- [this.primaryKey]: item.rowId,
144
- [this.deletedColumn]: false,
145
- };
146
- if (this.updatedAtColumn && !row[this.updatedAtColumn]) {
147
- row[this.updatedAtColumn] = new Date(item.timestamp).toISOString();
148
- }
149
- return row;
150
- };
151
140
  if (batch.length === 1) {
152
141
  try {
153
- const rowData = buildRowData(batch[0]);
142
+ const rowData = this.buildRowData(batch[0]);
154
143
  const { error } = await client.from(op.tableName).upsert(rowData);
155
144
  if (error) {
156
145
  const msg = (0, logger_js_1.formatSupabaseError)(error, { table: op.tableName, operation: 'INSERT' });
@@ -174,7 +163,7 @@ class SupabaseRemoteAdapter {
174
163
  const retryIndividually = async () => {
175
164
  for (const item of batch) {
176
165
  try {
177
- const singleRow = buildRowData(item);
166
+ const singleRow = this.buildRowData(item);
178
167
  const { error: singleErr } = await client.from(op.tableName).upsert(singleRow);
179
168
  if (singleErr) {
180
169
  const msg = (0, logger_js_1.formatSupabaseError)(singleErr, { table: op.tableName, operation: 'INSERT' });
@@ -193,7 +182,7 @@ class SupabaseRemoteAdapter {
193
182
  }
194
183
  };
195
184
  try {
196
- const rows = batch.map(buildRowData);
185
+ const rows = batch.map((item) => this.buildRowData(item));
197
186
  const { error } = await client.from(op.tableName).upsert(rows);
198
187
  if (!error) {
199
188
  for (const item of batch)
@@ -209,87 +198,173 @@ class SupabaseRemoteAdapter {
209
198
  }
210
199
  continue;
211
200
  }
212
- // Handle DELETE
201
+ // Batch consecutive DELETEs for the same table
213
202
  if (op.operation === 'DELETE') {
214
- try {
215
- const patch = {
216
- [this.deletedColumn]: this.deletedValue,
217
- };
218
- if (this.updatedAtColumn) {
219
- patch[this.updatedAtColumn] = new Date(op.timestamp).toISOString();
203
+ const batch = [];
204
+ while (i < operations.length &&
205
+ operations[i].operation === 'DELETE' &&
206
+ operations[i].tableName === op.tableName) {
207
+ batch.push(operations[i]);
208
+ i++;
209
+ }
210
+ const patch = {
211
+ [this.deletedColumn]: this.deletedValue,
212
+ };
213
+ if (this.updatedAtColumn) {
214
+ patch[this.updatedAtColumn] = new Date(op.timestamp).toISOString();
215
+ }
216
+ if (batch.length === 1) {
217
+ try {
218
+ const { error } = await client
219
+ .from(op.tableName)
220
+ .update(patch)
221
+ .eq(this.primaryKey, op.rowId);
222
+ if (error) {
223
+ const msg = (0, logger_js_1.formatSupabaseError)(error, { table: op.tableName, operation: 'DELETE' });
224
+ this.logger.error(msg);
225
+ failedIds.push(op.id);
226
+ errors[op.id] = msg;
227
+ }
228
+ else {
229
+ successfulIds.push(op.id);
230
+ }
220
231
  }
221
- const { error } = await client
222
- .from(op.tableName)
223
- .update(patch)
224
- .eq(this.primaryKey, op.rowId);
225
- if (error) {
226
- const msg = (0, logger_js_1.formatSupabaseError)(error, { table: op.tableName, operation: 'DELETE' });
232
+ catch (err) {
233
+ const msg = (0, logger_js_1.formatSupabaseError)(err, { table: op.tableName, operation: 'DELETE' });
227
234
  this.logger.error(msg);
228
235
  failedIds.push(op.id);
229
236
  errors[op.id] = msg;
230
237
  }
231
- else {
232
- successfulIds.push(op.id);
233
- }
234
238
  }
235
- catch (err) {
236
- const msg = (0, logger_js_1.formatSupabaseError)(err, { table: op.tableName, operation: 'DELETE' });
237
- this.logger.error(msg);
238
- failedIds.push(op.id);
239
- errors[op.id] = msg;
239
+ else {
240
+ // Bulk soft-delete using .in() to collapse N round-trips into 1
241
+ const rowIds = batch.map((item) => item.rowId);
242
+ try {
243
+ const { error } = await client
244
+ .from(op.tableName)
245
+ .update(patch)
246
+ .in(this.primaryKey, rowIds);
247
+ if (!error) {
248
+ for (const item of batch)
249
+ successfulIds.push(item.id);
250
+ }
251
+ else {
252
+ // Fallback to individual updates if .in() fails
253
+ for (const item of batch) {
254
+ try {
255
+ const { error: singleErr } = await client
256
+ .from(op.tableName)
257
+ .update(patch)
258
+ .eq(this.primaryKey, item.rowId);
259
+ if (singleErr) {
260
+ const msg = (0, logger_js_1.formatSupabaseError)(singleErr, { table: op.tableName, operation: 'DELETE' });
261
+ failedIds.push(item.id);
262
+ errors[item.id] = msg;
263
+ }
264
+ else {
265
+ successfulIds.push(item.id);
266
+ }
267
+ }
268
+ catch (singleErr) {
269
+ const msg = (0, logger_js_1.formatSupabaseError)(singleErr, { table: op.tableName, operation: 'DELETE' });
270
+ failedIds.push(item.id);
271
+ errors[item.id] = msg;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ catch {
277
+ for (const item of batch)
278
+ failedIds.push(item.id);
279
+ }
240
280
  }
241
- i++;
242
281
  continue;
243
282
  }
244
- // Handle UPDATE
283
+ // Batch consecutive UPDATEs for the same table
245
284
  if (op.operation === 'UPDATE') {
246
- try {
247
- const patch = {
248
- ...(op.payload || {}),
249
- [this.deletedColumn]: false,
250
- };
251
- if (this.updatedAtColumn) {
252
- patch[this.updatedAtColumn] = new Date(op.timestamp).toISOString();
285
+ const batch = [];
286
+ while (i < operations.length &&
287
+ operations[i].operation === 'UPDATE' &&
288
+ operations[i].tableName === op.tableName) {
289
+ batch.push(operations[i]);
290
+ i++;
291
+ }
292
+ if (batch.length === 1) {
293
+ const item = batch[0];
294
+ try {
295
+ const patch = this.buildRowData(item);
296
+ const { error, data } = await client
297
+ .from(op.tableName)
298
+ .update(patch)
299
+ .eq(this.primaryKey, item.rowId)
300
+ .select(this.primaryKey);
301
+ if (error) {
302
+ const msg = (0, logger_js_1.formatSupabaseError)(error, { table: op.tableName, operation: 'UPDATE' });
303
+ this.logger.error(msg);
304
+ failedIds.push(item.id);
305
+ errors[item.id] = msg;
306
+ }
307
+ else if (!data || data.length === 0) {
308
+ const { error: upsertErr } = await client.from(op.tableName).upsert(patch);
309
+ if (upsertErr) {
310
+ const msg = (0, logger_js_1.formatSupabaseError)(upsertErr, { table: op.tableName, operation: 'UPSERT_FALLBACK' });
311
+ this.logger.error(msg);
312
+ failedIds.push(item.id);
313
+ errors[item.id] = msg;
314
+ }
315
+ else {
316
+ successfulIds.push(item.id);
317
+ }
318
+ }
319
+ else {
320
+ successfulIds.push(item.id);
321
+ }
253
322
  }
254
- const { error, data } = await client
255
- .from(op.tableName)
256
- .update(patch)
257
- .eq(this.primaryKey, op.rowId)
258
- .select(this.primaryKey);
259
- if (error) {
260
- const msg = (0, logger_js_1.formatSupabaseError)(error, { table: op.tableName, operation: 'UPDATE' });
323
+ catch (err) {
324
+ const msg = (0, logger_js_1.formatSupabaseError)(err, { table: op.tableName, operation: 'UPDATE' });
261
325
  this.logger.error(msg);
262
- failedIds.push(op.id);
263
- errors[op.id] = msg;
326
+ failedIds.push(item.id);
327
+ errors[item.id] = msg;
264
328
  }
265
- else if (!data || data.length === 0) {
266
- // If row did not exist remotely, fallback to upsert
267
- const rowData = {
268
- ...patch,
269
- [this.primaryKey]: op.rowId,
270
- };
271
- const { error: upsertErr } = await client.from(op.tableName).upsert(rowData);
272
- if (upsertErr) {
273
- const msg = (0, logger_js_1.formatSupabaseError)(upsertErr, { table: op.tableName, operation: 'UPSERT_FALLBACK' });
274
- this.logger.error(msg);
275
- failedIds.push(op.id);
276
- errors[op.id] = msg;
329
+ }
330
+ else {
331
+ // Bulk upsert for multiple consecutive updates (merges fields by primaryKey in 1 HTTP call)
332
+ const rows = batch.map((item) => this.buildRowData(item));
333
+ try {
334
+ const { error } = await client.from(op.tableName).upsert(rows);
335
+ if (!error) {
336
+ for (const item of batch)
337
+ successfulIds.push(item.id);
277
338
  }
278
339
  else {
279
- successfulIds.push(op.id);
340
+ // Fallback to individual updates if bulk upsert has a schema conflict
341
+ for (const item of batch) {
342
+ try {
343
+ const singleRow = this.buildRowData(item);
344
+ const { error: singleErr } = await client.from(op.tableName).upsert(singleRow);
345
+ if (singleErr) {
346
+ const msg = (0, logger_js_1.formatSupabaseError)(singleErr, { table: op.tableName, operation: 'UPDATE' });
347
+ failedIds.push(item.id);
348
+ errors[item.id] = msg;
349
+ }
350
+ else {
351
+ successfulIds.push(item.id);
352
+ }
353
+ }
354
+ catch (singleErr) {
355
+ const msg = (0, logger_js_1.formatSupabaseError)(singleErr, { table: op.tableName, operation: 'UPDATE' });
356
+ failedIds.push(item.id);
357
+ errors[item.id] = msg;
358
+ }
359
+ }
280
360
  }
281
361
  }
282
- else {
283
- successfulIds.push(op.id);
362
+ catch {
363
+ for (const item of batch) {
364
+ failedIds.push(item.id);
365
+ }
284
366
  }
285
367
  }
286
- catch (err) {
287
- const msg = (0, logger_js_1.formatSupabaseError)(err, { table: op.tableName, operation: 'UPDATE' });
288
- this.logger.error(msg);
289
- failedIds.push(op.id);
290
- errors[op.id] = msg;
291
- }
292
- i++;
293
368
  continue;
294
369
  }
295
370
  i++;
@@ -438,6 +513,20 @@ class SupabaseRemoteAdapter {
438
513
  changes,
439
514
  };
440
515
  }
516
+ /**
517
+ * Formats a row payload for remote Supabase storage (adds primaryKey, deleted: false, updated_at).
518
+ */
519
+ buildRowData(item) {
520
+ const row = {
521
+ ...(item.payload || {}),
522
+ [this.primaryKey]: item.rowId,
523
+ [this.deletedColumn]: false,
524
+ };
525
+ if (this.updatedAtColumn && !row[this.updatedAtColumn]) {
526
+ row[this.updatedAtColumn] = new Date(item.timestamp).toISOString();
527
+ }
528
+ return row;
529
+ }
441
530
  /**
442
531
  * Helper to determine if a record represents a soft delete.
443
532
  */
@@ -454,6 +543,7 @@ class SupabaseRemoteAdapter {
454
543
  }
455
544
  /**
456
545
  * Subscribes to live realtime postgres_changes from Supabase.
546
+ * Batches rapid burst events in the same microtask turn into a single SQLite sync transaction.
457
547
  */
458
548
  subscribeToLiveChanges(callback) {
459
549
  this.activeLiveCallback = callback;
@@ -479,6 +569,23 @@ class SupabaseRemoteAdapter {
479
569
  };
480
570
  }
481
571
  const channel = client.channel(this.channelName);
572
+ let pendingLiveChanges = [];
573
+ let isScheduled = false;
574
+ const flushLiveChanges = () => {
575
+ isScheduled = false;
576
+ if (pendingLiveChanges.length === 0)
577
+ return;
578
+ const toSend = pendingLiveChanges;
579
+ pendingLiveChanges = [];
580
+ const maxTimestamp = Math.max(...toSend.map((c) => c.timestamp || 0), Date.now());
581
+ callback({
582
+ timestamp: maxTimestamp,
583
+ changes: toSend,
584
+ });
585
+ };
586
+ const scheduleFlush = typeof queueMicrotask === 'function'
587
+ ? queueMicrotask
588
+ : (fn) => setTimeout(fn, 0);
482
589
  for (const table of this.tables) {
483
590
  channel.on('postgres_changes', { event: '*', schema: this.schema, table }, (payload) => {
484
591
  let op = 'UPDATE';
@@ -495,18 +602,17 @@ class SupabaseRemoteAdapter {
495
602
  const timestamp = record[this.updatedAtColumn]
496
603
  ? new Date(record[this.updatedAtColumn]).getTime()
497
604
  : Date.now();
498
- callback({
605
+ pendingLiveChanges.push({
606
+ tableName: table,
607
+ operation: finalOp,
608
+ rowId,
609
+ payload: finalOp === 'DELETE' ? null : record,
499
610
  timestamp,
500
- changes: [
501
- {
502
- tableName: table,
503
- operation: finalOp,
504
- rowId,
505
- payload: finalOp === 'DELETE' ? null : record,
506
- timestamp,
507
- },
508
- ],
509
611
  });
612
+ if (!isScheduled) {
613
+ isScheduled = true;
614
+ scheduleFlush(flushLiveChanges);
615
+ }
510
616
  });
511
617
  }
512
618
  channel.subscribe((status) => {
@@ -516,6 +622,7 @@ class SupabaseRemoteAdapter {
516
622
  });
517
623
  this.activeChannel = channel;
518
624
  return () => {
625
+ flushLiveChanges();
519
626
  this.activeLiveCallback = undefined;
520
627
  this.destroy();
521
628
  };
@@ -212,14 +212,6 @@ export declare class LocalFirstClient {
212
212
  findById<T = any>(tableName: string, id: string, options?: {
213
213
  includeDeleted?: boolean;
214
214
  }): Promise<T | null>;
215
- /**
216
- * Finds multiple records by primary keys in a single batched SQL query.
217
- * Eliminates the N-roundtrip bridge overhead of multiple findById calls.
218
- * Automatically omits soft-deleted records unless includeDeleted: true.
219
- *
220
- * Example:
221
- * const tenants = await client.findByIds('tenants', ['ten-1', 'ten-2', 'ten-3']);
222
- */
223
215
  findByIds<T = any>(tableName: string, ids: string[], options?: {
224
216
  includeDeleted?: boolean;
225
217
  }): Promise<T[]>;
@@ -11,6 +11,43 @@ 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
+ 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
+ }
14
51
  class TransactionalClient {
15
52
  parent;
16
53
  tx;
@@ -74,41 +111,10 @@ class TransactionalClient {
74
111
  await this.mutate(tableName, rowId, 'DELETE', null, options);
75
112
  }
76
113
  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
- }
114
+ return executeFindById(this.tx, tableName, id, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
92
115
  }
93
116
  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
- }
117
+ return executeFindByIds(this.tx, tableName, ids, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
112
118
  }
113
119
  async findMany(tableName, options) {
114
120
  const rows = await this.from(tableName).applyOptions(options).execute();
@@ -274,6 +280,15 @@ class LocalFirstClient {
274
280
  await this.initialization;
275
281
  }
276
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
+ }
277
292
  await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
278
293
  await meta_store_js_1.MetaStore.ensureSchema(this.db);
279
294
  await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
@@ -344,6 +359,7 @@ class LocalFirstClient {
344
359
  const generatedIds = [];
345
360
  const queuedEvents = [];
346
361
  const queuedOutboxIds = [];
362
+ const pendingOutboxOps = [];
347
363
  const executeInTx = async (tx) => {
348
364
  for (const m of mutations) {
349
365
  const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
@@ -360,8 +376,22 @@ class LocalFirstClient {
360
376
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
361
377
  if (!skipOutbox) {
362
378
  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) {
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
+ });
365
395
  queuedOutboxIds.push(outboxId);
366
396
  }
367
397
  }
@@ -375,6 +405,9 @@ class LocalFirstClient {
375
405
  },
376
406
  });
377
407
  }
408
+ if (pendingOutboxOps.length > 0) {
409
+ await outbox_store_js_1.OutboxStore.insertRecords(tx, pendingOutboxOps);
410
+ }
378
411
  };
379
412
  if (options.tx) {
380
413
  await executeInTx(options.tx);
@@ -639,59 +672,10 @@ class LocalFirstClient {
639
672
  * const tenant = await client.findById('tenants', 'ten-1');
640
673
  */
641
674
  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
- }
675
+ return executeFindById(this.db, tableName, id, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
662
676
  }
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
677
  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
- }
678
+ return executeFindByIds(this.db, tableName, ids, this.primaryKey, (t, r) => this.hydrateRow(t, r), options?.includeDeleted);
695
679
  }
696
680
  /**
697
681
  * 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;