local-lite 0.0.3 → 0.0.4

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 (55) hide show
  1. package/README.md +421 -362
  2. package/dist/adapters/local/better-sqlite3.d.ts +2 -0
  3. package/dist/adapters/local/better-sqlite3.js +46 -25
  4. package/dist/adapters/local/expo-sqlite.d.ts +5 -1
  5. package/dist/adapters/local/expo-sqlite.js +40 -7
  6. package/dist/adapters/local/index.d.ts +1 -0
  7. package/dist/adapters/local/node-sqlite.d.ts +2 -0
  8. package/dist/adapters/local/node-sqlite.js +45 -22
  9. package/dist/adapters/local/op-sqlite.d.ts +1 -0
  10. package/dist/adapters/local/op-sqlite.js +6 -0
  11. package/dist/adapters/local/sql-js.d.ts +1 -0
  12. package/dist/adapters/local/sql-js.js +5 -0
  13. package/dist/adapters/network/index.js +33 -0
  14. package/dist/adapters/remote/convex.d.ts +2 -2
  15. package/dist/adapters/remote/convex.js +8 -12
  16. package/dist/adapters/remote/demo.d.ts +1 -1
  17. package/dist/adapters/remote/demo.js +3 -0
  18. package/dist/adapters/remote/firestore.d.ts +3 -2
  19. package/dist/adapters/remote/firestore.js +69 -46
  20. package/dist/adapters/remote/index.d.ts +6 -4
  21. package/dist/adapters/remote/supabase.d.ts +34 -6
  22. package/dist/adapters/remote/supabase.js +340 -79
  23. package/dist/config/index.js +7 -10
  24. package/dist/config/resolve-local.js +12 -1
  25. package/dist/config/resolve-remote.js +33 -28
  26. package/dist/config/types.d.ts +30 -5
  27. package/dist/core/client.d.ts +86 -3
  28. package/dist/core/client.js +423 -41
  29. package/dist/core/index.d.ts +14 -0
  30. package/dist/core/index.js +29 -0
  31. package/dist/core/outbox-store.d.ts +8 -4
  32. package/dist/core/outbox-store.js +84 -65
  33. package/dist/core/query.d.ts +8 -0
  34. package/dist/core/query.js +37 -0
  35. package/dist/core/schema-manager.d.ts +9 -0
  36. package/dist/core/schema-manager.js +68 -0
  37. package/dist/core/sync-worker.d.ts +38 -1
  38. package/dist/core/sync-worker.js +351 -104
  39. package/dist/core/table-manager.js +26 -29
  40. package/dist/core/types.d.ts +8 -0
  41. package/dist/react/BenchmarkModal.d.ts +32 -0
  42. package/dist/react/BenchmarkModal.js +777 -0
  43. package/dist/react/SyncInspector.d.ts +31 -0
  44. package/dist/react/SyncInspector.js +655 -0
  45. package/dist/react/index.d.ts +51 -19
  46. package/dist/react/index.js +265 -105
  47. package/dist/react/runner.d.ts +7 -0
  48. package/dist/react/runner.js +42 -0
  49. package/dist/react/status.d.ts +24 -0
  50. package/dist/react/status.js +123 -0
  51. package/dist/react/theme.d.ts +18 -0
  52. package/dist/react/theme.js +46 -0
  53. package/dist/utils/logger.d.ts +4 -0
  54. package/dist/utils/logger.js +20 -0
  55. package/package.json +138 -133
@@ -23,22 +23,16 @@ class TransactionalClient {
23
23
  }
24
24
  async mutate(tableName, rowId, operation, payload, options = {}) {
25
25
  const pk = options.primaryKey ?? this.parent.getPrimaryKey();
26
- const outboxId = (0, uuid_js_1.generateUUID)();
27
26
  const timestamp = options.customTimestamp ?? Date.now();
28
27
  await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, {
29
28
  autoSchema: this.parent.autoSchema,
30
29
  schemaManager: this.parent.schemaManager,
31
30
  });
