@comergehq/studio 0.1.21 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comergehq/studio",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Comerge studio",
5
5
  "main": "src/index.ts",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,112 @@
1
+ import { log } from '../../logger';
2
+ import { getSupabaseClient } from './client';
3
+
4
+ type SupabaseClient = ReturnType<typeof getSupabaseClient>;
5
+ type RealtimeChannel = ReturnType<SupabaseClient['channel']>;
6
+ type ChannelConfigurer = (channel: RealtimeChannel) => void;
7
+
8
+ type ChannelEntry = {
9
+ key: string;
10
+ channel: RealtimeChannel | null;
11
+ subscribers: Map<number, ChannelConfigurer>;
12
+ backoffMs: number;
13
+ timer: ReturnType<typeof setTimeout> | null;
14
+ };
15
+
16
+ const INITIAL_BACKOFF_MS = 1000;
17
+ const MAX_BACKOFF_MS = 30000;
18
+
19
+ const realtimeLog = log.extend('realtime');
20
+ const entries = new Map<string, ChannelEntry>();
21
+ let subscriberIdCounter = 0;
22
+
23
+ function clearTimer(entry: ChannelEntry) {
24
+ if (!entry.timer) return;
25
+ clearTimeout(entry.timer);
26
+ entry.timer = null;
27
+ }
28
+
29
+ function buildChannel(entry: ChannelEntry): RealtimeChannel {
30
+ const supabase = getSupabaseClient();
31
+ const channel = supabase.channel(entry.key);
32
+ entry.subscribers.forEach((configure) => {
33
+ configure(channel);
34
+ });
35
+ return channel;
36
+ }
37
+
38
+ function scheduleResubscribe(entry: ChannelEntry, reason: string) {
39
+ if (entry.timer) return;
40
+ const delay = entry.backoffMs;
41
+ entry.backoffMs = Math.min(entry.backoffMs * 2, MAX_BACKOFF_MS);
42
+ realtimeLog.warn(`[realtime] channel ${entry.key} ${reason}; resubscribe in ${delay}ms`);
43
+ entry.timer = setTimeout(() => {
44
+ entry.timer = null;
45
+ if (!entries.has(entry.key)) return;
46
+ if (entry.subscribers.size === 0) return;
47
+ subscribeChannel(entry);
48
+ }, delay);
49
+ }
50
+
51
+ function handleStatus(entry: ChannelEntry, status: string) {
52
+ if (status === 'SUBSCRIBED') {
53
+ entry.backoffMs = INITIAL_BACKOFF_MS;
54
+ clearTimer(entry);
55
+ return;
56
+ }
57
+ if (status === 'CLOSED' || status === 'TIMED_OUT' || status === 'CHANNEL_ERROR') {
58
+ scheduleResubscribe(entry, status);
59
+ }
60
+ }
61
+
62
+ function subscribeChannel(entry: ChannelEntry) {
63
+ try {
64
+ const supabase = getSupabaseClient();
65
+ if (entry.channel) supabase.removeChannel(entry.channel);
66
+ const channel = buildChannel(entry);
67
+ entry.channel = channel;
68
+ channel.subscribe((status) => handleStatus(entry, status));
69
+ } catch (error) {
70
+ realtimeLog.warn('[realtime] subscribe failed', error);
71
+ scheduleResubscribe(entry, 'SUBSCRIBE_FAILED');
72
+ }
73
+ }
74
+
75
+ export function subscribeManagedChannel(key: string, configure: ChannelConfigurer): () => void {
76
+ let entry = entries.get(key);
77
+ if (!entry) {
78
+ entry = {
79
+ key,
80
+ channel: null,
81
+ subscribers: new Map(),
82
+ backoffMs: INITIAL_BACKOFF_MS,
83
+ timer: null,
84
+ };
85
+ entries.set(key, entry);
86
+ }
87
+
88
+ const subscriberId = ++subscriberIdCounter;
89
+ entry.subscribers.set(subscriberId, configure);
90
+
91
+ if (!entry.channel) {
92
+ subscribeChannel(entry);
93
+ } else {
94
+ configure(entry.channel);
95
+ }
96
+
97
+ return () => {
98
+ const current = entries.get(key);
99
+ if (!current) return;
100
+ current.subscribers.delete(subscriberId);
101
+ if (current.subscribers.size === 0) {
102
+ clearTimer(current);
103
+ try {
104
+ if (current.channel) getSupabaseClient().removeChannel(current.channel);
105
+ } finally {
106
+ entries.delete(key);
107
+ }
108
+ return;
109
+ }
110
+ subscribeChannel(current);
111
+ };
112
+ }
@@ -2,7 +2,7 @@ import type { EditQueueRemoteDataSource } from './remote';
2
2
  import { editQueueRemoteDataSource } from './remote';
