@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/dist/index.js +177 -94
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +177 -94
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/services/supabase/realtimeManager.ts +112 -0
- package/src/data/apps/edit-queue/repository.ts +35 -40
- package/src/data/apps/repository.ts +28 -33
- package/src/data/messages/repository.ts +28 -33
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 {
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|