local-lite 0.0.5 → 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.
@@ -611,9 +611,12 @@ class SyncWorker {
611
611
  const isLastChunk = i + DELTA_CHUNK_SIZE >= totalChanges;
612
612
  const queuedEvents = [];
613
613
  await this.db.transaction(async (tx) => {
614
+ // Pre-fetch active pending outbox records in 1 query for this chunk to eliminate N+1 bridge roundtrips
615
+ const pendingOutboxMap = await outbox_store_js_1.OutboxStore.getActivePendingMap(tx);
614
616
  for (const change of chunk) {
615
- // 1. Check for pending local mutations for this (table_name, row_id)
616
- const localPendingRecord = await outbox_store_js_1.OutboxStore.findPendingByRow(tx, change.tableName, change.rowId);
617
+ const outboxKey = `${change.tableName}:${change.rowId}`;
618
+ // 1. Check in-memory map for pending local mutations for this (table_name, row_id)
619
+ const localPendingRecord = pendingOutboxMap.get(outboxKey);
617
620
  // 2. Conflict resolution if there is a pending local change
618
621
  if (localPendingRecord) {
619
622
  // Only load the current local row when a resolver actually needs it.
@@ -640,11 +643,13 @@ class SyncWorker {
640
643
  if (resolution === 'apply-remote') {
641
644
  // Remote change wins: remove the superseded local pending outbox mutation
642
645
  await outbox_store_js_1.OutboxStore.deleteRecord(tx, localPendingRecord.id);
646
+ pendingOutboxMap.delete(outboxKey);
643
647
  }
644
648
  else if (typeof resolution === 'object' && resolution.mergePayload) {
645
649
  // Merge scenario
646
650
  change.payload = resolution.mergePayload;
647
651
  await outbox_store_js_1.OutboxStore.updatePayload(tx, localPendingRecord.id, resolution.mergePayload);
652
+ localPendingRecord.payload = resolution.mergePayload;
648
653
  }
649
654
  }
650
655
  // 3. Apply remote change to local database
@@ -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,50 @@ 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
+ async function fetchDeduped(key, fn) {
120
+ let inFlight = inFlightQueries.get(key);
121
+ if (!inFlight) {
122
+ inFlight = fn().finally(() => inFlightQueries.delete(key));
123
+ inFlightQueries.set(key, inFlight);
124
+ }
125
+ return inFlight;
126
+ }
86
127
  /**
87
128
  * Clears the in-memory live query cache.
88
129
  */
@@ -124,27 +165,14 @@ function useLiveQuery(client, tableName, queryOrOptions) {
124
165
  if (!client || !client.db)
125
166
  return;
126
167
  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) || [];
168
+ const res = await fetchDeduped(cacheKey, async () => {
169
+ const param = queryParamRef.current;
170
+ if (typeof param === 'function')
171
+ return (await param(client)) || [];
172
+ if (typeof param === 'object' && param !== null)
173
+ return await client.findMany(tableName, param);
174
+ return await client.findMany(tableName);
175
+ });
148
176
  queryCache.set(cacheKey, res);
149
177
  setData((prev) => (areRowArraysEqual(prev, res) ? prev : res));
150
178
  }
@@ -291,26 +319,14 @@ function useLiveQueries(client, queries) {
291
319
  return;
292
320
  try {
293
321
  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) {
322
+ try {
323
+ const rows = await fetchDeduped(item.cacheKey, async () => {
324
+ if (item.fn)
298
325
  return (await item.fn(client)) || [];
299
- }
300
- else if (item.options) {
326
+ if (item.options)
301
327
  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);
328
+ return await client.findMany(item.table);
310
329
  });
311
- }
312
- try {
313
- const rows = (await inFlight) || [];
314
330
  queryCache.set(item.cacheKey, rows);
315
331
  return { key: item.key, rows };
316
332
  }
@@ -376,13 +392,10 @@ function useLiveRow(client, tableName, rowId, options) {
376
392
  return;
377
393
  }
378
394
  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;
395
+ const res = await fetchDeduped(cacheKey, async () => {
396
+ const r = await client.findById(tableName, rowId, options);
397
+ return r ? [r] : [];
398
+ });
386
399
  const row = res.length > 0 ? res[0] : null;
387
400
  if (row) {
388
401
  queryCache.set(cacheKey, [row]);
@@ -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.6",
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",