local-lite 0.0.3 → 0.0.5

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 (63) 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 +133 -5
  28. package/dist/core/client.js +431 -46
  29. package/dist/core/index.d.ts +15 -0
  30. package/dist/core/index.js +30 -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-builder.d.ts +119 -0
  34. package/dist/core/query-builder.js +372 -0
  35. package/dist/core/query.d.ts +8 -0
  36. package/dist/core/query.js +37 -0
  37. package/dist/core/schema-manager.d.ts +9 -0
  38. package/dist/core/schema-manager.js +103 -0
  39. package/dist/core/sync-worker.d.ts +38 -1
  40. package/dist/core/sync-worker.js +351 -104
  41. package/dist/core/table-manager.js +26 -29
  42. package/dist/core/types.d.ts +8 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +2 -0
  45. package/dist/react/BenchmarkModal.d.ts +32 -0
  46. package/dist/react/BenchmarkModal.js +777 -0
  47. package/dist/react/SyncInspector.d.ts +31 -0
  48. package/dist/react/SyncInspector.js +585 -0
  49. package/dist/react/index.d.ts +89 -18
  50. package/dist/react/index.js +450 -105
  51. package/dist/react/runner.d.ts +7 -0
  52. package/dist/react/runner.js +42 -0
  53. package/dist/react/status.d.ts +24 -0
  54. package/dist/react/status.js +123 -0
  55. package/dist/react/theme.d.ts +18 -0
  56. package/dist/react/theme.js +46 -0
  57. package/dist/schema/generator.d.ts +49 -0
  58. package/dist/schema/generator.js +209 -0
  59. package/dist/schema/index.d.ts +1 -0
  60. package/dist/schema/index.js +17 -0
  61. package/dist/utils/logger.d.ts +4 -0
  62. package/dist/utils/logger.js +20 -0
  63. package/package.json +143 -133
@@ -10,6 +10,7 @@ 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");
13
14
  class TransactionalClient {
14
15
  parent;
15
16
  tx;
@@ -23,22 +24,16 @@ class TransactionalClient {
23
24
  }
24
25
  async mutate(tableName, rowId, operation, payload, options = {}) {
25
26
  const pk = options.primaryKey ?? this.parent.getPrimaryKey();
26
- const outboxId = (0, uuid_js_1.generateUUID)();
27
27
  const timestamp = options.customTimestamp ?? Date.now();
28
28
  await table_manager_js_1.TableManager.applyMutation(this.tx, tableName, rowId, operation, payload, pk, {
29
29
  autoSchema: this.parent.autoSchema,
30
30
  schemaManager: this.parent.schemaManager,
31
31
  });
32
32
  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);
33
+ const outboxId = await this.parent.recordOutboxMutation(this.tx, tableName, rowId, operation, payload, timestamp);
34
+ if (outboxId) {
35
+ this.queuedOutboxIds.push(outboxId);
36
+ }
42
37
  }
43
38
  this.queuedTableEvents.push({
44
39
  tableName,
@@ -70,9 +65,61 @@ class TransactionalClient {
70
65
  async update(tableName, rowId, payload, options = {}) {
71
66
  await this.mutate(tableName, rowId, 'UPDATE', payload, options);
72
67
  }
68
+ async updateMany(tableName, updates, options = {}) {
69
+ for (const u of updates) {
70
+ await this.update(tableName, u.id, u.payload, options);
71
+ }
72
+ }
73
73
  async delete(tableName, rowId, options = {}) {
74
74
  await this.mutate(tableName, rowId, 'DELETE', null, options);
75
75
  }
76
+ 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
+ }
92
+ }
93
+ 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
+ }
112
+ }
113
+ async findMany(tableName, options) {
114
+ const rows = await this.from(tableName).applyOptions(options).execute();
115
+ return (0, query_js_1.applyQueryOptions)(rows, options);
116
+ }
117
+ async query(sql, params = []) {
118
+ return (await this.tx.execute(sql, params));
119
+ }
120
+ from(tableName) {
121
+ return new query_builder_js_1.QueryBuilder(this, tableName);
122
+ }
76
123
  get rawTx() {
77
124
  return this.tx;
78
125
  }
