@fitzzero/quickdraw-core 0.1.0 → 1.0.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 CHANGED
@@ -1,4 +1,4 @@
1
- # @quickdraw/core
1
+ # @fitzzero/quickdraw-core
2
2
 
3
3
  Fast fullstack patterns for real-time applications with Socket.io and TanStack Query.
4
4
 
@@ -13,7 +13,7 @@ Fast fullstack patterns for real-time applications with Socket.io and TanStack Q
13
13
  ## Installation
14
14
 
15
15
  ```bash
16
- pnpm add @quickdraw/core
16
+ pnpm add @fitzzero/quickdraw-core
17
17
  ```
18
18
 
19
19
  ## Quick Start
@@ -21,7 +21,7 @@ pnpm add @quickdraw/core
21
21
  ### Server Setup
22
22
 
23
23
  ```typescript
24
- import { createQuickdrawServer, BaseService } from '@quickdraw/core/server';
24
+ import { createQuickdrawServer, BaseService } from '@fitzzero/quickdraw-core/server';
25
25
  import { PrismaClient } from '@prisma/client';
26
26
 
27
27
  const prisma = new PrismaClient();
@@ -62,7 +62,7 @@ const { io, httpServer } = createQuickdrawServer({
62
62
 
63
63
  ```tsx
64
64
  // app/layout.tsx
65
- import { QuickdrawProvider } from '@quickdraw/core/client';
65
+ import { QuickdrawProvider } from '@fitzzero/quickdraw-core/client';
66
66
 
