@fitzzero/quickdraw-core 3.6.0 → 3.7.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.
Files changed (30) hide show
  1. package/README.md +113 -64
  2. package/dist/client/index.js +32 -61
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/client/testing.js.map +1 -1
  5. package/dist/server/express/index.d.ts +37 -0
  6. package/dist/server/express/index.js +60 -0
  7. package/dist/server/express/index.js.map +1 -0
  8. package/dist/server/index.d.ts +227 -1
  9. package/dist/server/index.js +400 -142
  10. package/dist/server/index.js.map +1 -1
  11. package/dist/server/testing-prisma.d.ts +101 -0
  12. package/dist/server/testing-prisma.js +172 -0
  13. package/dist/server/testing-prisma.js.map +1 -0
  14. package/dist/server/testing.js +5 -22
  15. package/dist/server/testing.js.map +1 -1
  16. package/dist/shared/index.js.map +1 -1
  17. package/eslint-config/index.mjs +1 -2
  18. package/eslint-plugin-quickdraw/index.mjs +15 -7
  19. package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
  20. package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
  21. package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
  22. package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
  23. package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
  24. package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
  25. package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
  26. package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
  27. package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
  28. package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
  29. package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
  30. package/package.json +74 -50
package/README.md CHANGED
@@ -21,31 +21,36 @@ pnpm add @fitzzero/quickdraw-core
21
21
  ### Server Setup
22
22
 
23
23
  ```typescript
24
- import { createQuickdrawServer, BaseService } from '@fitzzero/quickdraw-core/server';
25
- import { PrismaClient } from '@prisma/client';
24
+ import { createQuickdrawServer, BaseService } from "@fitzzero/quickdraw-core/server";
25
+ import { PrismaClient } from "@prisma/client";
26
26
 
27
27
  const prisma = new PrismaClient();
28
28
 
29
29
  // Define your service
30
- class ChatService extends BaseService<Chat, Prisma.ChatCreateInput, Prisma.ChatUpdateInput, ChatServiceMethods> {
30
+ class ChatService extends BaseService<
31
+ Chat,
32
+ Prisma.ChatCreateInput,
33
+ Prisma.ChatUpdateInput,
34
+ ChatServiceMethods
35
+ > {
31
36
  constructor() {
32
- super({ serviceName: 'chatService', hasEntryACL: true });
37
+ super({ serviceName: "chatService", hasEntryACL: true });
33
38
  this.setDelegate(prisma.chat);
34
-
39
+
35
40
  // Define public methods
36
- this.createChat = this.defineMethod('createChat', 'Read', async (payload, ctx) => {
41
+ this.createChat = this.defineMethod("createChat", "Read", async (payload, ctx) => {
37
42
  const chat = await this.create({ title: payload.title, ownerId: ctx.userId });
38
43
  return { id: chat.id };
39
44
  });
40
45
  }
41
-
42
- createChat: ReturnType<typeof this.defineMethod<'createChat'>>;
46
+
47
+ createChat: ReturnType<typeof this.defineMethod<"createChat">>;
43
48
  }
44
49
 
45
50
  // Start server
46
51
  const { io, httpServer } = createQuickdrawServer({
47
52
  port: 4000,
48
- cors: { origin: 'http://localhost:3000' },
53
+ cors: { origin: "http://localhost:3000" },
49
54
  services: {
50
55
  chatService: new ChatService(),
51
56
  },
@@ -62,45 +67,42 @@ const { io, httpServer } = createQuickdrawServer({
62
67
 
63
68
  ```tsx
64
69
  // app/layout.tsx
65
- import { QuickdrawProvider } from '@fitzzero/quickdraw-core/client';
70
+ import { QuickdrawProvider } from "@fitzzero/quickdraw-core/client";
66
71
 
67
72
  export default function RootLayout({ children }) {
68
73
  return (
69
- <QuickdrawProvider
70
- serverUrl="http://localhost:4000"
71
- authToken={getAuthToken()}
72
- >
74
+ <QuickdrawProvider serverUrl="http://localhost:4000" authToken={getAuthToken()}>
73
75
  {children}
74
76
  </QuickdrawProvider>
75
77
  );
76
78
  }
77
79
 
78
80
  // app/chat/page.tsx