32
31
  if (!options.skipOutbox) {
33
- await outbox_store_js_1.OutboxStore.insertRecord(this.tx, {
34
- id: outboxId,
35
- tableName,
36
- rowId,
37
- operation,
38
- payload,
39
- timestamp,
40
- });
41
- this.queuedOutboxIds.push(outboxId);
32
+ const outboxId = await this.parent.recordOutboxMutation(this.tx, tableName, rowId, operation, payload, timestamp);
33
+ if (outboxId) {
34
+ this.queuedOutboxIds.push(outboxId);
35
+ }
42
36
  }
43
37
  this.queuedTableEvents.push({
44
38
  tableName,
@@ -70,9 +64,54 @@ class TransactionalClient {
70
64
  async update(tableName, rowId, payload, options = {}) {
71
65
  await this.mutate(tableName, rowId, 'UPDATE', payload, options);
72
66
  }
67
+ async updateMany(tableName, updates, options = {}) {
68
+ for (const u of updates) {
69
+ await this.update(tableName, u.id, u.payload, options);
70
+ }
71
+ }
73
72
  async delete(tableName, rowId, options = {}) {
74
73
  await this.mutate(tableName, rowId, 'DELETE', null, options);
75
74
  }
75
+ 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
+ }
91
+ }
92
+ 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
+ }
111
+ }
112
+ async findMany(tableName, options) {
113
+ return this.parent.findManyInternal(this.tx, tableName, options);
114
+ }
76
115
  get rawTx() {
77
116
  return this.tx;
78
117
  }