67
67
  export default function RootLayout({ children }) {
68
68
  return (
@@ -76,7 +76,7 @@ export default function RootLayout({ children }) {
76
76
  }
77
77
 
78
78
  // app/chat/page.tsx
79
- import { useService, useSubscription } from '@quickdraw/core/client';
79
+ import { useService, useSubscription } from '@fitzzero/quickdraw-core/client';
80
80
 
81
81
  function ChatPage({ chatId }: { chatId: string }) {
82
82
  // Subscribe to real-time updates
@@ -103,7 +103,7 @@ function ChatPage({ chatId }: { chatId: string }) {
103
103
  ### Socket Inputs
104
104
 
105
105
  ```tsx
106
- import { SocketTextField } from '@quickdraw/core/client';
106
+ import { SocketTextField } from '@fitzzero/quickdraw-core/client';
107
107
 
108
108
  function ChatTitleEditor({ chat, updateChat }) {
109
109
  return (
@@ -123,7 +123,7 @@ function ChatTitleEditor({ chat, updateChat }) {
123
123
 
124
124
  ```typescript
125
125
  // Shared types (both server and client)
126
- import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@quickdraw/core';
126
+ import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@fitzzero/quickdraw-core';
127
127
 
128
128
  // Server
129
129
  import {
@@ -134,10 +134,10 @@ import {
134
134
  verifyJWT,
135
135
  discordProvider,
136
136
  googleProvider,
137
- } from '@quickdraw/core/server';
137
+ } from '@fitzzero/quickdraw-core/server';
138
138
 
139
139
  // Server testing
140
- import { createTestServer, connectAsUser, emitWithAck } from '@quickdraw/core/server/testing';
140
+ import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
141
141
 
142
142
  // Client
143
143
  import {
@@ -150,10 +150,24 @@ import {
150
150
  SocketSelect,
151
151
  SocketSlider,
152
152
  SocketSwitch,
153
- } from '@quickdraw/core/client';
153
+ } from '@fitzzero/quickdraw-core/client';
154
154
 
155
155
  // Client testing
156
- import { createMockSocket, createTestWrapper } from '@quickdraw/core/client/testing';
156
+ import { createMockSocket, createTestWrapper } from '@fitzzero/quickdraw-core/client/testing';
157
+ ```
158
+
159
+ ## Local Development
160
+
161
+ This package is developed alongside [quickdraw-chat](https://github.com/fitzzero/quickdraw-chat), a reference implementation.
162
+
163
+ ### Using pnpm link
164
+
165
+ ```bash
166
+ # In quickdraw-chat, the package is linked:
167
+ "@fitzzero/quickdraw-core": "link:../../../quickdraw"
168
+
169
+ # Changes to quickdraw-core are instantly available after rebuild
170
+ pnpm build # or pnpm dev for watch mode
157
171
  ```
158
172
 
159
173
  ## Type Definitions
@@ -162,7 +176,7 @@ Define your service methods in a shared types file:
162
176
 
163
177
  ```typescript
164
178
  // shared/types.ts
165
- import type { ServiceMethodMap } from '@quickdraw/core';
179
+ import type { ServiceMethodMap } from '@fitzzero/quickdraw-core';
166
180
 
167
181
  export type ChatServiceMethods = ServiceMethodMap<{
168
182
  createChat: {
@@ -221,38 +235,118 @@ export type ChatServiceMethods = ServiceMethodMap<{
221
235
 
222
236
  ## Access Control
223
237
 
224
- Quickdraw supports two levels of access control:
238
+ Quickdraw provides flexible ACL with two complementary levels:
225
239
 
226
240
  ### Service-level ACL
227
241
 
228
- Stored in `user.serviceAccess` JSON field:
229
- ```json
230
- { "chatService": "Admin", "userService": "Read" }
242
+ Blanket permissions across all entries in a service. Stored in `user.serviceAccess`:
243
+
244
+ ```typescript
245
+ // User model must satisfy QuickdrawUser interface
246
+ interface QuickdrawUser {
247
+ id: string;
248
+ serviceAccess?: Record<string, AccessLevel> | null;
249
+ }
250
+
251
+ // Example: Admin access to all chats
252
+ user.serviceAccess = { chatService: "Admin", userService: "Read" };
231
253
  ```
232
254
 
233
255
  ### Entry-level ACL
234
256
 
235
- Stored in entity's `acl` JSON field (when `hasEntryACL: true`):
236
- ```json
237
- [
238
- { "userId": "user-1", "level": "Admin" },
239
- { "userId": "user-2", "level": "Read" }
240
- ]
257
+ Per-entity permissions. Quickdraw supports two patterns:
258
+
259
+ #### Pattern 1: JSON ACL (Simple)
260
+
261
+ Store ACL directly on the entity. Best for:
262
+ - Simple ownership models (owner + collaborators)
263
+ - When you don't need to query "all entities user X can access" efficiently
264
+ - Minimal schema complexity
265
+
266
+ ```typescript
267
+ // Entity must satisfy ACLEntity interface
268
+ interface ACLEntity {
269
+ id: string;
270
+ acl?: ACL | null; // ACL = Array<{ userId: string; level: AccessLevel }>
271
+ }
272
+
273
+ // Prisma schema
274
+ model Document {
275
+ id String @id @default(cuid())
276
+ acl Json? // Stores [{ userId: "...", level: "Read" }]
277
+ }
278
+
279
+ // Service - uses default checkEntryACL (no override needed)
280
+ class DocumentService extends BaseService<Document, ...> {
281
+ constructor(prisma: PrismaClient) {
282
+ super({ serviceName: "documentService", hasEntryACL: true });
283
+ this.setDelegate(prisma.document);
284
+ }
285
+ }
286
+ ```
287
+
288
+ #### Pattern 2: Membership Table (Complex)
289
+
290
+ Separate table for memberships. Best for:
291
+ - Querying "all entities user X can access" efficiently
292
+ - Complex role hierarchies
293
+ - Additional membership metadata (join date, invited by, etc.)
294
+
295
+ ```typescript
296
+ // Prisma schema
297
+ model Chat {
298
+ id String @id
299
+ members ChatMember[]
300
+ }
301
+
302
+ model ChatMember {
303
+ chatId String
304
+ userId String
305
+ level String // "Read" | "Moderate" | "Admin"
306
+
307
+ @@unique([chatId, userId])
308
+ }
309
+
310
+ // Service - override checkEntryACL to use membership table
311
+ class ChatService extends BaseService<Chat, ...> {
312
+ protected override async checkEntryACL(
313
+ userId: string,
314
+ chatId: string,
315
+ requiredLevel: AccessLevel
316
+ ): Promise<boolean> {
317
+ const member = await this.prisma.chatMember.findUnique({
318
+ where: { chatId_userId: { chatId, userId } },
319
+ });
320
+ if (!member) return false;
321
+ return this.isLevelSufficient(member.level as AccessLevel, requiredLevel);
322
+ }
323
+ }
241
324
  ```
242
325
 
326
+ ### Access Check Order
327
+
328
+ When a method is called, `ensureAccessForMethod` checks in this order:
329
+
330
+ 1. **Service-level**: `socket.serviceAccess[serviceName] >= requiredLevel` → Allow
331
+ 2. **Custom override**: `checkAccess()` returns true → Allow (use for self-access patterns)
332
+ 3. **Entry-level**: `checkEntryACL()` returns true → Allow (JSON ACL or membership table)
333
+ 4. **Deny** if none of the above
334
+
243
335
  ### Access Levels
244
336
 
245
- - **Public**: No authentication required
246
- - **Read**: Authenticated users (default for subscriptions)
247
- - **Moderate**: Edit access (update, moderate content)
248
- - **Admin**: Full access (delete, manage ACL)
337
+ | Level | Value | Typical Use |
338
+ |-------|-------|-------------|
339
+ | Public | 0 | No authentication required |
340
+ | Read | 1 | View data, subscribe to updates |
341
+ | Moderate | 2 | Edit content, manage members |
342
+ | Admin | 3 | Delete, manage ACL, full control |
249
343
 
250
344
  ## Testing
251
345
 
252
346
  ### Server Integration Tests
253
347
 
254
348
  ```typescript
255
- import { createTestServer, connectAsUser, emitWithAck } from '@quickdraw/core/server/testing';
349
+ import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
256
350
 
257
351
  describe('ChatService', () => {
258
352
  let server;
@@ -278,7 +372,7 @@ describe('ChatService', () => {
278
372
  ### Client Component Tests
279
373
 
280
374
  ```typescript
281
- import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@quickdraw/core/client/testing';
375
+ import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@fitzzero/quickdraw-core/client/testing';
282
376
 
283
377
  test('renders chat', () => {
284
378
  const mockSocket = createMockSocket();
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
- import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseSubscriptionOptions, d as UseSubscriptionResult } from '../types-CUExGF7F.js';
3
- export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-CUExGF7F.js';
2
+ import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseSubscriptionOptions, d as UseSubscriptionResult } from '../types-BADirRXn.js';
3
+ export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-BADirRXn.js';
4
4
  import 'socket.io-client';
5
5
  import '@tanstack/react-query';
6
6
  import '../shared/index.js';
@@ -33,52 +33,44 @@ declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken
33
33
  /**
34
34
  * Hook for invoking service methods via Socket.io with TanStack Query integration.
35
35
  *
36
- * @typeParam TServiceMethods - Map of service method definitions
37
- * @typeParam TService - Service name
38
- * @typeParam TMethod - Method name
36
+ * This hook supports two usage patterns:
39
37
  *
40
- * @example
38
+ * 1. **Simple usage** - Just pass service/method names with explicit payload/response types:
41
39
  * ```tsx
42
- * // Define your service methods type
43
- * type ChatServiceMethods = {
44
- * createChat: { payload: { title: string }; response: { id: string } };
45
- * updateTitle: { payload: { id: string; title: string }; response: { id: string; title: string } };
46
- * };
47
- *
48
- * // In your component
49
- * function CreateChatButton() {
50
- * const createChat = useService<ChatServiceMethods, 'chatService', 'createChat'>(
51
- * 'chatService',
52
- * 'createChat',
53
- * {
54
- * onSuccess: (data) => {
55
- * router.push(`/chat/${data.id}`);
56
- * },
57
- * }
58
- * );
40
+ * const createChat = useService<{ title: string }, { id: string }>(
41
+ * 'chatService',
42
+ * 'createChat'
43
+ * );
44
+ * ```
59
45
  *
60
- * return (
61
- * <button
62
- * onClick={() => createChat.mutate({ title: 'New Chat' })}
63
- * disabled={createChat.isPending}
64
- * >
65
- * {createChat.isPending ? 'Creating...' : 'Create Chat'}
66
- * </button>
67
- * );
46
+ * 2. **Type-safe usage** - Pass a service methods map for full type inference:
47
+ * ```tsx
48
+ * // In your app, create a typed wrapper:
49
+ * function useTypedService<
50
+ * TService extends keyof ServiceMethodsMap,
51
+ * TMethod extends keyof ServiceMethodsMap[TService] & string,
52
+ * >(serviceName: TService, methodName: TMethod, options?: ...) {
53
+ * type Methods = ServiceMethodsMap[TService];
54
+ * type Payload = Methods[TMethod] extends { payload: infer P } ? P : never;
55
+ * type Response = Methods[TMethod] extends { response: infer R } ? R : never;
56
+ * return useService<Payload, Response>(serviceName, methodName, options);
68
57
  * }
69
58
  * ```
59
+ *
60
+ * @typeParam TPayload - The payload type for the method
61
+ * @typeParam TResponse - The response type for the method
70
62
  */
71
- declare function useService<TServiceMethods extends Record<string, Record<string, {
72
- payload: unknown;
73
- response: unknown;
74
- }>>, TService extends keyof TServiceMethods & string, TMethod extends keyof TServiceMethods[TService] & string>(serviceName: TService, methodName: TMethod, options?: UseServiceOptions<TServiceMethods[TService][TMethod]["response"]>): UseServiceResult<TServiceMethods[TService][TMethod]["payload"], TServiceMethods[TService][TMethod]["response"]>;
63
+ declare function useService<TPayload = unknown, TResponse = unknown>(serviceName: string, methodName: string, options?: UseServiceOptions<TResponse>): UseServiceResult<TPayload, TResponse>;
75
64
  /**
76
65
  * Simplified hook for basic service method calls.
77
66
  * Use this when you don't need the full TanStack Query mutation features.
78
67
  *
79
68
  * @example
80
69
  * ```tsx
81
- * const { execute, loading, error, data } = useServiceMethod('chatService', 'createChat');
70
+ * const { execute, loading, error, data } = useServiceMethod<
71
+ * { title: string },
72
+ * { id: string }
73
+ * >('chatService', 'createChat');
82
74
  *
83
75
  * const handleCreate = async () => {
84
76
  * const result = await execute({ title: 'New Chat' });
@@ -102,8 +94,9 @@ declare function useServiceMethod<TPayload = unknown, TResponse = unknown>(servi
102
94
  * Features:
103
95
  * - Automatic subscription management
104
96
  * - TanStack Query cache integration for optimistic updates
105
- * - Subscription deduplication across components
97
+ * - Subscription deduplication across components (via context-based registry)
106
98
  * - Automatic reconnection handling
99
+ * - HMR/Fast Refresh safe (no module-level state)
107
100
  *
108
101
  * @typeParam TData - The entity type
109
102
  *
@@ -1,9 +1,47 @@
1
1
  import * as React from 'react';
2
2
  import { io } from 'socket.io-client';
3
- import { QueryClient, QueryClientProvider, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
3
+ import { QueryClientProvider, useMutation, useQueryClient, useQuery, QueryClient } from '@tanstack/react-query';
4
4
  import { jsx } from 'react/jsx-runtime';
5
5
 
6
6
  // src/client/QuickdrawProvider.tsx
7
+ function createSubscriptionRegistry() {
8
+ const subscriptions = /* @__PURE__ */ new Map();
9
+ return {
10
+ acquire(key) {
11
+ const existing = subscriptions.get(key);
12
+ if (existing) {
13
+ existing.refCount++;
14
+ return { isNew: false, entry: existing };
15
+ }
16
+ const entry = { refCount: 1 };
17
+ subscriptions.set(key, entry);
18
+ return { isNew: true, entry };
19
+ },
20
+ release(key) {
21
+ const entry = subscriptions.get(key);
22
+ if (!entry) return false;
23
+ entry.refCount--;
24
+ if (entry.refCount <= 0) {
25
+ entry.cleanup?.();
26
+ subscriptions.delete(key);
27
+ return true;
28
+ }
29
+ return false;
30
+ },
31
+ setCleanup(key, cleanup) {
32
+ const entry = subscriptions.get(key);
33
+ if (entry) {
34
+ entry.cleanup = cleanup;
35
+ }
36
+ },
37
+ clear() {
38
+ for (const entry of subscriptions.values()) {
39
+ entry.cleanup?.();
40
+ }
41
+ subscriptions.clear();
42
+ }
43
+ };
44
+ }
7
45
  var QuickdrawSocketContext = React.createContext(null);
8
46
  function useQuickdrawSocket() {
9
47
  const context = React.useContext(QuickdrawSocketContext);
@@ -14,29 +52,36 @@ function useQuickdrawSocket() {
14
52
  }
15
53
  return context;
16
54
  }
17
- var defaultQueryClient = new QueryClient({
18
- defaultOptions: {
19
- queries: {
20
- staleTime: 1e3 * 60 * 5,
21
- // 5 minutes
22
- retry: 1
23
- },
24
- mutations: {
25
- retry: 0
55
+ function createQueryClient() {
56
+ return new QueryClient({
57
+ defaultOptions: {
58
+ queries: {
59
+ staleTime: 1e3 * 60 * 5,
60
+ // 5 minutes
61
+ retry: 1
62
+ },
63
+ mutations: {
64
+ retry: 0
65
+ }
26
66
  }
27
- }
28
- });
67
+ });
68
+ }
29
69
  function QuickdrawProvider({
30
70
  children,
31
71
  serverUrl,
32
- queryClient = defaultQueryClient,
72
+ queryClient,
33
73
  authToken,
34
74
  autoConnect = !!authToken
35
75
  }) {
76
+ const [defaultQueryClient] = React.useState(() => createQueryClient());
77
+ const actualQueryClient = queryClient ?? defaultQueryClient;
36
78
  const [socket, setSocket] = React.useState(null);
37
79
  const [isConnected, setIsConnected] = React.useState(false);
38
80
  const [userId, setUserId] = React.useState(null);
39
81
  const [serviceAccess, setServiceAccess] = React.useState({});
82
+ const subscriptionRegistryRef = React.useRef(
83
+ createSubscriptionRegistry()
84
+ );
40
85
  const authTokenRef = React.useRef(authToken);
41
86
  React.useEffect(() => {
42
87
  authTokenRef.current = authToken;
@@ -44,6 +89,8 @@ function QuickdrawProvider({
44
89
  const connect = React.useCallback(
45
90
  (token) => {
46
91
  const authToUse = token ?? authTokenRef.current;
92
+ subscriptionRegistryRef.current.clear();
93
+ subscriptionRegistryRef.current = createSubscriptionRegistry();
47
94
  const newSocket = io(serverUrl, {
48
95
  auth: authToUse ? { token: authToUse } : void 0,
49
96
  transports: ["websocket", "polling"],
@@ -54,6 +101,7 @@ function QuickdrawProvider({
54
101
  });
55
102
  newSocket.on("disconnect", () => {
56
103
  setIsConnected(false);
104
+ subscriptionRegistryRef.current.clear();
57
105
  });
58
106
  newSocket.on(
59
107
  "auth:info",
@@ -71,6 +119,7 @@ function QuickdrawProvider({
71
119
  );
72
120
  const disconnect = React.useCallback(() => {
73
121
  if (socket) {
122
+ subscriptionRegistryRef.current.clear();
74
123
  socket.disconnect();
75
124
  setSocket(null);
76
125
  setIsConnected(false);
@@ -84,6 +133,7 @@ function QuickdrawProvider({
84
133
  }
85
134
  return () => {
86
135
  if (socket) {
136
+ subscriptionRegistryRef.current.clear();
87
137
  socket.disconnect();
88
138
  }
89
139
  };
@@ -103,11 +153,12 @@ function QuickdrawProvider({
103
153
  userId,
104
154
  serviceAccess,
105
155
  connect,
106
- disconnect
156
+ disconnect,
157
+ subscriptionRegistry: subscriptionRegistryRef.current
107
158
  }),
108
159
  [socket, isConnected, userId, serviceAccess, connect, disconnect]
109
160
  );
110
- return /* @__PURE__ */ jsx(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
161
+ return /* @__PURE__ */ jsx(QueryClientProvider, { client: actualQueryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
111
162
  }
112
163
  function useService(serviceName, methodName, options) {
113
164
  const { socket, isConnected } = useQuickdrawSocket();
@@ -126,7 +177,7 @@ function useService(serviceName, methodName, options) {
126
177
  const eventName = `${serviceName}:${methodName}`;
127
178
  const timeout = setTimeout(() => {
128
179
  reject(new Error("Request timeout"));
129
- }, 1e4);
180
+ }, options?.timeout ?? 1e4);
130
181
  socket.emit(
131
182
  eventName,
132
183
  payload,
@@ -190,7 +241,7 @@ function useServiceMethod(serviceName, methodName, options) {
190
241
  setLoading(false);
191
242
  optionsRef.current?.onError?.(errorMsg);
192
243
  resolve(null);
193
- }, 1e4);
244
+ }, options?.timeout ?? 1e4);
194
245
  socket.emit(
195
246
  eventName,
196
247
  payload,
@@ -211,7 +262,7 @@ function useServiceMethod(serviceName, methodName, options) {
211
262
  );
212
263
  });
213
264
  },
214
- [socket, isConnected, serviceName, methodName]
265
+ [socket, isConnected, serviceName, methodName, options?.timeout]
215
266
  );
216
267
  return {
217
268
  execute,
@@ -221,9 +272,8 @@ function useServiceMethod(serviceName, methodName, options) {
221
272
  isReady: !!socket && isConnected
222
273
  };
223
274
  }
224
- var activeSubscriptions = /* @__PURE__ */ new Map();
225
275
  function useSubscription(serviceName, entryId, options = {}) {
226
- const { socket, isConnected } = useQuickdrawSocket();
276
+ const { socket, isConnected, subscriptionRegistry } = useQuickdrawSocket();
227
277
  const queryClient = useQueryClient();
228
278
  const [isSubscribed, setIsSubscribed] = React.useState(false);
229
279
  const [error, setError] = React.useState(null);
@@ -236,7 +286,10 @@ function useSubscription(serviceName, entryId, options = {}) {
236
286
  // Subscriptions should stay fresh via socket updates
237
287
  } = options;
238
288
  const subscriptionKey = entryId ? `${serviceName}:${entryId}` : null;
239
- const queryKey = [serviceName, "subscription", entryId];
289
+ const queryKey = React.useMemo(
290
+ () => [serviceName, "subscription", entryId],
291
+ [serviceName, entryId]
292
+ );
240
293
  const onDataRef = React.useRef(onData);
241
294
  const onErrorRef = React.useRef(onError);
242
295
  React.useEffect(() => {
@@ -256,16 +309,11 @@ function useSubscription(serviceName, entryId, options = {}) {
256
309
  if (!socket || !isConnected || !subscriptionKey || !enabled || !entryId) {
257
310
  return;
258
311
  }
259
- const existing = activeSubscriptions.get(subscriptionKey);
260
- if (existing) {
261
- existing.refCount++;
312
+ const { isNew } = subscriptionRegistry.acquire(subscriptionKey);
313
+ if (!isNew) {
262
314
  setIsSubscribed(true);
263
315
  return () => {
264
- existing.refCount--;
265
- if (existing.refCount === 0) {
266
- existing.cleanup?.();
267
- activeSubscriptions.delete(subscriptionKey);
268
- }
316
+ subscriptionRegistry.release(subscriptionKey);
269
317
  };
270
318
  }
271
319
  const subscribeEvent = `${serviceName}:subscribe`;
@@ -307,19 +355,9 @@ function useSubscription(serviceName, entryId, options = {}) {
307
355
  socket.emit(`${serviceName}:unsubscribe`, { entryId });
308
356
  setIsSubscribed(false);
309
357
  };
310
- activeSubscriptions.set(subscriptionKey, {
311
- refCount: 1,
312
- cleanup
313
- });
358
+ subscriptionRegistry.setCleanup(subscriptionKey, cleanup);
314
359
  return () => {
315
- const sub = activeSubscriptions.get(subscriptionKey);
316
- if (sub) {
317
- sub.refCount--;
318
- if (sub.refCount === 0) {
319
- sub.cleanup?.();
320
- activeSubscriptions.delete(subscriptionKey);
321
- }
322
- }
360
+ subscriptionRegistry.release(subscriptionKey);
323
361
  };
324
362
  }, [
325
363
  socket,
@@ -330,7 +368,8 @@ function useSubscription(serviceName, entryId, options = {}) {
330
368
  serviceName,
331
369
  requiredLevel,
332
370
  queryClient,
333
- queryKey
371
+ queryKey,
372
+ subscriptionRegistry
334
373
  ]);
335
374
  const subscribe = React.useCallback(() => {
336
375
  if (!socket || !isConnected || !entryId) return;