79
- import { useService, useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
81
+ import { useService, useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
80
82
 
81
83
  function ChatPage({ chatId }: { chatId: string }) {
82
84
  // Subscribe to real-time entity updates
83
- const { data: chat, isLoading } = useSubscription('chatService', chatId);
84
-
85
+ const { data: chat, isLoading } = useSubscription("chatService", chatId);
86
+
85
87
  // Mutation hook
86
- const updateTitle = useService('chatService', 'updateTitle', {
87
- onSuccess: () => console.log('Title updated!'),
88
+ const updateTitle = useService("chatService", "updateTitle", {
89
+ onSuccess: () => console.log("Title updated!"),
88
90
  });
89
-
91
+
90
92
  // Listen for custom events broadcast to the chat room
91
93
  const [typing, setTyping] = useState(false);
92
94
  useRoomEvents({
93
- 'chat:message': (msg) => appendMessage(msg),
94
- 'agent_typing_start': () => setTyping(true),
95
- 'agent_typing_stop': () => setTyping(false),
95
+ "chat:message": (msg) => appendMessage(msg),
96
+ agent_typing_start: () => setTyping(true),
97
+ agent_typing_stop: () => setTyping(false),
96
98
  });
97
-
99
+
98
100
  if (isLoading) return <div>Loading...</div>;
99
-
101
+
100
102
  return (
101
103
  <div>
102
104
  <h1>{chat?.title}</h1>
103
- <button onClick={() => updateTitle.mutate({ id: chatId, title: 'New Title' })}>
105
+ <button onClick={() => updateTitle.mutate({ id: chatId, title: "New Title" })}>
104
106
  Update Title
105
107
  </button>
106
108
  </div>
@@ -111,7 +113,7 @@ function ChatPage({ chatId }: { chatId: string }) {
111
113
  ### Socket Inputs
112
114
 
113
115
  ```tsx
114
- import { SocketTextField } from '@fitzzero/quickdraw-core/client';
116
+ import { SocketTextField } from "@fitzzero/quickdraw-core/client";
115
117
 
116
118
  function ChatTitleEditor({ chat, updateChat }) {
117
119
  return (
@@ -132,16 +134,16 @@ function ChatTitleEditor({ chat, updateChat }) {
132
134
  When your server broadcasts custom events to subscription rooms via `emitToRoom`, use `useRoomEvents` on the client to listen with proper lifecycle management:
133
135
 
134
136
  ```tsx
135
- import { useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
137
+ import { useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
136
138
 
137
139
  function ProjectBoard({ projectId }: { projectId: string }) {
138
- const { data: project } = useSubscription('projectService', projectId);
140
+ const { data: project } = useSubscription("projectService", projectId);
139
141
  const [tasks, setTasks] = useState<Task[]>([]);
140
142
 
141
143
  // Lifecycle-managed event listeners — cleanup handled automatically
142
144
  useRoomEvents({
143
- 'task:created': (task: Task) => setTasks(prev => [...prev, task]),
144
- 'task:deleted': ({ id }: { id: string }) => setTasks(prev => prev.filter(t => t.id !== id)),
145
+ "task:created": (task: Task) => setTasks((prev) => [...prev, task]),
146
+ "task:deleted": ({ id }: { id: string }) => setTasks((prev) => prev.filter((t) => t.id !== id)),
145
147
  });
146
148
 
147
149
  return <Board tasks={tasks} />;
@@ -153,15 +155,26 @@ function ProjectBoard({ projectId }: { projectId: string }) {
153
155
  For list queries that should refresh when related events fire, use `invalidateOn`:
154
156
 
155
157
  ```tsx
156
- import { useServiceQuery } from '@fitzzero/quickdraw-core/client';
158
+ import { useServiceQuery } from "@fitzzero/quickdraw-core/client";
157
159
 
158
160
  function TaskList({ projectId }: { projectId: string }) {
159
161
  // Auto-refetches when tasks are created, deleted, or change status
160
- const { data: tasks } = useServiceQuery('taskService', 'listTasks', { projectId }, {
161
- invalidateOn: ['task:created', 'task:deleted', 'task:statusUpdate'],
162
- });
162
+ const { data: tasks } = useServiceQuery(
163
+ "taskService",
164
+ "listTasks",
165
+ { projectId },
166
+ {
167
+ invalidateOn: ["task:created", "task:deleted", "task:statusUpdate"],
168
+ },
169
+ );
163
170
 
164
- return <ul>{tasks?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
171
+ return (
172
+ <ul>
173
+ {tasks?.map((t) => (
174
+ <li key={t.id}>{t.title}</li>
175
+ ))}
176
+ </ul>
177
+ );
165
178
  }
166
179
  ```
167
180
 
@@ -171,21 +184,49 @@ Rapid-fire events within 100ms are debounced into a single refetch.
171
184
 
172
185
  ```typescript
173
186
  // Shared types (both server and client)
174
- import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@fitzzero/quickdraw-core';
187
+ import { ServiceResponse, AccessLevel, ServiceMethodMap } from "@fitzzero/quickdraw-core";
175
188
 
176
189
  // Server
177
- import {
178
- BaseService,
179
- ServiceRegistry,
190
+ import {
191
+ BaseService,
192
+ ServiceRegistry,
180
193
  createQuickdrawServer,
181
194
  createJWT,
182
195
  verifyJWT,
183
196
  discordProvider,
184
197
  googleProvider,
185
- } from '@fitzzero/quickdraw-core/server';
198
+ // Auth & security (3.7+)
199
+ createMockOAuthProvider,
200
+ registerMockOAuthProvider,
201
+ isMockOAuthEnabled,
202
+ validateRedirectOrigin,
203
+ setSessionCookie,
204
+ clearSessionCookie,
205
+ createRequireAuth,
206
+ encrypt,
207
+ decrypt,
208
+ } from "@fitzzero/quickdraw-core/server";
209
+
210
+ // Express rate-limit presets (3.7+, requires the optional express-rate-limit peer)
211
+ import {
212
+ createAuthLimiter,
213
+ createWebhookLimiter,
214
+ createPublicApiLimiter,
215
+ } from "@fitzzero/quickdraw-core/server/express";
186
216
 
187
217
  // Server testing
188
- import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
218
+ import {
219
+ createTestServer,
220
+ connectAsUser,
221
+ emitWithAck,
222
+ } from "@fitzzero/quickdraw-core/server/testing";
223
+
224
+ // Dual-mode Prisma test databases (3.7+, optional peers: @electric-sql/pglite, pg)
225
+ import {
226
+ createPrismaTestGlobalSetup,
227
+ resetDatabase,
228
+ workerDatabaseUrl,
229
+ } from "@fitzzero/quickdraw-core/server/testing/prisma";
189
230
 
190
231
  // Client
191
232
  import {
@@ -200,10 +241,10 @@ import {
200
241
  SocketSelect,
201
242
  SocketSlider,
202
243
  SocketSwitch,
203
- } from '@fitzzero/quickdraw-core/client';
244
+ } from "@fitzzero/quickdraw-core/client";
204
245
 
205
246
  // Client testing
206
- import { createMockSocket, createTestWrapper } from '@fitzzero/quickdraw-core/client/testing';
247
+ import { createMockSocket, createTestWrapper } from "@fitzzero/quickdraw-core/client/testing";
207
248
  ```
208
249
 
209
250
  ## Local Development
@@ -226,7 +267,7 @@ Define your service methods in a shared types file:
226
267
 
227
268
  ```typescript
228
269
  // shared/types.ts
229
- import type { ServiceMethodMap } from '@fitzzero/quickdraw-core';
270
+ import type { ServiceMethodMap } from "@fitzzero/quickdraw-core";
230
271
 
231
272
  export type ChatServiceMethods = ServiceMethodMap<{
232
273
  createChat: {
@@ -238,7 +279,7 @@ export type ChatServiceMethods = ServiceMethodMap<{
238
279
  response: { id: string; title: string };
239
280
  };
240
281
  inviteUser: {
241
- payload: { id: string; userId: string; level: 'Read' | 'Moderate' | 'Admin' };
282
+ payload: { id: string; userId: string; level: "Read" | "Moderate" | "Admin" };
242
283
  response: { id: string };
243
284
  };
244
285
  }>;
@@ -309,6 +350,7 @@ Per-entity permissions. Quickdraw supports two patterns:
309
350
  #### Pattern 1: JSON ACL (Simple)
310
351
 
311
352
  Store ACL directly on the entity. Best for:
353
+
312
354
  - Simple ownership models (owner + collaborators)
313
355
  - When you don't need to query "all entities user X can access" efficiently
314
356
  - Minimal schema complexity
@@ -338,6 +380,7 @@ class DocumentService extends BaseService<Document, ...> {
338
380
  #### Pattern 2: Membership Table (Complex)
339
381
 
340
382
  Separate table for memberships. Best for:
383
+
341
384
  - Querying "all entities user X can access" efficiently
342
385
  - Complex role hierarchies
343
386
  - Additional membership metadata (join date, invited by, etc.)
@@ -353,7 +396,7 @@ model ChatMember {
353
396
  chatId String
354
397
  userId String
355
398
  level String // "Read" | "Moderate" | "Admin"
356
-
399
+
357
400
  @@unique([chatId, userId])
358
401
  }
359
402
 
@@ -384,35 +427,41 @@ When a method is called, `ensureAccessForMethod` checks in this order:
384
427
 
385
428
  ### Access Levels
386
429
 
387
- | Level | Value | Typical Use |
388
- |-------|-------|-------------|
389
- | Public | 0 | No authentication required |
390
- | Read | 1 | View data, subscribe to updates |
391
- | Moderate | 2 | Edit content, manage members |
392
- | Admin | 3 | Delete, manage ACL, full control |
430
+ | Level | Value | Typical Use |
431
+ | -------- | ----- | -------------------------------- |
432
+ | Public | 0 | No authentication required |
433
+ | Read | 1 | View data, subscribe to updates |
434
+ | Moderate | 2 | Edit content, manage members |
435
+ | Admin | 3 | Delete, manage ACL, full control |
393
436
 
394
437
  ## Testing
395
438
 
396
439
  ### Server Integration Tests
397
440
 
398
441
  ```typescript
399
- import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
442
+ import {
443
+ createTestServer,
444
+ connectAsUser,
445
+ emitWithAck,
446
+ } from "@fitzzero/quickdraw-core/server/testing";
400
447
 
401
- describe('ChatService', () => {
448
+ describe("ChatService", () => {
402
449
  let server;
403
-
450
+
404
451
  beforeAll(async () => {
405
452
  server = await createTestServer({
406
453
  services: { chatService: new ChatService() },
407
- seedDb: async () => { /* seed test data */ },
454
+ seedDb: async () => {
455
+ /* seed test data */
456
+ },
408
457
  });
409
458
  });
410
-
459
+
411
460
  afterAll(() => server.stop());
412
-
413
- it('creates chat', async () => {
414
- const client = await server.connectAs('user-id');
415
- const chat = await client.emit('chatService:createChat', { title: 'Test' });
461
+
462
+ it("creates chat", async () => {
463
+ const client = await server.connectAs("user-id");
464
+ const chat = await client.emit("chatService:createChat", { title: "Test" });
416
465
  expect(chat.id).toBeDefined();
417
466
  client.close();
418
467
  });
@@ -427,10 +476,10 @@ import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@fitzzero/
427
476
  test('renders chat', () => {
428
477
  const mockSocket = createMockSocket();
429
478
  mockSocket.emit.mockImplementation(mockSuccessEmit({ title: 'Test Chat' }));
430
-
479
+
431
480
  const wrapper = createTestWrapper({ socketContext: { socket: mockSocket } });
432
481
  render(<ChatView chatId="123" />, { wrapper });
433
-
482
+
434
483
  expect(screen.getByText('Test Chat')).toBeInTheDocument();
435
484
  });
436
485
  ```
@@ -115,9 +115,7 @@ var QuickdrawSocketContext = React.createContext(null);
115
115
  function useQuickdrawSocket() {
116
116
  const context = React.useContext(QuickdrawSocketContext);
117
117
  if (!context) {
118
- throw new Error(
119
- "useQuickdrawSocket must be used within a QuickdrawProvider"
120
- );
118
+ throw new Error("useQuickdrawSocket must be used within a QuickdrawProvider");
121
119
  }
122
120
  return context;
123
121
  }
@@ -149,9 +147,7 @@ function QuickdrawProvider({
149
147
  const [isConnected, setIsConnected] = React.useState(false);
150
148
  const [userId, setUserId] = React.useState(null);
151
149
  const [serviceAccess, setServiceAccess] = React.useState({});
152
- const subscriptionRegistryRef = React.useRef(
153
- createSubscriptionRegistry()
154
- );
150
+ const subscriptionRegistryRef = React.useRef(createSubscriptionRegistry());
155
151
  const prevAuthTokenRef = React.useRef(authToken);
156
152
  const socketRef = React.useRef(null);
157
153
  const subscriptionBatcherRef = React.useRef(
@@ -259,18 +255,14 @@ function useService(serviceName, methodName, options) {
259
255
  const timeout = setTimeout(() => {
260
256
  reject(new Error("Request timeout"));
261
257
  }, options?.timeout ?? 1e4);
262
- socket.emit(
263
- eventName,
264
- payload,
265
- (response) => {
266
- clearTimeout(timeout);
267
- if (response.success) {
268
- resolve(response.data);
269
- } else {
270
- reject(new Error(response.error));
271
- }
258
+ socket.emit(eventName, payload, (response) => {
259
+ clearTimeout(timeout);
260
+ if (response.success) {
261
+ resolve(response.data);
262
+ } else {
263
+ reject(new Error(response.error));
272
264
  }
273
- );
265
+ });
274
266
  });
275
267
  },
276
268
  onSuccess: (data) => {
@@ -334,24 +326,20 @@ function useServiceMethod(serviceName, methodName, options) {
334
326
  optionsRef.current?.onError?.(errorMsg);
335
327
  resolve(null);
336
328
  }, options?.timeout ?? 1e4);
337
- socket.emit(
338
- eventName,
339
- payload,
340
- (response) => {
341
- clearTimeout(timeout);
342
- setLoading(false);
343
- if (response.success) {
344
- setError(null);
345
- setData(response.data);
346
- optionsRef.current?.onSuccess?.(response.data);
347
- resolve(response.data);
348
- } else {
349
- setError(response.error);
350
- optionsRef.current?.onError?.(response.error);
351
- resolve(null);
352
- }
329
+ socket.emit(eventName, payload, (response) => {
330
+ clearTimeout(timeout);
331
+ setLoading(false);
332
+ if (response.success) {
333
+ setError(null);
334
+ setData(response.data);
335
+ optionsRef.current?.onSuccess?.(response.data);
336
+ resolve(response.data);
337
+ } else {
338
+ setError(response.error);
339
+ optionsRef.current?.onError?.(response.error);
340
+ resolve(null);
353
341
  }
354
- );
342
+ });
355
343
  });
356
344
  },
357
345
  [socket, isConnected, serviceName, methodName, options?.timeout]
@@ -402,18 +390,14 @@ function useServiceQuery(serviceName, methodName, payload, options) {
402
390
  const timeoutId = setTimeout(() => {
403
391
  reject(new Error("Request timeout"));
404
392
  }, timeout);
405
- socket.emit(
406
- eventName,
407
- payload,
408
- (response) => {
409
- clearTimeout(timeoutId);
410
- if (response.success) {
411
- resolve(response.data);
412
- } else {
413
- reject(new Error(response.error));
414
- }
393
+ socket.emit(eventName, payload, (response) => {
394
+ clearTimeout(timeoutId);
395
+ if (response.success) {
396
+ resolve(response.data);
397
+ } else {
398
+ reject(new Error(response.error));
415
399
  }
416
- );
400
+ });
417
401
  });
418
402
  }, [socket, isConnected, serviceName, methodName, payload, timeout]);
419
403
  React.useEffect(() => {
@@ -564,10 +548,7 @@ function useSubscription(serviceName, entryId, options = {}) {
564
548
  socket.on(updateEvent, handleUpdate);
565
549
  const handleBatchResponse = (response) => {
566
550
  if (response.success && response.data) {
567
- queryClient.setQueryData(
568
- queryKey,
569
- response.data
570
- );
551
+ queryClient.setQueryData(queryKey, response.data);
571
552
  setIsSubscribed(true);
572
553
  setError(null);
573
554
  onDataRef.current?.(response.data);
@@ -577,24 +558,14 @@ function useSubscription(serviceName, entryId, options = {}) {
577
558
  onErrorRef.current?.(response.error ?? "Subscription failed");
578
559
  }
579
560
  };
580
- subscriptionBatcher.enqueue(
581
- serviceName,
582
- entryId,
583
- requiredLevel,
584
- handleBatchResponse
585
- );
561
+ subscriptionBatcher.enqueue(serviceName, entryId, requiredLevel, handleBatchResponse);
586
562
  let visibilityDebounceTimer = null;
587
563
  const handleVisibilityChange = () => {
588
564
  if (document.visibilityState !== "visible") return;
589
565
  if (visibilityDebounceTimer) clearTimeout(visibilityDebounceTimer);
590
566
  visibilityDebounceTimer = setTimeout(() => {
591
567
  visibilityDebounceTimer = null;
592
- subscriptionBatcher.enqueue(
593
- serviceName,
594
- entryId,
595
- requiredLevel,
596
- handleBatchResponse
597
- );
568
+ subscriptionBatcher.enqueue(serviceName, entryId, requiredLevel, handleBatchResponse);
598
569
  }, 200);
599
570
  };
600
571
  if (refetchOnWindowFocus) {