local-lite 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/adapters/local/better-sqlite3.d.ts +4 -1
  2. package/dist/adapters/local/better-sqlite3.js +34 -14
  3. package/dist/adapters/local/expo-sqlite.d.ts +10 -2
  4. package/dist/adapters/local/expo-sqlite.js +36 -20
  5. package/dist/adapters/local/node-sqlite.d.ts +4 -1
  6. package/dist/adapters/local/node-sqlite.js +29 -12
  7. package/dist/adapters/remote/firestore.js +39 -32
  8. package/dist/adapters/remote/supabase.d.ts +7 -2
  9. package/dist/adapters/remote/supabase.js +192 -85
  10. package/dist/config/types.d.ts +1 -1
  11. package/dist/core/client.d.ts +52 -15
  12. package/dist/core/client.js +195 -208
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.js +1 -0
  15. package/dist/core/outbox-store.d.ts +5 -0
  16. package/dist/core/outbox-store.js +29 -4
  17. package/dist/core/query-builder.d.ts +120 -0
  18. package/dist/core/query-builder.js +441 -0
  19. package/dist/core/query.js +28 -32
  20. package/dist/core/schema-manager.d.ts +4 -15
  21. package/dist/core/schema-manager.js +48 -103
  22. package/dist/core/sync-worker.js +7 -2
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/react/SyncInspector.js +0 -70
  26. package/dist/react/index.d.ts +39 -0
  27. package/dist/react/index.js +232 -34
  28. package/dist/react/status.d.ts +5 -0
  29. package/dist/react/status.js +20 -12
  30. package/dist/schema/generator.d.ts +49 -0
  31. package/dist/schema/generator.js +209 -0
  32. package/dist/schema/index.d.ts +1 -0
  33. package/dist/schema/index.js +17 -0
  34. package/dist/utils/uuid.d.ts +1 -1
  35. package/dist/utils/uuid.js +7 -16
  36. package/package.json +7 -2
@@ -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
  };
@@ -178,7 +178,7 @@ export interface LocalLiteConfig {
178
178
  */
179
179
  autoHydrateJSON?: boolean;
180
180
  /**
181
- * Scoped synchronization filter (e.g. { business_id: '...' } or dynamic resolver callback).
181
+ * Scoped synchronization filter (e.g. { org_id: '...' } or dynamic resolver callback).
182
182
  */
183
183
  syncScope?: Record<string, any> | (() => Record<string, any> | Promise<Record<string, any>>);
184
184
  }
@@ -2,9 +2,10 @@ import { LocalDBAdapter, LocalTransaction } from '../adapters/local/index.js';
2
2
  import { RemoteDBAdapter } from '../adapters/remote/index.js';
3
3
  import { EventEmitter } from '../reactive/event-emitter.js';
4
4
  import { SyncWorker } from './sync-worker.js';
5
- import { SchemaManager, DBExecutable } from './schema-manager.js';
6
- import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SyncMetrics, SyncOp, TableChangeEvent } from './types.js';
5
+ import { SchemaManager } from './schema-manager.js';
6
+ import { BatchMutation, ConflictResolver, ConflictStrategy, MutateOptions, NetworkStatusProvider, OutboxRecord, OutboxStatus, QueryOptions, RetryConfig, SyncMetrics, SyncOp, TableChangeEvent, WhereFilter } from './types.js';
7
7
  import { SyncLogger, CustomLogger, LogLevel } from '../utils/logger.js';
8
+ import { QueryBuilder, AggregateSpec, AggregateResult } from './query-builder.js';
8
9
  export interface LocalFirstClientConfig {
9
10
  local: LocalDBAdapter;
10
11
  remote: RemoteDBAdapter;
@@ -51,6 +52,8 @@ export declare class TransactionalClient {
51
52
  includeDeleted?: boolean;
52
53
  }): Promise<T[]>;
53
54
  findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
55
+ query<T = any>(sql: string, params?: any[]): Promise<T[]>;
56
+ from<T = any>(tableName: string): QueryBuilder<T>;
54
57
  get rawTx(): LocalTransaction;
55
58
  }
