local-lite 0.0.6 → 0.0.7

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.
@@ -16,6 +16,7 @@ export interface BetterSqliteDatabase {
16
16
  * Adapter for better-sqlite3 in Node.js, Electron, Next.js, and server environments.
17
17
  */
18
18
  export declare class BetterSQLite3Adapter implements LocalDBAdapter {
19
+ readonly supportsSqlPushdown = true;
19
20
  private db;
20
21
  private txQueue;
21
22
  private statementCache;
@@ -5,6 +5,7 @@ exports.BetterSQLite3Adapter = void 0;
5
5
  * Adapter for better-sqlite3 in Node.js, Electron, Next.js, and server environments.
6
6
  */
7
7
  class BetterSQLite3Adapter {
8
+ supportsSqlPushdown = true;
8
9
  db;
9
10
  txQueue = Promise.resolve();
10
11
  statementCache = new Map();
@@ -8,6 +8,7 @@ export interface ExpoDatabase {
8
8
  closeSync?(): void;
9
9
  }
10
10
  export declare class ExpoSQLiteAdapter implements LocalDBAdapter {
11
+ readonly supportsSqlPushdown = true;
11
12
  private db;
12
13
  private writeQueue;
13
14
  constructor(expoDbInstance: any);
@@ -4,6 +4,7 @@ exports.ExpoSQLiteAdapter = void 0;
4
4
  // Pre-compiled RegExp hoisted to module scope — 0 regex allocations per query
5
5
  const READ_QUERY_REGEX = /^(?:SELECT|PRAGMA)\b/i;
6
6
  class ExpoSQLiteAdapter {
7
+ supportsSqlPushdown = true;
7
8
  db;
8
9
  writeQueue = Promise.resolve();
9
10
  constructor(expoDbInstance) {
@@ -3,6 +3,8 @@ export interface LocalTransaction {
3
3
  }
4
4
  export interface LocalDBAdapter {
5
5
  execute(query: string, params?: any[]): Promise<any[]>;
6
+ /** True when execute runs real SQL with filtering, ordering, paging, and projection. */
7
+ readonly supportsSqlPushdown?: boolean;
6
8
  transaction<T>(action: (tx: LocalTransaction) => Promise<T>): Promise<T>;
7
9
  close?(): Promise<void> | void;
8
10
  }
@@ -17,6 +17,7 @@ export interface LibSQLClient {
17
17
  * Supports local SQLite files, embedded replicas, in-memory databases, and server/edge deployments.
18
18
  */
19
19
  export declare class LibSQLAdapter implements LocalDBAdapter {
20
+ readonly supportsSqlPushdown = true;
20
21
  private client;
21
22
  constructor(libsqlClient: any);
22
23
  execute(query: string, params?: any[]): Promise<any[]>;
@@ -28,6 +28,7 @@ function normalizeRows(res) {
28
28
  * Supports local SQLite files, embedded replicas, in-memory databases, and server/edge deployments.
29
29
  */
30
30
  class LibSQLAdapter {
31
+ supportsSqlPushdown = true;
31
32
  client;
32
33
  constructor(libsqlClient) {
33
34
  this.client = libsqlClient;
@@ -17,6 +17,7 @@ export interface NodeSqliteDatabaseSync {
17
17
  * Provides zero-dependency, native SQLite on modern Node.js runtimes.
18
18
  */
19
19
  export declare class NodeSQLiteAdapter implements LocalDBAdapter {
20
+ readonly supportsSqlPushdown = true;
20
21
  private db;
21
22
  private txQueue;
22
23
  private statementCache;
@@ -6,6 +6,7 @@ exports.NodeSQLiteAdapter = void 0;
6
6
  * Provides zero-dependency, native SQLite on modern Node.js runtimes.
7
7
  */
8
8
  class NodeSQLiteAdapter {
9
+ supportsSqlPushdown = true;
9
10
  db;
10
11
  txQueue = Promise.resolve();
11
12
  statementCache = new Map();
@@ -18,6 +18,7 @@ export interface OpSQLiteDatabase {
18
18
  * Enables ultra-fast C++ JSI SQLite in bare React Native.
19
19
  */
20
20
  export declare class OpSQLiteAdapter implements LocalDBAdapter {
21
+ readonly supportsSqlPushdown = true;
21
22
  private db;
22
23
  private txMutex;
23
24
  constructor(opSqliteInstance: any);
@@ -26,6 +26,7 @@ function extractRows(res) {
26
26
  * Enables ultra-fast C++ JSI SQLite in bare React Native.
27
27
  */
28
28
  class OpSQLiteAdapter {
29
+ supportsSqlPushdown = true;
29
30
  db;
30
31
  txMutex = Promise.resolve();
31
32
  constructor(opSqliteInstance) {
@@ -19,6 +19,7 @@ export interface SqlJsDatabase {
19
19
  * Adapter for sql.js (WebAssembly SQLite in Web Browsers, PWAs, and Expo Web).
20
20
  */
21
21
  export declare class SqlJsAdapter implements LocalDBAdapter {
22
+ readonly supportsSqlPushdown = true;
22
23
  private db;
23
24
  constructor(sqlJsDatabase: any);
24
25
  execute(query: string, params?: any[]): Promise<any[]>;
@@ -5,6 +5,7 @@ exports.SqlJsAdapter = void 0;
5
5
  * Adapter for sql.js (WebAssembly SQLite in Web Browsers, PWAs, and Expo Web).
6
6
  */
7
7
  class SqlJsAdapter {
8
+ supportsSqlPushdown = true;
8
9
  db;
9
10
  constructor(sqlJsDatabase) {
10
11
  this.db = sqlJsDatabase;
@@ -81,7 +81,7 @@ export declare class LocalFirstClient {
81
81
  * Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
82
82
  * Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
83
83
  */
84
- recordOutboxMutation(tx: LocalTransaction, tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, timestamp: number): Promise<string | undefined>;
84
+ recordOutboxMutation(tx: LocalTransaction, tableName: string, rowId: string, operation: SyncOp, payload: Record<string, any> | null, timestamp: number, pendingOutboxMap?: Map<string, OutboxRecord>): Promise<string | undefined>;
85
85
  /**
86
86
  * Initializes the client: creates sync_outbox schema, ensures autoSchema tables if enabled,
87
87
  * applies migrations, and optionally starts automatic sync. Concurrent/repeated calls
@@ -11,6 +11,18 @@ 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
+ function hasFunctionFilter(value) {
15
+ if (typeof value === 'function')
16
+ return true;
17
+ if (Array.isArray(value))
18
+ return value.some(hasFunctionFilter);
19
+ if (value && typeof value === 'object')
20
+ return Object.values(value).some(hasFunctionFilter);
21
+ return false;
22
+ }
23
+ function shouldUseClientSideQuery(options) {
24
+ return hasFunctionFilter(options?.where);
25
+ }
14
26
  async function executeFindById(db, tableName, id, primaryKey, hydrateRow, includeDeleted) {
15
27
  const safeTable = (0, sql_js_1.escapeIdentifier)(tableName);
16
28
  const safePk = (0, sql_js_1.escapeIdentifier)(primaryKey);
@@ -117,8 +129,11 @@ class TransactionalClient {
117
129
  return executeFindByIds(this.tx, tableName, ids, this.parent.getPrimaryKey(), (t, r) => this.parent.hydrateRow(t, r), options?.includeDeleted);
118
130
  }
119
131
  async findMany(tableName, options) {
120
- const rows = await this.from(tableName).applyOptions(options).execute();
121
- return (0, query_js_1.applyQueryOptions)(rows, options);
132
+ if (!this.parent.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
133
+ const rows = await this.from(tableName).execute();
134
+ return (0, query_js_1.applyQueryOptions)(rows, options);
135
+ }
136
+ return this.from(tableName).applyOptions(options).execute();
122
137
  }
123
138
  async query(sql, params = []) {
124
139
  return (await this.tx.execute(sql, params));
@@ -228,40 +243,52 @@ class LocalFirstClient {
228
243
  * Writes a mutation to the outbox table, optionally coalescing with existing PENDING records for the same row.
229
244
  * Returns the affected outboxId, or undefined if an offline INSERT was deleted offline before pushing.
230
245
  */
231
- async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp) {
246
+ async recordOutboxMutation(tx, tableName, rowId, operation, payload, timestamp, pendingOutboxMap) {
247
+ const pendingKey = tableName + ':' + rowId;
232
248
  if (this.coalesceOutbox) {
233
- const existing = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
249
+ const existing = pendingOutboxMap
250
+ ? pendingOutboxMap.get(pendingKey)
251
+ : await outbox_store_js_1.OutboxStore.findPendingByRow(tx, tableName, rowId);
234
252
  if (existing && existing.status === 'PENDING') {
235
253
  if (operation === 'DELETE') {
236
254
  if (existing.operation === 'INSERT') {
237
- // Created offline and deleted offline before ever being pushed to server: clean up outbox completely
238
255
  await outbox_store_js_1.OutboxStore.deleteRecord(tx, existing.id);
256
+ pendingOutboxMap?.delete(pendingKey);
239
257
  return undefined;
240
258
  }
241
- else {
242
- await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
243
- return existing.id;
259
+ await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'DELETE', null, timestamp);
260
+ if (pendingOutboxMap) {
261
+ existing.operation = 'DELETE';
262
+ existing.payload = null;
263
+ existing.timestamp = timestamp;
244
264
  }
265
+ return existing.id;
245
266
  }
246
- else if (operation === 'UPDATE') {
267
+ if (operation === 'UPDATE') {
247
268
  const merged = { ...(existing.payload || {}), ...(payload || {}) };
248
269
  await outbox_store_js_1.OutboxStore.updatePayload(tx, existing.id, merged, timestamp);
270
+ if (pendingOutboxMap) {
271
+ existing.payload = merged;
272
+ existing.timestamp = timestamp;
273
+ }
249
274
  return existing.id;
250
275
  }
251
- else if (operation === 'INSERT') {
276
+ if (operation === 'INSERT') {
252
277
  await outbox_store_js_1.OutboxStore.updateOperation(tx, existing.id, 'INSERT', payload, timestamp);
278
+ if (pendingOutboxMap) {
279
+ existing.operation = 'INSERT';
280
+ existing.payload = payload;
281
+ existing.timestamp = timestamp;
282
+ }
253
283
  return existing.id;
254
284
  }
255
285
  }
256
286
  }
257
287
  const outboxId = (0, uuid_js_1.generateUUID)();
258
- await outbox_store_js_1.OutboxStore.insertRecord(tx, {
259
- id: outboxId,
260
- tableName,
261
- rowId,
262
- operation,
263
- payload,
264
- timestamp,
288
+ await outbox_store_js_1.OutboxStore.insertRecord(tx, { id: outboxId, tableName, rowId, operation, payload, timestamp });
289
+ pendingOutboxMap?.set(pendingKey, {
290
+ id: outboxId, tableName, rowId, operation, payload, timestamp,
291
+ status: 'PENDING', retryCount: 0, lastError: null,
265
292
  });
266
293
  return outboxId;
267
294
  }
@@ -360,7 +387,11 @@ class LocalFirstClient {
360
387
  const queuedEvents = [];
361
388
  const queuedOutboxIds = [];
362
389
  const pendingOutboxOps = [];
390
+ let pendingOutboxMap;
363
391
  const executeInTx = async (tx) => {
392
+ if (this.coalesceOutbox && mutations.length > 1) {
393
+ pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
394
+ }
364
395
  for (const m of mutations) {
365
396
  const pk = m.primaryKey ?? options.primaryKey ?? this.primaryKey;
366
397
  const payload = m.payload ?? null;
@@ -377,7 +408,7 @@ class LocalFirstClient {
377
408
  if (!skipOutbox) {
378
409
  const timestamp = m.customTimestamp ?? options.customTimestamp ?? Date.now();
379
410
  if (this.coalesceOutbox && m.operation !== 'INSERT') {
380
- const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp);
411
+ const outboxId = await this.recordOutboxMutation(tx, m.tableName, rowId, m.operation, payload, timestamp, pendingOutboxMap);
381
412
  if (outboxId) {
382
413
  queuedOutboxIds.push(outboxId);
383
414
  }
@@ -662,8 +693,11 @@ class LocalFirstClient {
662
693
  return result;
663
694
  }
664
695
  async findMany(tableName, options) {
665
- const rows = await this.from(tableName).applyOptions(options).execute();
666
- return (0, query_js_1.applyQueryOptions)(rows, options);
696
+ if (!this.db.supportsSqlPushdown || shouldUseClientSideQuery(options)) {
697
+ const rows = await this.from(tableName).execute();
698
+ return (0, query_js_1.applyQueryOptions)(rows, options);
699
+ }
700
+ return this.from(tableName).applyOptions(options).execute();
667
701
  }
668
702
  /**
669
703
  * Finds a single record by primary key.
@@ -116,6 +116,39 @@ class LRUQueryCache {
116
116
  // In-memory query result cache capped with LRU eviction to prevent memory leaks
117
117
  const queryCache = new LRUQueryCache();
118
118
  const inFlightQueries = new Map();
119
+ const tableVersions = new WeakMap();
120
+ const clientIds = new WeakMap();
121
+ let nextClientId = 1;
122
+ function getClientScope(client) {
123
+ if (!client)
124
+ return 'no-client';
125
+ let id = clientIds.get(client);
126
+ if (!id) {
127
+ id = nextClientId++;
128
+ clientIds.set(client, id);
129
+ }
130
+ return 'client-' + id;
131
+ }
132
+ function getScopedCacheKey(client, key) {
133
+ return getClientScope(client) + ':' + key;
134
+ }
135
+ function getTableVersion(client, tableName) {
136
+ if (!client)
137
+ return 0;
138
+ return tableVersions.get(client)?.get(tableName) || 0;
139
+ }
140
+ function advanceTableVersion(client, tableName) {
141
+ if (!client)
142
+ return 0;
143
+ let versions = tableVersions.get(client);
144
+ if (!versions) {
145
+ versions = new Map();
146
+ tableVersions.set(client, versions);
147
+ }
148
+ const nextVersion = getTableVersion(client, tableName) + 1;
149
+ versions.set(tableName, nextVersion);
150
+ return nextVersion;
151
+ }
119
152
  async function fetchDeduped(key, fn) {
120
153
  let inFlight = inFlightQueries.get(key);
121
154
  if (!inFlight) {
@@ -155,7 +188,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
155
188
  : typeof queryOrOptions === 'function'
156
189
  ? 'custom'
157
190
  : 'all';
158
- const cacheKey = `${tableName}:${queryKey}`;
191
+ const cacheKey = getScopedCacheKey(client, `${tableName}:${queryKey}`);
159
192
  // Read-through cache: return instant cached rows on mount (0ms latency, zero flicker)
160
193
  const [data, setData] = (0, react_1.useState)(() => queryCache.get(cacheKey) || []);
161
194
  const [loading, setLoading] = (0, react_1.useState)(() => !queryCache.has(cacheKey));
@@ -164,8 +197,9 @@ function useLiveQuery(client, tableName, queryOrOptions) {
164
197
  const refetch = (0, react_1.useCallback)(async () => {
165
198
  if (!client || !client.db)
166
199
  return;
200
+ const versionAtStart = getTableVersion(client, tableName);
167
201
  try {
168
- const res = await fetchDeduped(cacheKey, async () => {
202
+ const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
169
203
  const param = queryParamRef.current;
170
204
  if (typeof param === 'function')
171
205
  return (await param(client)) || [];
@@ -173,6 +207,8 @@ function useLiveQuery(client, tableName, queryOrOptions) {
173
207
  return await client.findMany(tableName, param);
174
208
  return await client.findMany(tableName);
175
209
  });
210
+ if (versionAtStart !== getTableVersion(client, tableName))
211
+ return;
176
212
  queryCache.set(cacheKey, res);
177
213
  setData((prev) => (areRowArraysEqual(prev, res) ? prev : res));
178
214
  }
@@ -196,6 +232,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
196
232
  return;
197
233
  refetch();
198
234
  const unsubscribe = client.events.on(tableName, () => {
235
+ advanceTableVersion(client, tableName);
199
236
  scheduleRefetch();
200
237
  });
201
238
  return () => {
@@ -297,10 +334,11 @@ function useLiveQueries(client, queries) {
297
334
  return JSON.stringify(queries, (_, val) => (typeof val === 'function' ? 'fn' : val));
298
335
  }, [queries]);
299
336
  const items = (0, react_1.useMemo)(() => normalizeMultiQueries(queries), [signature]);
337
+ const clientScope = getClientScope(client);
300
338
  const [data, setData] = (0, react_1.useState)(() => {
301
339
  const initial = {};
302
340
  for (const item of items) {
303
- const cached = queryCache.get(item.cacheKey);
341
+ const cached = queryCache.get(clientScope + ':' + item.cacheKey);
304
342
  initial[item.key] = cached ? cached : [];
305
343
  }
306
344
  return initial;
@@ -309,7 +347,7 @@ function useLiveQueries(client, queries) {
309
347
  if (items.length === 0)
310
348
  return false;
311
349
  for (const item of items) {
312
- if (!queryCache.has(item.cacheKey))
350
+ if (!queryCache.has(clientScope + ':' + item.cacheKey))
313
351
  return true;
314
352
  }
315
353
  return false;
@@ -320,14 +358,18 @@ function useLiveQueries(client, queries) {
320
358
  try {
321
359
  const results = await Promise.all(items.map(async (item) => {
322
360
  try {
323
- const rows = await fetchDeduped(item.cacheKey, async () => {
361
+ const versionAtStart = getTableVersion(client, item.table);
362
+ const scopedCacheKey = clientScope + ':' + item.cacheKey;
363
+ const rows = await fetchDeduped(scopedCacheKey + ':v' + versionAtStart, async () => {
324
364
  if (item.fn)
325
365
  return (await item.fn(client)) || [];
326
366
  if (item.options)
327
367
  return await client.findMany(item.table, item.options);
328
368
  return await client.findMany(item.table);
329
369
  });
330
- queryCache.set(item.cacheKey, rows);
370
+ if (versionAtStart !== getTableVersion(client, item.table))
371
+ return null;
372
+ queryCache.set(scopedCacheKey, rows);
331
373
  return { key: item.key, rows };
332
374
  }
333
375
  catch (err) {
@@ -343,6 +385,8 @@ function useLiveQueries(client, queries) {
343
385
  let changed = false;
344
386
  const next = { ...prev };
345
387
  for (const res of results) {
388
+ if (!res)
389
+ continue;
346
390
  if (!areRowArraysEqual(prev[res.key], res.rows)) {
347
391
  next[res.key] = res.rows;
348
392
  changed = true;
@@ -362,6 +406,7 @@ function useLiveQueries(client, queries) {
362
406
  refetch();
363
407
  const tables = Array.from(new Set(items.map((i) => i.table).filter(Boolean)));
364
408
  const unsubs = tables.map((tbl) => client.events.on(tbl, () => {
409
+ advanceTableVersion(client, tbl);
365
410
  scheduleRefetch();
366
411
  }));
367
412
  return () => {
@@ -377,7 +422,7 @@ function useLiveQueries(client, queries) {
377
422
  * Automatically re-renders only when this specific row or table changes.
378
423
  */
379
424
  function useLiveRow(client, tableName, rowId, options) {
380
- const cacheKey = `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}`;
425
+ const cacheKey = getScopedCacheKey(client, `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}`);
381
426
  const [data, setData] = (0, react_1.useState)(() => {
382
427
  if (!rowId)
383
428
  return null;
@@ -392,10 +437,13 @@ function useLiveRow(client, tableName, rowId, options) {
392
437
  return;
393
438
  }
394
439
  try {
395
- const res = await fetchDeduped(cacheKey, async () => {
440
+ const versionAtStart = getTableVersion(client, tableName);
441
+ const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
396
442
  const r = await client.findById(tableName, rowId, options);
397
443
  return r ? [r] : [];
398
444
  });
445
+ if (versionAtStart !== getTableVersion(client, tableName))
446
+ return;
399
447
  const row = res.length > 0 ? res[0] : null;
400
448
  if (row) {
401
449
  queryCache.set(cacheKey, [row]);
@@ -424,6 +472,7 @@ function useLiveRow(client, tableName, rowId, options) {
424
472
  return;
425
473
  refetch();
426
474
  const unsubscribe = client.events.on(tableName, (event) => {
475
+ advanceTableVersion(client, tableName);
427
476
  // Precise row-level invalidation: only refetch if event affects this rowId or is a bulk event
428
477
  if (!event || !event.rowId || event.rowId === rowId || event.rowId === 'batch') {
429
478
  scheduleRefetch();
@@ -455,6 +504,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
455
504
  return;
456
505
  if (isNextPage)
457
506
  setIsFetchingNextPage(true);
507
+ const versionAtStart = getTableVersion(client, tableName);
458
508
  try {
459
509
  const currentOpts = optionsRef.current;
460
510
  const fetchLimit = pages * pageSize + 1; // +1 to check for hasNextPage
@@ -463,6 +513,8 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
463
513
  limit: fetchLimit,
464
514
  offset: currentOpts.offset || 0,
465
515
  });
516
+ if (versionAtStart !== getTableVersion(client, tableName))
517
+ return;
466
518
  if (rows.length > pages * pageSize) {
467
519
  setHasNextPage(true);
468
520
  setData(rows.slice(0, pages * pageSize));
@@ -504,6 +556,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
504
556
  return;
505
557
  loadData(1, false);
506
558
  const unsubscribe = client.events.on(tableName, () => {
559
+ advanceTableVersion(client, tableName);
507
560
  scheduleRefetch();
508
561
  });
509
562
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Never let a lost connection break a promise. Local-first sync that faithfully keeps data safe until you're back online.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -129,8 +129,9 @@
129
129
  }
130
130
  },
131
131
  "devDependencies": {
132
- "@supabase/supabase-js": "^2.118.0",
133
- "@types/react": "^19.3.0",
132
+ "@supabase/supabase-js": "^2.118.0",
133
+ "@types/node": "^26.6.5",
134
+ "@types/react": "^19.3.0",
134
135
  "@types/uuid": "^11.0.0",
135
136
  "expo-sqlite": "^58.0.13",
136
137
  "react": "^19.3.1",
@@ -140,4 +141,4 @@
140
141
  "dependencies": {
141
142
  "uuid": "^14.0.3"
142
143
  }
143
- }
144
+ }