@@ -85,15 +124,26 @@ class LocalFirstClient {
85
124
  syncWorker;
86
125
  schemaManager;
87
126
  autoSchema;
127
+ coalesceOutbox;
88
128
  tables;
129
+ jsonColumns;
130
+ autoHydrateJSON;
89
131
  primaryKey;
132
+ migrations;
133
+ autoStart;
134
+ initialization;
90
135
  constructor(config) {
91
136
  this.db = config.local;
92
137
  this.remote = config.remote;
93
138
  this.events = new event_emitter_js_1.EventEmitter();
94
139
  this.primaryKey = config.primaryKey ?? 'id';
95
140
  this.autoSchema = config.autoSchema ?? false;
141
+ this.coalesceOutbox = config.coalesceOutbox ?? true;
96
142
  this.tables = config.tables ?? [];
143
+ this.migrations = config.migrations ?? [];
144
+ this.autoStart = config.autoStart ?? true;
145
+ this.jsonColumns = config.jsonColumns ?? {};
146
+ this.autoHydrateJSON = config.autoHydrateJSON ?? false;
97
147
  this.schemaManager = new schema_manager_js_1.SchemaManager();
98
148
  const workerOptions = {
99
149
  network: config.network,
@@ -107,26 +157,133 @@ class LocalFirstClient {
107
157
  logLevel: config.logLevel,
108
158
  autoSchema: this.autoSchema,
109
159
  schemaManager: this.schemaManager,
160
+ autoSync: this.autoStart,
161
+ syncScope: config.syncScope,
162
+ autoPauseOnBackground: config.autoPauseOnBackground,
110
163
  };
111
164
  this.syncWorker = new sync_worker_js_1.SyncWorker(this.db, this.remote, this.events, workerOptions);
112
165
  }
113
166
  getPrimaryKey() {
114
167
  return this.primaryKey;
115
168
  }
169
+ /**
170
+ * Automatically deserializes JSON string columns into JavaScript objects/arrays.
171
+ */
172
+ hydrateRow(tableName, row) {
173
+ if (!row || typeof row !== 'object')
174
+ return row;
175
+ const cols = this.jsonColumns[tableName];
176
+ if (cols && cols.length > 0) {
177
+ const copy = { ...row };
178
+ for (const col of cols) {
179
+ if (typeof copy[col] === 'string') {
180
+ const val = copy[col].trim();
181
+ if ((val.startsWith('{') && val.endsWith('}')) || (val.startsWith('[') && val.endsWith(']'))) {
182
+ try {
183
+ copy[col] = JSON.parse(val);
184
+ }
185
+ catch {
186
+ // keep string if not valid JSON
187
+ }
188
+ }
189
+ }
190
+ }
191
+ return copy;
192
+ }
193
+ if (this.autoHydrateJSON) {
194
+ const copy = { ...row };
195
+ for (const [key, val] of Object.entries(copy)) {
196
+ if (typeof val === 'string') {
197
+ const trimmed = val.trim();
198
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
199
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
200
+ try {
201
+ copy[key] = JSON.parse(trimmed);
202
+ }
203
+ catch {
204
+ // keep string
205
+ }
206
+ }
207
+ }
208
+ }
209
+ return copy;
210
+ }
211
+ return row;
212
+ }
213
+ /**
214
+ * Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
215
+ * Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
216
+ */
217
+ async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp) {
218
+ if (this.coalesceOutbox) {
219
+ const existing = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
220
+ if (existing && existing.status === 'PENDING') {
221
+ if (operation === 'DELETE') {
222
+ if (existing.operation === 'INSERT') {
223
+ // Created offline and deleted offline before ever being pushed to server: clean up outbox completely
224
+ await outbox_store_js_1.OutboxStore.deleteRecord(tx, existing.id);
225
+ return undefined;
226
+ }
227
+ else {
228
+ await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
229
+ return existing.id;
230
+ }
231
+ }
232
+ else if (operation === 'UPDATE') {
233
+ const merged = { ...(existing.payload || {}), ...(payload || {}) };
234
+ await outbox_store_js_1.OutboxStore.updatePayload(tx, existing.id, merged, timestamp);
235
+ return existing.id;
236
+ }
237
+ else if (operation === 'INSERT') {
238
+ await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'INSERT', payload, timestamp);
239
+ return existing.id;
240
+ }
241
+ }
242
+ }
243
+ const outboxId = (0, uuid_js_1.generateUUID)();
244
+ await outbox_store_js_1.OutboxStore.insertRecord(tx, {
245
+ id: outboxId,
246
+ tableName,
247
+ rowId,
248
+ operation,
249
+ payload,
250
+ timestamp,
251
+ });
252
+ return outboxId;
253
+ }
116
254
  /**
117
255
  * Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
118
- * and starts the background sync worker.
256
+ * applies migrations, and optionally starts automatic sync. Concurrent/repeated calls
257
+ * share initialization; failures can be retried. After stop(), explicitly start the worker.
119
258
  */
120
259
  async initialize() {
260
+ if (!this.initialization) {
261
+ this.initialization = this.initializeStorage().catch((error) => {
262
+ this.initialization = undefined;
263
+ throw error;
264
+ });
265
+ }
266
+ await this.initialization;
267
+ }
268
+ async initializeStorage() {
121
269
  await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
122
270
  await meta_store_js_1.MetaStore.ensureSchema(this.db);
123
271
  await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
272
+ if (this.migrations.length > 0) {
273
+ await this.db.transaction(async (tx) => {
274
+ for (const query of this.migrations) {
275
+ if (query.trim())
276
+ await tx.execute(query);
277
+ }
278
+ });
279
+ }
124
280
  if (this.autoSchema && this.tables.length > 0) {
125
281
  for (const table of this.tables) {
126
282
  await this.schemaManager.ensureTable(this.db, table, this.primaryKey);
127
283
  }
128
284
  }
129
- this.syncWorker.start();
285
+ if (this.autoStart)
286
+ this.syncWorker.start();
130
287
  }
131
288
  /**
132
289
  * Performs an atomic dual-write: mutates the local data table AND writes
@@ -134,8 +291,8 @@ class LocalFirstClient {
134
291
  */
