local-lite 0.0.5 → 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.
@@ -33,14 +33,14 @@ function areRowsEqual(a, b) {
33
33
  if (aObj.id !== undefined && bObj.id !== undefined && aObj.id !== bObj.id) {
34
34
  return false;
35
35
  }
36
- // Fast path: if both records have an identical updated_at timestamp, they are equal.
37
- // This bypasses expensive Object.keys loops and JSON.stringify on large metadata columns.
36
+ const aTs = aObj.updated_at ?? aObj.updatedAt;
37
+ const bTs = bObj.updated_at ?? bObj.updatedAt;
38
38
  if (aObj.id !== undefined &&
39
39
  bObj.id !== undefined &&
40
40
  aObj.id === bObj.id &&
41
- aObj.updated_at !== undefined &&
42
- bObj.updated_at !== undefined) {
43
- return aObj.updated_at === bObj.updated_at;
41
+ aTs !== undefined &&
42
+ bTs !== undefined) {
43
+ return aTs === bTs;
44
44
  }
45
45
  const aKeys = Object.keys(aObj);
46
46
  const bKeys = Object.keys(bObj);
@@ -80,9 +80,83 @@ function areRowArraysEqual(a, b) {
80
80
  }
81
81
  return true;
82
82
  }
83
- // In-memory query result cache to eliminate initial render latency and flickering
84
- const queryCache = new Map();
83
+ const MAX_QUERY_CACHE_SIZE = 100;
84
+ class LRUQueryCache {
85
+ map = new Map();
86
+ get(key) {
87
+ const val = this.map.get(key);
88
+ if (val !== undefined) {
89
+ this.map.delete(key);
90
+ this.map.set(key, val);
91
+ }
92
+ return val;
93
+ }
94
+ set(key, val) {
95
+ if (this.map.has(key)) {
96
+ this.map.delete(key);
97
+ }
98
+ else if (this.map.size >= MAX_QUERY_CACHE_SIZE) {
99
+ const oldestKey = this.map.keys().next().value;
100
+ if (oldestKey !== undefined) {
101
+ this.map.delete(oldestKey);
102
+ }
103
+ }
104
+ this.map.set(key, val);
105
+ }
106
+ has(key) {
107
+ return this.map.has(key);
108
+ }
109
+ delete(key) {
110
+ return this.map.delete(key);
111
+ }
112
+ clear() {
113
+ this.map.clear();
114
+ }
115
+ }
116
+ // In-memory query result cache capped with LRU eviction to prevent memory leaks
117
+ const queryCache = new LRUQueryCache();
85
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
+ }
152
+ async function fetchDeduped(key, fn) {
153
+ let inFlight = inFlightQueries.get(key);
154
+ if (!inFlight) {
155
+ inFlight = fn().finally(() => inFlightQueries.delete(key));
156
+ inFlightQueries.set(key, inFlight);
157
+ }
158
+ return inFlight;
159
+ }
86
160
  /**
87
161
  * Clears the in-memory live query cache.
88
162
  */
@@ -114,7 +188,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
114
188
  : typeof queryOrOptions === 'function'
115
189
  ? 'custom'
116
190
  : 'all';
117
- const cacheKey = `${tableName}:${queryKey}`;
191
+ const cacheKey = getScopedCacheKey(client, `${tableName}:${queryKey}`);
118
192
  // Read-through cache: return instant cached rows on mount (0ms latency, zero flicker)
119
193
  const [data, setData] = (0, react_1.useState)(() => queryCache.get(cacheKey) || []);
120
194
  const [loading, setLoading] = (0, react_1.useState)(() => !queryCache.has(cacheKey));
