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.
- package/dist/adapters/local/better-sqlite3.d.ts +4 -1
- package/dist/adapters/local/better-sqlite3.js +34 -14
- package/dist/adapters/local/expo-sqlite.d.ts +10 -2
- package/dist/adapters/local/expo-sqlite.js +36 -20
- package/dist/adapters/local/node-sqlite.d.ts +4 -1
- package/dist/adapters/local/node-sqlite.js +29 -12
- 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 +0 -8
- package/dist/core/client.js +70 -86
- 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 +65 -52
- 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 +1 -1
|
@@ -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
|
-
//
|
|
201
|
+
// Batch consecutive DELETEs for the same table
|
|
213
202
|
if (op.operation === 'DELETE') {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
.
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
283
|
+
// Batch consecutive UPDATEs for the same table
|
|
245
284
|
if (op.operation === 'UPDATE') {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
.
|
|
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(
|
|
263
|
-
errors[
|
|
326
|
+
failedIds.push(item.id);
|
|
327
|
+
errors[item.id] = msg;
|
|
264
328
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|