135
292
  async mutate(tableName, rowId, operation, payload, options = {}) {
136
293
  const pk = options.primaryKey ?? this.primaryKey;
137
- const outboxId = (0, uuid_js_1.generateUUID)();
138
294
  const timestamp = options.customTimestamp ?? Date.now();
295
+ let affectedOutboxId;
139
296
  const executeInTransaction = async (tx) => {
140
297
  // 1. Mutate local table
141
298
  await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, {
@@ -144,14 +301,7 @@ class LocalFirstClient {
144
301
  });
145
302
  // 2. Insert into sync_outbox (if not skipped)
146
303
  if (!options.skipOutbox) {
147
- await outbox_store_js_1.OutboxStore.insertRecord(tx, {
148
- id: outboxId,
149
- tableName,
150
- rowId,
151
- operation,
152
- payload,
153
- timestamp,
154
- });
304
+ affectedOutboxId = await this.recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp);
155
305
  }
156
306
  };
157
307
  if (options.tx) {
@@ -168,7 +318,10 @@ class LocalFirstClient {
168
318
  source: 'local',
169
319
  });
170
320
  if (!options.skipOutbox) {
171
- this.events.emit('sync_outbox', { operation: 'INSERT', rowId: outboxId });
321
+ this.events.emit('sync_outbox', {
322
+ operation: affectedOutboxId ? 'INSERT' : 'DELETE',
323
+ rowId: affectedOutboxId || rowId,
324
+ });
172
325
  // 4. Trigger immediate debounced push
173
326
  this.syncWorker.schedulePush(20);
174
327
  }
@@ -198,17 +351,11 @@ class LocalFirstClient {
198
351
  });
199
352
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
200
353
  if (!skipOutbox) {
201
- const outboxId = (0, uuid_js_1.generateUUID)();
202
354
  const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
203
- await outbox_store_js_1.OutboxStore.insertRecord(tx, {
204
- id: outboxId,
205
- tableName: m.tableName,
206
- rowId,
207
- operation: m.operation,
208
- payload,
209
- timestamp,
210
- });
211
- queuedOutboxIds.push(outboxId);
355
+ const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
356
+ if (outboxId) {
357
+ queuedOutboxIds.push(outboxId);
358
+ }
212
359
  }
213
360
  queuedEvents.push({
214
361
  tableName: m.tableName,
@@ -309,6 +456,27 @@ class LocalFirstClient {
309
456
  async update(tableName, rowId, payload, options = {}) {
310
457
  await this.mutate(tableName, rowId, 'UPDATE', payload, options);
311
458
  }
459
+ /**
460
+ * Updates multiple records atomically in a single database transaction.
461
+ * Emits reactive change events and queues outbox entries together.
462
+ *
463
+ * Example:
464
+ * await client.updateMany('todos', [
465
+ * { id: 'todo-1', payload: { completed: 1 } },
466
+ * { id: 'todo-2', payload: { completed: 1 } },
467
+ * ]);
468
+ */
469
+ async updateMany(tableName, updates, options = {}) {
470
+ if (!updates || updates.length === 0)
471
+ return;
472
+ const mutations = updates.map((u) => ({
473
+ tableName,
474
+ rowId: u.id,
475
+ operation: 'UPDATE',
476
+ payload: u.payload,
477
+ }));
478
+ await this.batch(mutations, options);
479
+ }
312
480
  /**
313
481
  * Convenience helper to delete a record by ID.
314
482
  *
@@ -321,6 +489,7 @@ class LocalFirstClient {
321
489
  /**
322
490
  * Reads records from a local SQLite table without requiring manual SQL.
323
491
  * Supports where filtering with rich operators ($in, $gt, $contains, $or, etc.), order, limit, and offset.
492
+ * Pushes simple equality where clauses down to SQLite for high performance, with memory fallback.
324
493
  * Gracefully returns [] if table or columns have not been populated yet on empty schemas.
325
494
  *
326
495
  * Example:
@@ -328,19 +497,137 @@ class LocalFirstClient {
328
497
  * const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
329
498
  */
330
499
  async findMany(tableName, options) {
500
+ return this.findManyInternal(this.db, tableName, options);
501
+ }
502
+ /**
503
+ * Internal findMany implementation shared between LocalFirstClient and TransactionalClient.
504
+ */
505
+ async findManyInternal(db, tableName, options) {
506
+ await this.schemaManager.ensureTableIntrospected(db, tableName);
331
507
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
332
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
+ }
590
+ }
591
+ if (whereClauses.length > 0) {
592
+ sql += ` WHERE ${whereClauses.join(' AND ')}`;
593
+ }
594
+ }
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);
608
+ }
609
+ }
333
610
  try {
334
- rows = await this.db.execute(`SELECT * FROM ${safeTable}`);
611
+ rows = await db.execute(sql, params);
335
612
  }