3
3
  import type { EditQueueItem, EditQueueListResponse, UpdateEditQueueItemRequest } from './types';
4
4
  import { BaseRepository } from '../../base-repository';
5
- import { getSupabaseClient } from '../../../core/services/supabase';
5
+ import { subscribeManagedChannel } from '../../../core/services/supabase/realtimeManager';
6
6
  import type { AttachmentMeta } from '../../attachment/types';
7
7
 
8
8
  type DbAppJobQueueRow = {
@@ -89,45 +89,40 @@ class EditQueueRepositoryImpl extends BaseRepository implements EditQueueReposit
89
89
  onDelete?: (item: EditQueueItem) => void;
90
90
  }
91
91
  ): () => void {
92
- const supabase = getSupabaseClient();
93
- const channel = supabase
94
- .channel(`edit-queue:app:${appId}`)
95
- .on(
96
- 'postgres_changes',
97
- { event: 'INSERT', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
98
- (payload) => {
99
- const row = payload.new as DbAppJobQueueRow;
100
- if (row.kind !== 'edit') return;
101
- const item = mapQueueItem(row);
102
- if (!ACTIVE_STATUSES.includes(item.status)) return;
103
- handlers.onInsert?.(item);
104
- }
105
- )
106
- .on(
107
- 'postgres_changes',
108
- { event: 'UPDATE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
109
- (payload) => {
110
- const row = payload.new as DbAppJobQueueRow;
111
- if (row.kind !== 'edit') return;
112
- const item = mapQueueItem(row);
113
- if (ACTIVE_STATUSES.includes(item.status)) handlers.onUpdate?.(item);
114
- else handlers.onDelete?.(item);
115
- }
116
- )
117
- .on(
118
- 'postgres_changes',
119
- { event: 'DELETE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
120
- (payload) => {
121
- const row = payload.old as DbAppJobQueueRow;
122
- if (row.kind !== 'edit') return;
123
- handlers.onDelete?.(mapQueueItem(row));
124
- }
125
- )
126
- .subscribe();
127
-
128
- return () => {
129
- supabase.removeChannel(channel);
130
- };
92
+ return subscribeManagedChannel(`edit-queue:app:${appId}`, (channel) => {
93
+ channel
94
+ .on(
95
+ 'postgres_changes',
96
+ { event: 'INSERT', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
97
+ (payload) => {
98
+ const row = payload.new as DbAppJobQueueRow;
99
+ if (row.kind !== 'edit') return;
100
+ const item = mapQueueItem(row);
101
+ if (!ACTIVE_STATUSES.includes(item.status)) return;
102
+ handlers.onInsert?.(item);
103
+ }
104
+ )
105
+ .on(
106
+ 'postgres_changes',
107
+ { event: 'UPDATE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
108
+ (payload) => {
109
+ const row = payload.new as DbAppJobQueueRow;
110
+ if (row.kind !== 'edit') return;
111
+ const item = mapQueueItem(row);
112
+ if (ACTIVE_STATUSES.includes(item.status)) handlers.onUpdate?.(item);
113
+ else handlers.onDelete?.(item);
114
+ }
115
+ )
116
+ .on(
117
+ 'postgres_changes',
118
+ { event: 'DELETE', schema: 'public', table: 'app_job_queue', filter: `app_id=eq.${appId}` },
119
+ (payload) => {
120
+ const row = payload.old as DbAppJobQueueRow;
121
+ if (row.kind !== 'edit') return;
122
+ handlers.onDelete?.(mapQueueItem(row));
123
+ }
124
+ );
125
+ });
131
126
  }
132
127
  }
133
128
 
@@ -16,7 +16,7 @@ import type {
16
16
  import { appsRemoteDataSource } from './remote';
17
17
  import type { AppsRemoteDataSource } from './remote';
18
18
  import { BaseRepository } from '../../data/base-repository';
19
- import { getSupabaseClient } from '../../core/services/supabase';
19
+ import { subscribeManagedChannel } from '../../core/services/supabase/realtimeManager';
20
20
 
21
21
  type DbAppRow = {
22
22
  id: string;
@@ -152,38 +152,33 @@ class AppsRepositoryImpl extends BaseRepository implements AppsRepository {
152
152
  }
153
153
 
154
154
  private subscribeToAppChannel(channelKey: string, filter: string, handlers: AppSubscriptionHandlers): () => void {
155
- const supabase = getSupabaseClient();
156
- const channel = supabase
157
- .channel(channelKey)
158
- .on(
159
- 'postgres_changes',
160
- { event: 'INSERT', schema: 'public', table: 'app', filter },
161
- (payload) => {
162
- console.log('[subscribeToAppChannel] onInsert', payload);
163
- handlers.onInsert?.(mapDbAppRow(payload.new as DbAppRow));
164
- }
165
- )
166
- .on(
167
- 'postgres_changes',
168
- { event: 'UPDATE', schema: 'public', table: 'app', filter },
169
- (payload) => {
170
- console.log('[subscribeToAppChannel] onUpdate', payload);
171
- handlers.onUpdate?.(mapDbAppRow(payload.new as DbAppRow));
172
- }
173
- )
174
- .on(
175
- 'postgres_changes',
176
- { event: 'DELETE', schema: 'public', table: 'app', filter },
177
- (payload) => {
178
- console.log('[subscribeToAppChannel] onDelete', payload);
179
- handlers.onDelete?.(mapDbAppRow(payload.old as DbAppRow));
180
- }
181
- )
182
- .subscribe();
183
-
184
- return () => {
185
- supabase.removeChannel(channel);
186
- };
155
+ return subscribeManagedChannel(channelKey, (channel) => {
156
+ channel
157
+ .on(
158
+ 'postgres_changes',
159
+ { event: 'INSERT', schema: 'public', table: 'app', filter },
160
+ (payload) => {
161
+ console.log('[subscribeToAppChannel] onInsert', payload);
162
+ handlers.onInsert?.(mapDbAppRow(payload.new as DbAppRow));
163
+ }
164
+ )
165
+ .on(
166
+ 'postgres_changes',
167
+ { event: 'UPDATE', schema: 'public', table: 'app', filter },
168
+ (payload) => {
169
+ console.log('[subscribeToAppChannel] onUpdate', payload);
170
+ handlers.onUpdate?.(mapDbAppRow(payload.new as DbAppRow));
171
+ }
172
+ )
173
+ .on(
174
+ 'postgres_changes',
175
+ { event: 'DELETE', schema: 'public', table: 'app', filter },
176
+ (payload) => {
177
+ console.log('[subscribeToAppChannel] onDelete', payload);
178
+ handlers.onDelete?.(mapDbAppRow(payload.old as DbAppRow));
179
+ }
180
+ );
181
+ });
187
182
  }
188
183
  }
189
184
 
@@ -2,7 +2,7 @@ import type { MessagesRemoteDataSource } from './remote';
2
2
  import { messagesRemoteDataSource } from './remote';
3
3
  import type { Message } from './types';
4
4
  import { BaseRepository } from '../../data/base-repository';
5
- import { getSupabaseClient } from '../../core/services/supabase';
5
+ import { subscribeManagedChannel } from '../../core/services/supabase/realtimeManager';
6
6
 
7
7
  type DbMessageRow = {
8
8
  id: string;
@@ -64,38 +64,33 @@ class MessagesRepositoryImpl extends BaseRepository implements MessagesRepositor
64
64
  onDelete?: (m: Message) => void;
65
65
  }
66
66
  ): () => void {
67
- const supabase = getSupabaseClient();
68
- const channel = supabase
69
- .channel(`messages:thread:${threadId}`)
70
- .on(
71
- 'postgres_changes',
72
- { event: 'INSERT', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
73
- (payload) => {
74
- const row = payload.new as DbMessageRow;
75
- handlers.onInsert?.(mapDbRowToMessage(row));
76
- }
77
- )
78
- .on(
79
- 'postgres_changes',
80
- { event: 'UPDATE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
81
- (payload) => {
82
- const row = payload.new as DbMessageRow;
83
- handlers.onUpdate?.(mapDbRowToMessage(row));
84
- }
85
- )
86
- .on(
87
- 'postgres_changes',
88
- { event: 'DELETE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
89
- (payload) => {
90
- const row = payload.old as DbMessageRow;
91
- handlers.onDelete?.(mapDbRowToMessage(row));
92
- }
93
- )
94
- .subscribe();
95
-
96
- return () => {
97
- supabase.removeChannel(channel);
98
- };
67
+ return subscribeManagedChannel(`messages:thread:${threadId}`, (channel) => {
68
+ channel
69
+ .on(
70
+ 'postgres_changes',
71
+ { event: 'INSERT', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
72
+ (payload) => {
73
+ const row = payload.new as DbMessageRow;
74
+ handlers.onInsert?.(mapDbRowToMessage(row));
75
+ }
76
+ )
77
+ .on(
78
+ 'postgres_changes',
79
+ { event: 'UPDATE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
80
+ (payload) => {
81
+ const row = payload.new as DbMessageRow;
82
+ handlers.onUpdate?.(mapDbRowToMessage(row));
83
+ }
84
+ )
85
+ .on(
86
+ 'postgres_changes',
87
+ { event: 'DELETE', schema: 'public', table: 'message', filter: `thread_id=eq.${threadId}` },
88
+ (payload) => {
89
+ const row = payload.old as DbMessageRow;
90
+ handlers.onDelete?.(mapDbRowToMessage(row));
91
+ }
92
+ );
93
+ });
99
94
  }
100
95
  }
101
96