@revealui/sync 0.2.0

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.
Files changed (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/dist/collab/agent-client-factory.d.ts +16 -0
  4. package/dist/collab/agent-client-factory.d.ts.map +1 -0
  5. package/dist/collab/agent-client-factory.js +24 -0
  6. package/dist/collab/agent-client.d.ts +55 -0
  7. package/dist/collab/agent-client.d.ts.map +1 -0
  8. package/dist/collab/agent-client.js +253 -0
  9. package/dist/collab/index.d.ts +7 -0
  10. package/dist/collab/index.d.ts.map +1 -0
  11. package/dist/collab/index.js +3 -0
  12. package/dist/collab/server-index.d.ts +5 -0
  13. package/dist/collab/server-index.d.ts.map +1 -0
  14. package/dist/collab/server-index.js +2 -0
  15. package/dist/collab/use-collab-document.d.ts +8 -0
  16. package/dist/collab/use-collab-document.d.ts.map +1 -0
  17. package/dist/collab/use-collab-document.js +46 -0
  18. package/dist/collab/use-collaboration.d.ts +26 -0
  19. package/dist/collab/use-collaboration.d.ts.map +1 -0
  20. package/dist/collab/use-collaboration.js +65 -0
  21. package/dist/collab/yjs-websocket-provider.d.ts +36 -0
  22. package/dist/collab/yjs-websocket-provider.d.ts.map +1 -0
  23. package/dist/collab/yjs-websocket-provider.js +173 -0
  24. package/dist/hooks/useConversations.d.ts +11 -0
  25. package/dist/hooks/useConversations.d.ts.map +1 -0
  26. package/dist/hooks/useConversations.js +16 -0
  27. package/dist/index.d.ts +5 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +3 -0
  30. package/dist/provider/index.d.ts +7 -0
  31. package/dist/provider/index.d.ts.map +1 -0
  32. package/dist/provider/index.js +8 -0
  33. package/dist/test-setup.d.ts +2 -0
  34. package/dist/test-setup.d.ts.map +1 -0
  35. package/dist/test-setup.js +1 -0
  36. package/package.json +67 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 RevealUI Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # @revealui/sync
2
+
3
+ **Status:** 🟡 Active Development | ⚠️ NOT Production Ready
4
+
5
+ See [Project Status](../../docs/PROJECT_STATUS.md) for framework readiness.
6
+
7
+ ElectricSQL sync utilities for RevealUI - real-time data synchronization with local-first architecture.
8
+
9
+ ## Features
10
+
11
+ - **ElectricSQL Integration**: Real-time sync with ElectricSQL
12
+ - **React Hooks**: Use sync data in React components
13
+ - **Type-safe**: Full TypeScript support with database types
14
+ - **Local-first**: Works offline, syncs when online
15
+ - **React Provider**: Easy setup with `ElectricProvider`
16
+ - **Optimistic Updates**: Instant UI updates with server reconciliation
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pnpm add @revealui/sync
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Setup Provider
27
+
28
+ Wrap your app with `ElectricProvider`:
29
+
30
+ ```typescript
31
+ import { ElectricProvider } from '@revealui/sync/provider'
32
+
33
+ export default function App() {
34
+ return (
35
+ <ElectricProvider>
36
+ <YourComponents />
37
+ </ElectricProvider>
38
+ )
39
+ }
40
+ ```
41
+
42
+ ### Use Synced Data
43
+
44
+ ```typescript
45
+ import { useAgentContexts } from '@revealui/sync'
46
+
47
+ function MyComponent() {
48
+ const { data: contexts, isLoading } = useAgentContexts()
49
+
50
+ if (isLoading) return <div>Loading...</div>
51
+
52
+ return (
53
+ <ul>
54
+ {contexts.map(context => (
55
+ <li key={context.id}>{context.name}</li>
56
+ ))}
57
+ </ul>
58
+ )
59
+ }
60
+ ```
61
+
62
+ ### Mutations
63
+
64
+ ```typescript
65
+ import { useAgentContexts } from '@revealui/sync'
66
+
67
+ function CreateContext() {
68
+ const { create } = useAgentContexts()
69
+
70
+ const handleCreate = async () => {
71
+ await create({
72
+ name: 'New Context',
73
+ agent_id: 'agent-123',
74
+ // ... other fields
75
+ })
76
+ }
77
+
78
+ return <button onClick={handleCreate}>Create</button>
79
+ }
80
+ ```
81
+
82
+ ## Available Hooks
83
+
84
+ ### `useAgentContexts()`
85
+
86
+ Sync agent contexts (task context, working memory, etc.)
87
+
88
+ ```typescript
89
+ const {
90
+ data, // Agent contexts array
91
+ isLoading, // Loading state
92
+ error, // Error state
93
+ create, // Create function
94
+ update, // Update function
95
+ remove // Delete function
96
+ } = useAgentContexts()
97
+ ```
98
+
99
+ ### `useAgentMemory()`
100
+
101
+ Sync agent memory (episodic, semantic, working)
102
+
103
+ ```typescript
104
+ const {
105
+ data, // Memory entries array
106
+ isLoading,
107
+ error,
108
+ create,
109
+ update,
110
+ remove
111
+ } = useAgentMemory()
112
+ ```
113
+
114
+ ### `useConversations()`
115
+
116
+ Sync conversation history
117
+
118
+ ```typescript
119
+ const {
120
+ data, // Conversations array
121
+ isLoading,
122
+ error,
123
+ create,
124
+ update,
125
+ remove
126
+ } = useConversations()
127
+ ```
128
+
129
+ ## How It Works
130
+
131
+ 1. **ElectricSQL Service**: Runs as a sync service between Postgres and clients
132
+ 2. **Shape Subscriptions**: Subscribe to "shapes" of data (queries)
133
+ 3. **Local Cache**: Data cached locally in browser
134
+ 4. **Real-time Updates**: Changes propagate instantly to all connected clients
135
+ 5. **Conflict Resolution**: CRDT-based conflict resolution for offline edits
136
+
137
+ ## Environment Variables
138
+
139
+ ```env
140
+ # ElectricSQL service URL
141
+ NEXT_PUBLIC_ELECTRIC_SERVICE_URL=http://localhost:5133
142
+
143
+ # Optional: Server-side Electric URL (if different)
144
+ ELECTRIC_SERVICE_URL=http://localhost:5133
145
+ ```
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ # Build package
151
+ pnpm --filter @revealui/sync build
152
+
153
+ # Watch mode
154
+ pnpm --filter @revealui/sync dev
155
+
156
+ # Run tests
157
+ pnpm --filter @revealui/sync test
158
+
159
+ # Type check
160
+ pnpm --filter @revealui/sync typecheck
161
+ ```
162
+
163
+ ## Testing
164
+
165
+ ```bash
166
+ # Run all tests
167
+ pnpm --filter @revealui/sync test
168
+
169
+ # Watch mode
170
+ pnpm --filter @revealui/sync test:watch
171
+
172
+ # Coverage
173
+ pnpm --filter @revealui/sync test:coverage
174
+ ```
175
+
176
+ ## Architecture
177
+
178
+ ```
179
+ ┌─────────────────┐
180
+ │ React Component │
181
+ │ (useAgentContexts) │
182
+ └────────┬────────┘
183
+
184
+
185
+ ┌─────────────────┐
186
+ │ ElectricSQL │
187
+ │ Shape Hook │
188
+ └────────┬────────┘
189
+
190
+
191
+ ┌─────────────────┐
192
+ │ Electric Sync │
193
+ │ Service │
194
+ └────────┬────────┘
195
+
196
+
197
+ ┌─────────────────┐
198
+ │ PostgreSQL │
199
+ │ Database │
200
+ └─────────────────┘
201
+ ```
202
+
203
+ ## Limitations
204
+
205
+ ⚠️ **CRITICAL**: ElectricSQL API endpoints need independent verification before production use.
206
+
207
+ **Status:** ⚠️ NEEDS VERIFICATION
208
+ - ElectricSQL integration exists but requires testing
209
+ - API endpoints based on assumptions
210
+ - No integration tests performed yet
211
+ - Not recommended for production until verified
212
+
213
+ See [Project Roadmap](../../docs/PROJECT_ROADMAP.md) and [Production Readiness](../../docs/PRODUCTION_READINESS.md) for details.
214
+
215
+ ## Related Documentation
216
+
217
+ - [ElectricSQL Documentation](https://electric-sql.com/docs) - Official ElectricSQL docs
218
+ - [Architecture](../../docs/ARCHITECTURE.md) - System architecture overview
219
+ - [Database Guide](../../docs/DATABASE.md) - Database setup
220
+
221
+ ## License
222
+
223
+ MIT
@@ -0,0 +1,16 @@
1
+ import { AgentCollabClient } from './agent-client.js';
2
+ export interface CreateAgentClientOptions {
3
+ serverUrl: string;
4
+ documentId: string;
5
+ name?: string;
6
+ model?: string;
7
+ color?: string;
8
+ authToken?: string;
9
+ autoReconnect?: boolean;
10
+ defaultTextName?: string;
11
+ }
12
+ export declare function createAgentClient(options: CreateAgentClientOptions): AgentCollabClient;
13
+ export declare function createAndConnectAgentClient(options: CreateAgentClientOptions & {
14
+ syncTimeoutMs?: number;
15
+ }): Promise<AgentCollabClient>;
16
+ //# sourceMappingURL=agent-client-factory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-client-factory.d.ts","sourceRoot":"","sources":["../../src/collab/agent-client-factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAsB,MAAM,mBAAmB,CAAA;AAIzE,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,iBAAiB,CAgBtF;AAED,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,wBAAwB,GAAG;IAAE,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAK5B"}
@@ -0,0 +1,24 @@
1
+ import { AgentCollabClient } from './agent-client.js';
2
+ const DEFAULT_AGENT_COLOR = '#8B5CF6';
3
+ export function createAgentClient(options) {
4
+ const identity = {
5
+ type: 'agent',
6
+ name: options.name ?? 'AI Agent',
7
+ model: options.model ?? 'unknown',
8
+ color: options.color ?? DEFAULT_AGENT_COLOR,
9
+ };
10
+ return new AgentCollabClient({
11
+ serverUrl: options.serverUrl,
12
+ documentId: options.documentId,
13
+ identity,
14
+ authToken: options.authToken,
15
+ autoReconnect: options.autoReconnect,
16
+ defaultTextName: options.defaultTextName,
17
+ });
18
+ }
19
+ export async function createAndConnectAgentClient(options) {
20
+ const client = createAgentClient(options);
21
+ client.connect();
22
+ await client.waitForSync(options.syncTimeoutMs ?? 5000);
23
+ return client;
24
+ }
@@ -0,0 +1,55 @@
1
+ import { Observable } from 'lib0/observable';
2
+ import * as awarenessProtocol from 'y-protocols/awareness';
3
+ import * as Y from 'yjs';
4
+ export interface AgentIdentity {
5
+ type: 'agent';
6
+ name: string;
7
+ model: string;
8
+ color: string;
9
+ }
10
+ export interface AgentCollabClientOptions {
11
+ serverUrl: string;
12
+ documentId: string;
13
+ identity: AgentIdentity;
14
+ authToken?: string;
15
+ autoReconnect?: boolean;
16
+ defaultTextName?: string;
17
+ }
18
+ export declare class AgentCollabClient extends Observable<string> {
19
+ readonly doc: Y.Doc;
20
+ readonly awareness: awarenessProtocol.Awareness;
21
+ private readonly serverUrl;
22
+ private readonly documentId;
23
+ private readonly identity;
24
+ private readonly authToken?;
25
+ private readonly autoReconnect;
26
+ private readonly defaultTextName;
27
+ private ws;
28
+ private synced;
29
+ private reconnectAttempts;
30
+ private reconnectTimer;
31
+ private updateHandler;
32
+ private awarenessUpdateHandler;
33
+ private destroyed;
34
+ constructor(options: AgentCollabClientOptions);
35
+ connect(): void;
36
+ disconnect(): void;
37
+ getDocument(): Y.Doc;
38
+ getText(name?: string): Y.Text;
39
+ getTextContent(name?: string): string;
40
+ insertText(index: number, content: string, name?: string): void;
41
+ deleteText(index: number, length: number, name?: string): void;
42
+ replaceAll(content: string, name?: string): void;
43
+ onUpdate(callback: (update: Uint8Array) => void): () => void;
44
+ getConnectedUsers(): Map<number, Record<string, unknown>>;
45
+ waitForSync(timeoutMs?: number): Promise<void>;
46
+ destroy(): void;
47
+ private buildWebSocketUrl;
48
+ private handleOpen;
49
+ private handleMessage;
50
+ private handleClose;
51
+ private handleError;
52
+ private scheduleReconnect;
53
+ private cancelReconnect;
54
+ }
55
+ //# sourceMappingURL=agent-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-client.d.ts","sourceRoot":"","sources":["../../src/collab/agent-client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAE5C,OAAO,KAAK,iBAAiB,MAAM,uBAAuB,CAAA;AAE1D,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAQxB,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,OAAO,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,aAAa,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,qBAAa,iBAAkB,SAAQ,UAAU,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,CAAA;IACnB,QAAQ,CAAC,SAAS,EAAE,iBAAiB,CAAC,SAAS,CAAA;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAQ;IAExC,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,iBAAiB,CAAI;IAC7B,OAAO,CAAC,cAAc,CAA6C;IACnE,OAAO,CAAC,aAAa,CAA+D;IACpF,OAAO,CAAC,sBAAsB,CAGrB;IACT,OAAO,CAAC,SAAS,CAAQ;gBAEb,OAAO,EAAE,wBAAwB;IA0B7C,OAAO,IAAI,IAAI;IAcf,UAAU,IAAI,IAAI;IAkBlB,WAAW,IAAI,CAAC,CAAC,GAAG;IAIpB,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,IAAI;IAI9B,cAAc,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAMrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAI/D,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAI9D,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAQhD,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI;IAU5D,iBAAiB,IAAI,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAUzD,WAAW,CAAC,SAAS,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB5C,OAAO,IAAI,IAAI;IASf,OAAO,CAAC,iBAAiB;IAazB,OAAO,CAAC,UAAU;IAoClB,OAAO,CAAC,aAAa;IA+BrB,OAAO,CAAC,WAAW;IAenB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,eAAe;CAOxB"}
@@ -0,0 +1,253 @@
1
+ import * as decoding from 'lib0/decoding';
2
+ import * as encoding from 'lib0/encoding';
3
+ import { Observable } from 'lib0/observable';
4
+ import WebSocket from 'ws';
5
+ import * as awarenessProtocol from 'y-protocols/awareness';
6
+ import * as syncProtocol from 'y-protocols/sync';
7
+ import * as Y from 'yjs';
8
+ const MESSAGE_SYNC = 0;
9
+ const MESSAGE_AWARENESS = 1;
10
+ const MAX_RECONNECT_ATTEMPTS = 5;
11
+ const BASE_RECONNECT_DELAY = 1000;
12
+ const RECONNECT_MULTIPLIER = 2;
13
+ export class AgentCollabClient extends Observable {
14
+ doc;
15
+ awareness;
16
+ serverUrl;
17
+ documentId;
18
+ identity;
19
+ authToken;
20
+ autoReconnect;
21
+ defaultTextName;
22
+ ws = null;
23
+ synced = false;
24
+ reconnectAttempts = 0;
25
+ reconnectTimer = null;
26
+ updateHandler = null;
27
+ awarenessUpdateHandler;
28
+ destroyed = false;
29
+ constructor(options) {
30
+ super();
31
+ this.serverUrl = options.serverUrl;
32
+ this.documentId = options.documentId;
33
+ this.identity = options.identity;
34
+ this.authToken = options.authToken;
35
+ this.autoReconnect = options.autoReconnect ?? true;
36
+ this.defaultTextName = options.defaultTextName ?? 'content';
37
+ this.doc = new Y.Doc();
38
+ this.awareness = new awarenessProtocol.Awareness(this.doc);
39
+ this.awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
40
+ if (origin === 'remote')
41
+ return;
42
+ const changedClients = added.concat(updated, removed);
43
+ const update = awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients);
44
+ const encoder = encoding.createEncoder();
45
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
46
+ encoding.writeVarUint8Array(encoder, update);
47
+ if (this.ws?.readyState === WebSocket.OPEN) {
48
+ this.ws.send(encoding.toUint8Array(encoder));
49
+ }
50
+ };
51
+ this.awareness.on('update', this.awarenessUpdateHandler);
52
+ }
53
+ connect() {
54
+ if (this.ws || this.destroyed)
55
+ return;
56
+ const url = this.buildWebSocketUrl();
57
+ this.ws = new WebSocket(url);
58
+ this.emit('status', [{ status: 'connecting' }]);
59
+ this.ws.on('open', () => this.handleOpen());
60
+ this.ws.on('message', (data) => this.handleMessage(data));
61
+ this.ws.on('close', () => this.handleClose());
62
+ this.ws.on('error', (err) => this.handleError(err));
63
+ }
64
+ disconnect() {
65
+ this.cancelReconnect();
66
+ if (this.updateHandler) {
67
+ this.doc.off('update', this.updateHandler);
68
+ this.updateHandler = null;
69
+ }
70
+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'local');
71
+ if (this.ws) {
72
+ this.ws.removeAllListeners();
73
+ this.ws.close();
74
+ this.ws = null;
75
+ }
76
+ this.synced = false;
77
+ this.emit('status', [{ status: 'disconnected' }]);
78
+ }
79
+ getDocument() {
80
+ return this.doc;
81
+ }
82
+ getText(name) {
83
+ return this.doc.getText(name ?? this.defaultTextName);
84
+ }
85
+ getTextContent(name) {
86
+ // Y.Text.toString() returns the text content — ESLint doesn't recognize this
87
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
88
+ return this.getText(name).toString();
89
+ }
90
+ insertText(index, content, name) {
91
+ this.getText(name).insert(index, content);
92
+ }
93
+ deleteText(index, length, name) {
94
+ this.getText(name).delete(index, length);
95
+ }
96
+ replaceAll(content, name) {
97
+ const text = this.getText(name);
98
+ this.doc.transact(() => {
99
+ text.delete(0, text.length);
100
+ text.insert(0, content);
101
+ });
102
+ }
103
+ onUpdate(callback) {
104
+ const handler = (update, origin) => {
105
+ if (origin !== this) {
106
+ callback(update);
107
+ }
108
+ };
109
+ this.doc.on('update', handler);
110
+ return () => this.doc.off('update', handler);
111
+ }
112
+ getConnectedUsers() {
113
+ const users = new Map();
114
+ for (const [clientId, state] of this.awareness.getStates()) {
115
+ if (state && typeof state === 'object') {
116
+ users.set(clientId, state);
117
+ }
118
+ }
119
+ return users;
120
+ }
121
+ waitForSync(timeoutMs = 5000) {
122
+ if (this.synced)
123
+ return Promise.resolve();
124
+ return new Promise((resolve, reject) => {
125
+ const timeout = setTimeout(() => {
126
+ this.off('sync', handler);
127
+ reject(new Error(`Sync timeout after ${timeoutMs}ms`));
128
+ }, timeoutMs);
129
+ const handler = (isSynced) => {
130
+ if (isSynced) {
131
+ clearTimeout(timeout);
132
+ this.off('sync', handler);
133
+ resolve();
134
+ }
135
+ };
136
+ this.on('sync', handler);
137
+ });
138
+ }
139
+ destroy() {
140
+ this.destroyed = true;
141
+ this.awareness.off('update', this.awarenessUpdateHandler);
142
+ this.disconnect();
143
+ this.awareness.destroy();
144
+ this.doc.destroy();
145
+ super.destroy();
146
+ }
147
+ buildWebSocketUrl() {
148
+ const params = new URLSearchParams({
149
+ name: this.identity.name,
150
+ color: this.identity.color,
151
+ type: 'agent',
152
+ agentModel: this.identity.model,
153
+ });
154
+ if (this.authToken) {
155
+ params.set('token', this.authToken);
156
+ }
157
+ return `${this.serverUrl}/ws/collab/${this.documentId}?${params.toString()}`;
158
+ }
159
+ handleOpen() {
160
+ this.reconnectAttempts = 0;
161
+ this.emit('status', [{ status: 'connected' }]);
162
+ const encoder = encoding.createEncoder();
163
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
164
+ syncProtocol.writeSyncStep1(encoder, this.doc);
165
+ this.ws?.send(encoding.toUint8Array(encoder));
166
+ this.awareness.setLocalState({
167
+ name: this.identity.name,
168
+ color: this.identity.color,
169
+ type: 'agent',
170
+ agentModel: this.identity.model,
171
+ });
172
+ const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
173
+ this.doc.clientID,
174
+ ]);
175
+ const awarenessEncoder = encoding.createEncoder();
176
+ encoding.writeVarUint(awarenessEncoder, MESSAGE_AWARENESS);
177
+ encoding.writeVarUint8Array(awarenessEncoder, awarenessUpdate);
178
+ this.ws?.send(encoding.toUint8Array(awarenessEncoder));
179
+ this.updateHandler = (update, origin) => {
180
+ if (origin === this)
181
+ return;
182
+ const updateEncoder = encoding.createEncoder();
183
+ encoding.writeVarUint(updateEncoder, MESSAGE_SYNC);
184
+ syncProtocol.writeUpdate(updateEncoder, update);
185
+ if (this.ws?.readyState === WebSocket.OPEN) {
186
+ this.ws.send(encoding.toUint8Array(updateEncoder));
187
+ }
188
+ };
189
+ this.doc.on('update', this.updateHandler);
190
+ }
191
+ handleMessage(data) {
192
+ const uint8 = data instanceof ArrayBuffer
193
+ ? new Uint8Array(data)
194
+ : Array.isArray(data)
195
+ ? new Uint8Array(Buffer.concat(data))
196
+ : new Uint8Array(data);
197
+ const decoder = decoding.createDecoder(uint8);
198
+ const messageType = decoding.readVarUint(decoder);
199
+ if (messageType === MESSAGE_SYNC) {
200
+ const responseEncoder = encoding.createEncoder();
201
+ encoding.writeVarUint(responseEncoder, MESSAGE_SYNC);
202
+ const syncMessageType = syncProtocol.readSyncMessage(decoder, responseEncoder, this.doc, this);
203
+ if (encoding.length(responseEncoder) > 1) {
204
+ this.ws?.send(encoding.toUint8Array(responseEncoder));
205
+ }
206
+ if (syncMessageType === 1 && !this.synced) {
207
+ this.synced = true;
208
+ }
209
+ this.emit('sync', [true]);
210
+ }
211
+ else if (messageType === MESSAGE_AWARENESS) {
212
+ const update = decoding.readVarUint8Array(decoder);
213
+ awarenessProtocol.applyAwarenessUpdate(this.awareness, update, 'remote');
214
+ this.emit('awareness', [this.getConnectedUsers()]);
215
+ }
216
+ }
217
+ handleClose() {
218
+ if (this.updateHandler) {
219
+ this.doc.off('update', this.updateHandler);
220
+ this.updateHandler = null;
221
+ }
222
+ this.ws = null;
223
+ this.synced = false;
224
+ this.emit('sync', [false]);
225
+ this.emit('status', [{ status: 'disconnected' }]);
226
+ if (this.autoReconnect && !this.destroyed) {
227
+ this.scheduleReconnect();
228
+ }
229
+ }
230
+ handleError(err) {
231
+ this.emit('error', [err]);
232
+ this.ws?.close();
233
+ }
234
+ scheduleReconnect() {
235
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
236
+ this.emit('status', [{ status: 'failed' }]);
237
+ return;
238
+ }
239
+ const delay = BASE_RECONNECT_DELAY * RECONNECT_MULTIPLIER ** this.reconnectAttempts;
240
+ this.reconnectAttempts++;
241
+ this.reconnectTimer = setTimeout(() => {
242
+ this.reconnectTimer = null;
243
+ this.connect();
244
+ }, delay);
245
+ }
246
+ cancelReconnect() {
247
+ if (this.reconnectTimer) {
248
+ clearTimeout(this.reconnectTimer);
249
+ this.reconnectTimer = null;
250
+ }
251
+ this.reconnectAttempts = 0;
252
+ }
253
+ }
@@ -0,0 +1,7 @@
1
+ export type { CollabDocumentState } from './use-collab-document.js';
2
+ export { useCollabDocument } from './use-collab-document.js';
3
+ export type { CollaborationIdentity, UseCollaborationOptions, UseCollaborationResult, } from './use-collaboration.js';
4
+ export { useCollaboration } from './use-collaboration.js';
5
+ export type { UserPresence } from './yjs-websocket-provider.js';
6
+ export { CollabProvider } from './yjs-websocket-provider.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/collab/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAC5D,YAAY,EACV,qBAAqB,EACrB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA"}
@@ -0,0 +1,3 @@
1
+ export { useCollabDocument } from './use-collab-document.js';
2
+ export { useCollaboration } from './use-collaboration.js';
3
+ export { CollabProvider } from './yjs-websocket-provider.js';
@@ -0,0 +1,5 @@
1
+ export type { AgentCollabClientOptions, AgentIdentity } from './agent-client.js';
2
+ export { AgentCollabClient } from './agent-client.js';
3
+ export type { CreateAgentClientOptions } from './agent-client-factory.js';
4
+ export { createAgentClient, createAndConnectAgentClient } from './agent-client-factory.js';
5
+ //# sourceMappingURL=server-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-index.d.ts","sourceRoot":"","sources":["../../src/collab/server-index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,wBAAwB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAChF,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACrD,YAAY,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAA;AACzE,OAAO,EAAE,iBAAiB,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAA"}
@@ -0,0 +1,2 @@
1
+ export { AgentCollabClient } from './agent-client.js';
2
+ export { createAgentClient, createAndConnectAgentClient } from './agent-client-factory.js';
@@ -0,0 +1,8 @@
1
+ export interface CollabDocumentState {
2
+ initialState: Uint8Array | null;
3
+ connectedClients: number;
4
+ isLoading: boolean;
5
+ error: Error | null;
6
+ }
7
+ export declare function useCollabDocument(documentId: string, electricUrl: string): CollabDocumentState;
8
+ //# sourceMappingURL=use-collab-document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-collab-document.d.ts","sourceRoot":"","sources":["../../src/collab/use-collab-document.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,UAAU,GAAG,IAAI,CAAA;IAC/B,gBAAgB,EAAE,MAAM,CAAA;IACxB,SAAS,EAAE,OAAO,CAAA;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAKD,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,mBAAmB,CA+C9F"}
@@ -0,0 +1,46 @@
1
+ import { useShape } from '@electric-sql/react';
2
+ // UUID v4 pattern — only format accepted as a yjs_documents PK
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ export function useCollabDocument(documentId, electricUrl) {
5
+ // Validate before interpolating into the WHERE clause. yjs_documents PKs are
6
+ // always UUIDs — reject anything else so no untrusted string enters the query.
7
+ const isValidId = UUID_RE.test(documentId);
8
+ // Hook must always be called (Rules of Hooks). Pass an impossible WHERE when
9
+ // the ID is invalid so the shape returns no rows but the hook still runs.
10
+ const { data, isLoading, error } = useShape({
11
+ url: `${electricUrl}/v1/shape`,
12
+ params: {
13
+ table: 'yjs_documents',
14
+ where: isValidId ? `id = '${documentId}'` : `id = 'invalid'`,
15
+ },
16
+ });
17
+ if (!isValidId) {
18
+ return {
19
+ initialState: null,
20
+ connectedClients: 0,
21
+ isLoading: false,
22
+ error: new Error('Invalid documentId: must be a UUID'),
23
+ };
24
+ }
25
+ let initialState = null;
26
+ let connectedClients = 0;
27
+ if (data && data.length > 0) {
28
+ const row = data[0];
29
+ const state = row.state;
30
+ if (state) {
31
+ const binary = atob(state);
32
+ const bytes = new Uint8Array(binary.length);
33
+ for (let i = 0; i < binary.length; i++) {
34
+ bytes[i] = binary.charCodeAt(i);
35
+ }
36
+ initialState = bytes;
37
+ }
38
+ connectedClients = row.connected_clients ?? 0;
39
+ }
40
+ return {
41
+ initialState,
42
+ connectedClients,
43
+ isLoading,
44
+ error: error ? new Error(String(error)) : null,
45
+ };
46
+ }
@@ -0,0 +1,26 @@
1
+ import * as Y from 'yjs';
2
+ import type { UserPresence } from './yjs-websocket-provider.js';
3
+ import { CollabProvider } from './yjs-websocket-provider.js';
4
+ export interface CollaborationIdentity {
5
+ name: string;
6
+ color: string;
7
+ type?: 'human' | 'agent';
8
+ agentModel?: string;
9
+ }
10
+ export interface UseCollaborationOptions {
11
+ documentId: string;
12
+ serverUrl: string;
13
+ enabled?: boolean;
14
+ initialState?: Uint8Array | null;
15
+ identity?: CollaborationIdentity;
16
+ }
17
+ export interface UseCollaborationResult {
18
+ doc: Y.Doc | null;
19
+ provider: CollabProvider | null;
20
+ synced: boolean;
21
+ status: string;
22
+ error: Error | null;
23
+ connectedUsers: Map<number, UserPresence>;
24
+ }
25
+ export declare function useCollaboration({ documentId, serverUrl, enabled, initialState, identity, }: UseCollaborationOptions): UseCollaborationResult;
26
+ //# sourceMappingURL=use-collaboration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-collaboration.d.ts","sourceRoot":"","sources":["../../src/collab/use-collaboration.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AACxB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAA;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAA;AAE5D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,YAAY,CAAC,EAAE,UAAU,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,EAAE,qBAAqB,CAAA;CACjC;AAED,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,IAAI,CAAA;IACjB,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAA;IAC/B,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;CAC1C;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,UAAU,EACV,SAAS,EACT,OAAc,EACd,YAAmB,EACnB,QAAQ,GACT,EAAE,uBAAuB,GAAG,sBAAsB,CAqElD"}
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import * as Y from 'yjs';
3
+ import { CollabProvider } from './yjs-websocket-provider.js';
4
+ export function useCollaboration({ documentId, serverUrl, enabled = true, initialState = null, identity, }) {
5
+ const [synced, setSynced] = useState(false);
6
+ const [status, setStatus] = useState('disconnected');
7
+ const [error, setError] = useState(null);
8
+ const [connectedUsers, setConnectedUsers] = useState(new Map());
9
+ const docRef = useRef(null);
10
+ const providerRef = useRef(null);
11
+ useEffect(() => {
12
+ if (!(enabled && documentId))
13
+ return;
14
+ const doc = new Y.Doc();
15
+ docRef.current = doc;
16
+ try {
17
+ const provider = new CollabProvider(serverUrl, documentId, doc, { initialState });
18
+ providerRef.current = provider;
19
+ // Set local identity for awareness
20
+ if (identity) {
21
+ provider.setLocalIdentity({
22
+ name: identity.name,
23
+ color: identity.color,
24
+ type: identity.type ?? 'human',
25
+ ...(identity.agentModel ? { agentModel: identity.agentModel } : {}),
26
+ });
27
+ }
28
+ provider.on('sync', (isSynced) => {
29
+ setSynced(isSynced);
30
+ });
31
+ provider.on('status', (event) => {
32
+ const statusEvent = event;
33
+ setStatus(statusEvent.status);
34
+ });
35
+ provider.on('awareness', (users) => {
36
+ setConnectedUsers(new Map(users));
37
+ });
38
+ provider.connect();
39
+ }
40
+ catch (err) {
41
+ setError(err instanceof Error ? err : new Error(String(err)));
42
+ }
43
+ return () => {
44
+ if (providerRef.current) {
45
+ providerRef.current.destroy();
46
+ providerRef.current = null;
47
+ }
48
+ if (docRef.current) {
49
+ docRef.current.destroy();
50
+ docRef.current = null;
51
+ }
52
+ setSynced(false);
53
+ setStatus('disconnected');
54
+ setConnectedUsers(new Map());
55
+ };
56
+ }, [documentId, serverUrl, enabled, initialState, identity]);
57
+ return {
58
+ doc: docRef.current,
59
+ provider: providerRef.current,
60
+ synced,
61
+ status,
62
+ error,
63
+ connectedUsers,
64
+ };
65
+ }
@@ -0,0 +1,36 @@
1
+ import { Observable } from 'lib0/observable';
2
+ import * as awarenessProtocol from 'y-protocols/awareness';
3
+ import * as Y from 'yjs';
4
+ export interface UserPresence {
5
+ name: string;
6
+ color: string;
7
+ type: 'human' | 'agent';
8
+ agentModel?: string;
9
+ cursor?: {
10
+ index: number;
11
+ length: number;
12
+ };
13
+ }
14
+ export declare class CollabProvider extends Observable<string> {
15
+ doc: Y.Doc;
16
+ awareness: awarenessProtocol.Awareness;
17
+ private serverUrl;
18
+ private documentId;
19
+ private ws;
20
+ private synced;
21
+ private reconnectAttempts;
22
+ private reconnectTimer;
23
+ private updateHandler;
24
+ private awarenessUpdateHandler;
25
+ constructor(serverUrl: string, documentId: string, doc: Y.Doc, options?: {
26
+ initialState?: Uint8Array | null;
27
+ });
28
+ setLocalIdentity(identity: UserPresence): void;
29
+ getConnectedUsers(): Map<number, UserPresence>;
30
+ connect(): void;
31
+ disconnect(): void;
32
+ private scheduleReconnect;
33
+ private cancelReconnect;
34
+ destroy(): void;
35
+ }
36
+ //# sourceMappingURL=yjs-websocket-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"yjs-websocket-provider.d.ts","sourceRoot":"","sources":["../../src/collab/yjs-websocket-provider.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,iBAAiB,MAAM,uBAAuB,CAAA;AAE1D,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAQxB,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;CAC3C;AAED,qBAAa,cAAe,SAAQ,UAAU,CAAC,MAAM,CAAC;IACpD,GAAG,EAAE,CAAC,CAAC,GAAG,CAAA;IACV,SAAS,EAAE,iBAAiB,CAAC,SAAS,CAAA;IACtC,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,iBAAiB,CAAI;IAC7B,OAAO,CAAC,cAAc,CAA6C;IACnE,OAAO,CAAC,aAAa,CAA+D;IACpF,OAAO,CAAC,sBAAsB,CAGrB;gBAGP,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,CAAC,CAAC,GAAG,EACV,OAAO,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,UAAU,GAAG,IAAI,CAAA;KAAE;IA2BhD,gBAAgB,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI;IAI9C,iBAAiB,IAAI,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC;IAU9C,OAAO,IAAI,IAAI;IAuFf,UAAU,IAAI,IAAI;IAmBlB,OAAO,CAAC,iBAAiB;IAezB,OAAO,CAAC,eAAe;IAQvB,OAAO,IAAI,IAAI;CAMhB"}
@@ -0,0 +1,173 @@
1
+ import * as decoding from 'lib0/decoding';
2
+ import * as encoding from 'lib0/encoding';
3
+ import { Observable } from 'lib0/observable';
4
+ import * as awarenessProtocol from 'y-protocols/awareness';
5
+ import * as syncProtocol from 'y-protocols/sync';
6
+ import * as Y from 'yjs';
7
+ const MESSAGE_SYNC = 0;
8
+ const MESSAGE_AWARENESS = 1;
9
+ const MAX_RECONNECT_ATTEMPTS = 10;
10
+ const BASE_RECONNECT_DELAY = 1000;
11
+ const RECONNECT_MULTIPLIER = 1.5;
12
+ export class CollabProvider extends Observable {
13
+ doc;
14
+ awareness;
15
+ serverUrl;
16
+ documentId;
17
+ ws = null;
18
+ synced = false;
19
+ reconnectAttempts = 0;
20
+ reconnectTimer = null;
21
+ updateHandler = null;
22
+ awarenessUpdateHandler;
23
+ constructor(serverUrl, documentId, doc, options) {
24
+ super();
25
+ this.serverUrl = serverUrl;
26
+ this.documentId = documentId;
27
+ this.doc = doc;
28
+ this.awareness = new awarenessProtocol.Awareness(doc);
29
+ if (options?.initialState) {
30
+ Y.applyUpdate(doc, options.initialState);
31
+ }
32
+ // Send awareness updates over WebSocket
33
+ this.awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
34
+ if (origin === 'remote')
35
+ return;
36
+ const changedClients = added.concat(updated, removed);
37
+ const update = awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients);
38
+ const encoder = encoding.createEncoder();
39
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
40
+ encoding.writeVarUint8Array(encoder, update);
41
+ if (this.ws?.readyState === WebSocket.OPEN) {
42
+ this.ws.send(encoding.toUint8Array(encoder));
43
+ }
44
+ };
45
+ this.awareness.on('update', this.awarenessUpdateHandler);
46
+ }
47
+ setLocalIdentity(identity) {
48
+ this.awareness.setLocalState(identity);
49
+ }
50
+ getConnectedUsers() {
51
+ const users = new Map();
52
+ for (const [clientId, state] of this.awareness.getStates()) {
53
+ if (state && typeof state === 'object') {
54
+ users.set(clientId, state);
55
+ }
56
+ }
57
+ return users;
58
+ }
59
+ connect() {
60
+ if (this.ws)
61
+ return;
62
+ const url = `${this.serverUrl}/ws/collab/${this.documentId}`;
63
+ this.ws = new WebSocket(url);
64
+ this.ws.binaryType = 'arraybuffer';
65
+ this.emit('status', [{ status: 'connecting' }]);
66
+ this.ws.onopen = () => {
67
+ this.reconnectAttempts = 0;
68
+ this.emit('status', [{ status: 'connected' }]);
69
+ const encoder = encoding.createEncoder();
70
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
71
+ syncProtocol.writeSyncStep1(encoder, this.doc);
72
+ this.ws?.send(encoding.toUint8Array(encoder));
73
+ // Send local awareness state to server
74
+ const localState = this.awareness.getLocalState();
75
+ if (localState) {
76
+ const awarenessUpdate = awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
77
+ this.doc.clientID,
78
+ ]);
79
+ const awarenessEncoder = encoding.createEncoder();
80
+ encoding.writeVarUint(awarenessEncoder, MESSAGE_AWARENESS);
81
+ encoding.writeVarUint8Array(awarenessEncoder, awarenessUpdate);
82
+ this.ws?.send(encoding.toUint8Array(awarenessEncoder));
83
+ }
84
+ this.updateHandler = (update, origin) => {
85
+ if (origin === this)
86
+ return;
87
+ const updateEncoder = encoding.createEncoder();
88
+ encoding.writeVarUint(updateEncoder, MESSAGE_SYNC);
89
+ syncProtocol.writeUpdate(updateEncoder, update);
90
+ if (this.ws?.readyState === WebSocket.OPEN) {
91
+ this.ws.send(encoding.toUint8Array(updateEncoder));
92
+ }
93
+ };
94
+ this.doc.on('update', this.updateHandler);
95
+ };
96
+ this.ws.onmessage = (event) => {
97
+ const data = new Uint8Array(event.data);
98
+ const decoder = decoding.createDecoder(data);
99
+ const messageType = decoding.readVarUint(decoder);
100
+ if (messageType === MESSAGE_SYNC) {
101
+ const responseEncoder = encoding.createEncoder();
102
+ encoding.writeVarUint(responseEncoder, MESSAGE_SYNC);
103
+ const syncMessageType = syncProtocol.readSyncMessage(decoder, responseEncoder, this.doc, this);
104
+ if (encoding.length(responseEncoder) > 1) {
105
+ this.ws?.send(encoding.toUint8Array(responseEncoder));
106
+ }
107
+ if (syncMessageType === 1 && !this.synced)
108
+ this.synced = true;
109
+ this.emit('sync', [true]);
110
+ }
111
+ else if (messageType === MESSAGE_AWARENESS) {
112
+ const update = decoding.readVarUint8Array(decoder);
113
+ awarenessProtocol.applyAwarenessUpdate(this.awareness, update, 'remote');
114
+ this.emit('awareness', [this.getConnectedUsers()]);
115
+ }
116
+ };
117
+ this.ws.onclose = () => {
118
+ if (this.updateHandler) {
119
+ this.doc.off('update', this.updateHandler);
120
+ this.updateHandler = null;
121
+ }
122
+ this.ws = null;
123
+ this.synced = false;
124
+ this.emit('sync', [false]);
125
+ this.emit('status', [{ status: 'disconnected' }]);
126
+ this.scheduleReconnect();
127
+ };
128
+ this.ws.onerror = () => {
129
+ this.ws?.close();
130
+ };
131
+ }
132
+ disconnect() {
133
+ this.cancelReconnect();
134
+ if (this.updateHandler) {
135
+ this.doc.off('update', this.updateHandler);
136
+ this.updateHandler = null;
137
+ }
138
+ // Broadcast awareness removal before disconnecting
139
+ awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'local');
140
+ if (this.ws) {
141
+ this.ws.onclose = null;
142
+ this.ws.close();
143
+ this.ws = null;
144
+ }
145
+ this.synced = false;
146
+ this.emit('status', [{ status: 'disconnected' }]);
147
+ }
148
+ scheduleReconnect() {
149
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
150
+ this.emit('status', [{ status: 'failed' }]);
151
+ return;
152
+ }
153
+ const delay = BASE_RECONNECT_DELAY * RECONNECT_MULTIPLIER ** this.reconnectAttempts;
154
+ this.reconnectAttempts++;
155
+ this.reconnectTimer = setTimeout(() => {
156
+ this.reconnectTimer = null;
157
+ this.connect();
158
+ }, delay);
159
+ }
160
+ cancelReconnect() {
161
+ if (this.reconnectTimer) {
162
+ clearTimeout(this.reconnectTimer);
163
+ this.reconnectTimer = null;
164
+ }
165
+ this.reconnectAttempts = 0;
166
+ }
167
+ destroy() {
168
+ this.awareness.off('update', this.awarenessUpdateHandler);
169
+ this.disconnect();
170
+ this.awareness.destroy();
171
+ super.destroy();
172
+ }
173
+ }
@@ -0,0 +1,11 @@
1
+ type ConversationRecord = {
2
+ id: string | number;
3
+ title?: string | null;
4
+ };
5
+ export declare function useConversations(_userId: string): {
6
+ conversations: ConversationRecord[];
7
+ isLoading: boolean;
8
+ error: Error | null;
9
+ };
10
+ export {};
11
+ //# sourceMappingURL=useConversations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useConversations.d.ts","sourceRoot":"","sources":["../../src/hooks/useConversations.ts"],"names":[],"mappings":"AAIA,KAAK,kBAAkB,GAAG;IACxB,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB,CAAA;AAID,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM;;;WAU5B,KAAK,GAAG,IAAI;EAE/B"}
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { useShape } from '@electric-sql/react';
3
+ // _userId kept for API compatibility — filtering is enforced by the server-side
4
+ // proxy at /api/shapes/conversations, which reads the session cookie directly.
5
+ export function useConversations(_userId) {
6
+ // The proxy validates the session and enforces row-level filtering server-side.
7
+ // Client-provided params are not forwarded — the proxy overrides them.
8
+ const { data, isLoading, error } = useShape({
9
+ url: `/api/shapes/conversations`,
10
+ });
11
+ return {
12
+ conversations: Array.isArray(data) ? data : [],
13
+ isLoading,
14
+ error: error,
15
+ };
16
+ }
@@ -0,0 +1,5 @@
1
+ export type { CollabDocumentState, UseCollaborationOptions, UseCollaborationResult, } from './collab/index.js';
2
+ export { CollabProvider, useCollabDocument, useCollaboration, } from './collab/index.js';
3
+ export { useConversations } from './hooks/useConversations.js';
4
+ export { ElectricProvider } from './provider/index.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAC9D,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { CollabProvider, useCollabDocument, useCollaboration, } from './collab/index.js';
2
+ export { useConversations } from './hooks/useConversations.js';
3
+ export { ElectricProvider } from './provider/index.js';
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react';
2
+ export declare function ElectricProvider({ children, }: {
3
+ children: ReactNode;
4
+ serviceUrl?: string;
5
+ debug?: boolean;
6
+ }): import("react/jsx-runtime").JSX.Element;
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/provider/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtC,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,SAAS,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB,2CAIA"}
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ // ElectricProvider placeholder - useShape works with proxy API
4
+ export function ElectricProvider({ children, }) {
5
+ // For now, just pass through children
6
+ // useShape hooks work directly with proxy API endpoints
7
+ return _jsx(_Fragment, { children: children });
8
+ }
@@ -0,0 +1,2 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ //# sourceMappingURL=test-setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-setup.d.ts","sourceRoot":"","sources":["../src/test-setup.ts"],"names":[],"mappings":"AAAA,OAAO,kCAAkC,CAAA"}
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@revealui/sync",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "description": "ElectricSQL sync utilities for RevealUI",
9
+ "dependencies": {
10
+ "@electric-sql/react": "^1.0.27",
11
+ "lib0": "^0.2.117",
12
+ "ws": "^8.18.0",
13
+ "y-protocols": "^1.0.7",
14
+ "yjs": "^13.6.29",
15
+ "@revealui/contracts": "1.0.0",
16
+ "@revealui/db": "0.2.0"
17
+ },
18
+ "devDependencies": {
19
+ "@testing-library/jest-dom": "^6.6.4",
20
+ "@types/ws": "^8.5.14",
21
+ "@testing-library/react": "^16.3.0",
22
+ "@testing-library/react-hooks": "^8.0.1",
23
+ "@vitest/coverage-v8": "^4.0.18",
24
+ "jsdom": "^27.4.0",
25
+ "react": "^19.2.3",
26
+ "react-dom": "^19.2.3",
27
+ "typescript": "^5.9.3",
28
+ "vitest": "^4.0.18",
29
+ "dev": "0.0.1"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ },
36
+ "./provider": {
37
+ "types": "./dist/provider/index.d.ts",
38
+ "import": "./dist/provider/index.js"
39
+ },
40
+ "./collab": {
41
+ "types": "./dist/collab/index.d.ts",
42
+ "import": "./dist/collab/index.js"
43
+ },
44
+ "./collab/server": {
45
+ "types": "./dist/collab/server-index.d.ts",
46
+ "import": "./dist/collab/server-index.js"
47
+ }
48
+ },
49
+ "main": "./dist/index.js",
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org"
53
+ },
54
+ "type": "module",
55
+ "types": "./dist/index.d.ts",
56
+ "scripts": {
57
+ "build": "tsc",
58
+ "clean": "rm -rf dist",
59
+ "dev": "tsc --watch",
60
+ "lint": "biome check .",
61
+ "lint:eslint": "eslint .",
62
+ "test": "vitest run",
63
+ "test:coverage": "vitest run --coverage",
64
+ "test:watch": "vitest",
65
+ "typecheck": "tsc --noEmit"
66
+ }
67
+ }