@@ -85,15 +132,26 @@ class LocalFirstClient {
85
132
  syncWorker;
86
133
  schemaManager;
87
134
  autoSchema;
135
+ coalesceOutbox;
88
136
  tables;
137
+ jsonColumns;
138
+ autoHydrateJSON;
89
139
  primaryKey;
140
+ migrations;
141
+ autoStart;
142
+ initialization;
90
143
  constructor(config) {
91
144
  this.db = config.local;
92
145
  this.remote = config.remote;
93
146
  this.events = new event_emitter_js_1.EventEmitter();
94
147
  this.primaryKey = config.primaryKey ?? 'id';
95
148
  this.autoSchema = config.autoSchema ?? false;
149
+ this.coalesceOutbox = config.coalesceOutbox ?? true;
96
150
  this.tables = config.tables ?? [];
151
+ this.migrations = config.migrations ?? [];
152
+ this.autoStart = config.autoStart ?? true;
153
+ this.jsonColumns = config.jsonColumns ?? {};
154
+ this.autoHydrateJSON = config.autoHydrateJSON ?? false;
97
155
  this.schemaManager = new schema_manager_js_1.SchemaManager();
98
156
  const workerOptions = {
99
157
  network: config.network,
@@ -107,26 +165,133 @@ class LocalFirstClient {
107
165
  logLevel: config.logLevel,
108
166
  autoSchema: this.autoSchema,
109
167
  schemaManager: this.schemaManager,
168
+ autoSync: this.autoStart,
169
+ syncScope: config.syncScope,
170
+ autoPauseOnBackground: config.autoPauseOnBackground,
110
171
  };
111
172
  this.syncWorker = new sync_worker_js_1.SyncWorker(this.db, this.remote, this.events, workerOptions);
112
173
  }
113
174
  getPrimaryKey() {
114
175
  return this.primaryKey;
115
176
  }
177
+ /**
178
+ * Automatically deserializes JSON string columns into JavaScript objects/arrays.
179
+ */
180
+ hydrateRow(tableName, row) {
181
+ if (!row || typeof row !== 'object')
182
+ return row;
183
+ const cols = this.jsonColumns[tableName];
184
+ if (cols && cols.length > 0) {
185
+ const copy = { ...row };
186
+ for (const col of cols) {
187
+ if (typeof copy[col] === 'string') {
188
+ const val = copy[col].trim();
189
+ if ((val.startsWith('{') && val.endsWith('}')) || (val.startsWith('[') && val.endsWith(']'))) {
190
+ try {
191
+ copy[col] = JSON.parse(val);
192
+ }
193
+ catch {
194
+ // keep string if not valid JSON
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return copy;
200
+ }
201
+ if (this.autoHydrateJSON) {
202
+ const copy = { ...row };
203
+ for (const [key, val] of Object.entries(copy)) {
204
+ if (typeof val === 'string') {
205
+ const trimmed = val.trim();
206
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
207
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
208
+ try {
209
+ copy[key] = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ // keep string
213
+ }
214
+ }
215
+ }
216
+ }
217
+ return copy;
218
+ }
219
+ return row;
220
+ }
221
+ /**
222
+ * Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
223
+ * Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
224
+ */
225
+ async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp) {
226
+ if (this.coalesceOutbox) {
227
+ const existing = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
228
+ if (existing && existing.status === 'PENDING') {
229
+ if (operation === 'DELETE') {
230
+ if (existing.operation === 'INSERT') {
231
+ // Created offline and deleted offline before ever being pushed to server: clean up outbox completely
232
+ await outbox_store_js_1.OutboxStore.deleteRecord(tx, existing.id);
233
+ return undefined;
234
+ }
235
+ else {
236
+ await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
237
+ return existing.id;
238
+ }
239
+ }
240
+ else if (operation === 'UPDATE') {
241
+ const merged = { ...(existing.payload || {}), ...(payload || {}) };
242
+ await outbox_store_js_1.OutboxStore.updatePayload(tx, existing.id, merged, timestamp);
243
+ return existing.id;
244
+ }
245
+ else if (operation === 'INSERT') {
246
+ await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'INSERT', payload, timestamp);
247
+ return existing.id;
248
+ }
249
+ }
250
+ }
251
+ const outboxId = (0, uuid_js_1.generateUUID)();
252
+ await outbox_store_js_1.OutboxStore.insertRecord(tx, {
253
+ id: outboxId,
254
+ tableName,
255
+ rowId,
256
+ operation,
257
+ payload,
258
+ timestamp,
259
+ });
260
+ return outboxId;
261
+ }
116
262
  /**
117
263
  * Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
118
- * and starts the background sync worker.
264
+ * applies migrations, and optionally starts automatic sync. Concurrent/repeated calls
265
+ * share initialization; failures can be retried. After stop(), explicitly start the worker.
119
266
  */
120
267
  async initialize() {
268
+ if (!this.initialization) {
269
+ this.initialization = this.initializeStorage().catch((error) => {
270
+ this.initialization = undefined;
271
+ throw error;
272
+ });
273
+ }
274
+ await this.initialization;
275
+ }
276
+ async initializeStorage() {
121
277
  await outbox_store_js_1.OutboxStore.ensureSchema(this.db);
122
278
  await meta_store_js_1.MetaStore.ensureSchema(this.db);
123
279
  await outbox_store_js_1.OutboxStore.resetStaleProcessing(this.db);
280
+ if (this.migrations.length > 0) {
281
+ await this.db.transaction(async (tx) => {
282
+ for (const query of this.migrations) {
283
+ if (query.trim())
284
+ await tx.execute(query);
285
+ }
286
+ });
287
+ }
124
288
  if (this.autoSchema && this.tables.length > 0) {
125
289
  for (const table of this.tables) {
126
290
  await this.schemaManager.ensureTable(this.db, table, this.primaryKey);
127
291
  }
128
292
  }
129
- this.syncWorker.start();
293
+ if (this.autoStart)
294
+ this.syncWorker.start();
130
295
  }
131
296
  /**
132
297
  * Performs an atomic dual-write: mutates the local data table AND writes
@@ -134,8 +299,8 @@ class LocalFirstClient {
134
299
  */
135
300
  async mutate(tableName, rowId, operation, payload, options = {}) {
136
301
  const pk = options.primaryKey ?? this.primaryKey;
137
- const outboxId = (0, uuid_js_1.generateUUID)();
138
302
  const timestamp = options.customTimestamp ?? Date.now();
303
+ let affectedOutboxId;
139
304
  const executeInTransaction = async (tx) => {
140
305
  // 1. Mutate local table
141
306
  await table_manager_js_1.TableManager.applyMutation(tx, tableName, rowId, operation, payload, pk, {
@@ -144,14 +309,7 @@ class LocalFirstClient {
144
309
  });
145
310
  // 2. Insert into sync_outbox (if not skipped)
146
311
  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
- });
312
+ affectedOutboxId = await this.recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp);
155
313
  }
156
314
  };
157
315
  if (options.tx) {
@@ -168,7 +326,10 @@ class LocalFirstClient {
168
326
  source: 'local',
169
327
  });
170
328
  if (!options.skipOutbox) {
171
- this.events.emit('sync_outbox', { operation: 'INSERT', rowId: outboxId });
329
+ this.events.emit('sync_outbox', {
330
+ operation: affectedOutboxId ? 'INSERT' : 'DELETE',
331
+ rowId: affectedOutboxId || rowId,
332
+ });
172
333
  // 4. Trigger immediate debounced push
173
334
  this.syncWorker.schedulePush(20);
174
335
  }
@@ -198,17 +359,11 @@ class LocalFirstClient {
198
359
  });