336
613
  catch (err) {
337
614
  const msg = String(err?.message || err || '');
338
615
  if (msg.includes('no such table') || msg.includes('no such column')) {
339
616
  return [];
340
617
  }
341
- throw err;
618
+ // Fallback to select * if pushdown syntax differs
619
+ try {
620
+ rows = await db.execute(`SELECT * FROM ${safeTable}`);
621
+ }
622
+ catch {
623
+ return [];
624
+ }
342
625
  }
343
- return (0, query_js_1.applyQueryOptions)(rows, options);
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);
344
631
  }
345
632
  /**
346
633
  * Finds a single record by primary key.
@@ -348,12 +635,60 @@ class LocalFirstClient {
348
635
  * Example:
349
636
  * const tenant = await client.findById('tenants', 'ten-1');
350
637
  */
351
- async findById(tableName, id) {
352
- const results = await this.findMany(tableName, {
353
- where: { [this.primaryKey]: id },
354
- limit: 1,
355
- });
356
- return results.length > 0 ? results[0] : null;
638
+ 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
+ }
659
+ }
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
+ 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
+ }
357
692
  }
358
693
  /**
359
694
  * Tracks an existing mutation into sync_outbox without modifying the table.
@@ -435,11 +770,58 @@ class LocalFirstClient {
435
770
  async resetSyncCursor() {
436
771
  await this.syncWorker.resetSyncCursor();
437
772
  }
773
+ /**
774
+ * Dynamically sets or updates the active sync scope.
775
+ */
776
+ setSyncScope(scope) {
777
+ this.syncWorker.setSyncScope(scope);
778
+ }
438
779
  /**
439
780
  * Stops all active intervals, background sync loops, and network listeners.
440
781
  */
441
782
  stop() {
442
783
  this.syncWorker.stop();
443
784
  }
785
+ /**
786
+ * Stops background sync workers, cleans up remote listeners,
787
+ * and closes the local database adapter connection (releasing native locks).
788
+ */
789
+ async close() {
790
+ this.stop();
791
+ if (typeof this.remote.destroy === 'function') {
792
+ this.remote.destroy();
793
+ }
794
+ if (typeof this.db.close === 'function') {
795
+ await this.db.close();
796
+ }
797
+ }
798
+ /**
799
+ * Resets local state by stopping sync and deleting or dropping all managed tables,
800
+ * clearing the sync_outbox and _sync_meta stores, and resetting the sync cursor.
801
+ */
802
+ async reset(options = {}) {
803
+ this.stop();
804
+ const tablesToReset = Array.from(new Set([...this.tables, 'sync_outbox', 'sync_meta']));
805
+ for (const table of tablesToReset) {
806
+ try {
807
+ if (options.dropTables) {
808
+ await this.db.execute(`DROP TABLE IF EXISTS "${table}"`);
809
+ }
810
+ else {
811
+ await this.db.execute(`DELETE FROM "${table}"`);
812
+ }
813
+ }
814
+ catch {
815
+ // Table may not exist yet; safe to ignore
816
+ }
817
+ }
818
+ if (options.dropTables) {
819
+ this.initialization = undefined;
820
+ // Dropped tables must be re-created by autoSchema on next write.
821
+ this.schemaManager.clearCache();
822
+ }
823
+ await this.resetSyncCursor();
824
+ this.events.emit('reset', { dropTables: options.dropTables ?? false });
825
+ }
444
826
  }
