@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 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,3 @@
1
+ export { createConnectionState, ConnectionStatus } from './connection-state.js';
2
+ export { createRealtimeEnhancer } from './create-realtime-enhancer.js';
3
+ export * from './types.js';
@@ -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
+ }