@fitzzero/quickdraw-core 0.1.0 → 1.1.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,36 +52,44 @@ 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({});
40
- const authTokenRef = React.useRef(authToken);
41
- React.useEffect(() => {
42
- authTokenRef.current = authToken;
43
- }, [authToken]);
82
+ const subscriptionRegistryRef = React.useRef(
83
+ createSubscriptionRegistry()
84
+ );
85
+ const prevAuthTokenRef = React.useRef(authToken);
86
+ const socketRef = React.useRef(null);
44
87
  const connect = React.useCallback(
45
88
  (token) => {
46
- const authToUse = token ?? authTokenRef.current;
89
+ if (socketRef.current) return;
90
+ const authToUse = token;
91
+ subscriptionRegistryRef.current.clear();
92
+ subscriptionRegistryRef.current = createSubscriptionRegistry();
47
93
  const newSocket = io(serverUrl, {
48
94
  auth: authToUse ? { token: authToUse } : void 0,
49
95
  transports: ["websocket", "polling"],
@@ -54,6 +100,7 @@ function QuickdrawProvider({
54
100
  });
55
101
  newSocket.on("disconnect", () => {
56
102
  setIsConnected(false);
103
+ subscriptionRegistryRef.current.clear();
57
104
  });
58
105
  newSocket.on(
59
106
  "auth:info",
@@ -65,37 +112,46 @@ function QuickdrawProvider({
65
112
  newSocket.on("connect_error", (error) => {
66
113
  console.error("Socket connection error:", error.message);
67
114
  });
115
+ socketRef.current = newSocket;
68
116
  setSocket(newSocket);
69
117
  },
70
118
  [serverUrl]
71
119
  );
72
120
  const disconnect = React.useCallback(() => {
73
- if (socket) {
74
- socket.disconnect();
121
+ if (socketRef.current) {
122
+ subscriptionRegistryRef.current.clear();
123
+ socketRef.current.disconnect();
124
+ socketRef.current = null;
75
125
  setSocket(null);
76
126
  setIsConnected(false);
77
127
  setUserId(null);
78
128
  setServiceAccess({});
79
129
  }
80
- }, [socket]);
130
+ }, []);
81
131
  React.useEffect(() => {
82
- if (autoConnect && !socket) {
83
- connect();
132
+ if (autoConnect && !socketRef.current) {
133
+ connect(authToken);
84
134
  }
85
135
  return () => {
86
- if (socket) {
87
- socket.disconnect();
136
+ if (socketRef.current) {
137
+ subscriptionRegistryRef.current.clear();
138
+ socketRef.current.disconnect();
139
+ socketRef.current = null;
88
140
  }
89
141
  };
90
- }, [autoConnect]);
142
+ }, [autoConnect, authToken]);
91
143
  React.useEffect(() => {
92
- if (socket && authToken !== authTokenRef.current) {
93
- disconnect();
144
+ const prevToken = prevAuthTokenRef.current;
145
+ prevAuthTokenRef.current = authToken;
146
+ if (authToken !== prevToken) {
147
+ if (socketRef.current) {
148
+ disconnect();
149
+ }
94
150
  if (authToken) {
95
151
  connect(authToken);
96
152
  }
97
153
  }
98
- }, [authToken, socket, disconnect, connect]);
154
+ }, [authToken, disconnect, connect]);
99
155
  const contextValue = React.useMemo(
100
156
  () => ({
101
157
  socket,
@@ -103,11 +159,12 @@ function QuickdrawProvider({
103
159
  userId,
104
160
  serviceAccess,
105
161
  connect,
106
- disconnect
162
+ disconnect,
163
+ subscriptionRegistry: subscriptionRegistryRef.current
107
164
  }),
108
165
  [socket, isConnected, userId, serviceAccess, connect, disconnect]
109
166
  );
110
- return /* @__PURE__ */ jsx(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
167
+ return /* @__PURE__ */ jsx(QueryClientProvider, { client: actualQueryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
111
168
  }
112
169
  function useService(serviceName, methodName, options) {
113
170
  const { socket, isConnected } = useQuickdrawSocket();
@@ -126,7 +183,7 @@ function useService(serviceName, methodName, options) {
126
183
  const eventName = `${serviceName}:${methodName}`;
127
184
  const timeout = setTimeout(() => {
128
185
  reject(new Error("Request timeout"));
129
- }, 1e4);
186
+ }, options?.timeout ?? 1e4);
130
187
  socket.emit(
131
188
  eventName,
132
189
  payload,
@@ -190,7 +247,7 @@ function useServiceMethod(serviceName, methodName, options) {
190
247
  setLoading(false);
191
248
  optionsRef.current?.onError?.(errorMsg);
192
249
  resolve(null);
193
- }, 1e4);
250
+ }, options?.timeout ?? 1e4);
194
251
  socket.emit(
195
252
  eventName,
196
253
  payload,
@@ -211,7 +268,7 @@ function useServiceMethod(serviceName, methodName, options) {
211
268
  );
212
269
  });
213
270
  },
214
- [socket, isConnected, serviceName, methodName]
271
+ [socket, isConnected, serviceName, methodName, options?.timeout]
215
272
  );
216
273
  return {
217
274
  execute,
@@ -221,9 +278,8 @@ function useServiceMethod(serviceName, methodName, options) {
221
278
  isReady: !!socket && isConnected
222
279
  };
223
280
  }
224
- var activeSubscriptions = /* @__PURE__ */ new Map();
225
281
  function useSubscription(serviceName, entryId, options = {}) {
226
- const { socket, isConnected } = useQuickdrawSocket();
282
+ const { socket, isConnected, subscriptionRegistry } = useQuickdrawSocket();
227
283
  const queryClient = useQueryClient();
228
284
  const [isSubscribed, setIsSubscribed] = React.useState(false);
229
285
  const [error, setError] = React.useState(null);
@@ -236,7 +292,10 @@ function useSubscription(serviceName, entryId, options = {}) {
236
292
  // Subscriptions should stay fresh via socket updates
237
293
  } = options;
238
294
  const subscriptionKey = entryId ? `${serviceName}:${entryId}` : null;
239
- const queryKey = [serviceName, "subscription", entryId];
295
+ const queryKey = React.useMemo(
296
+ () => [serviceName, "subscription", entryId],
297
+ [serviceName, entryId]
298
+ );
240
299
  const onDataRef = React.useRef(onData);
241
300
  const onErrorRef = React.useRef(onError);
242
301
  React.useEffect(() => {
@@ -256,16 +315,11 @@ function useSubscription(serviceName, entryId, options = {}) {
256
315
  if (!socket || !isConnected || !subscriptionKey || !enabled || !entryId) {
257
316
  return;
258
317
  }
259
- const existing = activeSubscriptions.get(subscriptionKey);
260
- if (existing) {
261
- existing.refCount++;
318
+ const { isNew } = subscriptionRegistry.acquire(subscriptionKey);
319
+ if (!isNew) {
262
320
  setIsSubscribed(true);
263
321
  return () => {
264
- existing.refCount--;
265
- if (existing.refCount === 0) {
266
- existing.cleanup?.();
267
- activeSubscriptions.delete(subscriptionKey);
268
- }
322
+ subscriptionRegistry.release(subscriptionKey);
269
323
  };
270
324
  }
271
325
  const subscribeEvent = `${serviceName}:subscribe`;
@@ -307,19 +361,9 @@ function useSubscription(serviceName, entryId, options = {}) {
307
361
  socket.emit(`${serviceName}:unsubscribe`, { entryId });
308
362
  setIsSubscribed(false);
309
363
  };
310
- activeSubscriptions.set(subscriptionKey, {
311
- refCount: 1,
312
- cleanup
313
- });
364
+ subscriptionRegistry.setCleanup(subscriptionKey, cleanup);
314
365
  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
- }
366
+ subscriptionRegistry.release(subscriptionKey);
323
367
  };
324
368
  }, [
325
369
  socket,
@@ -330,7 +374,8 @@ function useSubscription(serviceName, entryId, options = {}) {
330
374
  serviceName,
331
375
  requiredLevel,
332
376
  queryClient,
333
- queryKey
377
+ queryKey,
378
+ subscriptionRegistry
334
379
  ]);
335
380
  const subscribe = React.useCallback(() => {
336
381
  if (!socket || !isConnected || !entryId) return;