@signaltree/realtime 7.3.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.
- package/README.md +255 -0
- package/dist/connection-state.js +46 -0
- package/dist/create-realtime-enhancer.js +236 -0
- package/dist/index.js +3 -0
- package/dist/supabase/index.js +1 -0
- package/dist/supabase/supabase-realtime.js +224 -0
- package/dist/types.js +18 -0
- package/package.json +68 -0
- package/src/connection-state.d.ts +39 -0
- package/src/create-realtime-enhancer.d.ts +49 -0
- package/src/index.d.ts +41 -0
- package/src/supabase/index.d.ts +45 -0
- package/src/supabase/supabase-realtime.d.ts +125 -0
- package/src/types.d.ts +111 -0
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# @signaltree/realtime
|
|
2
|
+
|
|
3
|
+
Real-time data synchronization enhancers for SignalTree. Provides seamless integration with Supabase Realtime, with a generic adapter pattern for Firebase and custom WebSocket implementations.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @signaltree/realtime @supabase/supabase-js
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @signaltree/realtime @supabase/supabase-js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
17
|
+
import { supabaseRealtime } from '@signaltree/realtime/supabase';
|
|
18
|
+
import { createClient } from '@supabase/supabase-js';
|
|
19
|
+
|
|
20
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
21
|
+
|
|
22
|
+
interface Listing {
|
|
23
|
+
id: number;
|
|
24
|
+
title: string;
|
|
25
|
+
price: number;
|
|
26
|
+
status: 'active' | 'sold' | 'draft';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Create tree with realtime sync
|
|
30
|
+
const tree = signalTree({
|
|
31
|
+
listings: entityMap<Listing, number>(),
|
|
32
|
+
}).with(
|
|
33
|
+
supabaseRealtime(supabase, {
|
|
34
|
+
listings: {
|
|
35
|
+
table: 'listings',
|
|
36
|
+
event: '*', // Listen for INSERT, UPDATE, DELETE
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// EntityMaps automatically sync with the database!
|
|
42
|
+
// When someone inserts a listing, it appears in tree.$.listings.all()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
### Automatic EntityMap Sync
|
|
48
|
+
|
|
49
|
+
The realtime enhancer maps database events to entityMap operations:
|
|
50
|
+
|
|
51
|
+
| Database Event | EntityMap Operation |
|
|
52
|
+
| -------------- | ------------------- |
|
|
53
|
+
| INSERT | `upsertOne()` |
|
|
54
|
+
| UPDATE | `upsertOne()` |
|
|
55
|
+
| DELETE | `removeOne()` |
|
|
56
|
+
|
|
57
|
+
### Connection State
|
|
58
|
+
|
|
59
|
+
Access reactive connection state:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Check connection status
|
|
63
|
+
effect(() => {
|
|
64
|
+
if (tree.realtime.connection.isConnected()) {
|
|
65
|
+
console.log('Connected to realtime!');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Monitor errors
|
|
70
|
+
effect(() => {
|
|
71
|
+
const error = tree.realtime.connection.error();
|
|
72
|
+
if (error) {
|
|
73
|
+
console.error('Connection error:', error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Check reconnection attempts
|
|
78
|
+
effect(() => {
|
|
79
|
+
const attempts = tree.realtime.connection.reconnectAttempts();
|
|
80
|
+
console.log(`Reconnect attempt: ${attempts}`);
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Manual Control
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Manually disconnect
|
|
88
|
+
tree.realtime.disconnect();
|
|
89
|
+
|
|
90
|
+
// Reconnect
|
|
91
|
+
tree.realtime.reconnect();
|
|
92
|
+
|
|
93
|
+
// Dynamic subscriptions
|
|
94
|
+
const cleanup = tree.realtime.subscribe('newPath', {
|
|
95
|
+
table: 'some_table',
|
|
96
|
+
event: 'INSERT',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Later: unsubscribe
|
|
100
|
+
cleanup();
|
|
101
|
+
// or
|
|
102
|
+
tree.realtime.unsubscribe('newPath');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Filtering
|
|
106
|
+
|
|
107
|
+
Use Supabase PostgREST filters:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
.with(supabaseRealtime(supabase, {
|
|
111
|
+
activeListings: {
|
|
112
|
+
table: 'listings',
|
|
113
|
+
event: '*',
|
|
114
|
+
filter: 'status=eq.active'
|
|
115
|
+
},
|
|
116
|
+
myListings: {
|
|
117
|
+
table: 'listings',
|
|
118
|
+
event: '*',
|
|
119
|
+
filter: `user_id=eq.${currentUserId}`
|
|
120
|
+
},
|
|
121
|
+
recentMessages: {
|
|
122
|
+
table: 'messages',
|
|
123
|
+
event: 'INSERT',
|
|
124
|
+
filter: `created_at=gt.${oneDayAgo.toISOString()}`
|
|
125
|
+
}
|
|
126
|
+
}))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Data Transformation
|
|
130
|
+
|
|
131
|
+
Transform snake_case database fields to camelCase:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
interface Listing {
|
|
135
|
+
id: number;
|
|
136
|
+
createdAt: Date;
|
|
137
|
+
updatedAt: Date;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.with(supabaseRealtime(supabase, {
|
|
141
|
+
listings: {
|
|
142
|
+
table: 'listings',
|
|
143
|
+
event: '*',
|
|
144
|
+
transform: (row: any) => ({
|
|
145
|
+
id: row.id,
|
|
146
|
+
createdAt: new Date(row.created_at),
|
|
147
|
+
updatedAt: new Date(row.updated_at)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Custom ID Selection
|
|
154
|
+
|
|
155
|
+
If your entity ID field isn't `id`:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
interface Item {
|
|
159
|
+
itemCode: string;
|
|
160
|
+
name: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.with(supabaseRealtime(supabase, {
|
|
164
|
+
items: {
|
|
165
|
+
table: 'items',
|
|
166
|
+
event: '*',
|
|
167
|
+
selectId: (item: Item) => item.itemCode
|
|
168
|
+
}
|
|
169
|
+
}))
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Configuration Options
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
supabaseRealtime(supabase, config, {
|
|
176
|
+
// Auto-reconnect on disconnect (default: true)
|
|
177
|
+
autoReconnect: true,
|
|
178
|
+
|
|
179
|
+
// Initial reconnect delay in ms (default: 1000)
|
|
180
|
+
// Uses exponential backoff
|
|
181
|
+
reconnectDelay: 1000,
|
|
182
|
+
|
|
183
|
+
// Max reconnect attempts (default: 10)
|
|
184
|
+
maxReconnectAttempts: 10,
|
|
185
|
+
|
|
186
|
+
// Log events in dev mode (default: true in dev)
|
|
187
|
+
debug: true,
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Custom Adapters
|
|
192
|
+
|
|
193
|
+
Create adapters for other realtime providers:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { createRealtimeEnhancer, RealtimeAdapter } from '@signaltree/realtime';
|
|
197
|
+
|
|
198
|
+
const customAdapter: RealtimeAdapter = {
|
|
199
|
+
async connect() {
|
|
200
|
+
// Connect to your WebSocket server
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
disconnect() {
|
|
204
|
+
// Clean up connections
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
subscribe(config, callback) {
|
|
208
|
+
// Set up subscription
|
|
209
|
+
// Call callback(event) when data changes
|
|
210
|
+
return () => {
|
|
211
|
+
// Cleanup function
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
isConnected() {
|
|
216
|
+
return true; // Return connection state
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
onConnectionChange(callback) {
|
|
220
|
+
// Set up connection state listener
|
|
221
|
+
return () => {
|
|
222
|
+
// Cleanup
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const tree = signalTree({ ... })
|
|
228
|
+
.with(createRealtimeEnhancer(customAdapter, config));
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## TypeScript
|
|
232
|
+
|
|
233
|
+
Full type inference is provided:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// The tree type includes the realtime property
|
|
237
|
+
const tree = signalTree({
|
|
238
|
+
listings: entityMap<Listing, number>()
|
|
239
|
+
}).with(supabaseRealtime(supabase, { ... }));
|
|
240
|
+
|
|
241
|
+
// Fully typed
|
|
242
|
+
tree.realtime.connection.isConnected(); // Signal<boolean>
|
|
243
|
+
tree.realtime.connection.error(); // Signal<string | null>
|
|
244
|
+
tree.$.listings.all(); // Signal<Listing[]>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Requirements
|
|
248
|
+
|
|
249
|
+
- Angular 20+
|
|
250
|
+
- @signaltree/core 7.0+
|
|
251
|
+
- @supabase/supabase-js 2.0+ (for Supabase integration)
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { signal, computed } from '@angular/core';
|
|
2
|
+
import { ConnectionStatus } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a writable connection state for internal use.
|
|
6
|
+
*/
|
|
7
|
+
function createConnectionState() {
|
|
8
|
+
const statusSignal = signal(ConnectionStatus.Disconnected);
|
|
9
|
+
const errorSignal = signal(null);
|
|
10
|
+
const lastConnectedAtSignal = signal(null);
|
|
11
|
+
const reconnectAttemptsSignal = signal(0);
|
|
12
|
+
const isConnected = computed(() => statusSignal() === ConnectionStatus.Connected);
|
|
13
|
+
return {
|
|
14
|
+
status: statusSignal.asReadonly(),
|
|
15
|
+
error: errorSignal.asReadonly(),
|
|
16
|
+
isConnected,
|
|
17
|
+
lastConnectedAt: lastConnectedAtSignal.asReadonly(),
|
|
18
|
+
reconnectAttempts: reconnectAttemptsSignal.asReadonly(),
|
|
19
|
+
// Internal setters
|
|
20
|
+
_setStatus: status => {
|
|
21
|
+
statusSignal.set(status);
|
|
22
|
+
if (status === ConnectionStatus.Connected) {
|
|
23
|
+
lastConnectedAtSignal.set(new Date());
|
|
24
|
+
reconnectAttemptsSignal.set(0);
|
|
25
|
+
errorSignal.set(null);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
_setError: error => {
|
|
29
|
+
errorSignal.set(error);
|
|
30
|
+
if (error) {
|
|
31
|
+
statusSignal.set(ConnectionStatus.Error);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
_incrementReconnectAttempts: () => {
|
|
35
|
+
reconnectAttemptsSignal.update(n => n + 1);
|
|
36
|
+
},
|
|
37
|
+
_resetReconnectAttempts: () => {
|
|
38
|
+
reconnectAttemptsSignal.set(0);
|
|
39
|
+
},
|
|
40
|
+
_setLastConnectedAt: date => {
|
|
41
|
+
lastConnectedAtSignal.set(date);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { ConnectionStatus, createConnectionState };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createConnectionState } from './connection-state.js';
|
|
2
|
+
import { ConnectionStatus } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a generic real-time enhancer that works with any RealtimeAdapter.
|
|
6
|
+
*
|
|
7
|
+
* This is the base enhancer used by provider-specific enhancers like
|
|
8
|
+
* `supabaseRealtime` and `firebaseRealtime`.
|
|
9
|
+
*
|
|
10
|
+
* @param adapter - The real-time adapter implementation
|
|
11
|
+
* @param config - Configuration mapping tree paths to subscriptions
|
|
12
|
+
* @param options - Enhancer options
|
|
13
|
+
* @returns A tree enhancer that syncs entityMaps with real-time data
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* // Custom WebSocket adapter
|
|
18
|
+
* const myAdapter: RealtimeAdapter = {
|
|
19
|
+
* connect: async () => { ... },
|
|
20
|
+
* disconnect: () => { ... },
|
|
21
|
+
* subscribe: (config, callback) => { ... },
|
|
22
|
+
* isConnected: () => { ... },
|
|
23
|
+
* onConnectionChange: (callback) => { ... }
|
|
24
|
+
* };
|
|
25
|
+
*
|
|
26
|
+
* const tree = signalTree({ ... })
|
|
27
|
+
* .with(createRealtimeEnhancer(myAdapter, config));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
function createRealtimeEnhancer(adapter, config, options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
autoReconnect = true,
|
|
33
|
+
reconnectDelay = 1000,
|
|
34
|
+
maxReconnectAttempts = 10,
|
|
35
|
+
debug = typeof ngDevMode === 'undefined' || ngDevMode
|
|
36
|
+
} = options;
|
|
37
|
+
return tree => {
|
|
38
|
+
const connection = createConnectionState();
|
|
39
|
+
const subscriptions = new Map();
|
|
40
|
+
let connectionCleanup = null;
|
|
41
|
+
let reconnectTimeout = null;
|
|
42
|
+
let isManuallyDisconnected = false;
|
|
43
|
+
const log = (message, ...args) => {
|
|
44
|
+
if (debug) {
|
|
45
|
+
console.log(`[SignalTree Realtime] ${message}`, ...args);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
// Handle connection state changes
|
|
49
|
+
const handleConnectionChange = (connected, error) => {
|
|
50
|
+
if (connected) {
|
|
51
|
+
connection._setStatus(ConnectionStatus.Connected);
|
|
52
|
+
log('Connected to real-time service');
|
|
53
|
+
} else if (error) {
|
|
54
|
+
connection._setError(error.message);
|
|
55
|
+
log('Connection error:', error.message);
|
|
56
|
+
if (autoReconnect && !isManuallyDisconnected) {
|
|
57
|
+
scheduleReconnect();
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
connection._setStatus(ConnectionStatus.Disconnected);
|
|
61
|
+
log('Disconnected from real-time service');
|
|
62
|
+
if (autoReconnect && !isManuallyDisconnected) {
|
|
63
|
+
scheduleReconnect();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
// Schedule a reconnection attempt
|
|
68
|
+
const scheduleReconnect = () => {
|
|
69
|
+
const attempts = connection.reconnectAttempts();
|
|
70
|
+
if (attempts >= maxReconnectAttempts) {
|
|
71
|
+
log(`Max reconnect attempts (${maxReconnectAttempts}) reached`);
|
|
72
|
+
connection._setError('Max reconnect attempts reached');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const delay = reconnectDelay * Math.pow(2, Math.min(attempts, 5)); // Exponential backoff, capped
|
|
76
|
+
connection._setStatus(ConnectionStatus.Reconnecting);
|
|
77
|
+
connection._incrementReconnectAttempts();
|
|
78
|
+
log(`Scheduling reconnect attempt ${attempts + 1} in ${delay}ms`);
|
|
79
|
+
reconnectTimeout = setTimeout(async () => {
|
|
80
|
+
try {
|
|
81
|
+
await connect();
|
|
82
|
+
} catch {
|
|
83
|
+
// handleConnectionChange will schedule next attempt
|
|
84
|
+
}
|
|
85
|
+
}, delay);
|
|
86
|
+
};
|
|
87
|
+
// Connect to real-time service
|
|
88
|
+
const connect = async () => {
|
|
89
|
+
connection._setStatus(ConnectionStatus.Connecting);
|
|
90
|
+
isManuallyDisconnected = false;
|
|
91
|
+
try {
|
|
92
|
+
// Set up connection state listener
|
|
93
|
+
connectionCleanup = adapter.onConnectionChange(handleConnectionChange);
|
|
94
|
+
await adapter.connect();
|
|
95
|
+
// Subscribe to all configured paths
|
|
96
|
+
for (const [path, subConfig] of Object.entries(config)) {
|
|
97
|
+
if (subConfig) {
|
|
98
|
+
subscribeToPath(path, subConfig);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
connection._setStatus(ConnectionStatus.Connected);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
connection._setError(error.message);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
// Subscribe to a specific path
|
|
108
|
+
const subscribeToPath = (path, subConfig) => {
|
|
109
|
+
// Get the entity signal at this path
|
|
110
|
+
const pathParts = path.split('.');
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
let entitySignal = tree.$;
|
|
113
|
+
for (const part of pathParts) {
|
|
114
|
+
entitySignal = entitySignal?.[part];
|
|
115
|
+
}
|
|
116
|
+
if (!entitySignal) {
|
|
117
|
+
log(`Warning: No signal found at path "${path}"`);
|
|
118
|
+
return () => {
|
|
119
|
+
/* noop */
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Check if it's an entityMap
|
|
123
|
+
const hasUpsertOne = typeof entitySignal.upsertOne === 'function';
|
|
124
|
+
const hasRemoveOne = typeof entitySignal.removeOne === 'function';
|
|
125
|
+
if (!hasUpsertOne || !hasRemoveOne) {
|
|
126
|
+
log(`Warning: Signal at "${path}" is not an entityMap. Realtime sync requires entityMap.`);
|
|
127
|
+
return () => {
|
|
128
|
+
/* noop */
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const selectId = subConfig.selectId ?? (entity => entity.id);
|
|
132
|
+
const callback = event => {
|
|
133
|
+
const entity = subConfig.transform ? subConfig.transform(event.new ?? event.old) : event.new ?? event.old;
|
|
134
|
+
if (!entity) {
|
|
135
|
+
log(`Warning: Received event without entity data`, event);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
switch (event.eventType) {
|
|
139
|
+
case 'INSERT':
|
|
140
|
+
case 'UPDATE':
|
|
141
|
+
if (event.new) {
|
|
142
|
+
const transformed = subConfig.transform ? subConfig.transform(event.new) : event.new;
|
|
143
|
+
entitySignal.upsertOne(transformed, {
|
|
144
|
+
selectId
|
|
145
|
+
});
|
|
146
|
+
log(`${event.eventType} on ${path}:`, transformed);
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case 'DELETE':
|
|
150
|
+
if (event.old) {
|
|
151
|
+
const id = selectId(event.old);
|
|
152
|
+
entitySignal.removeOne(id);
|
|
153
|
+
log(`DELETE on ${path}: id=${id}`);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
default:
|
|
157
|
+
log(`Unknown event type: ${event.eventType}`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const cleanup = adapter.subscribe(subConfig, callback);
|
|
161
|
+
subscriptions.set(path, cleanup);
|
|
162
|
+
log(`Subscribed to "${subConfig.table}" for path "${path}"`);
|
|
163
|
+
return cleanup;
|
|
164
|
+
};
|
|
165
|
+
// Disconnect from real-time service
|
|
166
|
+
const disconnect = () => {
|
|
167
|
+
isManuallyDisconnected = true;
|
|
168
|
+
if (reconnectTimeout) {
|
|
169
|
+
clearTimeout(reconnectTimeout);
|
|
170
|
+
reconnectTimeout = null;
|
|
171
|
+
}
|
|
172
|
+
// Clean up all subscriptions
|
|
173
|
+
for (const cleanup of subscriptions.values()) {
|
|
174
|
+
cleanup();
|
|
175
|
+
}
|
|
176
|
+
subscriptions.clear();
|
|
177
|
+
// Clean up connection listener
|
|
178
|
+
if (connectionCleanup) {
|
|
179
|
+
connectionCleanup();
|
|
180
|
+
connectionCleanup = null;
|
|
181
|
+
}
|
|
182
|
+
adapter.disconnect();
|
|
183
|
+
connection._setStatus(ConnectionStatus.Disconnected);
|
|
184
|
+
log('Disconnected');
|
|
185
|
+
};
|
|
186
|
+
// Manual reconnect
|
|
187
|
+
const reconnect = async () => {
|
|
188
|
+
disconnect();
|
|
189
|
+
connection._resetReconnectAttempts();
|
|
190
|
+
await connect();
|
|
191
|
+
};
|
|
192
|
+
// Dynamic subscription
|
|
193
|
+
const subscribe = (path, subConfig) => {
|
|
194
|
+
if (!adapter.isConnected()) {
|
|
195
|
+
log(`Warning: Cannot subscribe while disconnected`);
|
|
196
|
+
return () => {
|
|
197
|
+
/* noop */
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return subscribeToPath(path, subConfig);
|
|
201
|
+
};
|
|
202
|
+
// Unsubscribe from a path
|
|
203
|
+
const unsubscribe = path => {
|
|
204
|
+
const cleanup = subscriptions.get(path);
|
|
205
|
+
if (cleanup) {
|
|
206
|
+
cleanup();
|
|
207
|
+
subscriptions.delete(path);
|
|
208
|
+
log(`Unsubscribed from "${path}"`);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
// Auto-connect on enhancer application
|
|
212
|
+
queueMicrotask(() => {
|
|
213
|
+
connect().catch(error => {
|
|
214
|
+
log('Initial connection failed:', error);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
// Return enhanced tree with realtime control
|
|
218
|
+
return Object.assign(tree, {
|
|
219
|
+
realtime: {
|
|
220
|
+
connection: {
|
|
221
|
+
status: connection.status,
|
|
222
|
+
error: connection.error,
|
|
223
|
+
isConnected: connection.isConnected,
|
|
224
|
+
lastConnectedAt: connection.lastConnectedAt,
|
|
225
|
+
reconnectAttempts: connection.reconnectAttempts
|
|
226
|
+
},
|
|
227
|
+
reconnect,
|
|
228
|
+
disconnect,
|
|
229
|
+
subscribe,
|
|
230
|
+
unsubscribe
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export { createRealtimeEnhancer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createSupabaseAdapter, supabaseRealtime } from './supabase-realtime.js';
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createRealtimeEnhancer } from '../create-realtime-enhancer.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts Supabase event type to our normalized type.
|
|
5
|
+
*/
|
|
6
|
+
function normalizeEventType(type) {
|
|
7
|
+
switch (type.toUpperCase()) {
|
|
8
|
+
case 'INSERT':
|
|
9
|
+
return 'INSERT';
|
|
10
|
+
case 'UPDATE':
|
|
11
|
+
return 'UPDATE';
|
|
12
|
+
case 'DELETE':
|
|
13
|
+
return 'DELETE';
|
|
14
|
+
default:
|
|
15
|
+
return '*';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Creates a Supabase realtime adapter.
|
|
20
|
+
*
|
|
21
|
+
* @param client - Supabase client instance
|
|
22
|
+
* @returns RealtimeAdapter for use with createRealtimeEnhancer
|
|
23
|
+
*/
|
|
24
|
+
function createSupabaseAdapter(client) {
|
|
25
|
+
let mainChannel = null;
|
|
26
|
+
const channels = new Map();
|
|
27
|
+
let connectionCallback = null;
|
|
28
|
+
return {
|
|
29
|
+
async connect() {
|
|
30
|
+
// Create a presence channel to track connection state
|
|
31
|
+
mainChannel = client.channel('signaltree-presence');
|
|
32
|
+
mainChannel.on('presence', {
|
|
33
|
+
event: 'sync'
|
|
34
|
+
}, () => {
|
|
35
|
+
connectionCallback?.(true);
|
|
36
|
+
}).subscribe((status, err) => {
|
|
37
|
+
if (status === 'SUBSCRIBED') {
|
|
38
|
+
connectionCallback?.(true);
|
|
39
|
+
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
|
40
|
+
connectionCallback?.(false, err ?? new Error(`Channel error: ${status}`));
|
|
41
|
+
} else if (status === 'CLOSED') {
|
|
42
|
+
connectionCallback?.(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
disconnect() {
|
|
47
|
+
// Unsubscribe from all channels
|
|
48
|
+
for (const channel of channels.values()) {
|
|
49
|
+
client.removeChannel(channel);
|
|
50
|
+
}
|
|
51
|
+
channels.clear();
|
|
52
|
+
if (mainChannel) {
|
|
53
|
+
client.removeChannel(mainChannel);
|
|
54
|
+
mainChannel = null;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
subscribe(config, callback) {
|
|
58
|
+
const {
|
|
59
|
+
table,
|
|
60
|
+
event,
|
|
61
|
+
filter,
|
|
62
|
+
schema = 'public'
|
|
63
|
+
} = config;
|
|
64
|
+
// Build channel name (unique per subscription)
|
|
65
|
+
const channelName = `signaltree:${schema}:${table}:${filter ?? 'all'}`;
|
|
66
|
+
// Check if we already have this channel
|
|
67
|
+
let channel = channels.get(channelName);
|
|
68
|
+
if (!channel) {
|
|
69
|
+
channel = client.channel(channelName);
|
|
70
|
+
channels.set(channelName, channel);
|
|
71
|
+
}
|
|
72
|
+
// Build the filter config
|
|
73
|
+
const filterConfig = {
|
|
74
|
+
event: event,
|
|
75
|
+
schema,
|
|
76
|
+
table
|
|
77
|
+
};
|
|
78
|
+
if (filter) {
|
|
79
|
+
filterConfig.filter = filter;
|
|
80
|
+
}
|
|
81
|
+
// Subscribe to postgres changes
|
|
82
|
+
// Use type assertion to work around strict Supabase generic constraints
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
channel.on('postgres_changes', filterConfig, payload => {
|
|
85
|
+
const realtimeEvent = {
|
|
86
|
+
eventType: normalizeEventType(payload.eventType),
|
|
87
|
+
new: payload.new,
|
|
88
|
+
old: payload.old,
|
|
89
|
+
table: payload.table,
|
|
90
|
+
schema: payload.schema,
|
|
91
|
+
timestamp: new Date()
|
|
92
|
+
};
|
|
93
|
+
callback(realtimeEvent);
|
|
94
|
+
});
|
|
95
|
+
// Subscribe to the channel if not already subscribed
|
|
96
|
+
if (channel.state !== 'joined' && channel.state !== 'joining') {
|
|
97
|
+
channel.subscribe();
|
|
98
|
+
}
|
|
99
|
+
// Return cleanup function
|
|
100
|
+
return () => {
|
|
101
|
+
// Note: Supabase doesn't support removing individual listeners,
|
|
102
|
+
// so we just remove the entire channel
|
|
103
|
+
const ch = channels.get(channelName);
|
|
104
|
+
if (ch) {
|
|
105
|
+
client.removeChannel(ch);
|
|
106
|
+
channels.delete(channelName);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
isConnected() {
|
|
111
|
+
return mainChannel?.state === 'joined';
|
|
112
|
+
},
|
|
113
|
+
onConnectionChange(callback) {
|
|
114
|
+
connectionCallback = callback;
|
|
115
|
+
return () => {
|
|
116
|
+
connectionCallback = null;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Creates a Supabase realtime enhancer for SignalTree.
|
|
123
|
+
*
|
|
124
|
+
* This enhancer automatically syncs entityMaps in your tree with
|
|
125
|
+
* Supabase Realtime PostgreSQL changes.
|
|
126
|
+
*
|
|
127
|
+
* @param client - Supabase client instance
|
|
128
|
+
* @param config - Configuration mapping tree paths to table subscriptions
|
|
129
|
+
* @param options - Enhancer options (reconnection, logging, etc.)
|
|
130
|
+
* @returns A tree enhancer
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* import { signalTree, entityMap } from '@signaltree/core';
|
|
135
|
+
* import { supabaseRealtime } from '@signaltree/realtime/supabase';
|
|
136
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
137
|
+
*
|
|
138
|
+
* const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
139
|
+
*
|
|
140
|
+
* // Define your database types
|
|
141
|
+
* interface Listing {
|
|
142
|
+
* id: number;
|
|
143
|
+
* title: string;
|
|
144
|
+
* price: number;
|
|
145
|
+
* created_at: string;
|
|
146
|
+
* }
|
|
147
|
+
*
|
|
148
|
+
* // Create tree with realtime sync
|
|
149
|
+
* const tree = signalTree({
|
|
150
|
+
* listings: entityMap<Listing, number>()
|
|
151
|
+
* })
|
|
152
|
+
* .with(supabaseRealtime(supabase, {
|
|
153
|
+
* listings: {
|
|
154
|
+
* table: 'listings',
|
|
155
|
+
* event: '*', // INSERT, UPDATE, DELETE
|
|
156
|
+
* // Optional: filter to only active listings
|
|
157
|
+
* // filter: 'status=eq.active'
|
|
158
|
+
* }
|
|
159
|
+
* }));
|
|
160
|
+
*
|
|
161
|
+
* // The tree now has realtime property for connection control
|
|
162
|
+
* effect(() => {
|
|
163
|
+
* if (tree.realtime.connection.isConnected()) {
|
|
164
|
+
* console.log('Connected to Supabase Realtime!');
|
|
165
|
+
* }
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* // EntityMap automatically updates when database changes
|
|
169
|
+
* // No manual refresh needed!
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* // With snake_case to camelCase transformation
|
|
175
|
+
* const tree = signalTree({
|
|
176
|
+
* listings: entityMap<Listing, number>()
|
|
177
|
+
* })
|
|
178
|
+
* .with(supabaseRealtime(supabase, {
|
|
179
|
+
* listings: {
|
|
180
|
+
* table: 'listings',
|
|
181
|
+
* event: '*',
|
|
182
|
+
* transform: (row: any) => ({
|
|
183
|
+
* id: row.id,
|
|
184
|
+
* title: row.title,
|
|
185
|
+
* createdAt: new Date(row.created_at),
|
|
186
|
+
* updatedAt: new Date(row.updated_at)
|
|
187
|
+
* })
|
|
188
|
+
* }
|
|
189
|
+
* }));
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* // Multiple tables with different filters
|
|
195
|
+
* const tree = signalTree({
|
|
196
|
+
* myListings: entityMap<Listing, number>(),
|
|
197
|
+
* allListings: entityMap<Listing, number>(),
|
|
198
|
+
* messages: entityMap<Message, string>()
|
|
199
|
+
* })
|
|
200
|
+
* .with(supabaseRealtime(supabase, {
|
|
201
|
+
* myListings: {
|
|
202
|
+
* table: 'listings',
|
|
203
|
+
* event: '*',
|
|
204
|
+
* filter: `user_id=eq.${currentUserId}`
|
|
205
|
+
* },
|
|
206
|
+
* allListings: {
|
|
207
|
+
* table: 'listings',
|
|
208
|
+
* event: '*',
|
|
209
|
+
* filter: 'status=eq.active'
|
|
210
|
+
* },
|
|
211
|
+
* messages: {
|
|
212
|
+
* table: 'messages',
|
|
213
|
+
* event: 'INSERT', // Only new messages
|
|
214
|
+
* filter: `chat_room_id=eq.${roomId}`
|
|
215
|
+
* }
|
|
216
|
+
* }));
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
function supabaseRealtime(client, config, options = {}) {
|
|
220
|
+
const adapter = createSupabaseAdapter(client);
|
|
221
|
+
return createRealtimeEnhancer(adapter, config, options);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { createSupabaseAdapter, supabaseRealtime };
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection status enum for real-time connections.
|
|
3
|
+
*/
|
|
4
|
+
var ConnectionStatus;
|
|
5
|
+
(function (ConnectionStatus) {
|
|
6
|
+
/** Initial state, not yet connected */
|
|
7
|
+
ConnectionStatus["Disconnected"] = "DISCONNECTED";
|
|
8
|
+
/** Attempting to connect */
|
|
9
|
+
ConnectionStatus["Connecting"] = "CONNECTING";
|
|
10
|
+
/** Successfully connected */
|
|
11
|
+
ConnectionStatus["Connected"] = "CONNECTED";
|
|
12
|
+
/** Connection error occurred */
|
|
13
|
+
ConnectionStatus["Error"] = "ERROR";
|
|
14
|
+
/** Reconnecting after disconnect */
|
|
15
|
+
ConnectionStatus["Reconnecting"] = "RECONNECTING";
|
|
16
|
+
})(ConnectionStatus || (ConnectionStatus = {}));
|
|
17
|
+
|
|
18
|
+
export { ConnectionStatus };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@signaltree/realtime",
|
|
3
|
+
"version": "7.3.0",
|
|
4
|
+
"description": "Real-time data synchronization enhancers for SignalTree - Supabase, Firebase, and WebSocket support",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./index.esm.js",
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./supabase": {
|
|
18
|
+
"types": "./src/supabase/index.d.ts",
|
|
19
|
+
"import": "./dist/supabase/index.js",
|
|
20
|
+
"default": "./dist/supabase/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@angular/core": "^20.0.0",
|
|
26
|
+
"@signaltree/core": "^7.0.0",
|
|
27
|
+
"@supabase/supabase-js": "^2.0.0",
|
|
28
|
+
"tslib": "^2.0.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@supabase/supabase-js": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"firebase": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@supabase/supabase-js": "^2.49.0",
|
|
40
|
+
"vitest": "^3.0.5"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist/**/*.js",
|
|
47
|
+
"src/**/*.d.ts",
|
|
48
|
+
"README.md"
|
|
49
|
+
],
|
|
50
|
+
"keywords": [
|
|
51
|
+
"angular",
|
|
52
|
+
"signals",
|
|
53
|
+
"state-management",
|
|
54
|
+
"realtime",
|
|
55
|
+
"supabase",
|
|
56
|
+
"firebase",
|
|
57
|
+
"websocket"
|
|
58
|
+
],
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "https://github.com/JBorgia/signaltree.git",
|
|
62
|
+
"directory": "packages/realtime"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/JBorgia/signaltree/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://signaltree.dev"
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ConnectionState, ConnectionStatus } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Re-export ConnectionStatus for convenience
|
|
4
|
+
*/
|
|
5
|
+
export { ConnectionStatus } from './types';
|
|
6
|
+
/**
|
|
7
|
+
* Creates a reactive connection state object.
|
|
8
|
+
*
|
|
9
|
+
* @returns Connection state with signals for status, error, etc.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const connection = createConnectionState();
|
|
14
|
+
*
|
|
15
|
+
* // Update connection status
|
|
16
|
+
* connection._setStatus(ConnectionStatus.Connected);
|
|
17
|
+
*
|
|
18
|
+
* // Read current status
|
|
19
|
+
* effect(() => {
|
|
20
|
+
* console.log('Connected:', connection.isConnected());
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface WritableConnectionState extends ConnectionState {
|
|
25
|
+
/** @internal Set the connection status */
|
|
26
|
+
_setStatus: (status: ConnectionStatus) => void;
|
|
27
|
+
/** @internal Set the error message */
|
|
28
|
+
_setError: (error: string | null) => void;
|
|
29
|
+
/** @internal Increment reconnect attempts */
|
|
30
|
+
_incrementReconnectAttempts: () => void;
|
|
31
|
+
/** @internal Reset reconnect attempts */
|
|
32
|
+
_resetReconnectAttempts: () => void;
|
|
33
|
+
/** @internal Set last connected time */
|
|
34
|
+
_setLastConnectedAt: (date: Date | null) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a writable connection state for internal use.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createConnectionState(): WritableConnectionState;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CleanupFn, RealtimeConfig, RealtimeEnhancerOptions, RealtimeEnhancerResult, RealtimeEvent, RealtimeSubscription } from './types';
|
|
2
|
+
import type { Enhancer } from '@signaltree/core';
|
|
3
|
+
/**
|
|
4
|
+
* Adapter interface for different real-time backends.
|
|
5
|
+
*
|
|
6
|
+
* Implement this interface to add support for new real-time providers
|
|
7
|
+
* (Supabase, Firebase, custom WebSocket, etc.).
|
|
8
|
+
*/
|
|
9
|
+
export interface RealtimeAdapter {
|
|
10
|
+
/** Connect to the real-time service */
|
|
11
|
+
connect(): Promise<void>;
|
|
12
|
+
/** Disconnect from the real-time service */
|
|
13
|
+
disconnect(): void;
|
|
14
|
+
/** Subscribe to a table/channel */
|
|
15
|
+
subscribe<T>(config: RealtimeSubscription<T>, callback: (event: RealtimeEvent<T>) => void): CleanupFn;
|
|
16
|
+
/** Check if currently connected */
|
|
17
|
+
isConnected(): boolean;
|
|
18
|
+
/** Set a callback for connection state changes */
|
|
19
|
+
onConnectionChange(callback: (connected: boolean, error?: Error) => void): CleanupFn;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Creates a generic real-time enhancer that works with any RealtimeAdapter.
|
|
23
|
+
*
|
|
24
|
+
* This is the base enhancer used by provider-specific enhancers like
|
|
25
|
+
* `supabaseRealtime` and `firebaseRealtime`.
|
|
26
|
+
*
|
|
27
|
+
* @param adapter - The real-time adapter implementation
|
|
28
|
+
* @param config - Configuration mapping tree paths to subscriptions
|
|
29
|
+
* @param options - Enhancer options
|
|
30
|
+
* @returns A tree enhancer that syncs entityMaps with real-time data
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* // Custom WebSocket adapter
|
|
35
|
+
* const myAdapter: RealtimeAdapter = {
|
|
36
|
+
* connect: async () => { ... },
|
|
37
|
+
* disconnect: () => { ... },
|
|
38
|
+
* subscribe: (config, callback) => { ... },
|
|
39
|
+
* isConnected: () => { ... },
|
|
40
|
+
* onConnectionChange: (callback) => { ... }
|
|
41
|
+
* };
|
|
42
|
+
*
|
|
43
|
+
* const tree = signalTree({ ... })
|
|
44
|
+
* .with(createRealtimeEnhancer(myAdapter, config));
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function createRealtimeEnhancer<TState extends object>(adapter: RealtimeAdapter, config: RealtimeConfig<TState>, options?: RealtimeEnhancerOptions): Enhancer<{
|
|
48
|
+
realtime: RealtimeEnhancerResult;
|
|
49
|
+
}>;
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @signaltree/realtime
|
|
3
|
+
*
|
|
4
|
+
* Real-time data synchronization enhancers for SignalTree.
|
|
5
|
+
* Provides seamless integration with Supabase, Firebase, and generic WebSocket.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { signalTree } from '@signaltree/core';
|
|
10
|
+
* import { supabaseRealtime } from '@signaltree/realtime/supabase';
|
|
11
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
12
|
+
*
|
|
13
|
+
* const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
|
14
|
+
*
|
|
15
|
+
* const tree = signalTree({
|
|
16
|
+
* listings: entityMap<Listing, number>(),
|
|
17
|
+
* messages: entityMap<Message, string>()
|
|
18
|
+
* })
|
|
19
|
+
* .with(supabaseRealtime(supabase, {
|
|
20
|
+
* listings: {
|
|
21
|
+
* table: 'listings',
|
|
22
|
+
* event: '*',
|
|
23
|
+
* filter: 'status=eq.active'
|
|
24
|
+
* },
|
|
25
|
+
* messages: {
|
|
26
|
+
* table: 'messages',
|
|
27
|
+
* event: 'INSERT'
|
|
28
|
+
* }
|
|
29
|
+
* }));
|
|
30
|
+
*
|
|
31
|
+
* // EntityMaps automatically sync with database!
|
|
32
|
+
* // INSERT -> upsertOne
|
|
33
|
+
* // UPDATE -> upsertOne
|
|
34
|
+
* // DELETE -> removeOne
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @packageDocumentation
|
|
38
|
+
*/
|
|
39
|
+
export { type RealtimeConfig, type RealtimeSubscription, type RealtimeEvent, type RealtimeEnhancerOptions, type ConnectionState, } from './types';
|
|
40
|
+
export { createConnectionState, ConnectionStatus } from './connection-state';
|
|
41
|
+
export { createRealtimeEnhancer } from './create-realtime-enhancer';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase Real-time integration for SignalTree.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* import { signalTree, entityMap } from '@signaltree/core';
|
|
7
|
+
* import { supabaseRealtime } from '@signaltree/realtime/supabase';
|
|
8
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
9
|
+
*
|
|
10
|
+
* const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
11
|
+
*
|
|
12
|
+
* interface Listing {
|
|
13
|
+
* id: number;
|
|
14
|
+
* title: string;
|
|
15
|
+
* price: number;
|
|
16
|
+
* status: 'active' | 'sold' | 'draft';
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* const tree = signalTree({
|
|
20
|
+
* listings: entityMap<Listing, number>(),
|
|
21
|
+
* activeListings: entityMap<Listing, number>()
|
|
22
|
+
* })
|
|
23
|
+
* .with(supabaseRealtime(supabase, {
|
|
24
|
+
* listings: {
|
|
25
|
+
* table: 'listings',
|
|
26
|
+
* event: '*'
|
|
27
|
+
* },
|
|
28
|
+
* activeListings: {
|
|
29
|
+
* table: 'listings',
|
|
30
|
+
* event: '*',
|
|
31
|
+
* filter: 'status=eq.active'
|
|
32
|
+
* }
|
|
33
|
+
* }));
|
|
34
|
+
*
|
|
35
|
+
* // Access connection state
|
|
36
|
+
* console.log(tree.realtime.connection.isConnected());
|
|
37
|
+
*
|
|
38
|
+
* // Manually reconnect if needed
|
|
39
|
+
* tree.realtime.reconnect();
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @packageDocumentation
|
|
43
|
+
*/
|
|
44
|
+
export { supabaseRealtime, createSupabaseAdapter } from './supabase-realtime';
|
|
45
|
+
export type { SupabaseRealtimeConfig, SupabaseSubscriptionConfig, } from './supabase-realtime';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { RealtimeAdapter } from '../create-realtime-enhancer';
|
|
2
|
+
import { RealtimeEnhancerOptions, RealtimeEnhancerResult, RealtimeSubscription } from '../types';
|
|
3
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
4
|
+
import type { Enhancer } from '@signaltree/core';
|
|
5
|
+
/**
|
|
6
|
+
* Supabase-specific subscription configuration.
|
|
7
|
+
*/
|
|
8
|
+
export interface SupabaseSubscriptionConfig<T = unknown> extends RealtimeSubscription<T> {
|
|
9
|
+
/** PostgreSQL schema (default: 'public') */
|
|
10
|
+
schema?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for Supabase realtime subscriptions.
|
|
14
|
+
*/
|
|
15
|
+
export type SupabaseRealtimeConfig<TState> = {
|
|
16
|
+
[K in keyof TState]?: SupabaseSubscriptionConfig;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Creates a Supabase realtime adapter.
|
|
20
|
+
*
|
|
21
|
+
* @param client - Supabase client instance
|
|
22
|
+
* @returns RealtimeAdapter for use with createRealtimeEnhancer
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSupabaseAdapter(client: SupabaseClient): RealtimeAdapter;
|
|
25
|
+
/**
|
|
26
|
+
* Creates a Supabase realtime enhancer for SignalTree.
|
|
27
|
+
*
|
|
28
|
+
* This enhancer automatically syncs entityMaps in your tree with
|
|
29
|
+
* Supabase Realtime PostgreSQL changes.
|
|
30
|
+
*
|
|
31
|
+
* @param client - Supabase client instance
|
|
32
|
+
* @param config - Configuration mapping tree paths to table subscriptions
|
|
33
|
+
* @param options - Enhancer options (reconnection, logging, etc.)
|
|
34
|
+
* @returns A tree enhancer
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* import { signalTree, entityMap } from '@signaltree/core';
|
|
39
|
+
* import { supabaseRealtime } from '@signaltree/realtime/supabase';
|
|
40
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
41
|
+
*
|
|
42
|
+
* const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
43
|
+
*
|
|
44
|
+
* // Define your database types
|
|
45
|
+
* interface Listing {
|
|
46
|
+
* id: number;
|
|
47
|
+
* title: string;
|
|
48
|
+
* price: number;
|
|
49
|
+
* created_at: string;
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* // Create tree with realtime sync
|
|
53
|
+
* const tree = signalTree({
|
|
54
|
+
* listings: entityMap<Listing, number>()
|
|
55
|
+
* })
|
|
56
|
+
* .with(supabaseRealtime(supabase, {
|
|
57
|
+
* listings: {
|
|
58
|
+
* table: 'listings',
|
|
59
|
+
* event: '*', // INSERT, UPDATE, DELETE
|
|
60
|
+
* // Optional: filter to only active listings
|
|
61
|
+
* // filter: 'status=eq.active'
|
|
62
|
+
* }
|
|
63
|
+
* }));
|
|
64
|
+
*
|
|
65
|
+
* // The tree now has realtime property for connection control
|
|
66
|
+
* effect(() => {
|
|
67
|
+
* if (tree.realtime.connection.isConnected()) {
|
|
68
|
+
* console.log('Connected to Supabase Realtime!');
|
|
69
|
+
* }
|
|
70
|
+
* });
|
|
71
|
+
*
|
|
72
|
+
* // EntityMap automatically updates when database changes
|
|
73
|
+
* // No manual refresh needed!
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* // With snake_case to camelCase transformation
|
|
79
|
+
* const tree = signalTree({
|
|
80
|
+
* listings: entityMap<Listing, number>()
|
|
81
|
+
* })
|
|
82
|
+
* .with(supabaseRealtime(supabase, {
|
|
83
|
+
* listings: {
|
|
84
|
+
* table: 'listings',
|
|
85
|
+
* event: '*',
|
|
86
|
+
* transform: (row: any) => ({
|
|
87
|
+
* id: row.id,
|
|
88
|
+
* title: row.title,
|
|
89
|
+
* createdAt: new Date(row.created_at),
|
|
90
|
+
* updatedAt: new Date(row.updated_at)
|
|
91
|
+
* })
|
|
92
|
+
* }
|
|
93
|
+
* }));
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* // Multiple tables with different filters
|
|
99
|
+
* const tree = signalTree({
|
|
100
|
+
* myListings: entityMap<Listing, number>(),
|
|
101
|
+
* allListings: entityMap<Listing, number>(),
|
|
102
|
+
* messages: entityMap<Message, string>()
|
|
103
|
+
* })
|
|
104
|
+
* .with(supabaseRealtime(supabase, {
|
|
105
|
+
* myListings: {
|
|
106
|
+
* table: 'listings',
|
|
107
|
+
* event: '*',
|
|
108
|
+
* filter: `user_id=eq.${currentUserId}`
|
|
109
|
+
* },
|
|
110
|
+
* allListings: {
|
|
111
|
+
* table: 'listings',
|
|
112
|
+
* event: '*',
|
|
113
|
+
* filter: 'status=eq.active'
|
|
114
|
+
* },
|
|
115
|
+
* messages: {
|
|
116
|
+
* table: 'messages',
|
|
117
|
+
* event: 'INSERT', // Only new messages
|
|
118
|
+
* filter: `chat_room_id=eq.${roomId}`
|
|
119
|
+
* }
|
|
120
|
+
* }));
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export declare function supabaseRealtime<TState extends object>(client: SupabaseClient, config: SupabaseRealtimeConfig<TState>, options?: RealtimeEnhancerOptions): Enhancer<{
|
|
124
|
+
realtime: RealtimeEnhancerResult;
|
|
125
|
+
}>;
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Signal } from '@angular/core';
|
|
2
|
+
/**
|
|
3
|
+
* Connection status enum for real-time connections.
|
|
4
|
+
*/
|
|
5
|
+
export declare enum ConnectionStatus {
|
|
6
|
+
/** Initial state, not yet connected */
|
|
7
|
+
Disconnected = "DISCONNECTED",
|
|
8
|
+
/** Attempting to connect */
|
|
9
|
+
Connecting = "CONNECTING",
|
|
10
|
+
/** Successfully connected */
|
|
11
|
+
Connected = "CONNECTED",
|
|
12
|
+
/** Connection error occurred */
|
|
13
|
+
Error = "ERROR",
|
|
14
|
+
/** Reconnecting after disconnect */
|
|
15
|
+
Reconnecting = "RECONNECTING"
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Connection state signal interface.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConnectionState {
|
|
21
|
+
/** Current connection status */
|
|
22
|
+
status: Signal<ConnectionStatus>;
|
|
23
|
+
/** Error message if status is ERROR */
|
|
24
|
+
error: Signal<string | null>;
|
|
25
|
+
/** Whether currently connected */
|
|
26
|
+
isConnected: Signal<boolean>;
|
|
27
|
+
/** Last successful connection time */
|
|
28
|
+
lastConnectedAt: Signal<Date | null>;
|
|
29
|
+
/** Number of reconnection attempts */
|
|
30
|
+
reconnectAttempts: Signal<number>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Event types for real-time subscriptions.
|
|
34
|
+
*/
|
|
35
|
+
export type RealtimeEventType = 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
|
36
|
+
/**
|
|
37
|
+
* A real-time event payload.
|
|
38
|
+
*/
|
|
39
|
+
export interface RealtimeEvent<T = unknown> {
|
|
40
|
+
/** Event type */
|
|
41
|
+
eventType: RealtimeEventType;
|
|
42
|
+
/** The entity data (new for INSERT/UPDATE, old for DELETE) */
|
|
43
|
+
new?: T;
|
|
44
|
+
/** The old entity data (for UPDATE/DELETE) */
|
|
45
|
+
old?: Partial<T>;
|
|
46
|
+
/** Table/collection name */
|
|
47
|
+
table: string;
|
|
48
|
+
/** Schema (database-specific) */
|
|
49
|
+
schema?: string;
|
|
50
|
+
/** Timestamp of the event */
|
|
51
|
+
timestamp?: Date;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Configuration for a single entity subscription.
|
|
55
|
+
*/
|
|
56
|
+
export interface RealtimeSubscription<T = unknown> {
|
|
57
|
+
/** Table/collection name to subscribe to */
|
|
58
|
+
table: string;
|
|
59
|
+
/** Event types to listen for */
|
|
60
|
+
event: RealtimeEventType;
|
|
61
|
+
/** Optional filter (e.g., 'status=eq.active' for Supabase) */
|
|
62
|
+
filter?: string;
|
|
63
|
+
/** Schema name (database-specific) */
|
|
64
|
+
schema?: string;
|
|
65
|
+
/** Custom ID selector for the entity */
|
|
66
|
+
selectId?: (entity: T) => string | number;
|
|
67
|
+
/**
|
|
68
|
+
* Transform function to convert database row to entity.
|
|
69
|
+
* Useful for snake_case to camelCase conversion.
|
|
70
|
+
*/
|
|
71
|
+
transform?: (row: unknown) => T;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Configuration for the realtime enhancer.
|
|
75
|
+
* Keys are entity paths in the tree (e.g., 'listings', 'messages').
|
|
76
|
+
*/
|
|
77
|
+
export type RealtimeConfig<TState = unknown> = {
|
|
78
|
+
[K in keyof TState]?: RealtimeSubscription;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Options for the realtime enhancer.
|
|
82
|
+
*/
|
|
83
|
+
export interface RealtimeEnhancerOptions {
|
|
84
|
+
/** Auto-reconnect on disconnect (default: true) */
|
|
85
|
+
autoReconnect?: boolean;
|
|
86
|
+
/** Reconnect delay in ms (default: 1000) */
|
|
87
|
+
reconnectDelay?: number;
|
|
88
|
+
/** Max reconnect attempts (default: 10) */
|
|
89
|
+
maxReconnectAttempts?: number;
|
|
90
|
+
/** Log events in dev mode (default: true) */
|
|
91
|
+
debug?: boolean;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Cleanup function returned by enhancer.
|
|
95
|
+
*/
|
|
96
|
+
export type CleanupFn = () => void;
|
|
97
|
+
/**
|
|
98
|
+
* Result from the realtime enhancer for accessing connection state.
|
|
99
|
+
*/
|
|
100
|
+
export interface RealtimeEnhancerResult {
|
|
101
|
+
/** Connection state signals */
|
|
102
|
+
connection: ConnectionState;
|
|
103
|
+
/** Manually reconnect */
|
|
104
|
+
reconnect: () => void;
|
|
105
|
+
/** Manually disconnect */
|
|
106
|
+
disconnect: () => void;
|
|
107
|
+
/** Subscribe to a specific table dynamically */
|
|
108
|
+
subscribe: <T>(path: string, config: RealtimeSubscription<T>) => CleanupFn;
|
|
109
|
+
/** Unsubscribe from a specific table */
|
|
110
|
+
unsubscribe: (path: string) => void;
|
|
111
|
+
}
|