199
360
  const skipOutbox = m.skipOutbox ?? options.skipOutbox ?? false;
200
361
  if (!skipOutbox) {
201
- const outboxId = (0, uuid_js_1.generateUUID)();
202
362
  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);
363
+ const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
364
+ if (outboxId) {
365
+ queuedOutboxIds.push(outboxId);
366
+ }
212
367
  }
213
368
  queuedEvents.push({
214
369
  tableName: m.tableName,
@@ -309,6 +464,27 @@ class LocalFirstClient {
309
464
  async update(tableName, rowId, payload, options = {}) {
310
465
  await this.mutate(tableName, rowId, 'UPDATE', payload, options);
311
466
  }
467
+ /**
468
+ * Updates multiple records atomically in a single database transaction.
469
+ * Emits reactive change events and queues outbox entries together.
470
+ *
471
+ * Example:
472
+ * await client.updateMany('todos', [
473
+ * { id: 'todo-1', payload: { completed: 1 } },
474
+ * { id: 'todo-2', payload: { completed: 1 } },
475
+ * ]);
476
+ */
477
+ async updateMany(tableName, updates, options = {}) {
478
+ if (!updates || updates.length === 0)
479
+ return;
480
+ const mutations = updates.map((u) => ({
481
+ tableName,
482
+ rowId: u.id,
483
+ operation: 'UPDATE',
484
+ payload: u.payload,
485
+ }));
486
+ await this.batch(mutations, options);
487
+ }
312
488
  /**
313
489
  * Convenience helper to delete a record by ID.
314
490
  *
@@ -321,39 +497,201 @@ class LocalFirstClient {
321
497
  /**
322
498
  * Reads records from a local SQLite table without requiring manual SQL.
323
499
  * Supports where filtering with rich operators ($in, $gt, $contains, $or, etc.), order, limit, and offset.
500
+ * Pushes simple equality where clauses down to SQLite for high performance, with memory fallback.
324
501
  * Gracefully returns [] if table or columns have not been populated yet on empty schemas.
325
502
  *
503
+ /**
504
+ * Executes a raw relational SQL query with parameters directly against SQLite.
505
+ * Enables complex multi-table JOINs, GROUP BY aggregations (COUNT, SUM, AVG), CTEs, and window functions.
506
+ */
507
+ async query(sql, params = []) {
508
+ return (await this.db.execute(sql, params));
509
+ }
510
+ /**
511
+ * Returns a fluent SQL QueryBuilder for the specified table.
512
+ * Allows writing clean JavaScript method chains (select, join, leftJoin, where, orderBy, groupBy)
513
+ * that automatically compile into optimized native SQLite queries.
514
+ *
515
+ * Example:
516
+ * const activeRentals = await client
517
+ * .from('contracts')
518
+ * .join('houses', 'contracts.house_id', 'houses.id')
519
+ * .leftJoin('tenants', 'contracts.tenant_id', 'tenants.id')
520
+ * .where({ 'contracts.business_id': bizId, 'contracts.status': 'ACTIVE' })
521
+ * .select('contracts.id', 'contracts.rent_amount', 'houses.house_number', 'tenants.name')
522
+ * .execute();
523
+ */
524
+ from(tableName) {
525
+ return new query_builder_js_1.QueryBuilder(this, tableName);
526
+ }
527
+ /**
528
+ * Executes a native SQLite COUNT(*) query and returns the count directly.
529
+ */
530
+ async count(tableName, options) {
531
+ const qb = this.from(tableName);
532
+ if (options?.where)
533
+ qb.where(options.where);
534
+ return qb.count();
535
+ }
536
+ /**
537
+ * Executes a native SQLite SUM(column) query and returns the sum directly.
538
+ */
539
+ async sum(tableName, column, options) {
540
+ const qb = this.from(tableName);
541
+ if (options?.where)
542
+ qb.where(options.where);
543
+ return qb.sum(column);
544
+ }
545
+ /**
546
+ * Executes a native SQLite AVG(column) query and returns the average directly.
547
+ */
548
+ async avg(tableName, column, options) {
549
+ const qb = this.from(tableName);
550
+ if (options?.where)
551
+ qb.where(options.where);
552
+ return qb.avg(column);
553
+ }
554
+ /**
555
+ * Executes native multi-metric SQLite aggregation in a single query.
556
+ *
326
557
  * Example:
327
- * const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
328
- * const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
558
+ * const stats = await client.aggregate('receipts', {
559
+ * where: { business_id: bizId },
560
+ * count: true,
561
+ * sum: ['amount', 'refunded_amount'],
562
+ * avg: ['amount']
563
+ * });
329
564
  */
565
+ async aggregate(tableName, spec) {
566
+ const projections = [];
567
+ if (spec.count) {
568
+ projections.push('COUNT(*) AS "_count"');
569
+ }
570
+ if (spec.sum) {
571
+ for (const col of spec.sum) {
572
+ projections.push(`COALESCE(SUM("${col.replace(/"/g, '""')}"), 0) AS "sum_${col}"`);
573
+ }
574
+ }
575
+ if (spec.avg) {
576
+ for (const col of spec.avg) {
577
+ projections.push(`AVG("${col.replace(/"/g, '""')}") AS "avg_${col}"`);
578
+ }
579
+ }
580
+ if (spec.min) {
581
+ for (const col of spec.min) {
582
+ projections.push(`MIN("${col.replace(/"/g, '""')}") AS "min_${col}"`);
583
+ }
584
+ }
585
+ if (spec.max) {
586
+ for (const col of spec.max) {
587
+ projections.push(`MAX("${col.replace(/"/g, '""')}") AS "max_${col}"`);
588
+ }
589
+ }
590
+ if (projections.length === 0) {
591
+ return {};
592
+ }
593
+ const qb = this.from(tableName).select(...projections);
594
+ if (spec.where) {
595
+ qb.where(spec.where);
596
+ }
597
+ const rows = await qb.execute();
598
+ const row = rows[0] || {};
599
+ const result = {};
600
+ if (spec.count) {
601
+ result.count = typeof row._count === 'number' ? row._count : Number(row._count) || 0;
602
+ }
603
+ if (spec.sum) {
604
+ result.sum = {};
605
+ for (const col of spec.sum) {
606
+ const val = row[`sum_${col}`];
607
+ result.sum[col] = typeof val === 'number' ? val : Number(val) || 0;
608
+ }
609
+ }
610
+ if (spec.avg) {
611
+ result.avg = {};
612
+ for (const col of spec.avg) {
613
+ const val = row[`avg_${col}`];
614
+ result.avg[col] = val === null || val === undefined ? null : Number(val) || 0;
615
+ }
616
+ }
617
+ if (spec.min) {
618
+ result.min = {};
619
+ for (const col of spec.min) {
620
+ result.min[col] = row[`min_${col}`];
621
+ }
622
+ }
623
+ if (spec.max) {
624
+ result.max = {};
625
+ for (const col of spec.max) {
626
+ result.max[col] = row[`max_${col}`];
627
+ }
628
+ }
629
+ return result;
630
+ }
330
631
  async findMany(tableName, options) {
632
+ const rows = await this.from(tableName).applyOptions(options).execute();
633
+ return (0, query_js_1.applyQueryOptions)(rows, options);
634
+ }
635
+ /**
636
+ * Finds a single record by primary key.
637
+ *
638
+ * Example:
639
+ * const tenant = await client.findById('tenants', 'ten-1');
640
+ */
641
+ async findById(tableName, id, options) {
642
+ // Primary-key lookup in SQLite instead of loading the whole table into JS.
331
643
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
332
- let rows;
644
+ const safePk = (0, sql_js_1.escapeIdentifier)(this.primaryKey);
333
645
  try {
334
- rows = await this.db.execute(`SELECT * FROM ${safeTable}`);
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;
335
654
  }
336
655
  catch (err) {
337
656
  const msg = String(err?.message || err || '');
338
657
  if (msg.includes('no such table') || msg.includes('no such column')) {
339
- return [];
658
+ return null;
340
659
  }
341
660
  throw err;
342
661
  }
343
- return (0, query_js_1.applyQueryOptions)(rows, options);
344
662
  }
345
663
  /**
346
- * Finds a single record by primary key.
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.
347
667
  *
348
668
  * Example:
349
- * const tenant = await client.findById('tenants', 'ten-1');
669
+ * const tenants = await client.findByIds('tenants', ['ten-1', 'ten-2', 'ten-3']);
350
670
  */
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;
671
+ 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
+ }
357
695
  }
358
696
  /**
359
697
  * Tracks an existing mutation into sync_outbox without modifying the table.
@@ -435,11 +773,58 @@ class LocalFirstClient {
435
773
  async resetSyncCursor() {
436
774
  await this.syncWorker.resetSyncCursor();
437
775
  }
776
+ /**
777
+ * Dynamically sets or updates the active sync scope.
778
+ */
779
+ setSyncScope(scope) {
780
+ this.syncWorker.setSyncScope(scope);
781
+ }
438
782
  /**
439
783
  * Stops all active intervals, background sync loops, and network listeners.
440
784
  */
441
785
  stop() {
442
786
  this.syncWorker.stop();
443
787
  }
788
+ /**
789
+ * Stops background sync workers, cleans up remote listeners,
790
+ * and closes the local database adapter connection (releasing native locks).
791
+ */
792
+ async close() {
793
+ this.stop();
794
+ if (typeof this.remote.destroy === 'function') {
795
+ this.remote.destroy();
796
+ }
797
+ if (typeof this.db.close === 'function') {
798
+ await this.db.close();
799
+ }
800
+ }
801
+ /**
802
+ * Resets local state by stopping sync and deleting or dropping all managed tables,
803
+ * clearing the sync_outbox and _sync_meta stores, and resetting the sync cursor.
804
+ */
805
+ async reset(options = {}) {
806
+ this.stop();
807
+ const tablesToReset = Array.from(new Set([...this.tables, 'sync_outbox', 'sync_meta']));
808
+ for (const table of tablesToReset) {
809
+ try {
810
+ if (options.dropTables) {
811
+ await this.db.execute(`DROP TABLE IF EXISTS "${table}"`);
812
+ }
813
+ else {
814
+ await this.db.execute(`DELETE FROM "${table}"`);
815
+ }
816
+ }
817
+ catch {
818
+ // Table may not exist yet; safe to ignore
819
+ }
820
+ }
821
+ if (options.dropTables) {
822
+ this.initialization = undefined;
823
+ // Dropped tables must be re-created by autoSchema on next write.
824
+ this.schemaManager.clearCache();
825
+ }
826
+ await this.resetSyncCursor();
827
+ this.events.emit('reset', { dropTables: options.dropTables ?? false });
828
+ }
444
829
  }
445
830
  exports.LocalFirstClient = LocalFirstClient;
@@ -0,0 +1,15 @@
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 './query-builder.js';
11
+ export * from '../reactive/event-emitter.js';
12
+ export * from '../adapters/network/index.js';
13
+ export type { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
14
+ export type { RemoteDBAdapter } from '../adapters/remote/index.js';
15
+ export * from '../utils/logger.js';
@@ -0,0 +1,30 @@
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("./query-builder.js"), exports);
28
+ __exportStar(require("../reactive/event-emitter.js"), exports);
29
+ __exportStar(require("../adapters/network/index.js"), exports);
30
+ __exportStar(require("../utils/logger.js"), exports);