@revealui/sync 0.3.3 → 0.3.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.
- package/README.md +14 -0
- package/dist/components/SyncStatusIndicator.d.ts +17 -0
- package/dist/components/SyncStatusIndicator.d.ts.map +1 -0
- package/dist/components/SyncStatusIndicator.js +65 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/useOfflineCache.d.ts +32 -0
- package/dist/hooks/useOfflineCache.d.ts.map +1 -0
- package/dist/hooks/useOfflineCache.js +129 -0
- package/dist/hooks/useOnlineStatus.d.ts +21 -0
- package/dist/hooks/useOnlineStatus.d.ts.map +1 -0
- package/dist/hooks/useOnlineStatus.js +74 -0
- package/dist/offline-queue.d.ts +55 -0
- package/dist/offline-queue.d.ts.map +1 -0
- package/dist/offline-queue.js +126 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -182,6 +182,20 @@ pnpm --filter @revealui/sync test # Run tests
|
|
|
182
182
|
pnpm --filter @revealui/sync typecheck # Type check
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
## When to Use This
|
|
186
|
+
|
|
187
|
+
- You need real-time data sync between your database and React UI via ElectricSQL
|
|
188
|
+
- You want CRDT-based collaborative editing (Yjs) for multi-user document workflows
|
|
189
|
+
- You need React hooks that subscribe to live database changes with automatic mutation support
|
|
190
|
+
- **Not** for batch data loading or static pages — use server components with `@revealui/db` directly
|
|
191
|
+
- **Not** for offline-first mobile apps — ElectricSQL targets web clients with persistent connections
|
|
192
|
+
|
|
193
|
+
## JOSHUA Alignment
|
|
194
|
+
|
|
195
|
+
- **Adaptive**: Shape subscriptions dynamically sync only the data your component needs — scales from one user to many
|
|
196
|
+
- **Sovereign**: Sync runs through your own CMS proxy and PostgreSQL — no third-party real-time service required
|
|
197
|
+
- **Hermetic**: All mutations go through authenticated REST endpoints; ElectricSQL replication is read-only on the client
|
|
198
|
+
|
|
185
199
|
## License
|
|
186
200
|
|
|
187
201
|
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
interface SyncStatusIndicatorProps {
|
|
2
|
+
/** Optional CSS class name for positioning or layout overrides. */
|
|
3
|
+
className?: string;
|
|
4
|
+
/** Whether data is currently being synced. */
|
|
5
|
+
isSyncing?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Visual indicator for sync / connectivity status.
|
|
9
|
+
*
|
|
10
|
+
* - **Online + synced**: green dot
|
|
11
|
+
* - **Online + syncing**: pulsing yellow dot
|
|
12
|
+
* - **Offline**: red dot with "Offline" label
|
|
13
|
+
* - **Recently reconnected**: green dot with "Synced" label (fades after 3 s)
|
|
14
|
+
*/
|
|
15
|
+
export declare function SyncStatusIndicator(props: SyncStatusIndicatorProps): React.ReactNode;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=SyncStatusIndicator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SyncStatusIndicator.d.ts","sourceRoot":"","sources":["../../src/components/SyncStatusIndicator.tsx"],"names":[],"mappings":"AAQA,UAAU,wBAAwB;IAChC,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,GAAG,KAAK,CAAC,SAAS,CAgEpF"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
|
|
5
|
+
/** Duration in ms before the "Synced" label fades away. */
|
|
6
|
+
const SYNCED_LABEL_DURATION_MS = 3_000;
|
|
7
|
+
/**
|
|
8
|
+
* Visual indicator for sync / connectivity status.
|
|
9
|
+
*
|
|
10
|
+
* - **Online + synced**: green dot
|
|
11
|
+
* - **Online + syncing**: pulsing yellow dot
|
|
12
|
+
* - **Offline**: red dot with "Offline" label
|
|
13
|
+
* - **Recently reconnected**: green dot with "Synced" label (fades after 3 s)
|
|
14
|
+
*/
|
|
15
|
+
export function SyncStatusIndicator(props) {
|
|
16
|
+
const { className, isSyncing = false } = props;
|
|
17
|
+
const { isOnline, wasOffline } = useOnlineStatus();
|
|
18
|
+
// Show "Synced" label for 3 s after reconnection.
|
|
19
|
+
const [showSyncedLabel, setShowSyncedLabel] = useState(false);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!wasOffline) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
setShowSyncedLabel(true);
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
setShowSyncedLabel(false);
|
|
27
|
+
}, SYNCED_LABEL_DURATION_MS);
|
|
28
|
+
return () => clearTimeout(timer);
|
|
29
|
+
}, [wasOffline]);
|
|
30
|
+
// Determine dot color and label.
|
|
31
|
+
let dotColor;
|
|
32
|
+
let label = null;
|
|
33
|
+
let pulse = false;
|
|
34
|
+
if (!isOnline) {
|
|
35
|
+
dotColor = '#ef4444'; // red-500
|
|
36
|
+
label = 'Offline';
|
|
37
|
+
}
|
|
38
|
+
else if (isSyncing) {
|
|
39
|
+
dotColor = '#eab308'; // yellow-500
|
|
40
|
+
pulse = true;
|
|
41
|
+
}
|
|
42
|
+
else if (showSyncedLabel) {
|
|
43
|
+
dotColor = '#22c55e'; // green-500
|
|
44
|
+
label = 'Synced';
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
dotColor = '#22c55e'; // green-500
|
|
48
|
+
}
|
|
49
|
+
const dotStyle = {
|
|
50
|
+
display: 'inline-block',
|
|
51
|
+
width: '8px',
|
|
52
|
+
height: '8px',
|
|
53
|
+
borderRadius: '50%',
|
|
54
|
+
backgroundColor: dotColor,
|
|
55
|
+
animation: pulse ? 'revealui-pulse 1.5s ease-in-out infinite' : undefined,
|
|
56
|
+
};
|
|
57
|
+
const containerStyle = {
|
|
58
|
+
display: 'inline-flex',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
gap: '6px',
|
|
61
|
+
fontSize: '12px',
|
|
62
|
+
lineHeight: '1',
|
|
63
|
+
};
|
|
64
|
+
return (_jsxs("span", { className: className, style: containerStyle, role: "status", "aria-label": label ?? 'Online', children: [_jsx("span", { style: dotStyle }), label !== null ? _jsx("span", { children: label }) : null, pulse ? (_jsx("style", { children: '@keyframes revealui-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }' })) : null] }));
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,YAAY,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface UseOfflineCacheOptions {
|
|
2
|
+
/** ElectricSQL shape subscription URL. */
|
|
3
|
+
shapeUrl: string;
|
|
4
|
+
/** Unique key for the localStorage cache entry. */
|
|
5
|
+
cacheKey: string;
|
|
6
|
+
/** How long cached data is considered fresh (seconds). Defaults to 3600. */
|
|
7
|
+
ttlSeconds?: number;
|
|
8
|
+
}
|
|
9
|
+
interface UseOfflineCacheResult<T> {
|
|
10
|
+
/** The current data — live from the shape when online, cached when offline. */
|
|
11
|
+
data: T[];
|
|
12
|
+
/** Whether the browser has network connectivity. */
|
|
13
|
+
isOnline: boolean;
|
|
14
|
+
/** Whether the shape subscription is currently loading fresh data. */
|
|
15
|
+
isSyncing: boolean;
|
|
16
|
+
/** Timestamp of the most recent successful sync to cache. */
|
|
17
|
+
lastSyncedAt: Date | null;
|
|
18
|
+
/** Shape subscription or cache-read error, if any. */
|
|
19
|
+
error: Error | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Wrap an ElectricSQL `useShape` subscription with offline-first caching.
|
|
23
|
+
*
|
|
24
|
+
* When online the hook delegates to `useShape` and mirrors results into
|
|
25
|
+
* `localStorage`. When offline (or during initial load) it returns the
|
|
26
|
+
* most recent cached snapshot if one exists within the TTL window.
|
|
27
|
+
*
|
|
28
|
+
* @typeParam T - Row type returned by the shape subscription.
|
|
29
|
+
*/
|
|
30
|
+
export declare function useOfflineCache<T>(options: UseOfflineCacheOptions): UseOfflineCacheResult<T>;
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=useOfflineCache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOfflineCache.d.ts","sourceRoot":"","sources":["../../src/hooks/useOfflineCache.ts"],"names":[],"mappings":"AAmBA,UAAU,sBAAsB;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,qBAAqB,CAAC,CAAC;IAC/B,+EAA+E;IAC/E,IAAI,EAAE,CAAC,EAAE,CAAC;IACV,oDAAoD;IACpD,QAAQ,EAAE,OAAO,CAAC;IAClB,sEAAsE;IACtE,SAAS,EAAE,OAAO,CAAC;IACnB,6DAA6D;IAC7D,YAAY,EAAE,IAAI,GAAG,IAAI,CAAC;IAC1B,sDAAsD;IACtD,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACrB;AAqED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAiD5F"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useShape } from '@electric-sql/react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { fetchWithTimeout } from '../fetch-with-timeout.js';
|
|
5
|
+
import { toRecords } from '../shape-utils.js';
|
|
6
|
+
import { useOnlineStatus } from './useOnlineStatus.js';
|
|
7
|
+
/** Prefix for all offline-cache localStorage keys. */
|
|
8
|
+
const CACHE_PREFIX = 'revealui:cache:';
|
|
9
|
+
/** Default time-to-live for cached data (seconds). */
|
|
10
|
+
const DEFAULT_TTL_SECONDS = 3600;
|
|
11
|
+
/**
|
|
12
|
+
* Check whether `localStorage` is usable.
|
|
13
|
+
*/
|
|
14
|
+
function hasLocalStorage() {
|
|
15
|
+
if (typeof window === 'undefined') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const testKey = '__revealui_oc_test__';
|
|
20
|
+
window.localStorage.setItem(testKey, '1');
|
|
21
|
+
window.localStorage.removeItem(testKey);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read cached data from localStorage. Returns `null` when the entry is
|
|
30
|
+
* missing, expired, or unreadable.
|
|
31
|
+
*/
|
|
32
|
+
function readCache(cacheKey, ttlSeconds) {
|
|
33
|
+
if (!hasLocalStorage()) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const raw = window.localStorage.getItem(CACHE_PREFIX + cacheKey);
|
|
38
|
+
if (raw === null) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (!Array.isArray(parsed.data)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Check TTL.
|
|
46
|
+
const cachedTime = new Date(parsed.cachedAt).getTime();
|
|
47
|
+
if (Number.isNaN(cachedTime)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const ageSeconds = (Date.now() - cachedTime) / 1_000;
|
|
51
|
+
if (ageSeconds > ttlSeconds) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Write data to the localStorage cache. Silently ignores failures.
|
|
62
|
+
*/
|
|
63
|
+
function writeCache(cacheKey, data) {
|
|
64
|
+
if (!hasLocalStorage()) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const payload = {
|
|
69
|
+
data,
|
|
70
|
+
cachedAt: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
window.localStorage.setItem(CACHE_PREFIX + cacheKey, JSON.stringify(payload));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Quota exceeded or private browsing — drop silently.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Wrap an ElectricSQL `useShape` subscription with offline-first caching.
|
|
80
|
+
*
|
|
81
|
+
* When online the hook delegates to `useShape` and mirrors results into
|
|
82
|
+
* `localStorage`. When offline (or during initial load) it returns the
|
|
83
|
+
* most recent cached snapshot if one exists within the TTL window.
|
|
84
|
+
*
|
|
85
|
+
* @typeParam T - Row type returned by the shape subscription.
|
|
86
|
+
*/
|
|
87
|
+
export function useOfflineCache(options) {
|
|
88
|
+
const { shapeUrl, cacheKey, ttlSeconds = DEFAULT_TTL_SECONDS } = options;
|
|
89
|
+
const { isOnline } = useOnlineStatus();
|
|
90
|
+
// Shape subscription — runs continuously; ElectricSQL handles reconnection.
|
|
91
|
+
const shape = useShape({ url: shapeUrl, fetchClient: fetchWithTimeout });
|
|
92
|
+
const [lastSyncedAt, setLastSyncedAt] = useState(null);
|
|
93
|
+
// Keep a ref to avoid stale closures in the sync effect.
|
|
94
|
+
const cacheKeyRef = useRef(cacheKey);
|
|
95
|
+
cacheKeyRef.current = cacheKey;
|
|
96
|
+
// Persist live data to cache whenever the shape delivers fresh rows.
|
|
97
|
+
const shapeData = shape.data;
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!isOnline) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(shapeData) || shapeData.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const typed = toRecords(shapeData);
|
|
106
|
+
writeCache(cacheKeyRef.current, typed);
|
|
107
|
+
setLastSyncedAt(new Date());
|
|
108
|
+
}, [shapeData, isOnline]);
|
|
109
|
+
// Determine what to return.
|
|
110
|
+
if (isOnline && Array.isArray(shapeData) && shapeData.length > 0) {
|
|
111
|
+
return {
|
|
112
|
+
data: toRecords(shapeData),
|
|
113
|
+
isOnline,
|
|
114
|
+
isSyncing: shape.isLoading,
|
|
115
|
+
lastSyncedAt,
|
|
116
|
+
error: shape.error || null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Offline or shape has not loaded yet — try the cache.
|
|
120
|
+
const cached = readCache(cacheKey, ttlSeconds);
|
|
121
|
+
const cachedSyncDate = cached !== null ? new Date(cached.cachedAt) : null;
|
|
122
|
+
return {
|
|
123
|
+
data: cached?.data ?? [],
|
|
124
|
+
isOnline,
|
|
125
|
+
isSyncing: isOnline && shape.isLoading,
|
|
126
|
+
lastSyncedAt: lastSyncedAt ?? cachedSyncDate,
|
|
127
|
+
error: shape.error || null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OnlineStatusResult {
|
|
2
|
+
/** Whether the browser currently has network connectivity. */
|
|
3
|
+
isOnline: boolean;
|
|
4
|
+
/** Whether the connection was recently restored (resets after 5 s). */
|
|
5
|
+
wasOffline: boolean;
|
|
6
|
+
/** Timestamp of the last time the browser was confirmed online. */
|
|
7
|
+
lastOnlineAt: Date | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Track browser online/offline status with reconnection awareness.
|
|
11
|
+
*
|
|
12
|
+
* - `isOnline` reflects `navigator.onLine` and live `online`/`offline` events.
|
|
13
|
+
* - `wasOffline` becomes `true` when connectivity is restored and resets to
|
|
14
|
+
* `false` after 5 seconds.
|
|
15
|
+
* - `lastOnlineAt` records the most recent reconnection timestamp.
|
|
16
|
+
*
|
|
17
|
+
* During SSR (when `window` is not available) the hook returns
|
|
18
|
+
* `{ isOnline: true, wasOffline: false, lastOnlineAt: null }`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useOnlineStatus(): OnlineStatusResult;
|
|
21
|
+
//# sourceMappingURL=useOnlineStatus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOnlineStatus.d.ts","sourceRoot":"","sources":["../../src/hooks/useOnlineStatus.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,kBAAkB;IACjC,8DAA8D;IAC9D,QAAQ,EAAE,OAAO,CAAC;IAClB,uEAAuE;IACvE,UAAU,EAAE,OAAO,CAAC;IACpB,mEAAmE;IACnE,YAAY,EAAE,IAAI,GAAG,IAAI,CAAC;CAC3B;AAiBD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,IAAI,kBAAkB,CAuDpD"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
/** Duration in ms before `wasOffline` resets after reconnection. */
|
|
4
|
+
const WAS_OFFLINE_RESET_MS = 5_000;
|
|
5
|
+
/** SSR-safe default: assume online when `window` is unavailable. */
|
|
6
|
+
const SSR_DEFAULT = {
|
|
7
|
+
isOnline: true,
|
|
8
|
+
wasOffline: false,
|
|
9
|
+
lastOnlineAt: null,
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Check whether the current environment is a browser.
|
|
13
|
+
* Returns `false` during SSR / Node.js test runs without a DOM.
|
|
14
|
+
*/
|
|
15
|
+
function isBrowser() {
|
|
16
|
+
return typeof window !== 'undefined';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Track browser online/offline status with reconnection awareness.
|
|
20
|
+
*
|
|
21
|
+
* - `isOnline` reflects `navigator.onLine` and live `online`/`offline` events.
|
|
22
|
+
* - `wasOffline` becomes `true` when connectivity is restored and resets to
|
|
23
|
+
* `false` after 5 seconds.
|
|
24
|
+
* - `lastOnlineAt` records the most recent reconnection timestamp.
|
|
25
|
+
*
|
|
26
|
+
* During SSR (when `window` is not available) the hook returns
|
|
27
|
+
* `{ isOnline: true, wasOffline: false, lastOnlineAt: null }`.
|
|
28
|
+
*/
|
|
29
|
+
export function useOnlineStatus() {
|
|
30
|
+
const [isOnline, setIsOnline] = useState(() => (isBrowser() ? navigator.onLine : true));
|
|
31
|
+
const [wasOffline, setWasOffline] = useState(false);
|
|
32
|
+
const [lastOnlineAt, setLastOnlineAt] = useState(null);
|
|
33
|
+
const resetTimerRef = useRef(null);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// No-op during SSR — the effect only runs in the browser.
|
|
36
|
+
if (!isBrowser()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
function handleOnline() {
|
|
40
|
+
setIsOnline(true);
|
|
41
|
+
setLastOnlineAt(new Date());
|
|
42
|
+
setWasOffline(true);
|
|
43
|
+
// Clear any existing timer before setting a new one.
|
|
44
|
+
if (resetTimerRef.current !== null) {
|
|
45
|
+
clearTimeout(resetTimerRef.current);
|
|
46
|
+
}
|
|
47
|
+
resetTimerRef.current = setTimeout(() => {
|
|
48
|
+
setWasOffline(false);
|
|
49
|
+
resetTimerRef.current = null;
|
|
50
|
+
}, WAS_OFFLINE_RESET_MS);
|
|
51
|
+
}
|
|
52
|
+
function handleOffline() {
|
|
53
|
+
setIsOnline(false);
|
|
54
|
+
setWasOffline(false);
|
|
55
|
+
if (resetTimerRef.current !== null) {
|
|
56
|
+
clearTimeout(resetTimerRef.current);
|
|
57
|
+
resetTimerRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
window.addEventListener('online', handleOnline);
|
|
61
|
+
window.addEventListener('offline', handleOffline);
|
|
62
|
+
return () => {
|
|
63
|
+
window.removeEventListener('online', handleOnline);
|
|
64
|
+
window.removeEventListener('offline', handleOffline);
|
|
65
|
+
if (resetTimerRef.current !== null) {
|
|
66
|
+
clearTimeout(resetTimerRef.current);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
if (!isBrowser()) {
|
|
71
|
+
return SSR_DEFAULT;
|
|
72
|
+
}
|
|
73
|
+
return { isOnline, wasOffline, lastOnlineAt };
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline mutation queue — stores pending mutations in localStorage
|
|
3
|
+
* so they survive page reloads and can be flushed when connectivity returns.
|
|
4
|
+
*/
|
|
5
|
+
/** A single pending mutation waiting to be sent to the server. */
|
|
6
|
+
export interface OfflineMutation {
|
|
7
|
+
/** Unique identifier for this mutation (UUID). */
|
|
8
|
+
id: string;
|
|
9
|
+
/** Database table the mutation targets. */
|
|
10
|
+
table: string;
|
|
11
|
+
/** Type of operation. */
|
|
12
|
+
operation: 'insert' | 'update' | 'delete';
|
|
13
|
+
/** Mutation payload (row data or partial update). */
|
|
14
|
+
data: Record<string, unknown>;
|
|
15
|
+
/** ISO-8601 timestamp when the mutation was enqueued. */
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Manages a FIFO queue of mutations that were created while the browser
|
|
20
|
+
* was offline. Mutations are persisted in `localStorage` under the key
|
|
21
|
+
* `revealui:offline-queue`.
|
|
22
|
+
*
|
|
23
|
+
* All operations are no-ops when `localStorage` is unavailable (SSR,
|
|
24
|
+
* private browsing that throws, etc.).
|
|
25
|
+
*/
|
|
26
|
+
export declare class OfflineMutationQueue {
|
|
27
|
+
/**
|
|
28
|
+
* Add a mutation to the end of the queue.
|
|
29
|
+
*
|
|
30
|
+
* @param mutation - The mutation to enqueue (must include a unique `id`).
|
|
31
|
+
*/
|
|
32
|
+
enqueue(mutation: OfflineMutation): void;
|
|
33
|
+
/**
|
|
34
|
+
* Execute all queued mutations in order via the provided executor.
|
|
35
|
+
* Each mutation is removed from the persisted queue only after the
|
|
36
|
+
* executor resolves successfully. Processing stops at the first failure
|
|
37
|
+
* so ordering is preserved.
|
|
38
|
+
*
|
|
39
|
+
* @param executor - Async function that sends a single mutation to the server.
|
|
40
|
+
*/
|
|
41
|
+
flush(executor: (mutation: OfflineMutation) => Promise<void>): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Return all pending mutations without removing them.
|
|
44
|
+
*/
|
|
45
|
+
peek(): OfflineMutation[];
|
|
46
|
+
/**
|
|
47
|
+
* Number of mutations currently in the queue.
|
|
48
|
+
*/
|
|
49
|
+
get size(): number;
|
|
50
|
+
/**
|
|
51
|
+
* Remove all pending mutations from the queue.
|
|
52
|
+
*/
|
|
53
|
+
clear(): void;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=offline-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offline-queue.d.ts","sourceRoot":"","sources":["../src/offline-queue.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,kEAAkE;AAClE,MAAM,WAAW,eAAe;IAC9B,kDAAkD;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,SAAS,EAAE,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;IAC1C,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,yDAAyD;IACzD,SAAS,EAAE,MAAM,CAAC;CACnB;AA0DD;;;;;;;GAOG;AACH,qBAAa,oBAAoB;IAC/B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAMxC;;;;;;;OAOG;IACG,KAAK,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAYlF;;OAEG;IACH,IAAI,IAAI,eAAe,EAAE;IAIzB;;OAEG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;OAEG;IACH,KAAK,IAAI,IAAI;CAUd"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline mutation queue — stores pending mutations in localStorage
|
|
3
|
+
* so they survive page reloads and can be flushed when connectivity returns.
|
|
4
|
+
*/
|
|
5
|
+
/** localStorage key for the persisted queue. */
|
|
6
|
+
const STORAGE_KEY = 'revealui:offline-queue';
|
|
7
|
+
/**
|
|
8
|
+
* Check whether `localStorage` is available.
|
|
9
|
+
* Returns `false` during SSR or in private-browsing contexts that throw on access.
|
|
10
|
+
*/
|
|
11
|
+
function hasLocalStorage() {
|
|
12
|
+
if (typeof window === 'undefined') {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const testKey = '__revealui_ls_test__';
|
|
17
|
+
window.localStorage.setItem(testKey, '1');
|
|
18
|
+
window.localStorage.removeItem(testKey);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read the current queue from localStorage.
|
|
27
|
+
* Returns an empty array when storage is unavailable or the data is corrupt.
|
|
28
|
+
*/
|
|
29
|
+
function readQueue() {
|
|
30
|
+
if (!hasLocalStorage()) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
35
|
+
if (raw === null) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
if (!Array.isArray(parsed)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Persist the queue to localStorage.
|
|
50
|
+
* Silently ignores errors (e.g. quota exceeded, private browsing).
|
|
51
|
+
*/
|
|
52
|
+
function writeQueue(queue) {
|
|
53
|
+
if (!hasLocalStorage()) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Quota exceeded or private browsing — drop silently.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Manages a FIFO queue of mutations that were created while the browser
|
|
65
|
+
* was offline. Mutations are persisted in `localStorage` under the key
|
|
66
|
+
* `revealui:offline-queue`.
|
|
67
|
+
*
|
|
68
|
+
* All operations are no-ops when `localStorage` is unavailable (SSR,
|
|
69
|
+
* private browsing that throws, etc.).
|
|
70
|
+
*/
|
|
71
|
+
export class OfflineMutationQueue {
|
|
72
|
+
/**
|
|
73
|
+
* Add a mutation to the end of the queue.
|
|
74
|
+
*
|
|
75
|
+
* @param mutation - The mutation to enqueue (must include a unique `id`).
|
|
76
|
+
*/
|
|
77
|
+
enqueue(mutation) {
|
|
78
|
+
const queue = readQueue();
|
|
79
|
+
queue.push(mutation);
|
|
80
|
+
writeQueue(queue);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Execute all queued mutations in order via the provided executor.
|
|
84
|
+
* Each mutation is removed from the persisted queue only after the
|
|
85
|
+
* executor resolves successfully. Processing stops at the first failure
|
|
86
|
+
* so ordering is preserved.
|
|
87
|
+
*
|
|
88
|
+
* @param executor - Async function that sends a single mutation to the server.
|
|
89
|
+
*/
|
|
90
|
+
async flush(executor) {
|
|
91
|
+
const queue = readQueue();
|
|
92
|
+
for (const mutation of queue) {
|
|
93
|
+
await executor(mutation);
|
|
94
|
+
// Remove the successfully-processed mutation from storage.
|
|
95
|
+
const current = readQueue();
|
|
96
|
+
const updated = current.filter((m) => m.id !== mutation.id);
|
|
97
|
+
writeQueue(updated);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Return all pending mutations without removing them.
|
|
102
|
+
*/
|
|
103
|
+
peek() {
|
|
104
|
+
return readQueue();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Number of mutations currently in the queue.
|
|
108
|
+
*/
|
|
109
|
+
get size() {
|
|
110
|
+
return readQueue().length;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove all pending mutations from the queue.
|
|
114
|
+
*/
|
|
115
|
+
clear() {
|
|
116
|
+
if (!hasLocalStorage()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Ignore — same guard as writeQueue.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revealui/sync",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "ElectricSQL sync utilities for RevealUI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"ws": "^8.18.0",
|
|
10
10
|
"y-protocols": "^1.0.7",
|
|
11
11
|
"yjs": "^13.6.29",
|
|
12
|
-
"@revealui/
|
|
13
|
-
"@revealui/
|
|
12
|
+
"@revealui/contracts": "1.3.5",
|
|
13
|
+
"@revealui/db": "0.3.5"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@testing-library/jest-dom": "^6.6.4",
|