@@ -123,28 +197,18 @@ function useLiveQuery(client, tableName, queryOrOptions) {
123
197
  const refetch = (0, react_1.useCallback)(async () => {
124
198
  if (!client || !client.db)
125
199
  return;
200
+ const versionAtStart = getTableVersion(client, tableName);
126
201
  try {
127
- // De-duplicate simultaneous identical in-flight queries
128
- let inFlight = inFlightQueries.get(cacheKey);
129
- if (!inFlight) {
130
- inFlight = (async () => {
131
- const param = queryParamRef.current;
132
- if (typeof param === 'function') {
133
- return (await param(client)) || [];
134
- }
135
- else if (typeof param === 'object' && param !== null) {
136
- return await client.findMany(tableName, param);
137
- }
138
- else {
139
- return await client.findMany(tableName);
140
- }
141
- })();
142
- inFlightQueries.set(cacheKey, inFlight);
143
- inFlight.finally(() => {
144
- inFlightQueries.delete(cacheKey);
145
- });
146
- }
147
- const res = (await inFlight) || [];
202
+ const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
203
+ const param = queryParamRef.current;
204
+ if (typeof param === 'function')
205
+ return (await param(client)) || [];
206
+ if (typeof param === 'object' && param !== null)
207
+ return await client.findMany(tableName, param);
208
+ return await client.findMany(tableName);
209
+ });
210
+ if (versionAtStart !== getTableVersion(client, tableName))
211
+ return;
148
212
  queryCache.set(cacheKey, res);
149
213
  setData((prev) => (areRowArraysEqual(prev, res) ? prev : res));
150
214
  }
@@ -168,6 +232,7 @@ function useLiveQuery(client, tableName, queryOrOptions) {
168
232
  return;
169
233
  refetch();
170
234
  const unsubscribe = client.events.on(tableName, () => {
235
+ advanceTableVersion(client, tableName);
171
236
  scheduleRefetch();
172
237
  });
173
238
  return () => {
@@ -269,10 +334,11 @@ function useLiveQueries(client, queries) {
269
334
  return JSON.stringify(queries, (_, val) => (typeof val === 'function' ? 'fn' : val));
270
335
  }, [queries]);
271
336
  const items = (0, react_1.useMemo)(() => normalizeMultiQueries(queries), [signature]);
337
+ const clientScope = getClientScope(client);
272
338
  const [data, setData] = (0, react_1.useState)(() => {
273
339
  const initial = {};
274
340
  for (const item of items) {
275
- const cached = queryCache.get(item.cacheKey);
341
+ const cached = queryCache.get(clientScope + ':' + item.cacheKey);
276
342
  initial[item.key] = cached ? cached : [];
277
343
  }
278
344
  return initial;
@@ -281,7 +347,7 @@ function useLiveQueries(client, queries) {
281
347
  if (items.length === 0)
282
348
  return false;
283
349
  for (const item of items) {
284
- if (!queryCache.has(item.cacheKey))
350
+ if (!queryCache.has(clientScope + ':' + item.cacheKey))
285
351
  return true;
286
352
  }
287
353
  return false;
@@ -291,27 +357,19 @@ function useLiveQueries(client, queries) {
291
357
  return;
292
358
  try {
293
359
  const results = await Promise.all(items.map(async (item) => {
294
- let inFlight = inFlightQueries.get(item.cacheKey);
295
- if (!inFlight) {
296
- inFlight = (async () => {
297
- if (item.fn) {
360
+ try {
361
+ const versionAtStart = getTableVersion(client, item.table);
362
+ const scopedCacheKey = clientScope + ':' + item.cacheKey;
363
+ const rows = await fetchDeduped(scopedCacheKey + ':v' + versionAtStart, async () => {
364
+ if (item.fn)
298
365
  return (await item.fn(client)) || [];
299
- }
300
- else if (item.options) {
366
+ if (item.options)
301
367
  return await client.findMany(item.table, item.options);
302
- }
303
- else {
304
- return await client.findMany(item.table);
305
- }
306
- })();
307
- inFlightQueries.set(item.cacheKey, inFlight);
308
- inFlight.finally(() => {
309
- inFlightQueries.delete(item.cacheKey);
368
+ return await client.findMany(item.table);
310
369
  });
311
- }
312
- try {
313
- const rows = (await inFlight) || [];
314
- queryCache.set(item.cacheKey, rows);
370
+ if (versionAtStart !== getTableVersion(client, item.table))
371
+ return null;
372
+ queryCache.set(scopedCacheKey, rows);
315
373
  return { key: item.key, rows };
316
374
  }
317
375
  catch (err) {
@@ -327,6 +385,8 @@ function useLiveQueries(client, queries) {
327
385
  let changed = false;
328
386
  const next = { ...prev };
329
387
  for (const res of results) {
388
+ if (!res)
389
+ continue;
330
390
  if (!areRowArraysEqual(prev[res.key], res.rows)) {
331
391
  next[res.key] = res.rows;
332
392
  changed = true;
@@ -346,6 +406,7 @@ function useLiveQueries(client, queries) {
346
406
  refetch();
347
407
  const tables = Array.from(new Set(items.map((i) => i.table).filter(Boolean)));
348
408
  const unsubs = tables.map((tbl) => client.events.on(tbl, () => {
409
+ advanceTableVersion(client, tbl);
349
410
  scheduleRefetch();
350
411
  }));
351
412
  return () => {
@@ -361,7 +422,7 @@ function useLiveQueries(client, queries) {
361
422
  * Automatically re-renders only when this specific row or table changes.
362
423
  */
363
424
  function useLiveRow(client, tableName, rowId, options) {
364
- const cacheKey = `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}`;
425
+ const cacheKey = getScopedCacheKey(client, `${tableName}:row:${rowId || 'null'}:${Boolean(options?.includeDeleted)}`);
365
426
  const [data, setData] = (0, react_1.useState)(() => {
366
427
  if (!rowId)
367
428
  return null;
@@ -376,13 +437,13 @@ function useLiveRow(client, tableName, rowId, options) {
376
437
  return;
377
438
  }
378
439
  try {
379
- let inFlight = inFlightQueries.get(cacheKey);
380
- if (!inFlight) {
381
- inFlight = client.findById(tableName, rowId, options).then((r) => (r ? [r] : []));
382
- inFlightQueries.set(cacheKey, inFlight);
383
- inFlight.finally(() => inFlightQueries.delete(cacheKey));
384
- }
385
- const res = await inFlight;
440
+ const versionAtStart = getTableVersion(client, tableName);
441
+ const res = await fetchDeduped(cacheKey + ':v' + versionAtStart, async () => {
442
+ const r = await client.findById(tableName, rowId, options);
443
+ return r ? [r] : [];
444
+ });
445
+ if (versionAtStart !== getTableVersion(client, tableName))
446
+ return;
386
447
  const row = res.length > 0 ? res[0] : null;
387
448
  if (row) {
388
449
  queryCache.set(cacheKey, [row]);
@@ -411,6 +472,7 @@ function useLiveRow(client, tableName, rowId, options) {
411
472
  return;
412
473
  refetch();
413
474
  const unsubscribe = client.events.on(tableName, (event) => {
475
+ advanceTableVersion(client, tableName);
414
476
  // Precise row-level invalidation: only refetch if event affects this rowId or is a bulk event
415
477
  if (!event || !event.rowId || event.rowId === rowId || event.rowId === 'batch') {
416
478
  scheduleRefetch();
@@ -442,6 +504,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
442
504
  return;
443
505
  if (isNextPage)
444
506
  setIsFetchingNextPage(true);
507
+ const versionAtStart = getTableVersion(client, tableName);
445
508
  try {
446
509
  const currentOpts = optionsRef.current;
447
510
  const fetchLimit = pages * pageSize + 1; // +1 to check for hasNextPage
@@ -450,6 +513,8 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
450
513
  limit: fetchLimit,
451
514
  offset: currentOpts.offset || 0,
452
515
  });
516
+ if (versionAtStart !== getTableVersion(client, tableName))
517
+ return;
453
518
  if (rows.length > pages * pageSize) {
454
519
  setHasNextPage(true);
455
520
  setData(rows.slice(0, pages * pageSize));
@@ -491,6 +556,7 @@ function useLiveInfiniteQuery(client, tableName, options = {}) {
491
556
  return;
492
557
  loadData(1, false);
493
558
  const unsubscribe = client.events.on(tableName, () => {
559
+ advanceTableVersion(client, tableName);
494
560
  scheduleRefetch();
495
561
  });
496
562
  return () => {
@@ -13,6 +13,11 @@ export interface UseSyncStatusResult {
13
13
  /**
14
14
  * Consolidated hook that tracks real-time sync connectivity, sync worker activity
15
15
  * (pushing/pulling/syncing), and pending items in the local SQLite outbox queue.
16
+ *
17
+ * Performance notes:
18
+ * - sync:status events update isOnline/isPushing/isPulling/pendingCount in-place (no DB query).
19
+ * - The 50-record outbox fetch (pendingRecords) only runs on mount and on explicit refresh().
20
+ * - sync:push / sync:pull only trigger a lightweight pendingCount update via sync:status.
16
21
  */
17
22
  export declare function useSyncStatus(client: LocalFirstClient | null | undefined): UseSyncStatusResult;
18
23
  /**
@@ -7,6 +7,11 @@ const runner_js_1 = require("./runner.js");
7
7
  /**
8
8
  * Consolidated hook that tracks real-time sync connectivity, sync worker activity
9
9
  * (pushing/pulling/syncing), and pending items in the local SQLite outbox queue.
10
+ *
11
+ * Performance notes:
12
+ * - sync:status events update isOnline/isPushing/isPulling/pendingCount in-place (no DB query).
13
+ * - The 50-record outbox fetch (pendingRecords) only runs on mount and on explicit refresh().
14
+ * - sync:push / sync:pull only trigger a lightweight pendingCount update via sync:status.
10
15
  */
11
16
  function useSyncStatus(client) {
12
17
  const [isOnline, setIsOnline] = (0, react_1.useState)(true);
@@ -44,33 +49,36 @@ function useSyncStatus(client) {
44
49
  // Ignored if client is stopping or tables initializing
45
50
  }
46
51
  }, [client]);
47
- const scheduleRefresh = (0, runner_js_1.useCoalescedRunner)(refresh);
48
52
  (0, react_1.useEffect)(() => {
49
53
  if (!client || !client.db)
50
54
  return;
55
+ // Initial full fetch on mount
51
56
  refresh();
57
+ // sync:status fires after every push/pull cycle — update scalar fields directly,
58
+ // no DB round-trip needed. pendingCount comes from the event payload.
52
59
  const unsubStatus = client.events.on('sync:status', (e) => {
53
60
  setIsOnline(e.isOnline);
54
61
  setIsPushing(e.isPushing);
55
62
  setIsPulling(e.isPulling);
56
63
  setPendingCount(e.pendingCount);
57
64
  });
58
- const unsubOutbox = client.events.on('sync_outbox', () => {
59
- scheduleRefresh();
60
- });
61
- const unsubPush = client.events.on('sync:push', () => {
62
- scheduleRefresh();
63
- });
64
- const unsubPull = client.events.on('sync:pull', () => {
65
- scheduleRefresh();
65
+ // Only refresh lastSyncedAt after a completed pull (lightweight meta query only)
66
+ const unsubPull = client.events.on('sync:pull', async (e) => {
67
+ if (e?.success) {
68
+ try {
69
+ const ts = await client.getLastSyncedAt().catch(() => null);
70
+ if (ts !== null && ts !== undefined) {
71
+ setLastSyncedAt(ts > 0 ? ts : null);
72
+ }
73
+ }
74
+ catch { /* ignored */ }
75
+ }
66
76
  });
67
77
  return () => {
68
78
  unsubStatus();
69
- unsubOutbox();
70
- unsubPush();
71
79
  unsubPull();
72
80
  };
73
- }, [client, refresh, scheduleRefresh]);
81
+ }, [client, refresh]);
74
82
  return {
75
83
  isOnline,
76
84
  isPushing,
@@ -1,7 +1,7 @@
1
1
  import { v4 as uuidv4 } from 'uuid';
2
2
  /**
3
3
  * Generates an RFC4122 v4 compliant UUID.
4
- * Uses native crypto.randomUUID() when supported, with uuid.v4 fallback.
4
+ * Uses native crypto.randomUUID() when available (detected once at import), with uuid.v4 fallback.
5
5
  */
6
6
  export declare function generateUUID(): string;
7
7
  export { uuidv4 };
@@ -4,24 +4,15 @@ exports.uuidv4 = void 0;
4
4
  exports.generateUUID = generateUUID;
5
5
  const uuid_1 = require("uuid");
6
6
  Object.defineProperty(exports, "uuidv4", { enumerable: true, get: function () { return uuid_1.v4; } });
7
+ // Detect once at module load — avoids 3 typeof checks per UUID call
8
+ const nativeRandomUUID = typeof globalThis !== 'undefined' &&
9
+ typeof globalThis.crypto?.randomUUID === 'function'
10
+ ? () => globalThis.crypto.randomUUID()
11
+ : null;
7
12
  /**
8
13
  * Generates an RFC4122 v4 compliant UUID.
9
- * Uses native crypto.randomUUID() when supported, with uuid.v4 fallback.
14
+ * Uses native crypto.randomUUID() when available (detected once at import), with uuid.v4 fallback.
10
15
  */
11
16
  function generateUUID() {
12
- try {
13
- if (typeof globalThis !== 'undefined' &&
14
- globalThis.crypto &&
15
- typeof globalThis.crypto.randomUUID === 'function') {
16
- return globalThis.crypto.randomUUID();
17
- }
18
- return (0, uuid_1.v4)();
19
- }
20
- catch {
21
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
22
- const r = (Math.random() * 16) | 0;
23
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
24
- return v.toString(16);
25
- });
26
- }
17
+ return nativeRandomUUID ? nativeRandomUUID() : (0, uuid_1.v4)();
27
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.5",
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
+ }