@revealui/sync 0.3.5 → 0.3.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @revealui/sync
2
2
 
3
- ElectricSQL sync utilities for RevealUIreal-time data synchronization with local-first architecture.
3
+ ElectricSQL sync utilities for RevealUI - real-time data synchronization with local-first architecture.
4
4
 
5
5
  ## Features
6
6
 
@@ -28,7 +28,7 @@ import { ElectricProvider } from '@revealui/sync/provider'
28
28
 
29
29
  export default function App() {
30
30
  return (
31
- <ElectricProvider proxyBaseUrl="https://cms.revealui.com">
31
+ <ElectricProvider proxyBaseUrl="https://admin.revealui.com">
32
32
  <YourComponents />
33
33
  </ElectricProvider>
34
34
  )
@@ -122,7 +122,7 @@ const {
122
122
 
123
123
  ### `useConversations(userId)`
124
124
 
125
- Subscribe to conversation history. Server-side proxy enforces row-level filtering by sessionthe `userId` parameter is for API compatibility but filtering is handled server-side.
125
+ Subscribe to conversation history. Server-side proxy enforces row-level filtering by session - the `userId` parameter is for API compatibility but filtering is handled server-side.
126
126
 
127
127
  ```typescript
128
128
  const {
@@ -187,13 +187,13 @@ pnpm --filter @revealui/sync typecheck # Type check
187
187
  - You need real-time data sync between your database and React UI via ElectricSQL
188
188
  - You want CRDT-based collaborative editing (Yjs) for multi-user document workflows
189
189
  - You need React hooks that subscribe to live database changes with automatic mutation support
190
- - **Not** for batch data loading or static pagesuse server components with `@revealui/db` directly
191
- - **Not** for offline-first mobile appsElectricSQL targets web clients with persistent connections
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
192
 
193
193
  ## JOSHUA Alignment
194
194
 
195
- - **Adaptive**: Shape subscriptions dynamically sync only the data your component needsscales from one user to many
196
- - **Sovereign**: Sync runs through your own CMS proxy and PostgreSQLno third-party real-time service required
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
197
  - **Hermetic**: All mutations go through authenticated REST endpoints; ElectricSQL replication is read-only on the client
198
198
 
199
199
  ## License
@@ -1,12 +1,12 @@
1
1
  import { useShape } from '@electric-sql/react';
2
2
  import { fetchWithTimeout } from '../fetch-with-timeout.js';
3
3
  import { useElectricConfig } from '../provider/index.js';
4
- // UUID v4 patternonly format accepted as a yjs_documents PK
4
+ // UUID v4 pattern - only format accepted as a yjs_documents PK
5
5
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
6
  export function useCollabDocument(documentId) {
7
7
  const { proxyBaseUrl } = useElectricConfig();
8
8
  // Validate before interpolating into the WHERE clause. yjs_documents PKs are
9
- // always UUIDsreject anything else so no untrusted string enters the query.
9
+ // always UUIDs - reject anything else so no untrusted string enters the query.
10
10
  const isValidId = UUID_RE.test(documentId);
11
11
  // Hook must always be called (Rules of Hooks). Pass an impossible WHERE when
12
12
  // the ID is invalid so the shape returns no rows but the hook still runs.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Conflict Resolution for Offline Edits
3
+ *
4
+ * Provides version-based conflict detection and configurable resolution
5
+ * strategies for mutations queued while offline.
6
+ *
7
+ * Architecture:
8
+ * - Each mutation carries a `baseVersion` (the version of the resource
9
+ * at the time the edit was made offline).
10
+ * - On replay, the server may respond with 409 Conflict if the resource
11
+ * has been modified since `baseVersion`.
12
+ * - The resolution strategy determines how to handle the conflict:
13
+ * last-write-wins, server-wins, or manual merge.
14
+ */
15
+ export type ConflictStrategy = 'last-write-wins' | 'server-wins' | 'manual';
16
+ export interface OfflineMutation {
17
+ /** Unique mutation identifier. */
18
+ id: string;
19
+ /** API endpoint URL. */
20
+ url: string;
21
+ /** HTTP method. */
22
+ method: string;
23
+ /** Request headers (serialized). */
24
+ headers: Record<string, string>;
25
+ /** Request body (serialized JSON string). */
26
+ body: string | null;
27
+ /** Timestamp when the mutation was created offline. */
28
+ timestamp: number;
29
+ /** Version of the resource when the edit was made (for conflict detection). */
30
+ baseVersion?: number;
31
+ /** Resource identifier (e.g. "posts:123") for grouping related mutations. */
32
+ resourceId?: string;
33
+ /** Number of replay attempts. */
34
+ retryCount: number;
35
+ }
36
+ export interface ConflictInfo {
37
+ /** The mutation that caused the conflict. */
38
+ mutation: OfflineMutation;
39
+ /** HTTP status code from the server (typically 409). */
40
+ statusCode: number;
41
+ /** Server's current version of the resource. */
42
+ serverVersion?: number;
43
+ /** Server's current resource data (if provided in the 409 response). */
44
+ serverData?: unknown;
45
+ }
46
+ export interface ReplayResult {
47
+ /** Mutations that were successfully replayed. */
48
+ succeeded: OfflineMutation[];
49
+ /** Mutations that hit a conflict. */
50
+ conflicts: ConflictInfo[];
51
+ /** Mutations that failed for non-conflict reasons (network, 5xx). */
52
+ failed: OfflineMutation[];
53
+ }
54
+ /**
55
+ * Apply the configured conflict resolution strategy.
56
+ *
57
+ * - `last-write-wins`: Retry the mutation with a force flag, overwriting the server version.
58
+ * - `server-wins`: Discard the offline mutation, keeping the server state.
59
+ * - `manual`: Return the conflict for UI-level resolution (e.g. a merge dialog).
60
+ */
61
+ export declare function resolveConflict(conflict: ConflictInfo, strategy: ConflictStrategy): Promise<{
62
+ resolved: boolean;
63
+ retryMutation?: OfflineMutation;
64
+ }>;
65
+ /**
66
+ * Replay a batch of offline mutations in FIFO order.
67
+ *
68
+ * Mutations targeting the same resource are coalesced: only the most recent
69
+ * mutation per resource is replayed, reducing unnecessary round-trips.
70
+ *
71
+ * @param mutations - Queued mutations in chronological order.
72
+ * @param strategy - Conflict resolution strategy.
73
+ * @returns Replay results grouped by outcome.
74
+ */
75
+ export declare function replayMutations(mutations: OfflineMutation[], strategy?: ConflictStrategy): Promise<ReplayResult>;
76
+ /**
77
+ * Coalesce mutations targeting the same resource.
78
+ * For each resourceId, keep only the latest mutation.
79
+ * Mutations without a resourceId are always included.
80
+ */
81
+ export declare function coalesceMutations(mutations: OfflineMutation[]): OfflineMutation[];
82
+ //# sourceMappingURL=conflict-resolution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conflict-resolution.d.ts","sourceRoot":"","sources":["../src/conflict-resolution.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,GAAG,aAAa,GAAG,QAAQ,CAAC;AAE5E,MAAM,WAAW,eAAe;IAC9B,kCAAkC;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,wBAAwB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,6CAA6C;IAC7C,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,6CAA6C;IAC7C,QAAQ,EAAE,eAAe,CAAC;IAC1B,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,SAAS,EAAE,eAAe,EAAE,CAAC;IAC7B,qCAAqC;IACrC,SAAS,EAAE,YAAY,EAAE,CAAC;IAC1B,qEAAqE;IACrE,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAID;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,aAAa,CAAC,EAAE,eAAe,CAAA;CAAE,CAAC,CAwBjE;AAID;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,SAAS,EAAE,eAAe,EAAE,EAC5B,QAAQ,GAAE,gBAAoC,GAC7C,OAAO,CAAC,YAAY,CAAC,CAwFvB;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,eAAe,EAAE,GAAG,eAAe,EAAE,CAkBjF"}
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Conflict Resolution for Offline Edits
3
+ *
4
+ * Provides version-based conflict detection and configurable resolution
5
+ * strategies for mutations queued while offline.
6
+ *
7
+ * Architecture:
8
+ * - Each mutation carries a `baseVersion` (the version of the resource
9
+ * at the time the edit was made offline).
10
+ * - On replay, the server may respond with 409 Conflict if the resource
11
+ * has been modified since `baseVersion`.
12
+ * - The resolution strategy determines how to handle the conflict:
13
+ * last-write-wins, server-wins, or manual merge.
14
+ */
15
+ // ─── Resolution ──────────────────────────────────────────────────────────────
16
+ /**
17
+ * Apply the configured conflict resolution strategy.
18
+ *
19
+ * - `last-write-wins`: Retry the mutation with a force flag, overwriting the server version.
20
+ * - `server-wins`: Discard the offline mutation, keeping the server state.
21
+ * - `manual`: Return the conflict for UI-level resolution (e.g. a merge dialog).
22
+ */
23
+ export async function resolveConflict(conflict, strategy) {
24
+ switch (strategy) {
25
+ case 'last-write-wins': {
26
+ // Retry with force header to tell the server to accept regardless of version
27
+ const retryMutation = {
28
+ ...conflict.mutation,
29
+ headers: {
30
+ ...conflict.mutation.headers,
31
+ 'X-Force-Overwrite': 'true',
32
+ 'X-Base-Version': String(conflict.serverVersion ?? 0),
33
+ },
34
+ retryCount: conflict.mutation.retryCount + 1,
35
+ };
36
+ return { resolved: true, retryMutation };
37
+ }
38
+ case 'server-wins':
39
+ // Discard the offline mutation
40
+ return { resolved: true };
41
+ case 'manual':
42
+ // Return unresolved for UI handling
43
+ return { resolved: false };
44
+ }
45
+ }
46
+ // ─── Replay Engine ─────────��─────────────────────────────────────────────────
47
+ /**
48
+ * Replay a batch of offline mutations in FIFO order.
49
+ *
50
+ * Mutations targeting the same resource are coalesced: only the most recent
51
+ * mutation per resource is replayed, reducing unnecessary round-trips.
52
+ *
53
+ * @param mutations - Queued mutations in chronological order.
54
+ * @param strategy - Conflict resolution strategy.
55
+ * @returns Replay results grouped by outcome.
56
+ */
57
+ export async function replayMutations(mutations, strategy = 'last-write-wins') {
58
+ const result = {
59
+ succeeded: [],
60
+ conflicts: [],
61
+ failed: [],
62
+ };
63
+ // Coalesce: for each resourceId, keep only the latest mutation
64
+ const coalesced = coalesceMutations(mutations);
65
+ for (const mutation of coalesced) {
66
+ try {
67
+ const response = await fetch(mutation.url, {
68
+ method: mutation.method,
69
+ headers: {
70
+ ...mutation.headers,
71
+ ...(mutation.baseVersion != null ? { 'If-Match': String(mutation.baseVersion) } : {}),
72
+ },
73
+ body: mutation.body || undefined,
74
+ });
75
+ if (response.ok) {
76
+ result.succeeded.push(mutation);
77
+ continue;
78
+ }
79
+ if (response.status === 409) {
80
+ let serverData;
81
+ let serverVersion;
82
+ try {
83
+ const body = await response.json();
84
+ serverData = body.data;
85
+ serverVersion = body.version;
86
+ }
87
+ catch {
88
+ // 409 without JSON body
89
+ }
90
+ const conflict = {
91
+ mutation,
92
+ statusCode: 409,
93
+ serverVersion,
94
+ serverData,
95
+ };
96
+ const resolution = await resolveConflict(conflict, strategy);
97
+ if (resolution.resolved && resolution.retryMutation) {
98
+ // Retry the mutation with force overwrite
99
+ try {
100
+ const retryResponse = await fetch(resolution.retryMutation.url, {
101
+ method: resolution.retryMutation.method,
102
+ headers: resolution.retryMutation.headers,
103
+ body: resolution.retryMutation.body || undefined,
104
+ });
105
+ if (retryResponse.ok) {
106
+ result.succeeded.push(mutation);
107
+ }
108
+ else {
109
+ result.failed.push(mutation);
110
+ }
111
+ }
112
+ catch {
113
+ result.failed.push(mutation);
114
+ }
115
+ }
116
+ else if (resolution.resolved) {
117
+ // server-wins: mutation discarded, count as succeeded (intentionally dropped)
118
+ result.succeeded.push(mutation);
119
+ }
120
+ else {
121
+ // manual: surface the conflict
122
+ result.conflicts.push(conflict);
123
+ }
124
+ continue;
125
+ }
126
+ // Server error or other non-conflict failure
127
+ if (response.status >= 500) {
128
+ result.failed.push(mutation);
129
+ // Stop replaying on server errors to avoid cascading failures
130
+ break;
131
+ }
132
+ // Client error (4xx other than 409): discard, not recoverable
133
+ result.failed.push(mutation);
134
+ }
135
+ catch {
136
+ // Network error: stop replaying
137
+ result.failed.push(mutation);
138
+ break;
139
+ }
140
+ }
141
+ return result;
142
+ }
143
+ // ─── Coalescing ──────��───────────────────────────────────────────────────────
144
+ /**
145
+ * Coalesce mutations targeting the same resource.
146
+ * For each resourceId, keep only the latest mutation.
147
+ * Mutations without a resourceId are always included.
148
+ */
149
+ export function coalesceMutations(mutations) {
150
+ const byResource = new Map();
151
+ const ungrouped = [];
152
+ for (const mutation of mutations) {
153
+ if (mutation.resourceId) {
154
+ const existing = byResource.get(mutation.resourceId);
155
+ if (!existing || mutation.timestamp > existing.timestamp) {
156
+ byResource.set(mutation.resourceId, mutation);
157
+ }
158
+ }
159
+ else {
160
+ ungrouped.push(mutation);
161
+ }
162
+ }
163
+ // Maintain chronological order: ungrouped first, then coalesced by timestamp
164
+ const coalesced = [...byResource.values()].sort((a, b) => a.timestamp - b.timestamp);
165
+ return [...ungrouped, ...coalesced];
166
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A fetch wrapper that aborts requests after {@link SHAPE_FETCH_TIMEOUT_MS} (10 s).
3
3
  * Passed as `fetchClient` to ElectricSQL `useShape` so that shape subscription
4
- * requests to the CMS proxy do not hang indefinitely.
4
+ * requests to the admin proxy do not hang indefinitely.
5
5
  */
6
6
  export declare function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
7
7
  //# sourceMappingURL=fetch-with-timeout.d.ts.map
@@ -3,7 +3,7 @@ const SHAPE_FETCH_TIMEOUT_MS = 10_000;
3
3
  /**
4
4
  * A fetch wrapper that aborts requests after {@link SHAPE_FETCH_TIMEOUT_MS} (10 s).
5
5
  * Passed as `fetchClient` to ElectricSQL `useShape` so that shape subscription
6
- * requests to the CMS proxy do not hang indefinitely.
6
+ * requests to the admin proxy do not hang indefinitely.
7
7
  */
8
8
  export function fetchWithTimeout(input, init) {
9
9
  // If the caller already provides a signal, respect it and compose with our timeout.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Browser cache factory for PGlite-backed offline cache.
3
+ * Separated into its own file so tests can vi.mock() it without
4
+ * triggering PGlite WASM loading in jsdom.
5
+ * @internal
6
+ */
7
+ interface CacheStoreLike {
8
+ get<T = unknown>(key: string): Promise<T | null>;
9
+ set<T = unknown>(key: string, value: T, ttlSeconds: number, tags?: string[]): Promise<void>;
10
+ delete(...keys: string[]): Promise<number>;
11
+ close(): Promise<void>;
12
+ }
13
+ export declare function createOfflineCache(): Promise<CacheStoreLike | null>;
14
+ export {};
15
+ //# sourceMappingURL=browser-cache-factory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-cache-factory.d.ts","sourceRoot":"","sources":["../../src/hooks/browser-cache-factory.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,UAAU,cAAc;IACtB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACjD,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5F,MAAM,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAUzE"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Browser cache factory for PGlite-backed offline cache.
3
+ * Separated into its own file so tests can vi.mock() it without
4
+ * triggering PGlite WASM loading in jsdom.
5
+ * @internal
6
+ */
7
+ export async function createOfflineCache() {
8
+ try {
9
+ const specifier = ['@revealui', 'cache', 'adapters'].join('/');
10
+ const mod = await Function('s', 'return import(s)')(specifier);
11
+ return await mod.createBrowserCache({ dbName: 'revealui-offline' });
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
@@ -1,4 +1,6 @@
1
1
  export { useOfflineCache } from './useOfflineCache.js';
2
2
  export type { OnlineStatusResult } from './useOnlineStatus.js';
3
3
  export { useOnlineStatus } from './useOnlineStatus.js';
4
+ export type { InvalidationAction } from './useShapeCacheInvalidation.js';
5
+ export { useShapeCacheInvalidation } from './useShapeCacheInvalidation.js';
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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;AACvD,YAAY,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AACzE,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC"}
@@ -1,2 +1,3 @@
1
1
  export { useOfflineCache } from './useOfflineCache.js';
2
2
  export { useOnlineStatus } from './useOnlineStatus.js';
3
+ export { useShapeCacheInvalidation } from './useShapeCacheInvalidation.js';
@@ -4,12 +4,12 @@ import { fetchWithTimeout } from '../fetch-with-timeout.js';
4
4
  import { useSyncMutations } from '../mutations.js';
5
5
  import { useElectricConfig } from '../provider/index.js';
6
6
  import { toRecords } from '../shape-utils.js';
7
- // _userId kept for API compatibilityfiltering is enforced by the server-side
7
+ // _userId kept for API compatibility - filtering is enforced by the server-side
8
8
  // proxy at /api/shapes/conversations, which reads the session cookie directly.
9
9
  export function useConversations(_userId) {
10
10
  const { proxyBaseUrl } = useElectricConfig();
11
11
  // The proxy validates the session and enforces row-level filtering server-side.
12
- // Client-provided params are not forwardedthe proxy overrides them.
12
+ // Client-provided params are not forwarded - the proxy overrides them.
13
13
  const { data, isLoading, error } = useShape({
14
14
  url: `${proxyBaseUrl}/api/shapes/conversations`,
15
15
  fetchClient: fetchWithTimeout,
@@ -1,13 +1,15 @@
1
1
  interface UseOfflineCacheOptions {
2
2
  /** ElectricSQL shape subscription URL. */
3
3
  shapeUrl: string;
4
- /** Unique key for the localStorage cache entry. */
4
+ /** Unique key for the cache entry. */
5
5
  cacheKey: string;
6
6
  /** How long cached data is considered fresh (seconds). Defaults to 3600. */
7
7
  ttlSeconds?: number;
8
+ /** Additional cache tags for targeted invalidation. */
9
+ tags?: string[];
8
10
  }
9
11
  interface UseOfflineCacheResult<T> {
10
- /** The current data live from the shape when online, cached when offline. */
12
+ /** The current data: live from the shape when online, cached when offline. */
11
13
  data: T[];
12
14
  /** Whether the browser has network connectivity. */
13
15
  isOnline: boolean;
@@ -17,13 +19,20 @@ interface UseOfflineCacheResult<T> {
17
19
  lastSyncedAt: Date | null;
18
20
  /** Shape subscription or cache-read error, if any. */
19
21
  error: Error | null;
22
+ /** Manually invalidate the cache for this key. */
23
+ invalidate: () => Promise<void>;
20
24
  }
25
+ /** Reset singleton state between tests. @internal */
26
+ export declare function _resetCacheState(): void;
21
27
  /**
22
28
  * Wrap an ElectricSQL `useShape` subscription with offline-first caching.
23
29
  *
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.
30
+ * When online the hook delegates to `useShape` and mirrors results into a
31
+ * PGlite browser cache (IndexedDB). When offline (or during initial load) it
32
+ * returns the most recent cached snapshot if one exists within the TTL window.
33
+ *
34
+ * Falls back to localStorage when PGlite is unavailable (e.g. private browsing
35
+ * or environments without WASM support).
27
36
  *
28
37
  * @typeParam T - Row type returned by the shape subscription.
29
38
  */
@@ -1 +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"}
1
+ {"version":3,"file":"useOfflineCache.d.ts","sourceRoot":"","sources":["../../src/hooks/useOfflineCache.ts"],"names":[],"mappings":"AAcA,UAAU,sBAAsB;IAC9B,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;IACjB,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,UAAU,qBAAqB,CAAC,CAAC;IAC/B,8EAA8E;IAC9E,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;IACpB,kDAAkD;IAClD,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACjC;AAuDD,qDAAqD;AACrD,wBAAgB,gBAAgB,IAAI,IAAI,CAIvC;AAuCD;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAmI5F"}
@@ -1,112 +1,213 @@
1
1
  'use client';
2
2
  import { useShape } from '@electric-sql/react';
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { fetchWithTimeout } from '../fetch-with-timeout.js';
5
5
  import { toRecords } from '../shape-utils.js';
6
6
  import { useOnlineStatus } from './useOnlineStatus.js';
7
- /** Prefix for all offline-cache localStorage keys. */
8
- const CACHE_PREFIX = 'revealui:cache:';
9
7
  /** Default time-to-live for cached data (seconds). */
10
8
  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
- }
9
+ /** Tag prefix applied to all offline cache entries for bulk invalidation. */
10
+ const OFFLINE_CACHE_TAG = 'offline-cache';
11
+ let browserCache = null;
12
+ let cacheInitPromise = null;
13
+ let cacheRefCount = 0;
14
+ function isBrowserWithIndexedDB() {
15
+ return (typeof window !== 'undefined' &&
16
+ typeof indexedDB !== 'undefined' &&
17
+ typeof navigator !== 'undefined' &&
18
+ // Exclude jsdom/test environments where PGlite WASM cannot run
19
+ navigator.userAgent.indexOf('jsdom') === -1);
27
20
  }
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()) {
21
+ async function getBrowserCache() {
22
+ if (browserCache)
23
+ return browserCache;
24
+ if (cacheInitPromise)
25
+ return cacheInitPromise;
26
+ if (!isBrowserWithIndexedDB())
34
27
  return null;
28
+ cacheInitPromise = (async () => {
29
+ try {
30
+ const { createOfflineCache } = await import('./browser-cache-factory.js');
31
+ const cache = await createOfflineCache();
32
+ browserCache = cache;
33
+ return cache;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ })();
39
+ return cacheInitPromise;
40
+ }
41
+ function releaseBrowserCache() {
42
+ cacheRefCount--;
43
+ if (cacheRefCount === 0 && browserCache) {
44
+ browserCache.close().catch(() => {
45
+ /* fire-and-forget cleanup */
46
+ });
47
+ browserCache = null;
48
+ cacheInitPromise = null;
35
49
  }
50
+ }
51
+ /** Reset singleton state between tests. @internal */
52
+ export function _resetCacheState() {
53
+ browserCache = null;
54
+ cacheInitPromise = null;
55
+ cacheRefCount = 0;
56
+ }
57
+ const LS_PREFIX = 'revealui:cache:';
58
+ function readLocalStorageCache(key, ttlSeconds) {
59
+ if (typeof window === 'undefined')
60
+ return null;
36
61
  try {
37
- const raw = window.localStorage.getItem(CACHE_PREFIX + cacheKey);
38
- if (raw === null) {
62
+ const raw = window.localStorage.getItem(LS_PREFIX + key);
63
+ if (raw === null)
39
64
  return null;
40
- }
41
65
  const parsed = JSON.parse(raw);
42
- if (!Array.isArray(parsed.data)) {
66
+ if (!Array.isArray(parsed.data))
43
67
  return null;
44
- }
45
- // Check TTL.
46
68
  const cachedTime = new Date(parsed.cachedAt).getTime();
47
- if (Number.isNaN(cachedTime)) {
69
+ if (Number.isNaN(cachedTime))
48
70
  return null;
49
- }
50
- const ageSeconds = (Date.now() - cachedTime) / 1_000;
51
- if (ageSeconds > ttlSeconds) {
71
+ if ((Date.now() - cachedTime) / 1_000 > ttlSeconds)
52
72
  return null;
53
- }
54
- return parsed;
73
+ return parsed.data;
55
74
  }
56
75
  catch {
57
76
  return null;
58
77
  }
59
78
  }
60
- /**
61
- * Write data to the localStorage cache. Silently ignores failures.
62
- */
63
- function writeCache(cacheKey, data) {
64
- if (!hasLocalStorage()) {
79
+ function writeLocalStorageCache(key, data) {
80
+ if (typeof window === 'undefined')
65
81
  return;
66
- }
67
82
  try {
68
- const payload = {
69
- data,
70
- cachedAt: new Date().toISOString(),
71
- };
72
- window.localStorage.setItem(CACHE_PREFIX + cacheKey, JSON.stringify(payload));
83
+ const payload = { data, cachedAt: new Date().toISOString() };
84
+ window.localStorage.setItem(LS_PREFIX + key, JSON.stringify(payload));
73
85
  }
74
86
  catch {
75
- // Quota exceeded or private browsing — drop silently.
87
+ // Quota exceeded or private browsing.
76
88
  }
77
89
  }
90
+ // ─── Hook ────────────────────────────────────────────────────────────────────
78
91
  /**
79
92
  * Wrap an ElectricSQL `useShape` subscription with offline-first caching.
80
93
  *
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.
94
+ * When online the hook delegates to `useShape` and mirrors results into a
95
+ * PGlite browser cache (IndexedDB). When offline (or during initial load) it
96
+ * returns the most recent cached snapshot if one exists within the TTL window.
97
+ *
98
+ * Falls back to localStorage when PGlite is unavailable (e.g. private browsing
99
+ * or environments without WASM support).
84
100
  *
85
101
  * @typeParam T - Row type returned by the shape subscription.
86
102
  */
87
103
  export function useOfflineCache(options) {
88
- const { shapeUrl, cacheKey, ttlSeconds = DEFAULT_TTL_SECONDS } = options;
104
+ const { shapeUrl, cacheKey, ttlSeconds = DEFAULT_TTL_SECONDS, tags } = options;
89
105
  const { isOnline } = useOnlineStatus();
90
- // Shape subscription — runs continuously; ElectricSQL handles reconnection.
91
106
  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.
107
+ // Read localStorage synchronously on first render for instant offline data
108
+ const [cache, setCache] = useState(browserCache);
109
+ const [cachedData, setCachedData] = useState(() => readLocalStorageCache(cacheKey, ttlSeconds));
110
+ const [lastSyncedAt, setLastSyncedAt] = useState(() => {
111
+ // Recover lastSyncedAt from localStorage cache timestamp
112
+ if (typeof window === 'undefined')
113
+ return null;
114
+ try {
115
+ const raw = window.localStorage.getItem(LS_PREFIX + cacheKey);
116
+ if (!raw)
117
+ return null;
118
+ const parsed = JSON.parse(raw);
119
+ const time = new Date(parsed.cachedAt).getTime();
120
+ return Number.isNaN(time) ? null : new Date(parsed.cachedAt);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ });
126
+ const mounted = useRef(true);
94
127
  const cacheKeyRef = useRef(cacheKey);
95
128
  cacheKeyRef.current = cacheKey;
96
- // Persist live data to cache whenever the shape delivers fresh rows.
129
+ const ttlSecondsRef = useRef(ttlSeconds);
130
+ ttlSecondsRef.current = ttlSeconds;
131
+ const tagsRef = useRef(tags);
132
+ tagsRef.current = tags;
133
+ // Initialize PGlite browser cache
134
+ useEffect(() => {
135
+ mounted.current = true;
136
+ cacheRefCount++;
137
+ getBrowserCache()
138
+ .then((c) => {
139
+ if (!mounted.current)
140
+ return null;
141
+ if (c) {
142
+ setCache(c);
143
+ return c.get(cacheKeyRef.current);
144
+ }
145
+ // PGlite unavailable; fall back to localStorage
146
+ const lsData = readLocalStorageCache(cacheKeyRef.current, ttlSecondsRef.current);
147
+ if (lsData && mounted.current)
148
+ setCachedData(lsData);
149
+ return null;
150
+ })
151
+ .then((data) => {
152
+ if (mounted.current && data) {
153
+ setCachedData(data);
154
+ }
155
+ })
156
+ .catch(() => {
157
+ if (mounted.current) {
158
+ // Fall back to localStorage
159
+ const lsData = readLocalStorageCache(cacheKeyRef.current, ttlSecondsRef.current);
160
+ if (lsData)
161
+ setCachedData(lsData);
162
+ }
163
+ });
164
+ return () => {
165
+ mounted.current = false;
166
+ releaseBrowserCache();
167
+ };
168
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
169
+ // Persist live data to PGlite (or localStorage fallback) when shape delivers fresh rows
97
170
  const shapeData = shape.data;
98
171
  useEffect(() => {
99
- if (!isOnline) {
172
+ if (!isOnline)
100
173
  return;
101
- }
102
- if (!Array.isArray(shapeData) || shapeData.length === 0) {
174
+ if (!Array.isArray(shapeData) || shapeData.length === 0)
103
175
  return;
104
- }
105
176
  const typed = toRecords(shapeData);
106
- writeCache(cacheKeyRef.current, typed);
107
- setLastSyncedAt(new Date());
108
- }, [shapeData, isOnline]);
109
- // Determine what to return.
177
+ const allTags = [OFFLINE_CACHE_TAG, ...(tagsRef.current ?? [])];
178
+ if (cache) {
179
+ cache.set(cacheKeyRef.current, typed, ttlSecondsRef.current, allTags).catch(() => {
180
+ // PGlite write failed; fall back to localStorage
181
+ writeLocalStorageCache(cacheKeyRef.current, typed);
182
+ });
183
+ }
184
+ else {
185
+ writeLocalStorageCache(cacheKeyRef.current, typed);
186
+ }
187
+ if (mounted.current) {
188
+ setCachedData(typed);
189
+ setLastSyncedAt(new Date());
190
+ }
191
+ }, [shapeData, isOnline, cache]);
192
+ // Manual invalidation
193
+ const invalidate = useCallback(async () => {
194
+ if (cache) {
195
+ await cache.delete(cacheKeyRef.current);
196
+ }
197
+ if (typeof window !== 'undefined') {
198
+ try {
199
+ window.localStorage.removeItem(LS_PREFIX + cacheKeyRef.current);
200
+ }
201
+ catch {
202
+ // Ignore
203
+ }
204
+ }
205
+ if (mounted.current) {
206
+ setCachedData(null);
207
+ setLastSyncedAt(null);
208
+ }
209
+ }, [cache]);
210
+ // Determine what to return
110
211
  if (isOnline && Array.isArray(shapeData) && shapeData.length > 0) {
111
212
  return {
112
213
  data: toRecords(shapeData),
@@ -114,16 +215,16 @@ export function useOfflineCache(options) {
114
215
  isSyncing: shape.isLoading,
115
216
  lastSyncedAt,
116
217
  error: shape.error || null,
218
+ invalidate,
117
219
  };
118
220
  }
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;
221
+ // Offline or shape has not loaded yet: use cached data
122
222
  return {
123
- data: cached?.data ?? [],
223
+ data: cachedData ?? [],
124
224
  isOnline,
125
225
  isSyncing: isOnline && shape.isLoading,
126
- lastSyncedAt: lastSyncedAt ?? cachedSyncDate,
226
+ lastSyncedAt,
127
227
  error: shape.error || null,
228
+ invalidate,
128
229
  };
129
230
  }
@@ -32,7 +32,7 @@ export function useOnlineStatus() {
32
32
  const [lastOnlineAt, setLastOnlineAt] = useState(null);
33
33
  const resetTimerRef = useRef(null);
34
34
  useEffect(() => {
35
- // No-op during SSRthe effect only runs in the browser.
35
+ // No-op during SSR - the effect only runs in the browser.
36
36
  if (!isBrowser()) {
37
37
  return;
38
38
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Invalidation action triggered when shape data changes.
3
+ * Maps to CacheInvalidationChannel event types.
4
+ */
5
+ export type InvalidationAction = {
6
+ type: 'delete';
7
+ keys: string[];
8
+ } | {
9
+ type: 'delete-prefix';
10
+ prefix: string;
11
+ } | {
12
+ type: 'delete-tags';
13
+ tags: string[];
14
+ } | {
15
+ type: 'clear';
16
+ };
17
+ interface UseShapeCacheInvalidationOptions {
18
+ /** ElectricSQL shape subscription URL to watch. */
19
+ shapeUrl: string;
20
+ /**
21
+ * Determine which cache entries to invalidate when shape data changes.
22
+ * Receives the new data from the shape subscription and returns one or
23
+ * more invalidation actions.
24
+ */
25
+ getInvalidations: (data: unknown[]) => InvalidationAction[];
26
+ /**
27
+ * Callback to execute the invalidation actions. Typically this calls
28
+ * methods on a CacheStore or CacheInvalidationChannel instance.
29
+ */
30
+ onInvalidate: (actions: InvalidationAction[]) => Promise<void>;
31
+ /** Whether invalidation is enabled (default: true). */
32
+ enabled?: boolean;
33
+ }
34
+ /**
35
+ * Subscribe to an ElectricSQL shape and trigger cache invalidation
36
+ * when the shape data changes.
37
+ *
38
+ * This hook bridges ElectricSQL's real-time sync with the cache
39
+ * invalidation system, enabling push-based cache busting without polling.
40
+ *
41
+ * Example:
42
+ * useShapeCacheInvalidation({
43
+ * shapeUrl: `${ELECTRIC_URL}/v1/shape?table=posts`,
44
+ * getInvalidations: (data) => [
45
+ * { type: 'delete-tags', tags: ['posts'] },
46
+ * ],
47
+ * onInvalidate: async (actions) => {
48
+ * for (const action of actions) {
49
+ * if (action.type === 'delete-tags') {
50
+ * await channel.publishDeleteTags(action.tags);
51
+ * }
52
+ * }
53
+ * },
54
+ * });
55
+ */
56
+ export declare function useShapeCacheInvalidation(options: UseShapeCacheInvalidationOptions): void;
57
+ export {};
58
+ //# sourceMappingURL=useShapeCacheInvalidation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useShapeCacheInvalidation.d.ts","sourceRoot":"","sources":["../../src/hooks/useShapeCacheInvalidation.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtB,UAAU,gCAAgC;IACxC,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,kBAAkB,EAAE,CAAC;IAC5D;;;OAGG;IACH,YAAY,EAAE,CAAC,OAAO,EAAE,kBAAkB,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,gCAAgC,GAAG,IAAI,CAyCzF"}
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+ import { useShape } from '@electric-sql/react';
3
+ import { useEffect, useRef } from 'react';
4
+ import { fetchWithTimeout } from '../fetch-with-timeout.js';
5
+ /**
6
+ * Subscribe to an ElectricSQL shape and trigger cache invalidation
7
+ * when the shape data changes.
8
+ *
9
+ * This hook bridges ElectricSQL's real-time sync with the cache
10
+ * invalidation system, enabling push-based cache busting without polling.
11
+ *
12
+ * Example:
13
+ * useShapeCacheInvalidation({
14
+ * shapeUrl: `${ELECTRIC_URL}/v1/shape?table=posts`,
15
+ * getInvalidations: (data) => [
16
+ * { type: 'delete-tags', tags: ['posts'] },
17
+ * ],
18
+ * onInvalidate: async (actions) => {
19
+ * for (const action of actions) {
20
+ * if (action.type === 'delete-tags') {
21
+ * await channel.publishDeleteTags(action.tags);
22
+ * }
23
+ * }
24
+ * },
25
+ * });
26
+ */
27
+ export function useShapeCacheInvalidation(options) {
28
+ const { shapeUrl, getInvalidations, onInvalidate, enabled = true } = options;
29
+ const shape = useShape({ url: shapeUrl, fetchClient: fetchWithTimeout });
30
+ const prevDataRef = useRef(null);
31
+ const mounted = useRef(true);
32
+ useEffect(() => {
33
+ mounted.current = true;
34
+ return () => {
35
+ mounted.current = false;
36
+ };
37
+ }, []);
38
+ const shapeData = shape.data;
39
+ useEffect(() => {
40
+ if (!enabled)
41
+ return;
42
+ if (shape.isLoading)
43
+ return;
44
+ if (!Array.isArray(shapeData))
45
+ return;
46
+ // Skip the initial load (no previous data to compare against)
47
+ if (prevDataRef.current === null) {
48
+ prevDataRef.current = shapeData;
49
+ return;
50
+ }
51
+ // Skip if data reference hasn't changed
52
+ if (prevDataRef.current === shapeData)
53
+ return;
54
+ prevDataRef.current = shapeData;
55
+ const actions = getInvalidations(shapeData);
56
+ if (actions.length === 0)
57
+ return;
58
+ if (mounted.current) {
59
+ onInvalidate(actions).catch(() => {
60
+ // Invalidation is best-effort; stale cache entries will expire via TTL
61
+ });
62
+ }
63
+ }, [shapeData, shape.isLoading, enabled, getInvalidations, onInvalidate]);
64
+ }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,18 @@
1
1
  /**
2
- * @revealui/syncReal-time collaboration and sync primitives.
2
+ * @revealui/sync - Real-time collaboration and sync primitives.
3
3
  *
4
4
  * The collab layer (Yjs-based) is fully functional.
5
5
  * ElectricProvider provides proxyBaseUrl config to child hooks. All hooks route
6
- * through the authenticated CMS proxy at /api/shapes/*no direct Electric client.
6
+ * through the authenticated admin proxy at /api/shapes/* - no direct Electric client.
7
7
  *
8
8
  * Reads use ElectricSQL shape subscriptions for real-time updates.
9
- * Writes use REST mutations via /api/sync/*changes propagate to all
9
+ * Writes use REST mutations via /api/sync/* - changes propagate to all
10
10
  * subscribers automatically through ElectricSQL replication.
11
11
  */
12
12
  export type { CollabDocumentState, UseCollaborationOptions, UseCollaborationResult, } from './collab/index.js';
13
13
  export { CollabProvider, useCollabDocument, useCollaboration, } from './collab/index.js';
14
+ export type { ConflictInfo, ConflictStrategy, OfflineMutation, ReplayResult, } from './conflict-resolution.js';
15
+ export { coalesceMutations, replayMutations, resolveConflict, } from './conflict-resolution.js';
14
16
  export type { AgentContextRecord, CreateAgentContextInput, UpdateAgentContextInput, UseAgentContextsResult, } from './hooks/useAgentContexts.js';
15
17
  export { useAgentContexts } from './hooks/useAgentContexts.js';
16
18
  export type { AgentMemoryRecord, CreateAgentMemoryInput, UpdateAgentMemoryInput, UseAgentMemoryResult, } from './hooks/useAgentMemory.js';
@@ -21,6 +23,11 @@ export type { CoordinationSessionRecord, CreateCoordinationSessionInput, UpdateC
21
23
  export { useCoordinationSessions } from './hooks/useCoordinationSessions.js';
22
24
  export type { CoordinationWorkItemRecord, CreateCoordinationWorkItemInput, UpdateCoordinationWorkItemInput, UseCoordinationWorkItemsResult, } from './hooks/useCoordinationWorkItems.js';
23
25
  export { useCoordinationWorkItems } from './hooks/useCoordinationWorkItems.js';
26
+ export { useOfflineCache } from './hooks/useOfflineCache.js';
27
+ export type { OnlineStatusResult } from './hooks/useOnlineStatus.js';
28
+ export { useOnlineStatus } from './hooks/useOnlineStatus.js';
29
+ export type { InvalidationAction } from './hooks/useShapeCacheInvalidation.js';
30
+ export { useShapeCacheInvalidation } from './hooks/useShapeCacheInvalidation.js';
24
31
  export type { MutationResult } from './mutations.js';
25
32
  export { ElectricProvider, useElectricConfig } from './provider/index.js';
26
33
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,YAAY,EACV,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC/D,YAAY,EACV,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAC3D,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC/D,YAAY,EACV,yBAAyB,EACzB,8BAA8B,EAC9B,8BAA8B,EAC9B,6BAA6B,GAC9B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,uBAAuB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,YAAY,EACV,0BAA0B,EAC1B,+BAA+B,EAC/B,+BAA+B,EAC/B,8BAA8B,GAC/B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,YAAY,EACV,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,YAAY,GACb,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,eAAe,GAChB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC/D,YAAY,EACV,iBAAiB,EACjB,sBAAsB,EACtB,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAC3D,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC/D,YAAY,EACV,yBAAyB,EACzB,8BAA8B,EAC9B,8BAA8B,EAC9B,6BAA6B,GAC9B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,uBAAuB,EAAE,MAAM,oCAAoC,CAAC;AAC7E,YAAY,EACV,0BAA0B,EAC1B,+BAA+B,EAC/B,+BAA+B,EAC/B,8BAA8B,GAC/B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EAAE,wBAAwB,EAAE,MAAM,qCAAqC,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACrE,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,YAAY,EAAE,kBAAkB,EAAE,MAAM,sCAAsC,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,sCAAsC,CAAC;AACjF,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,18 +1,22 @@
1
1
  /**
2
- * @revealui/syncReal-time collaboration and sync primitives.
2
+ * @revealui/sync - Real-time collaboration and sync primitives.
3
3
  *
4
4
  * The collab layer (Yjs-based) is fully functional.
5
5
  * ElectricProvider provides proxyBaseUrl config to child hooks. All hooks route
6
- * through the authenticated CMS proxy at /api/shapes/*no direct Electric client.
6
+ * through the authenticated admin proxy at /api/shapes/* - no direct Electric client.
7
7
  *
8
8
  * Reads use ElectricSQL shape subscriptions for real-time updates.
9
- * Writes use REST mutations via /api/sync/*changes propagate to all
9
+ * Writes use REST mutations via /api/sync/* - changes propagate to all
10
10
  * subscribers automatically through ElectricSQL replication.
11
11
  */
12
12
  export { CollabProvider, useCollabDocument, useCollaboration, } from './collab/index.js';
13
+ export { coalesceMutations, replayMutations, resolveConflict, } from './conflict-resolution.js';
13
14
  export { useAgentContexts } from './hooks/useAgentContexts.js';
14
15
  export { useAgentMemory } from './hooks/useAgentMemory.js';
15
16
  export { useConversations } from './hooks/useConversations.js';
16
17
  export { useCoordinationSessions } from './hooks/useCoordinationSessions.js';
17
18
  export { useCoordinationWorkItems } from './hooks/useCoordinationWorkItems.js';
19
+ export { useOfflineCache } from './hooks/useOfflineCache.js';
20
+ export { useOnlineStatus } from './hooks/useOnlineStatus.js';
21
+ export { useShapeCacheInvalidation } from './hooks/useShapeCacheInvalidation.js';
18
22
  export { ElectricProvider, useElectricConfig } from './provider/index.js';
package/dist/mutations.js CHANGED
@@ -4,7 +4,7 @@ import { useElectricConfig } from './provider/index.js';
4
4
  /** Default timeout for mutation fetch requests (milliseconds). */
5
5
  const MUTATION_FETCH_TIMEOUT_MS = 10_000;
6
6
  /**
7
- * Make an authenticated mutation request to the CMS API.
7
+ * Make an authenticated mutation request to the admin API.
8
8
  * Credentials are included so the session cookie is sent cross-origin.
9
9
  * Requests are aborted after {@link MUTATION_FETCH_TIMEOUT_MS} (10 s).
10
10
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Offline mutation queuestores pending mutations in localStorage
2
+ * Offline mutation queue - stores pending mutations in localStorage
3
3
  * so they survive page reloads and can be flushed when connectivity returns.
4
4
  */
5
5
  /** A single pending mutation waiting to be sent to the server. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Offline mutation queuestores pending mutations in localStorage
2
+ * Offline mutation queue - stores pending mutations in localStorage
3
3
  * so they survive page reloads and can be flushed when connectivity returns.
4
4
  */
5
5
  /** localStorage key for the persisted queue. */
@@ -57,7 +57,7 @@ function writeQueue(queue) {
57
57
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue));
58
58
  }
59
59
  catch {
60
- // Quota exceeded or private browsingdrop silently.
60
+ // Quota exceeded or private browsing - drop silently.
61
61
  }
62
62
  }
63
63
  /**
@@ -120,7 +120,7 @@ export class OfflineMutationQueue {
120
120
  window.localStorage.removeItem(STORAGE_KEY);
121
121
  }
122
122
  catch {
123
- // Ignoresame guard as writeQueue.
123
+ // Ignore - same guard as writeQueue.
124
124
  }
125
125
  }
126
126
  }
@@ -2,14 +2,14 @@ import { type ReactNode } from 'react';
2
2
  interface ElectricContextValue {
3
3
  /**
4
4
  * Direct Electric service URL (e.g. the Railway instance).
5
- * Stored in context for future usenot consumed by the current proxy-based hooks.
5
+ * Stored in context for future use - not consumed by the current proxy-based hooks.
6
6
  * All hooks use proxyBaseUrl + /api/shapes/* instead.
7
7
  */
8
8
  serviceUrl: string | null;
9
9
  /**
10
- * Base URL prefix for authenticated CMS shape proxy routes.
10
+ * Base URL prefix for authenticated admin shape proxy routes.
11
11
  * Default '' keeps all hook URLs relative (works for same-origin apps).
12
- * Set to 'https://cms.revealui.com' when consuming from a different origin.
12
+ * Set to 'https://admin.revealui.com' when consuming from a different origin.
13
13
  */
14
14
  proxyBaseUrl: string;
15
15
  debug: boolean;
@@ -18,7 +18,7 @@ interface ElectricContextValue {
18
18
  * Provides ElectricSQL configuration to child hooks (`useConversations`, `useCollabDocument`).
19
19
  *
20
20
  * Provides proxyBaseUrl (and optional serviceUrl/debug) to child hooks via context.
21
- * All hooks use the CMS proxy patternno direct Electric connection is established here.
21
+ * All hooks use the admin proxy pattern - no direct Electric connection is established here.
22
22
  */
23
23
  export declare function ElectricProvider(props: {
24
24
  children: ReactNode;
@@ -10,7 +10,7 @@ const ElectricContext = createContext({
10
10
  * Provides ElectricSQL configuration to child hooks (`useConversations`, `useCollabDocument`).
11
11
  *
12
12
  * Provides proxyBaseUrl (and optional serviceUrl/debug) to child hooks via context.
13
- * All hooks use the CMS proxy patternno direct Electric connection is established here.
13
+ * All hooks use the admin proxy pattern - no direct Electric connection is established here.
14
14
  */
15
15
  export function ElectricProvider(props) {
16
16
  const value = useMemo(() => ({
package/package.json CHANGED
@@ -1,28 +1,29 @@
1
1
  {
2
2
  "name": "@revealui/sync",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "ElectricSQL sync utilities for RevealUI",
5
5
  "license": "MIT",
6
6
  "dependencies": {
7
- "@electric-sql/react": "^1.0.27",
7
+ "@electric-sql/react": "^1.0.43",
8
8
  "lib0": "^0.2.117",
9
- "ws": "^8.18.0",
9
+ "ws": "^8.20.0",
10
10
  "y-protocols": "^1.0.7",
11
- "yjs": "^13.6.29",
12
- "@revealui/contracts": "1.3.5",
13
- "@revealui/db": "0.3.5"
11
+ "yjs": "^13.6.30",
12
+ "@revealui/cache": "0.1.4",
13
+ "@revealui/contracts": "1.3.7",
14
+ "@revealui/db": "0.3.7"
14
15
  },
15
16
  "devDependencies": {
16
- "@testing-library/jest-dom": "^6.6.4",
17
- "@testing-library/react": "^16.3.0",
18
- "@types/ws": "^8.5.14",
19
- "@vitest/coverage-v8": "^4.1.0",
20
- "jsdom": "^29.0.1",
21
- "react": "^19.2.3",
22
- "react-dom": "^19.2.3",
17
+ "@testing-library/jest-dom": "^6.9.1",
18
+ "@testing-library/react": "^16.3.2",
19
+ "@types/ws": "^8.18.1",
20
+ "@vitest/coverage-v8": "^4.1.3",
21
+ "jsdom": "29.0.1",
22
+ "react": "^19.2.5",
23
+ "react-dom": "^19.2.5",
23
24
  "typescript": "^6.0.2",
24
- "vitest": "^4.1.0",
25
- "dev": "0.0.1"
25
+ "vitest": "^4.1.3",
26
+ "@revealui/dev": "0.1.0"
26
27
  },
27
28
  "engines": {
28
29
  "node": ">=24.13.0"
@@ -56,6 +57,23 @@
56
57
  },
57
58
  "type": "module",
58
59
  "types": "./dist/index.d.ts",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/RevealUIStudio/revealui.git",
63
+ "directory": "packages/sync"
64
+ },
65
+ "homepage": "https://revealui.com",
66
+ "author": "RevealUI Studio <founder@revealui.com>",
67
+ "bugs": {
68
+ "url": "https://github.com/RevealUIStudio/revealui/issues"
69
+ },
70
+ "keywords": [
71
+ "revealui",
72
+ "sync",
73
+ "real-time",
74
+ "electricsql",
75
+ "collaborative"
76
+ ],
59
77
  "scripts": {
60
78
  "build": "tsc",
61
79
  "clean": "rm -rf dist",