445
827
  exports.LocalFirstClient = LocalFirstClient;
@@ -0,0 +1,14 @@
1
+ export * from './client.js';
2
+ export * from './types.js';
3
+ export * from './query.js';
4
+ export * from './sync-worker.js';
5
+ export * from './conflict.js';
6
+ export * from './outbox-store.js';
7
+ export * from './meta-store.js';
8
+ export * from './table-manager.js';
9
+ export * from './schema-manager.js';
10
+ export * from '../reactive/event-emitter.js';
11
+ export * from '../adapters/network/index.js';
12
+ export type { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
13
+ export type { RemoteDBAdapter } from '../adapters/remote/index.js';
14
+ export * from '../utils/logger.js';
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // Provider-independent entry point: no config resolvers, UI, or backend implementations.
18
+ __exportStar(require("./client.js"), exports);
19
+ __exportStar(require("./types.js"), exports);
20
+ __exportStar(require("./query.js"), exports);
21
+ __exportStar(require("./sync-worker.js"), exports);
22
+ __exportStar(require("./conflict.js"), exports);
23
+ __exportStar(require("./outbox-store.js"), exports);
24
+ __exportStar(require("./meta-store.js"), exports);
25
+ __exportStar(require("./table-manager.js"), exports);
26
+ __exportStar(require("./schema-manager.js"), exports);
27
+ __exportStar(require("../reactive/event-emitter.js"), exports);
28
+ __exportStar(require("../adapters/network/index.js"), exports);
29
+ __exportStar(require("../utils/logger.js"), exports);
@@ -1,5 +1,5 @@
1
1
  import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
2
- import { OutboxRecord, OutboxStatus, SyncOperation } from './types.js';
2
+ import { OutboxRecord, OutboxStatus, SyncOp, SyncOperation } from './types.js';
3
3
  export declare class OutboxStore {
4
4
  /**
5
5
  * Initializes the sync_outbox schema if it does not already exist.
@@ -10,7 +10,7 @@ export declare class OutboxStore {
10
10
  */
11
11
  static insertRecord(tx: LocalTransaction, op: SyncOperation): Promise<void>;
12
12
  /**
13
- * Inserts multiple outbox entries within an existing database transaction.
13
+ * Inserts multiple outbox entries in high-throughput multi-row SQL chunks within an existing transaction.
14
14
  */
15
15
  static insertRecords(tx: LocalTransaction, ops: SyncOperation[]): Promise<void>;
16
16
  /**
@@ -39,9 +39,13 @@ export declare class OutboxStore {
39
39
  */
40
40
  static deleteRecord(tx: LocalTransaction, id: string): Promise<void>;
41
41
  /**
42
- * Updates an outbox record's payload (used during conflict merge).
42
+ * Updates an outbox record's payload (used during conflict merge or coalescing).
43
43
  */
44
- static updatePayload(tx: LocalTransaction, id: string, payload: Record<string, any>): Promise<void>;
44
+ static updatePayload(tx: LocalTransaction, id: string, payload: Record<string, any>, timestamp?: number): Promise<void>;
45
+ /**
46
+ * Updates an outbox record's operation and payload (used during mutation coalescing).
47
+ */
48
+ static updateOperation(tx: LocalTransaction, id: string, operation: SyncOp, payload: Record<string, any> | null, timestamp?: number): Promise<void>;
45
49
  /**
46
50
  * Marks a batch of outbox IDs as 'FAILED' and increments retry counters.
47
51
  */