56
59
  export declare class LocalFirstClient {
@@ -149,15 +152,57 @@ export declare class LocalFirstClient {
149
152
  * Pushes simple equality where clauses down to SQLite for high performance, with memory fallback.
150
153
  * Gracefully returns [] if table or columns have not been populated yet on empty schemas.
151
154
  *
155
+ /**
156
+ * Executes a raw relational SQL query with parameters directly against SQLite.
157
+ * Enables complex multi-table JOINs, GROUP BY aggregations (COUNT, SUM, AVG), CTEs, and window functions.
158
+ */
159
+ query<T = any>(sql: string, params?: any[]): Promise<T[]>;
160
+ /**
161
+ * Returns a fluent SQL QueryBuilder for the specified table.
162
+ * Allows writing clean JavaScript method chains (select, join, leftJoin, where, orderBy, groupBy)
163
+ * that automatically compile into optimized native SQLite queries.
164
+ *
152
165
  * Example:
153
- * const tenants = await client.findMany('tenants', { where: { status: 'ACTIVE' }, orderBy: 'name' });
154
- * const expensive = await client.findMany('properties', { where: { rent: { $gte: 2000 } } });
166
+ * const activeRentals = await client
167
+ * .from('contracts')
168
+ * .join('houses', 'contracts.house_id', 'houses.id')
169
+ * .leftJoin('tenants', 'contracts.tenant_id', 'tenants.id')
170
+ * .where({ 'contracts.business_id': bizId, 'contracts.status': 'ACTIVE' })
171
+ * .select('contracts.id', 'contracts.rent_amount', 'houses.house_number', 'tenants.name')
172
+ * .execute();
155
173
  */
156
- findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
174
+ from<T = any>(tableName: string): QueryBuilder<T>;
175
+ /**
176
+ * Executes a native SQLite COUNT(*) query and returns the count directly.
177
+ */
178
+ count(tableName: string, options?: {
179
+ where?: WhereFilter;
180
+ }): Promise<number>;
181
+ /**
182
+ * Executes a native SQLite SUM(column) query and returns the sum directly.
183
+ */
184
+ sum(tableName: string, column: string, options?: {
185
+ where?: WhereFilter;
186
+ }): Promise<number>;
157
187
  /**
158
- * Internal findMany implementation shared between LocalFirstClient and TransactionalClient.
188
+ * Executes a native SQLite AVG(column) query and returns the average directly.
159
189
  */
160
- findManyInternal<T = any>(db: DBExecutable, tableName: string, options?: QueryOptions): Promise<T[]>;
190
+ avg(tableName: string, column: string, options?: {
191
+ where?: WhereFilter;
192
+ }): Promise<number | null>;
193
+ /**
194
+ * Executes native multi-metric SQLite aggregation in a single query.
195
+ *
196
+ * Example:
197
+ * const stats = await client.aggregate('receipts', {
198
+ * where: { business_id: bizId },
199
+ * count: true,
200
+ * sum: ['amount', 'refunded_amount'],
201
+ * avg: ['amount']
202
+ * });
203
+ */
204
+ aggregate(tableName: string, spec: AggregateSpec): Promise<AggregateResult>;
205
+ findMany<T = any>(tableName: string, options?: QueryOptions): Promise<T[]>;
161
206
  /**
162
207
  * Finds a single record by primary key.
163
208
  *
@@ -167,14 +212,6 @@ export declare class LocalFirstClient {
167
212
  findById<T = any>(tableName: string, id: string, options?: {
168
213
  includeDeleted?: boolean;
169
214
  }): Promise<T | null>;
170
- /**
171
- * Finds multiple records by primary keys in a single batched SQL query.
172
- * Eliminates the N-roundtrip bridge overhead of multiple findById calls.
173
- * Automatically omits soft-deleted records unless includeDeleted: true.
174
- *
175
- * Example:
176
- * const tenants = await client.findByIds('tenants', ['ten-1', 'ten-2', 'ten-3']);
177
- */
178
215
  findByIds<T = any>(tableName: string, ids: string[], options?: {
179
216
  includeDeleted?: boolean;
180
217
  }): Promise<T[]>;