@fitzzero/quickdraw-core 4.1.0 → 5.0.0-rc.2

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2402 -643
  3. package/dist/admin-Bl71J4IG.d.ts +445 -0
  4. package/dist/auth-DyCIKmTQ.d.ts +80 -0
  5. package/dist/caller-Cx5wuao-.d.ts +975 -0
  6. package/dist/chunk-47AEXVDM.js +44 -0
  7. package/dist/chunk-47AEXVDM.js.map +1 -0
  8. package/dist/chunk-5ADGYGC5.js +478 -0
  9. package/dist/chunk-5ADGYGC5.js.map +1 -0
  10. package/dist/chunk-5EZNDES3.js +269 -0
  11. package/dist/chunk-5EZNDES3.js.map +1 -0
  12. package/dist/chunk-7E7STZEO.js +37 -0
  13. package/dist/chunk-7E7STZEO.js.map +1 -0
  14. package/dist/chunk-CLPB6IGC.js +275 -0
  15. package/dist/chunk-CLPB6IGC.js.map +1 -0
  16. package/dist/chunk-DXTJNJM5.js +4850 -0
  17. package/dist/chunk-DXTJNJM5.js.map +1 -0
  18. package/dist/chunk-EI57GEMD.js +53 -0
  19. package/dist/chunk-EI57GEMD.js.map +1 -0
  20. package/dist/chunk-G47OHQYB.js +13 -0
  21. package/dist/chunk-G47OHQYB.js.map +1 -0
  22. package/dist/chunk-GXLJA7AH.js +379 -0
  23. package/dist/chunk-GXLJA7AH.js.map +1 -0
  24. package/dist/chunk-I2OMV4AD.js +8296 -0
  25. package/dist/chunk-I2OMV4AD.js.map +1 -0
  26. package/dist/chunk-JK5D3OXC.js +76 -0
  27. package/dist/chunk-JK5D3OXC.js.map +1 -0
  28. package/dist/chunk-K35VD4CQ.js +1302 -0
  29. package/dist/chunk-K35VD4CQ.js.map +1 -0
  30. package/dist/chunk-NRM4XAWR.js +516 -0
  31. package/dist/chunk-NRM4XAWR.js.map +1 -0
  32. package/dist/chunk-SSDLYYQV.js +117 -0
  33. package/dist/chunk-SSDLYYQV.js.map +1 -0
  34. package/dist/chunk-TBMKJ26R.js +90 -0
  35. package/dist/chunk-TBMKJ26R.js.map +1 -0
  36. package/dist/chunk-U32GOJRO.js +400 -0
  37. package/dist/chunk-U32GOJRO.js.map +1 -0
  38. package/dist/chunk-UJQJM7EG.js +30 -0
  39. package/dist/chunk-UJQJM7EG.js.map +1 -0
  40. package/dist/chunk-V3KNCTXJ.js +79 -0
  41. package/dist/chunk-V3KNCTXJ.js.map +1 -0
  42. package/dist/chunk-XPTZFIWZ.js +86 -0
  43. package/dist/chunk-XPTZFIWZ.js.map +1 -0
  44. package/dist/chunk-Z2LZ7IZ7.js +130 -0
  45. package/dist/chunk-Z2LZ7IZ7.js.map +1 -0
  46. package/dist/cli/quickdraw-docs.d.ts +1 -0
  47. package/dist/cli/quickdraw-docs.js +760 -0
  48. package/dist/cli/quickdraw-docs.js.map +1 -0
  49. package/dist/client/index.d.ts +627 -595
  50. package/dist/client/index.js +172 -1638
  51. package/dist/client/index.js.map +1 -1
  52. package/dist/clientTypes-B62bWHua.d.ts +684 -0
  53. package/dist/createServer-P61KBWro.d.ts +524 -0
  54. package/dist/createTestApp-BBm3TakW.d.ts +157 -0
  55. package/dist/envelope-CoiZY9KU.d.ts +557 -0
  56. package/dist/errors-S-bxEIBC.d.ts +72 -0
  57. package/dist/http-89lENH2D.d.ts +431 -0
  58. package/dist/index.d.ts +12 -0
  59. package/dist/index.js +663 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/infer-Cwq49ME5.d.ts +841 -0
  62. package/dist/keys-CqKkljVN.d.ts +46 -0
  63. package/dist/logger-BY28acW-.d.ts +16 -0
  64. package/dist/metrics-Ny1dFbyY.d.ts +35 -0
  65. package/dist/parser.d.ts +31 -0
  66. package/dist/parser.js +7 -0
  67. package/dist/parser.js.map +1 -0
  68. package/dist/prisma/index.d.ts +64 -0
  69. package/dist/prisma/index.js +492 -0
  70. package/dist/prisma/index.js.map +1 -0
  71. package/dist/rateLimit-ZMIZJUZF.js +21 -0
  72. package/dist/rateLimit-ZMIZJUZF.js.map +1 -0
  73. package/dist/searchSchemas-CBgKRa1b.d.ts +43 -0
  74. package/dist/server/auth/index.d.ts +723 -0
  75. package/dist/server/auth/index.js +1398 -0
  76. package/dist/server/auth/index.js.map +1 -0
  77. package/dist/server/express/index.d.ts +10 -1
  78. package/dist/server/express/index.js +19 -58
  79. package/dist/server/express/index.js.map +1 -1
  80. package/dist/server/index.d.ts +1046 -1519
  81. package/dist/server/index.js +3611 -3271
  82. package/dist/server/index.js.map +1 -1
  83. package/dist/server/mcp/index.d.ts +358 -0
  84. package/dist/server/mcp/index.js +936 -0
  85. package/dist/server/mcp/index.js.map +1 -0
  86. package/dist/server/otel.d.ts +30 -0
  87. package/dist/server/otel.js +108 -0
  88. package/dist/server/otel.js.map +1 -0
  89. package/dist/sharing-BHyufgVl.d.ts +252 -0
  90. package/dist/testing/client.d.ts +241 -0
  91. package/dist/testing/client.js +730 -0
  92. package/dist/testing/client.js.map +1 -0
  93. package/dist/testing/index.d.ts +168 -0
  94. package/dist/testing/index.js +776 -0
  95. package/dist/testing/index.js.map +1 -0
  96. package/dist/{server/testing-prisma.d.ts → testing/prisma.d.ts} +1 -1
  97. package/dist/{server/testing-prisma.js → testing/prisma.js} +17 -11
  98. package/dist/testing/prisma.js.map +1 -0
  99. package/dist/types-BDmssndG.d.ts +66 -0
  100. package/dist/utils/index.d.ts +140 -0
  101. package/dist/utils/index.js +44 -0
  102. package/dist/utils/index.js.map +1 -0
  103. package/package.json +113 -79
  104. package/dist/client/testing.d.ts +0 -73
  105. package/dist/client/testing.js +0 -115
  106. package/dist/client/testing.js.map +0 -1
  107. package/dist/server/testing-prisma.js.map +0 -1
  108. package/dist/server/testing.d.ts +0 -70
  109. package/dist/server/testing.js +0 -695
  110. package/dist/server/testing.js.map +0 -1
  111. package/dist/shared/index.d.ts +0 -467
  112. package/dist/shared/index.js +0 -31
  113. package/dist/shared/index.js.map +0 -1
  114. package/dist/types-87lWXyNP.d.ts +0 -185
  115. package/dist/types-BbnMKDA5.d.ts +0 -420
  116. package/eslint-config/index.mjs +0 -163
  117. package/eslint-plugin-quickdraw/index.mjs +0 -30
  118. package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +0 -112
  119. package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +0 -48
  120. package/eslint-plugin-quickdraw/rules/no-manual-collection-events.mjs +0 -68
  121. package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +0 -52
  122. package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +0 -140
  123. package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +0 -93
  124. package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +0 -79
  125. package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +0 -59
  126. package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +0 -52
  127. package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +0 -35
  128. package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +0 -41
  129. package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +0 -55
  130. package/oxlint.base.jsonc +0 -126
package/README.md CHANGED
@@ -1,807 +1,2566 @@
1
1
  # @fitzzero/quickdraw-core
2
2
 
3
- Fast fullstack patterns for real-time applications with Socket.io and TanStack Query.
4
-
5
- ## Features
6
-
7
- - **Server Core**: BaseService class with typed CRUD, ACL-based access control, and real-time subscriptions
8
- - **Collections**: declare a scope-keyed list once, get live add/update/remove deltas, pagination, and reconnect healing — no hand-wired events
9
- - **Client Core**: TanStack Query integration with Socket.io for real-time state management
10
- - **Socket Inputs**: Pre-built form components that sync with server state
11
- - **Custom OAuth**: JWT-based authentication with Discord and Google providers
12
- - **Type Safety**: End-to-end TypeScript support with shared type definitions
13
-
14
- ## Installation
3
+ Typed real-time fullstack services on Socket.IO, TanStack Query and Prisma.
4
+ A service is declared once, as a contract in the app's shared package; the
5
+ server implements it and the client is typed from it. Writes are tracked, so
6
+ every subscriber's live rows, lists and cached queries follow them without a
7
+ single hand-written event.
8
+
9
+ - **Contracts**: methods, projections, field tiers, collections, streams,
10
+ channels and events, as plain data plus schemas (Standard Schema; Zod 4.2
11
+ or later where JSON Schema is needed).
12
+ - **Services**: `qd.defineService(contract, { ... })`, one handler per
13
+ method, access declared and failing closed, row policies shared by every
14
+ surface.
15
+ - **Live data**: tracked Prisma writes become entity frames, collection
16
+ deltas and change topics, with revisions, resume after reconnect, and
17
+ revocation when access is lost.
18
+ - **Client**: `qd.<service>.<member>` hooks over TanStack Query, live
19
+ entities and collections, optimistic mutations, one invalidation
20
+ coordinator.
21
+ - **Kits**: read/write, search, sharing and membership, admin, presence and
22
+ streams, auth routes.
23
+ - **Transports**: Socket.IO (protocol 5), HTTP, in process, MCP, and a shim
24
+ for 4.x clients.
25
+ - **Testing**: a real test server, access matrices, performance budgets that
26
+ count statements and bytes, strict development warnings.
27
+
28
+ Design record: [`docs/rfcs/0003-v5.md`](../../docs/rfcs/0003-v5.md).
29
+
30
+ ## Install
15
31
 
16
32
  ```bash
17
- pnpm add @fitzzero/quickdraw-core
33
+ bun add @fitzzero/quickdraw-core@next zod
34
+ bun add express socket.io @prisma/client # the server
35
+ bun add socket.io-client @tanstack/react-query react # the web app
36
+ bun add -d @fitzzero/quickdraw-lint@next @fitzzero/quickdraw-skills@next oxlint
18
37
  ```
19
38
 
20
- ## Quick Start
39
+ Until 5.0.0 is released, 5.0 is published under the `next` dist-tag, which
40
+ these commands name; without it they install 4.x. Node 24 or later. Every
41
+ peer dependency is optional: install the ones the entries you import need.
42
+
43
+ | Entry | Needs |
44
+ | --------------------------------------- | ----------------------------------------------------------------------- |
45
+ | `.` (contracts, errors, protocol types) | a Standard Schema library: Zod 4.2 or later where JSON Schema is needed |
46
+ | `./server`, `./server/mcp` | `socket.io`; an Express 4 or 5 app (or none) for the HTTP transport |
47
+ | `./server/auth` | `express-rate-limit` for its default sign-in limits |
48
+ | `./server/express` | `express-rate-limit` |
49
+ | `./server/otel` | `@opentelemetry/api` |
50
+ | `./prisma` | `@prisma/client` 7 |
51
+ | `./client` | `socket.io-client`, `@tanstack/react-query` 5, `react` 19 |
52
+ | `./utils`, `./parser` | nothing more |
53
+ | `./testing` | `socket.io`, `socket.io-client` |
54
+ | `./testing/client` | the `./client` peers, and `@testing-library/react` |
55
+ | `./testing/prisma` | `pg` or `@electric-sql/pglite` |
56
+
57
+ ## Quick start
58
+
59
+ A board of tasks in the quickdraw template's layout: contracts in
60
+ `packages/shared`, the server in `apps/api`, the web app in `apps/web`. The
61
+ examples in this README compile: they are copies of
62
+ [`packages/core/test/readme/`](https://github.com/fitzzero/quickdraw/tree/dev/packages/core/test/readme),
63
+ which the package's typecheck builds. The pieces the quick start imports
64
+ without showing (the schemas, the auth helpers, the Prisma models) are under
65
+ [The example app](#the-example-app).
66
+
67
+ ### 1. The contract
68
+
69
+ `packages/shared/src/contracts/task.ts`. The shared package exports the
70
+ contracts, and a map of them for the client:
71
+ `contracts = { label, project, task }`.
72
+
73
+ <!-- example: packages/shared/src/contracts/task.ts -->
74
+
75
+ ```ts
76
+ import { crud, defineContract, mutation, query } from "@fitzzero/quickdraw-core";
77
+ import { z } from "zod";
78
+ import { cardSchema, taskSchema } from "../schemas";
79
+
80
+ export const taskContract = defineContract("taskService", {
81
+ // the full row; it must contain `id: string`
82
+ entity: taskSchema,
83
+ // lean shapes of the row
84
+ projections: { card: cardSchema },
85
+ // only callers with Admin on the task receive notes
86
+ fields: { notes: "Admin" },
87
+ methods: {
88
+ // the read/write kit's get (one task by id) and create
89
+ ...crud.contract({
90
+ entity: taskSchema,
91
+ get: true,
92
+ create: { input: z.object({ projectId: z.string(), title: z.string() }) },
93
+ }),
94
+ rename: mutation({
95
+ input: z.object({ id: z.string(), title: z.string() }),
96
+ output: "entity",
97
+ describe: "Renames a task.",
98
+ }),
99
+ countOnBoard: query({
100
+ input: z.object({ projectId: z.string() }),
101
+ output: z.number(),
102
+ // fetched again whenever the project's board changes
103
+ watch: { collection: "board", scope: (input) => input.projectId },
104
+ }),
105
+ },
106
+ collections: {
107
+ // every task of a project, live, in board order
108
+ board: {
109
+ scope: "projectId",
110
+ item: "card",
111
+ order: [
112
+ ["ordinal", "asc"],
113
+ ["id", "asc"],
114
+ ],
115
+ // sent for the whole board
116
+ index: ["status", "ordinal", "assigneeId"],
117
+ views: { mine: (row, who) => row.assigneeId === who.userId },
118
+ },
119
+ },
120
+ });
121
+ ```
21
122
 
22
- ### Server Setup
123
+ ### 2. The service
23
124
 
24
- ```typescript
25
- import { createQuickdrawServer, BaseService } from "@fitzzero/quickdraw-core/server";
26
- import { PrismaClient } from "@prisma/client";
125
+ The app states its types once, with one tracked database client.
27
126
 
28
- const prisma = new PrismaClient();
127
+ <!-- example: apps/api/src/db.ts -->
29
128
 
30
- // Define your service
31
- class ChatService extends BaseService<
32
- Chat,
33
- Prisma.ChatCreateInput,
34
- Prisma.ChatUpdateInput,
35
- ChatServiceMethods
36
- > {
37
- constructor() {
38
- super({ serviceName: "chatService", hasEntryACL: true });
39
- this.setDelegate(prisma.chat);
129
+ ```ts
130
+ import { trackPrisma } from "@fitzzero/quickdraw-core/prisma";
131
+ import { prisma } from "@project/db";
40
132
 
41
- // Define public methods
42
- this.createChat = this.defineMethod("createChat", "Read", async (payload, ctx) => {
43
- const chat = await this.create({ title: payload.title, ownerId: ctx.userId });
44
- return { id: chat.id };
45
- });
46
- }
133
+ // Every write through `db` is tracked: subscribers see it. Apply trackPrisma
134
+ // last, after any other client extension.
135
+ export const db = trackPrisma(prisma);
136
+ ```
137
+
138
+ <!-- example: apps/api/src/quickdraw.ts -->
139
+
140
+ ```ts
141
+ import { initQuickdraw, type Principal } from "@fitzzero/quickdraw-core/server";
142
+ import type { contracts } from "@project/shared";
143
+ import type { db } from "./db";
47
144
 
48
- createChat: ReturnType<typeof this.defineMethod<"createChat">>;
145
+ /** Who calls: a signed-in user, or an agent acting for one. */
146
+ export interface AppPrincipal extends Principal {
147
+ readonly kind: "user" | "agent";
49
148
  }
50
149
 
51
- // Start server
52
- const { io, httpServer } = createQuickdrawServer({
53
- port: 4000,
54
- cors: { origin: "http://localhost:3000" },
55
- services: {
56
- chatService: new ChatService(),
150
+ // The app's types, stated once: every service, handler and caller is typed from them.
151
+ export const qd = initQuickdraw<{
152
+ db: typeof db;
153
+ principal: AppPrincipal;
154
+ contracts: typeof contracts;
155
+ }>();
156
+ ```
157
+
158
+ `apps/api/src/services/task.ts` implements every method of the contract,
159
+ each with its access: the read/write kit's `get` and `create` in one spread,
160
+ and a handler for each of the others:
161
+
162
+ <!-- example: apps/api/src/services/task.ts -->
163
+
164
+ ```ts
165
+ import { crud, inherit } from "@fitzzero/quickdraw-core/server";
166
+ import { projectContract, taskContract } from "@project/shared";
167
+ import { qd } from "../quickdraw";
168
+
169
+ export const taskService = qd.defineService(taskContract, {
170
+ // the Prisma model its rows live in
171
+ model: "task",
172
+ // the level on the task's project
173
+ access: inherit({ from: projectContract, via: "projectId" }),
174
+ // a board opens with Read on its project
175
+ collections: { board: { anchor: projectContract } },
176
+ methods: {
177
+ // the kit implements get and create; each names who may call it
178
+ ...crud.handlers(taskContract, {
179
+ access: {
180
+ get: { entry: "Read" },
181
+ create: { scope: "Moderate", of: projectContract, id: "projectId" },
182
+ },
183
+ }),
184
+ rename: {
185
+ access: { entry: "Moderate" },
186
+ // return the row: the framework sends the projection's fields, dates as ISO strings
187
+ handler: ({ input, db }) =>
188
+ db.task.update({ where: { id: input.id }, data: { title: input.title } }),
189
+ },
190
+ countOnBoard: {
191
+ access: { scope: "Read", of: projectContract, id: "projectId" },
192
+ // identical concurrent calls by one user run once
193
+ share: "caller",
194
+ handler: ({ input, db }) => db.task.count({ where: { projectId: input.projectId } }),
195
+ },
57
196
  },
197
+ });
198
+ ```
199
+
200
+ ### 3. The server
201
+
202
+ `apps/api/src/index.ts`: the services on the app's own Express app.
203
+
204
+ <!-- example: apps/api/src/index.ts -->
205
+
206
+ ```ts
207
+ import { socketAuth } from "@fitzzero/quickdraw-core/server/auth";
208
+ import express, { type Express } from "express";
209
+ import { jwtSecret, loadGrants, sessions } from "./auth";
210
+ import { db } from "./db";
211
+ import { qd, type AppPrincipal } from "./quickdraw";
212
+ import { labelService } from "./services/label";
213
+ import { projectService } from "./services/project";
214
+ import { taskService } from "./services/task";
215
+
216
+ // the web app's origins: CORS, and the pages that may open a socket with the session cookie
217
+ const webOrigins = ["http://localhost:3000"];
218
+
219
+ export const app: Express = express();
220
+
221
+ export const server = qd.createServer({
222
+ // the HTTP transport is mounted on it: POST /qd/{service}/{method}
223
+ app,
224
+ services: [labelService, projectService, taskService],
225
+ db,
226
+ cors: { origin: webOrigins, credentials: true },
58
227
  auth: {
59
- authenticate: async (socket, auth) => {
60
- const payload = await verifyJWT(auth.token, process.env.JWT_SECRET);
61
- return payload?.userId;
62
- },
228
+ // the session cookie the auth routes set, else a bearer token (`auth.token`); none is anonymous
229
+ authenticate: socketAuth({
230
+ sessions,
231
+ jwtSecret,
232
+ allowedOrigins: webOrigins,
233
+ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "user" }),
234
+ }),
235
+ loadServiceAccess: (userId) => loadGrants(userId),
236
+ // a tracked write to User.serviceAccess refreshes that user's open sockets
237
+ serviceAccessSource: { model: "user", column: "serviceAccess" },
63
238
  },
239
+ // close on SIGTERM and SIGINT; the process is never exited
240
+ handleSignals: true,
64
241
  });
242
+
243
+ server.httpServer.listen(4000);
244
+ ```
245
+
246
+ ### 4. The client
247
+
248
+ <!-- example: apps/web/src/lib/quickdraw.ts -->
249
+
250
+ ```ts
251
+ import { createQuickdrawClient } from "@fitzzero/quickdraw-core/client";
252
+ import { contracts } from "@project/shared";
253
+
254
+ // One typed client for the app: qd.task and qd.project, from the contracts.
255
+ export const qd = createQuickdrawClient(contracts);
65
256
  ```
66
257
 
67
- ### Client Setup
258
+ <!-- example: apps/web/src/app/providers.tsx -->
68
259
 
69
260
  ```tsx
70
- // app/layout.tsx
261
+ "use client";
262
+
71
263
  import { QuickdrawProvider } from "@fitzzero/quickdraw-core/client";
264
+ import type { ReactNode } from "react";
265
+ import { qd } from "../lib/quickdraw";
72
266
 
73
- export default function RootLayout({ children }) {
267
+ export function Providers({ children }: { readonly children: ReactNode }) {
268
+ // Cookie sessions need no `auth`; a bearer token is `auth={token}`.
74
269
  return (
75
- <QuickdrawProvider serverUrl="http://localhost:4000" authToken={getAuthToken()}>
270
+ <QuickdrawProvider client={qd} url="http://localhost:4000">
76
271
  {children}
77
272
  </QuickdrawProvider>
78
273
  );
79
274
  }
275
+ ```
80
276
 
81
- // app/chat/page.tsx
82
- import { useService, useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
83
-
84
- function ChatPage({ chatId }: { chatId: string }) {
85
- // Subscribe to real-time entity updates
86
- const { data: chat, isLoading } = useSubscription("chatService", chatId);
277
+ <!-- example: apps/web/src/components/TaskBoard.tsx -->
87
278
 
88
- // Mutation hook
89
- const updateTitle = useService("chatService", "updateTitle", {
90
- onSuccess: () => console.log("Title updated!"),
91
- });
279
+ ```tsx
280
+ "use client";
92
281
 
93
- // Listen for custom events broadcast to the chat room
94
- const [typing, setTyping] = useState(false);
95
- useRoomEvents({
96
- "chat:message": (msg) => appendMessage(msg),
97
- agent_typing_start: () => setTyping(true),
98
- agent_typing_stop: () => setTyping(false),
99
- });
282
+ import { qd } from "../lib/quickdraw";
100
283
 
101
- if (isLoading) return <div>Loading...</div>;
284
+ export function TaskBoard({ projectId }: { readonly projectId: string }) {
285
+ // Live: tasks added, changed, moved or removed by anyone show at once.
286
+ const { items, isLoading } = qd.task.board.useCollection(projectId);
287
+ const { data: count } = qd.task.countOnBoard.useQuery({ projectId });
288
+ // Optimistic: the new title shows before the server answers.
289
+ const rename = qd.task.rename.useMutation();
102
290
 
291
+ if (isLoading) {
292
+ return <p>Loading…</p>;
293
+ }
103
294
  return (
104
- <div>
105
- <h1>{chat?.title}</h1>
106
- <button onClick={() => updateTitle.mutate({ id: chatId, title: "New Title" })}>
107
- Update Title
108
- </button>
109
- </div>
295
+ <section>
296
+ <h2>{`${String(count ?? items.length)} tasks`}</h2>
297
+ <ul>
298
+ {items.map((task) => (
299
+ <li key={task.id}>
300
+ {task.title}
301
+ <button type="button" onClick={() => rename.mutate({ id: task.id, title: "Done" })}>
302
+ Rename
303
+ </button>
304
+ </li>
305
+ ))}
306
+ </ul>
307
+ </section>
110
308
  );
111
309
  }
112
310
  ```
113
311
 
114
- ### Collections (Live Lists)
115
-
116
- Entity subscriptions cover single rows; **collections** cover lists. A
117
- collection is "rows of this service, grouped by a scope id derived from the
118
- row" — declare it once and the framework handles emission (multi-node-safe),
119
- pagination, and reconnect correctness. No more `task:created` /
120
- `task:deleted` mirror events, `invalidateOn` refetches, or hand-rolled
121
- merge-by-id state.
122
-
123
- **Server** — declare next to your methods; the CRUD trio emits deltas
124
- automatically (scope moves and predicate entry/exit included):
125
-
126
- ```typescript
127
- type MessageCollections = { byChat: { item: MessageDTO } };
128
-
129
- class MessageService extends BaseService<
130
- Message,
131
- Prisma.MessageCreateInput,
132
- Prisma.MessageUpdateInput,
133
- MessageServiceMethods,
134
- Record<string, unknown>, // channels
135
- MessageDTO, // TDto — wire shape
136
- MessageCollections // TCollections
137
- > {
138
- constructor(prisma: PrismaClient) {
139
- super({ serviceName: "messageService" });
140
- this.setDelegate(prisma.message);
141
-
142
- this.defineCollection("byChat", {
143
- resolveScopeId: (message) => message.chatId,
144
- checkScopeAccess: (userId, chatId) => this.isChatMember(userId, chatId),
145
- // Server-ordered first page + reconnect re-snapshot. Omit `ids` for
146
- // unbounded histories like this one; return it for bounded scopes so
147
- // reconnecting clients prune rows deleted while offline.
148
- snapshot: async (chatId, { cursor, limit }) => this.getMessagePage(chatId, cursor, limit),
149
- toItem: (message) => this.toDto(message),
150
- });
151
- }
152
- }
153
- ```
312
+ When another user adds, renames or moves a task, the board changes at once,
313
+ and `countOnBoard` is fetched again because it watches the board.
154
314
 
155
- **Client** — one hook per list; live deltas, `loadMore` paging, and
156
- re-snapshot-on-reconnect are built in:
315
+ ### The example app
157
316
 
158
- ```tsx
159
- import { useCollection } from "@fitzzero/quickdraw-core/client";
160
-
161
- function ChatWindow({ chatId }: { chatId: string }) {
162
- const {
163
- items: messages,
164
- isLoading,
165
- hasMore,
166
- loadMore,
167
- } = useCollection<MessageDTO>("messageService", "byChat", chatId, {
168
- compare: (a, b) => a.createdAt.localeCompare(b.createdAt),
169
- });
317
+ The quick start is part of a small app in the template's layout, whose
318
+ every file compiles with the package:
319
+ [`packages/core/test/readme/`](https://github.com/fitzzero/quickdraw/tree/dev/packages/core/test/readme).
320
+ The label and project services the server registers are there, with the
321
+ other sections' examples. These are the pieces the quick start imports
322
+ without showing, which an app writes itself.
170
323
 
171
- return <MessageList messages={messages} onScrollTop={hasMore ? loadMore : undefined} />;
172
- }
324
+ The schemas the contracts are built on, `packages/shared/src/schemas.ts`
325
+ (the shared package exports them beside the contracts):
326
+
327
+ <!-- example: packages/shared/src/schemas.ts -->
328
+
329
+ ```ts
330
+ import { z } from "zod";
331
+
332
+ export const projectSchema = z.object({ id: z.string(), name: z.string(), ownerId: z.string() });
333
+
334
+ export const taskSchema = z.object({
335
+ id: z.string(),
336
+ projectId: z.string(),
337
+ title: z.string(),
338
+ status: z.string(),
339
+ ordinal: z.number(),
340
+ assigneeId: z.string().nullable(),
341
+ notes: z.string().nullable(),
342
+ });
343
+
344
+ /** The lean shape a board shows. */
345
+ export const cardSchema = taskSchema.omit({ notes: true });
173
346
  ```
174
347
 
175
- Scopes don't have to be parent entities — `resolveScopeId` may return a
176
- `string[]` to fan out (e.g. a chat appearing in every member's `myChats`
177
- collection, scope = user id), or `null` to exclude a row (predicate
178
- filtering). For hand-rolled write paths, one-line choke points keep deltas
179
- flowing: `emitCollectionUpsert` / `emitCollectionRemove` /
180
- `emitCollectionMove` / `emitCollectionReset`, plus `kickFromCollection` for
181
- adapter-safe ACL revocation.
348
+ The sessions, the secret and the grants the server reads,
349
+ `apps/api/src/auth.ts`. Sessions are issued by the auth routes, mounted on
350
+ the same `app` (see the [auth routes kit](#auth-routes-kit)):
182
351
 
183
- ACL is deliberately simple: items are **scope-visible** — anyone who passes
184
- `checkScopeAccess` sees every item in full (strip sensitive fields in
185
- `toItem`/`snapshot`). If visibility varies per user within a scope, that's
186
- not a collection — use separate scopes or entity subscriptions.
352
+ <!-- example: apps/api/src/auth.ts -->
187
353
 
188
- When to use which:
354
+ ```ts
355
+ // The app's sessions, token check and grants, for the README's server examples.
189
356
 
190
- | Hook | Use for |
191
- | ----------------- | ------------------------------------------------------------- |
192
- | `useSubscription` | One entity, field-tiered (detail panels) |
193
- | `useCollection` | Live lists of a scope (boards, feeds, chat histories) |
194
- | `useServiceQuery` | Genuinely query-shaped reads (search, cross-scope aggregates) |
357
+ import type { AccessLevel } from "@fitzzero/quickdraw-core";
358
+ import { createMemorySessionStore, verifyJWT } from "@fitzzero/quickdraw-core/server/auth";
359
+ import { db } from "./db";
360
+ import type { AppPrincipal } from "./quickdraw";
195
361
 
196
- ### Socket Inputs
362
+ /** Signs the session JWTs: one secret for the auth routes and `socketAuth`, 32 characters or more. */
363
+ export const jwtSecret = process.env.JWT_SECRET ?? "";
197
364
 
198
- ```tsx
199
- import { SocketTextField } from "@fitzzero/quickdraw-core/client";
365
+ /** The sessions the auth routes issue. In production, a store over the database (see the auth routes kit). */
366
+ export const sessions = createMemorySessionStore();
200
367
 
201
- function ChatTitleEditor({ chat, updateChat }) {
202
- return (
203
- <SocketTextField
204
- state={chat}
205
- update={(patch) => updateChat.mutateAsync({ id: chat.id, ...patch })}
206
- property="title"
207
- commitMode="debounce"
208
- debounceMs={500}
209
- placeholder="Chat title..."
210
- />
211
- );
368
+ /** The user a bearer token signs in, or `null` for no token. */
369
+ export async function verifySession(token: unknown): Promise<AppPrincipal | null> {
370
+ if (typeof token !== "string") {
371
+ return null;
372
+ }
373
+ const payload = await verifyJWT(token, jwtSecret);
374
+ return payload === null ? null : { userId: payload.userId, kind: "user" };
212
375
  }
213
- ```
214
376
 
215
- ### Custom Room Events
377
+ /** A user's service-wide grants, as stored in `User.serviceAccess`. */
378
+ export async function loadGrants(userId: string): Promise<Record<string, AccessLevel>> {
379
+ const user = await db.user.findUnique({ where: { id: userId }, select: { serviceAccess: true } });
380
+ const stored: unknown = user?.serviceAccess;
381
+ return typeof stored === "object" && stored !== null
382
+ ? (stored as Record<string, AccessLevel>)
383
+ : {};
384
+ }
385
+ ```
216
386
 
217
- For genuinely custom, ephemeral events (typing indicators, presence pulses —
218
- things that aren't rows), broadcast with `emitToRoom` and listen with
219
- `useRoomEvents`:
387
+ The Prisma models of `@project/db` (`packages/db/prisma/schema.prisma`),
388
+ `User.serviceAccess` among them, which `loadGrants` reads and
389
+ `serviceAccessSource` watches:
390
+
391
+ <!-- example: ../prisma/schema.prisma#models -->
392
+
393
+ ```prisma
394
+ model User {
395
+ id String @id @default(cuid())
396
+ email String @unique
397
+ name String
398
+ // Service-wide grants, as 4.x apps store them: { "taskService": "Admin" }.
399
+ serviceAccess Json?
400
+ ownedProjects Project[]
401
+ memberships ProjectMember[]
402
+ assigned Task[]
403
+ }
220
404
 
221
- ```tsx
222
- import { useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
405
+ model Project {
406
+ id String @id @default(cuid())
407
+ name String
408
+ ownerId String
409
+ // A JSON access list, [{ userId, level }].
410
+ acl Json?
411
+ archived Boolean @default(false)
412
+ owner User @relation(fields: [ownerId], references: [id])
413
+ members ProjectMember[]
414
+ tasks Task[]
415
+ labels Label[]
416
+ }
223
417
 
224
- function ChatView({ chatId }: { chatId: string }) {
225
- const { data: chat } = useSubscription("chatService", chatId);
226
- const [typing, setTyping] = useState<string | null>(null);
418
+ // A membership table: role holds an access level ("Read" | "Moderate" | "Admin").
419
+ model ProjectMember {
420
+ id String @id @default(cuid())
421
+ projectId String
422
+ userId String
423
+ role String
424
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
425
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
426
+
427
+ @@unique([projectId, userId])
428
+ @@index([userId])
429
+ }
227
430
 
228
- // Lifecycle-managed event listeners — cleanup handled automatically
229
- useRoomEvents({
230
- "chat:typing": ({ userName }: { userName: string }) => setTyping(userName),
231
- "chat:typingStop": () => setTyping(null),
232
- });
431
+ model Task {
432
+ id String @id @default(cuid())
433
+ projectId String
434
+ parentTaskId String?
435
+ status String @default("open")
436
+ ordinal Int @default(0)
437
+ title String
438
+ assigneeId String?
439
+ notes String?
440
+ // A flag and a JSON column: every field type the admin kit edits.
441
+ pinned Boolean @default(false)
442
+ details Json?
443
+ createdAt DateTime @default(now())
444
+ updatedAt DateTime @updatedAt
445
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
446
+ parent Task? @relation("Subtasks", fields: [parentTaskId], references: [id], onDelete: SetNull)
447
+ subtasks Task[] @relation("Subtasks")
448
+ assignee User? @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
449
+ labels TaskLabel[]
450
+
451
+ @@index([projectId, status, ordinal])
452
+ }
233
453
 
234
- return <Chat chat={chat} typing={typing} />;
454
+ model Label {
455
+ id String @id @default(cuid())
456
+ projectId String
457
+ name String
458
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
459
+ tasks TaskLabel[]
235
460
  }
236
- ```
237
461
 
238
- Event names and payloads can be typed end-to-end by augmenting
239
- `QuickdrawEventMap` from the package root — `emitToRoom` and `useRoomEvents`
240
- then check payloads and autocomplete names (and degrade to
241
- `string`/`unknown` if you never augment it):
462
+ model TaskLabel {
463
+ id String @id @default(cuid())
464
+ taskId String
465
+ labelId String
466
+ task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
467
+ label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
242
468
 
243
- ```typescript
244
- declare module "@fitzzero/quickdraw-core" {
245
- interface QuickdrawEventMap {
246
- "chat:typing": { userName: string };
247
- "chat:typingStop": Record<string, never>;
248
- }
469
+ @@unique([taskId, labelId])
470
+ @@index([labelId])
249
471
  }
250
472
  ```
251
473
 
252
- Don't hand-emit row lifecycle events (`task:created`, `task:deleted`, …) —
253
- that's what collections automate; the shipped
254
- `quickdraw/no-manual-collection-events` lint rule flags them.
474
+ ## Contracts
255
475
 
256
- ### Auto-Invalidating Queries
476
+ A contract is plain data plus schemas, so browser code imports it without
477
+ any server code (design: `docs/rfcs/0003-v5.md`, section 2).
257
478
 
258
- For genuinely query-shaped reads (search results, cross-scope aggregates)
259
- that should refresh when related events fire, use `invalidateOn`:
479
+ <!-- example: packages/shared/src/contracts/examples.ts#outputs -->
260
480
 
261
- ```tsx
262
- import { useServiceQuery } from "@fitzzero/quickdraw-core/client";
481
+ ```ts
482
+ export const labelContract = defineContract("labelService", {
483
+ entity: labelSchema,
484
+ projections: { chip: z.object({ id: z.string(), name: z.string() }) },
485
+ methods: {
486
+ find: query({ input: z.object({ name: z.string() }), output: nullable("entity") }),
487
+ chips: query({ input: z.object({ projectId: z.string() }), output: listOf("chip") }),
488
+ usage: query({ input: z.undefined(), output: z.record(z.string(), z.number()) }),
489
+ },
490
+ });
263
491
 
264
- function SearchResults({ query }: { query: string }) {
265
- const { data: results } = useServiceQuery(
266
- "taskService",
267
- "searchTasks",
268
- { query },
269
- {
270
- invalidateOn: ["task:statusUpdate"],
271
- refetchInterval: 60_000, // optional periodic refresh
492
+ // No entity: an RPC-only service, with no projections, field tiers or collections.
493
+ export const healthContract = defineContract("healthService", {
494
+ methods: { ping: query({ input: z.undefined(), output: z.literal("pong") }) },
495
+ });
496
+ ```
497
+
498
+ - Every method is a `query` or a `mutation`, with an `input` and an
499
+ `output`. The kind decides request sharing, cancellation, the concurrency
500
+ cap, the MCP read-only hint and which client hook exists. `describe` is the
501
+ method's description for people and agents (the MCP tool's description).
502
+ - `output` is a schema, or a projection: `"entity"`, a named projection,
503
+ `nullable("entity")` or `listOf("card")`. A handler returns database rows
504
+ for a projection output, and the framework projects them.
505
+ - `entity` and every projection contain `id: string`. A contract without an
506
+ `entity` is an RPC-only service.
507
+ - Only a query may `watch` (`{ collection, scope: (input) => scopeId }`): the
508
+ client joins that scope's change topic and fetches the query again when it
509
+ changes.
510
+ - Methods, collections, streams, channels and events share one namespace,
511
+ because the client exposes each as `qd.<service>.<name>`. `subscribe`,
512
+ `unsubscribe`, `call`, `then`, `useEntity`, `useEntities`, `admin` and
513
+ names starting with `$` are reserved.
514
+ - Schemas are any Standard Schema. A projection's keys, admin field
515
+ metadata, MCP tool schemas and `quickdraw-docs` read Standard JSON Schema,
516
+ which Zod 4.2 or later provides; a projection whose schema cannot describe
517
+ itself declares `keys` in the service's `project` option.
518
+
519
+ Types come from the contract too:
520
+
521
+ <!-- example: packages/shared/src/contracts/examples.ts#types -->
522
+
523
+ ```ts
524
+ // { id: string; title: string }
525
+ export type RenameInput = InputOf<typeof taskContract, "rename">;
526
+ // the entity, as the wire has it
527
+ export type Task = OutputOf<typeof taskContract, "get">;
528
+ // one item of the board
529
+ export type Card = ItemOf<typeof taskContract, "board">;
530
+ ```
531
+
532
+ `InputOf`, `ParsedInputOf`, `OutputOf`, `EntityOf`, `ProjectionOf`,
533
+ `ItemOf`, `ScopeOf`, `IndexRowOf`, `ViewName` and the rest are exported from
534
+ the package root.
535
+
536
+ ## Services
537
+
538
+ `qd.defineService(contract, definition)` implements a contract (design:
539
+ section 3). `methods` must implement exactly the contract's methods; each is
540
+ `{ access, handler }`, plus `timeoutMs`, and for a query `share`, `ttlMs` and
541
+ `version`.
542
+
543
+ <!-- example: apps/api/src/services/examples/handlers.ts#handler -->
544
+
545
+ ```ts
546
+ export const taskService = qd.defineService(task, {
547
+ model: "task",
548
+ methods: {
549
+ assign: {
550
+ access: { service: "Moderate" },
551
+ // instead of the dispatcher's callTimeoutMs (30 s)
552
+ timeoutMs: 5_000,
553
+ handler: async ({ input, ctx, db }) => {
554
+ const found = await db.task.findUnique({ where: { id: input.id } });
555
+ if (found === null) {
556
+ // the caller receives the code
557
+ throw new QuickdrawError("NOT_FOUND", "No such task");
558
+ }
559
+ ctx.log.info("assigning", { by: ctx.principal.userId, transport: ctx.transport });
560
+ return db.task.update({ where: { id: input.id }, data: { assigneeId: input.assigneeId } });
561
+ },
272
562
  },
273
- );
563
+ },
564
+ });
565
+ ```
274
566
 
275
- return <Results items={results} />;
567
+ - A handler receives `{ input, ctx, db }`: the input after its schema ran,
568
+ the call's context, and the tracked database client. `ctx` holds
569
+ `principal` (`userId`, `kind`, `claims`, `serviceAccess`; `null` only in a
570
+ `"public"` method called anonymously), `signal` (aborts on cancel or time
571
+ limit), `log`, `requestId`, `transport` (`"socket"`, `"http"`, `"mcp"`,
572
+ `"internal"` or `"legacy"`), `touch` (below), `rooms` and `presence`
573
+ (realtime, below), `mcp` (the MCP bridge's context) and `services`.
574
+ - `ctx.services` calls the app's services in process, as the same
575
+ principal, by service name: `await ctx.services.projectService.get({ id })`.
576
+ Each call runs the whole pipeline with transport `"internal"` (input
577
+ check, access, handler, output check), so access is checked on the inner
578
+ call too; its writes join the calling method's unit of work, so both
579
+ flush together once; and it is cancelled with `ctx.signal`. It is typed
580
+ by the `contracts` of `initQuickdraw`'s types, as `qd.caller` is.
581
+ - `share: "caller"` runs identical concurrent calls of one principal once,
582
+ `share: "all"` across principals (not with `custom` access); `ttlMs` keeps
583
+ a shared result. Callers of a shared run whose levels hide the same fields
584
+ get one copy of its result, encoded once for all of their sockets.
585
+ `version(input, ctx)` answers "not modified" for a query whose result the
586
+ caller already holds.
587
+ - Every call runs a pipeline: look up, concurrency (16 queries in flight per
588
+ socket and 64 queued, then `RATE_LIMITED`; mutations are not queued behind
589
+ queries), input validation, access, "not modified", sharing, the handler
590
+ under a time limit (30 s by default), output validation outside
591
+ production, the reply, the flush, and one completion record (`onCall`).
592
+ A mutation ignores the caller's cancel: only its time limit stops it.
593
+
594
+ Errors are `QuickdrawError(code, message, data?)`. Anything else a handler
595
+ throws reaches the caller as `INTERNAL` with a generic message, and is
596
+ logged; Prisma's unique violation becomes `CONFLICT` and its missing row
597
+ `NOT_FOUND`.
598
+
599
+ | Code | HTTP | Meaning |
600
+ | ----------------- | ---- | -------------------------------------------------- |
601
+ | `UNAUTHENTICATED` | 401 | no principal |
602
+ | `FORBIDDEN` | 403 | access denied |
603
+ | `NOT_FOUND` | 404 | unknown service, method or row |
604
+ | `CONFLICT` | 409 | unique or state conflict |
605
+ | `VALIDATION` | 422 | input failed its schema; `data.issues` lists paths |
606
+ | `RATE_LIMITED` | 429 | limiter or queue overflow; `data.retryAfterMs` |
607
+ | `CANCELLED` | 499 | the caller cancelled |
608
+ | `TIMEOUT` | 504 | the handler ran past its time limit |
609
+ | `INTERNAL` | 500 | everything else |
610
+
611
+ An app adds its own fields to every `ctx` once, through `initQuickdraw`:
612
+
613
+ <!-- example: apps/api/src/services/examples/context.ts#context -->
614
+
615
+ ```ts
616
+ interface AppContext {
617
+ /** The tenant every query of this call is scoped to. */
618
+ readonly tenantId: string;
276
619
  }
620
+
621
+ export const qd = initQuickdraw<{ db: typeof db; principal: Principal; context: AppContext }>({
622
+ // runs once per call, before access is checked, so custom checks see it too
623
+ context: (base) => ({ tenantId: String(base.principal?.claims?.tenant ?? "public") }),
624
+ });
277
625
  ```
278
626
 
279
- Rapid-fire events within 100ms are debounced into a single refetch. For
280
- plain scope lists, prefer `useCollection` — it replaces the
281
- `invalidateOn` + refetch cycle with true deltas.
282
-
283
- ### Channels (High-Frequency Traffic)
284
-
285
- Methods are request/response: ack'd, ACL-checked against the database, and
286
- counted by the global rate limiter. **Channels** are their fire-and-forget
287
- counterpart for traffic where per-message overhead matters and losing a
288
- message is fine — game input, cursor positions, typing indicators, telemetry.
289
-
290
- Channel messages have no ack and no response. Each message is validated
291
- (zod schema required), access-checked entirely in memory (zero DB reads on
292
- the hot path), and governed by a per-socket, per-channel token bucket instead
293
- of the global limiter. Excess messages are silently dropped; sustained extreme
294
- flooding disconnects the socket.
295
-
296
- **Server** — define channels next to methods; broadcast tick data back with
297
- `emitToRoomVolatile` (backpressured clients drop frames instead of queueing):
298
-
299
- ```typescript
300
- type GameServiceChannels = ServiceChannelMap<{
301
- input: { seq: number; dx: number; dy: number; boost: boolean };
302
- }>;
303
-
304
- class GameService extends BaseService<
305
- GameWorld,
306
- Prisma.GameWorldCreateInput,
307
- Prisma.GameWorldUpdateInput,
308
- GameServiceMethods,
309
- GameServiceChannels // 5th type param
310
- > {
311
- constructor(prisma: PrismaClient) {
312
- super({ serviceName: "gameService" });
313
- this.setDelegate(prisma.gameWorld);
314
-
315
- this.defineChannel(
316
- "input",
317
- "Read",
318
- (payload, ctx) => this.sim.applyInput(ctx.userId, payload),
319
- {
320
- schema: gameInputSchema,
321
- ratePerSecond: 30, // default 30
322
- burst: 60, // default 2x rate
323
- requireRoom: () => this.getRoomName(WORLD_ID), // entry-level gate
324
- },
325
- );
326
- }
627
+ ## Access control
628
+
629
+ Each method declares who may call it, and a service with rows declares one
630
+ access policy that says how a principal's level on a row is found (design:
631
+ section 4). Everything fails closed: a method without `access` does not
632
+ compile, and a missing grant, an unknown level, a missing id, a row that does
633
+ not exist or a malformed access list denies.
634
+
635
+ <!-- example: apps/api/src/services/examples/access.ts#access -->
636
+
637
+ ```ts
638
+ import { anyOf, crud, custom, inherit, jsonAcl, members } from "@fitzzero/quickdraw-core/server";
639
+
640
+ export const projectService = qd.defineService(project, {
641
+ // the Prisma model the rows live in
642
+ model: "project",
643
+ access: anyOf(
644
+ // [{ userId, level }] plus Admin for the owner
645
+ jsonAcl("acl", { owner: "ownerId" }),
646
+ members({ model: "projectMember", entry: "projectId", user: "userId", level: "role" }),
647
+ ),
648
+ methods: {
649
+ // the read/write kit's get: Read on the project itself
650
+ ...crud.handlers(project, { access: { get: { entry: "Read" } } }),
651
+ title: {
652
+ // anyone may read any project's name by its id: the form is the whole check, on purpose
653
+ access: "public",
654
+ rowless: true,
655
+ handler: async ({ input, db }) =>
656
+ await db.project.findUniqueOrThrow({ where: { id: input.id }, select: { name: true } }),
657
+ },
658
+ },
659
+ });
327
660
 
328
- // In a 20Hz tick loop:
329
- broadcastSnapshot(snapshot: WorldSnapshot): void {
330
- this.emitToRoomVolatile(this.getRoomName(WORLD_ID), "game:snapshot", snapshot);
331
- }
661
+ export const taskService = qd.defineService(task, {
662
+ model: "task",
663
+ // the level on the task's project
664
+ access: inherit({ from: project, via: "projectId" }),
665
+ methods: {
666
+ rename: {
667
+ access: { entry: "Moderate" },
668
+ handler: ({ input, db }) =>
669
+ db.task.update({ where: { id: input.id }, data: { title: input.title } }),
670
+ },
671
+ // the kit's create: Moderate on the project the task goes into
672
+ ...crud.handlers(task, {
673
+ access: { create: { scope: "Moderate", of: project, id: "projectId" } },
674
+ }),
675
+ archiveAll: {
676
+ access: { service: "Admin" },
677
+ handler: async ({ db }) => (await db.task.updateMany({ data: { status: "archived" } })).count,
678
+ },
679
+ claim: {
680
+ access: custom((ctx, input) => input.id.length > 0 && ctx.principal.kind === "user"),
681
+ handler: ({ input, ctx, db }) =>
682
+ db.task.update({ where: { id: input.id }, data: { assigneeId: ctx.principal.userId } }),
683
+ },
684
+ },
685
+ });
686
+ ```
687
+
688
+ - Forms: `"public"`, `"authenticated"`, `{ service: L }` (the user's
689
+ service-wide grant), `{ entry: L, id? }` (the policy's level on the row;
690
+ `id` defaults to `input.id`), `{ service: L1, entry: L2 }` (either),
691
+ `{ scope: L, of, id }` (the level on a row of another service) and
692
+ `custom(fn)`. Without a principal every form but `"public"` answers
693
+ `UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
694
+ lowest first, are `Public`, `Read`, `Moderate` and `Admin`.
695
+ - On a service with a policy, a method whose input has `id` under a form
696
+ that checks no row (`"public"`, `"authenticated"`, `{ service: L }` below
697
+ `Admin`) would let anyone that form admits reach any row by its id, so
698
+ `defineService` refuses it, naming the method and the two ways out: a form
699
+ the policy decides (`{ entry: L }`, or `{ service: L, entry: L }` to keep
700
+ the grant), or `rowless: true` on the method when every such caller may
701
+ reach any row on purpose (the `title` method above; public profiles,
702
+ lookups by an id that tells nothing). A kit's methods take it as
703
+ `rowless: ["get"]` in the kit's options. The input's keys come from its
704
+ JSON Schema: an `id` in any branch of a union counts, and so does one
705
+ beside a value JSON Schema cannot write (a `Date`, a `Set`). Not checked:
706
+ an input without JSON Schema (Zod 3), one that is no object (a bare string
707
+ that is the id itself) and a row named by another key (`ids`, `taskId`, a
708
+ nested `where.id`).
709
+ - A service-wide `Admin` grant passes every check on its service
710
+ (`adminBypass: false` turns that off). A grant below `Admin` counts only
711
+ where the form names `service`: a `Read` grant does not read every row.
712
+ - Grants come from `principal.serviceAccess`, as `authenticate` returns it or
713
+ `createServer({ auth: { loadServiceAccess } })` loads it. With
714
+ `auth.serviceAccessSource: { model: "user", column: "serviceAccess" }`, a
715
+ tracked write to that column refreshes the user's open sockets
716
+ (`qd:access`).
717
+ - Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
718
+ `members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
719
+ `anyOf(...)` and `resolver({ levelsFor, where? })`. Their column names are
720
+ checked against the Prisma client's models at compile time. A lookup is one
721
+ batched query per table, memoized for the call, so checking 60 ids costs
722
+ what checking one does. `entry` access needs a policy; a service without
723
+ `model` may only use `"public"`, `"authenticated"`, `{ service }` and
724
+ `custom`. `inherit` uses the parent's policy only: grants on the parent's
725
+ service do not flow down.
726
+ - One policy decides every surface: method calls, entity subscriptions,
727
+ collection scopes, the kits' lists and searches, streams and channels.
728
+ - When a tracked write lowers or removes someone's access, their sockets
729
+ leave the rooms anchored on that row and get `qd:revoked`; a changed level
730
+ moves them to that level's room. In a cluster the change is broadcast to
731
+ every node.
732
+ - `server.dispatcher.access` gives the same answers to other code:
733
+ `levelsFor(service, principal, ids)`, `accessWhere(service, principal, level)`
734
+ (a `where` filter for `findMany`, or `"none"`) and `onAccessChanged(listener)`.
735
+ - `createServer({ access: { cacheMs: 30_000 } })` keeps policy lookups across
736
+ requests; tracked writes to the columns and membership tables the policies
737
+ read evict them. Writes the tracked client cannot see are picked up only
738
+ when the time passes, so the cache is off by default.
739
+
740
+ ## Tracked writes
741
+
742
+ `@fitzzero/quickdraw-core/prisma` wraps the app's Prisma client so the
743
+ framework sees every write made through it (design: section 5). Pass the
744
+ tracked client as `db`; the server finds the rest on it.
745
+
746
+ - Every handler runs in a unit of work. Each `create`, `update`, `upsert`,
747
+ `delete`, `createMany`, `updateMany` and `deleteMany` made through `db` is
748
+ recorded with its row ids, merged per row, and handed to the flush sinks
749
+ once the response has been sent, with one revision per flush. A handler
750
+ may return `db.task.update(...)` without awaiting it.
751
+ - Writes inside `db.$transaction` join the unit only when it commits; a
752
+ rollback drops them. Prefer the interactive form
753
+ (`db.$transaction(async (tx) => ...)`): an array-form
754
+ `db.$transaction([...])` has no transaction client, so the rows a
755
+ `deleteMany` or `updateMany` in it reads first, and the old values an
756
+ `update` that moves a row or changes who may see it reads first, are read
757
+ outside the batch, and rows its earlier statements changed may be missed
758
+ (a development warning names the model and operation).
759
+ - Write many rows in one statement when every row gets the same data
760
+ (`updateMany`, `createMany`). When each row's data differs (moving tasks
761
+ to different projects, say), write each row by id inside an interactive
762
+ transaction: inside `db.$transaction(async (tx) => ...)`, loop over the
763
+ rows and await `tx.task.update({ where: { id }, data })` for each. Neither
764
+ the N+1 warning nor `no-db-call-in-loop` counts those writes.
765
+ - A service lists the other models its handlers write
766
+ (`writes: ["taskLabel"]`); the `no-foreign-write` lint rule checks it.
767
+ - Jobs, scripts and webhooks wrap their writes in `qd.run(fn)`, which
768
+ flushes before it returns. A write made outside any unit of work flushes
769
+ on its own on the next tick, with a development warning.
770
+
771
+ <!-- example: apps/api/src/jobs/overdue.ts#run -->
772
+
773
+ ```ts
774
+ export async function markStale(before: Date): Promise<number> {
775
+ // a job's writes flush to subscribers when qd.run settles, as a method's do
776
+ const { count } = await qd.run(() =>
777
+ db.task.updateMany({
778
+ where: { status: "open", updatedAt: { lt: before } },
779
+ data: { status: "stale" },
780
+ }),
781
+ );
782
+ return count;
332
783
  }
333
784
  ```
334
785
 
335
- Exempt channel traffic from the global rate limiter (channels self-limit):
786
+ - Not seen: nested writes (`{ labels: { create: [...] } }`, which warn in
787
+ development), raw SQL and database cascades. Record raw SQL with
788
+ `ctx.touch("task", ids)`, or `{ removed: true }` for deleted rows. A job
789
+ gets the same `touch` from `qd.run`, whose `fn` receives
790
+ `{ touch, log, principal: null }`:
791
+
792
+ <!-- example: apps/api/src/jobs/overdue.ts#touch -->
793
+
794
+ ```ts
795
+ export async function spreadOrdinals(projectId: string): Promise<void> {
796
+ await qd.run(async (ctx) => {
797
+ const rows = await db.$queryRaw<{ id: string }[]>`
798
+ UPDATE "Task" SET "ordinal" = "ordinal" * 2 WHERE "projectId" = ${projectId} RETURNING "id"`;
799
+ // raw SQL is invisible to the tracked client: record the rows it changed
800
+ ctx.touch(
801
+ "task",
802
+ rows.map((row) => row.id),
803
+ );
804
+ });
805
+ }
806
+ ```
336
807
 
337
- ```typescript
338
- import { CHANNEL_EVENT_PREFIX } from "@fitzzero/quickdraw-core";
808
+ - Tracked models need a string `id` column; writes to other models pass
809
+ through untracked, with one warning.
810
+ - `createServer({ flushSink })` adds the app's own sinks (an audit log, say);
811
+ `createRecordingSink()` on `./testing` records what is flushed, for tests.
812
+
813
+ ## Projections and entity subscriptions
814
+
815
+ A projection is the wire shape of a row (design: section 6). Its keys decide
816
+ what a read selects, so a row is never read wider than what is sent:
817
+
818
+ <!-- example: apps/api/src/services/examples/projections.ts#projections -->
819
+
820
+ ```ts
821
+ import { crud, inherit } from "@fitzzero/quickdraw-core/server";
822
+
823
+ export const taskService = qd.defineService(task, {
824
+ model: "task",
825
+ access: inherit({ from: projectContract, via: "projectId" }),
826
+ // answers "not modified" from the row's own time
827
+ versionColumn: "updatedAt",
828
+ // a write to a subtask sends its parent again
829
+ affects: [{ service: task, id: "parentTaskId" }],
830
+ project: {
831
+ // a relation count: read with select, built by a pure, synchronous map
832
+ card: {
833
+ select: { title: true, status: true, _count: { select: { labels: true } } },
834
+ map: (row: { id: string; title: string; status: string; _count: { labels: number } }) => ({
835
+ id: row.id,
836
+ title: row.title,
837
+ status: row.status,
838
+ labelCount: row._count.labels,
839
+ }),
840
+ },
841
+ },
842
+ methods: {
843
+ // the kit's get reads the entity's keys only, and sends dates as ISO strings
844
+ ...crud.handlers(task, { access: { get: { entry: "Read" } } }),
845
+ // returns the database row `map` takes: the framework builds the card from it
846
+ card: {
847
+ access: { entry: "Read" },
848
+ handler: ({ input, db }) =>
849
+ db.task.findUniqueOrThrow({
850
+ where: { id: input.id },
851
+ select: { id: true, title: true, status: true, _count: { select: { labels: true } } },
852
+ }),
853
+ },
854
+ },
855
+ });
856
+ ```
339
857
 
340
- const rateLimiter = createRateLimiter({
341
- maxRequests: 100,
342
- excludePrefixes: [CHANNEL_EVENT_PREFIX],
858
+ - A projection's keys come from its schema's JSON Schema (Zod 4.2 or later),
859
+ or from `project: { <name>: { keys } }`; a service whose projection has
860
+ neither fails when it is defined. A handler returning a projection returns
861
+ rows (a `Date` is fine where the wire has a string, extra columns are
862
+ dropped); with `map`, it returns what `map` takes.
863
+ - Fields the contract's `fields` map puts above the caller's level on a row
864
+ are stripped from that caller's copy, after any shared run.
865
+ - `affects` names rows of other services a write changes too
866
+ (`{ service, id: column }`, or `{ service, id: (row) => ids, columns }`);
867
+ they are sent again after the flush, one hop.
868
+ - `qd.<service>.useEntity(id)` subscribes with `qd:sub { s, ids, revs? }` (up
869
+ to 500 ids per batch), which authorizes every id in one lookup, reads the
870
+ allowed rows in one query and joins the room of each row found for the
871
+ subscriber's level. A socket is never in the room of a row it could not
872
+ read.
873
+ - After each flush, subscribers get `qd:e`: `{ t: "u", s, id, rev, d }` with
874
+ the whole row (a create, a touch, a projection with `map`, an `affects` row),
875
+ `{ t: "p", s, id, rev, d }` with the changed fields only (an update of plain
876
+ projection fields), or `{ t: "r", s, id, rev }` (a delete). One read per
877
+ service per flush, none when no room has subscribers, and each frame is
878
+ stripped once per subscriber tier.
879
+ - "Not modified" (for `qd:sub` and for queries returning one projection row
880
+ by `id`) comes from `versionColumn`, or from an in-process change log of
881
+ recent flushes. The change log sees only this process's writes: an app
882
+ running several processes without a Socket.IO cluster adapter declares
883
+ `versionColumn`s or passes `changeLog: false`.
884
+ - Revisions are microseconds since the epoch: a flush takes
885
+ `max(Date.now() * 1000, last + 1)`. Clients compare them as numbers.
886
+ - Behind a cluster adapter (`setupRedisAdapter`), every touched row is read
887
+ (deleted ones too) and sent, whole, decided by the row as read at flush
888
+ time, since other nodes' rooms are not visible and frames from two nodes
889
+ can arrive out of order; access changes and refreshed grants are
890
+ broadcast to every node, and a flush sends its frames once every node
891
+ applied them (without waiting while a node does not answer, fail-open).
892
+ Flushes take their revisions from a counter in the cluster's Valkey, on
893
+ Valkey's clock in microseconds, so revisions from all nodes are one order;
894
+ its key needs persistence or replication. A node whose Valkey connection
895
+ comes back has its clients reconnect to catch up.
896
+ [docs/deploying.md](../../docs/deploying.md) has the wiring, what holds across
897
+ nodes, what it costs, and what happens when a node or Valkey stops
898
+ answering.
899
+
900
+ ## Collections and change topics
901
+
902
+ A collection is the rows of one service grouped by a scope value (design:
903
+ section 7). The contract declares it; the service says whose policy
904
+ authorizes a scope:
905
+
906
+ <!-- example: apps/api/src/services/examples/collections.ts#contract -->
907
+
908
+ ```ts
909
+ export const task = defineContract("taskService", {
910
+ entity: taskSchema,
911
+ projections: { card: cardSchema },
912
+ methods: { ...crud.contract({ entity: taskSchema, get: true }) },
913
+ collections: {
914
+ byProject: {
915
+ // a column holding the scope value
916
+ scope: "projectId",
917
+ // the projection each item is sent as
918
+ item: "card",
919
+ // membership: only open tasks
920
+ where: { status: "open" },
921
+ // ends in "id": the keyset cursor
922
+ order: [
923
+ ["ordinal", "asc"],
924
+ ["id", "asc"],
925
+ ],
926
+ // sent for the whole scope
927
+ index: ["ordinal", "assigneeId"],
928
+ views: { mine: (row, who) => row.assigneeId === who.userId },
929
+ },
930
+ // each user's own
931
+ assigned: { scope: "assigneeId", item: "card", order: [["id", "asc"]] },
932
+ },
343
933
  });
344
934
  ```
345
935
 
346
- **Client** — send with `useChannelSend`; receive broadcasts with the existing
347
- `useRoomEvents` (volatile room emits arrive as ordinary events):
936
+ <!-- example: apps/api/src/services/examples/collections.ts#service -->
348
937
 
349
- ```tsx
350
- import { useSubscription, useRoomEvents, useChannelSend } from "@fitzzero/quickdraw-core/client";
938
+ ```ts
939
+ import { crud, inherit } from "@fitzzero/quickdraw-core/server";
351
940
 
352
- function GameView({ worldId }: { worldId: string }) {
353
- useSubscription("gameService", worldId); // room membership gates the channel
354
- const { send, isReady } = useChannelSend<GameInput>("gameService", "input");
941
+ export const taskService = qd.defineService(task, {
942
+ model: "task",
943
+ // derived from the anchor
944
+ access: inherit({ from: projectContract, via: "projectId" }),
945
+ collections: {
946
+ // Read on the project opens its scope
947
+ byProject: { anchor: projectContract },
948
+ // a user opens only the scope that is their id
949
+ assigned: { scopeAccess: "self" },
950
+ },
951
+ // opens the service topic to Read grants; closed without it
952
+ watchAccess: { service: "Read" },
953
+ methods: { ...crud.handlers(task, { access: { get: { entry: "Read" } } }) },
954
+ });
955
+ ```
355
956
 
356
- useRoomEvents({
357
- "game:snapshot": (snap: WorldSnapshot) => applySnapshot(snap),
358
- });
957
+ - `scope` is a string column, or `via({ model, entry, scope })` for scopes
958
+ that come from a junction table (a chat in each member's list). `order`
959
+ ends in `id`; `limit` (default 100) and `maxLimit` (default 500) size its
960
+ pages; `where` is an equality filter on membership; `access` is the level
961
+ needed on the anchor (default `Read`).
962
+ - `qd:col:sub { s, c, scope }` authorizes the scope through its anchor's
963
+ policy, then answers a page and joins the scope's room; flushes send `qd:c`
964
+ deltas to it (`added`, `updated`, `patched`, `removed`, or `reset` for more
965
+ than `bulkThreshold` rows, default 200). A `"self"` scope is the
966
+ subscriber's own user id: its items are stripped at `Read`, and it may not
967
+ declare a higher `access`.
968
+ - Items are visible to everyone in the scope: no per-row policy or field
969
+ tier applies inside a collection. Derive the item service's own access
970
+ from the anchor (`inherit` from it, as above): a per-row policy on the item
971
+ service (an owner column, a row's access list) is not applied to
972
+ collection items, so a row it would hide still reaches everyone in its
973
+ scope.
974
+ - `index` sends one small row per member (`[id, rev, ...fields]`, up to
975
+ 50,000) with the first page, so the client knows the whole membership and
976
+ order at once; `views` are pure predicates over index rows the client runs.
977
+ When a collection declares `index`, every `order` column but `id` must be
978
+ an index field.
979
+ - A deleted anchor row (a project, for its tasks) closes its scopes with
980
+ `qd:revoked`; `qd.collections.reset(contract, collection, scope)` resets a
981
+ scope after a change tracked writes cannot describe.
982
+ - `qd:watch { s, topic }` joins a change topic: `{collection}:{scope}`,
983
+ authorized like a subscribe to that scope, or `service`, which changes
984
+ whenever any row of the service does. The service topic is closed
985
+ (`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
986
+ `"authenticated"` or `{ service: level }`). A watcher that loses access
987
+ leaves the topic after one last `qd:changed`.
988
+ - The socket rate limiter does not count subscription events; each socket
989
+ runs `qd:sub`, `qd:col:sub`, `qd:col:items` and `qd:watch` in a lane
990
+ instead: `limits.subscriptions` (8 at once, 64 waiting), then
991
+ `RATE_LIMITED`.
992
+
993
+ ## The server and its transports
994
+
995
+ `qd.createServer` attaches to the Express app and HTTP server the app already
996
+ owns, never listens or exits the process itself, and serves every service
997
+ over three transports (design: sections 3, 8 and 10):
998
+
999
+ - **Socket.IO** (protocol 5): a client connects with
1000
+ `auth: { token, qd: { protocol: 5, client } }`, receives `qd:hello` with the
1001
+ server's limits and who it acts for, and calls through `qd:call` and
1002
+ `qd:cancel`. Every socket gets the same few listeners however many methods
1003
+ the services have. The JSON-only parser is the default; `binary: true`
1004
+ restores the stock one. The socket rate limiter is on by default (600
1005
+ events per minute per socket; channels, cancels and subscription events not
1006
+ counted); configure it with `rateLimit`, or turn it off with
1007
+ `rateLimit: false`. There is no default CORS origin: pass `cors`.
1008
+ - **HTTP**: `POST /qd/{service}/{method}` with the input as a JSON body and
1009
+ `Content-Type: application/json` (required, even without a body, so a
1010
+ cross-site page cannot use a session cookie without a CORS preflight). The
1011
+ principal comes from the session cookie (`__Host-session` over HTTPS,
1012
+ `session` over plain HTTP or with `COOKIE_DOMAIN`, as the auth routes name
1013
+ it) or an `Authorization: Bearer` token through the same
1014
+ `authenticate`; the reply is `{ ok: true, d }` or
1015
+ `{ ok: false, e: { code, message, data? } }` with the code's HTTP status.
1016
+ Works on Express 4 and 5, and on a bare Node server. Move it with
1017
+ `http: { path }`, turn it off with `http: false`, or mount
1018
+ `createHttpRouter({ dispatcher, auth })` yourself. It has no rate limit of
1019
+ its own: on Express, set `http: { rateLimit: createCallLimiter() }` (from
1020
+ `./server/express`), which refuses with the `RATE_LIMITED` reply.
1021
+ - **In process**: `server.dispatcher.caller(principal)` or
1022
+ `qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
1023
+ typed by the `contracts` of `initQuickdraw`'s types.
1024
+
1025
+ `authenticate` takes one request (`{ transport, auth, headers, socket | req }`)
1026
+ for both transports and returns a principal, a user id, or nothing for an
1027
+ anonymous caller; throwing `QuickdrawError("UNAUTHENTICATED", ...)` refuses.
1028
+ Pass your own HTTP server as `httpServer` together with the `app` it was
1029
+ created from (or with `http: false`).
1030
+
1031
+ `server.close()` disconnects every socket, waits for the calls still in
1032
+ flight (a mutation runs to its end) and closes the HTTP server, giving up
1033
+ after `shutdownTimeoutMs` (default 10 s); behind a cluster adapter it
1034
+ disconnects its own sockets first, while the adapter still reaches the other
1035
+ nodes, so their rooms hear `left`, and waits for that presence work (at most
1036
+ `cluster.timeoutMs`), so the app can close its Valkey clients next.
1037
+ `handleSignals: true` calls it on SIGTERM and SIGINT. `server.rotate({ withinMs })` asks clients to reconnect
1038
+ within a window; `server.access.refresh(userId)` reloads a user's grants,
1039
+ pushes `qd:access` and resolves the user's entity subscriptions again;
1040
+ `server.access.disconnectUser(userId, { sessionId? })` ends a user's (or one
1041
+ session's) sockets, on every node behind a cluster adapter.
1042
+
1043
+ ### The 4.x legacy shim
1044
+
1045
+ With `legacyWire: true`, a client that connects without `auth.qd` is served
1046
+ as a 4.x client instead of being refused with `PROTOCOL_MISMATCH`. The shim
1047
+ serves request/response calls only: `socket.emit("taskService:get", payload, ack)`
1048
+ runs through the 5.0 pipeline and is answered in the 4.x `ServiceResponse`
1049
+ shape, `{ success: true, data }` or `{ success: false, error, code }`, with
1050
+ the HTTP status of the error code as `code`. 4.x subscriptions, collections
1051
+ and channels are not served. Each service, method and principal kind that
1052
+ calls through the shim is logged once at `warn`, so the remaining 4.x
1053
+ clients can be found.
1054
+
1055
+ ### MCP bridge
1056
+
1057
+ `@fitzzero/quickdraw-core/server/mcp` serves the services to AI agents as MCP
1058
+ tools generated from their contracts at startup: one tool per method, named
1059
+ `{service}_{method}`, described by the method's `describe` text, with the
1060
+ input schema's JSON Schema as its arguments and `readOnlyHint` on every
1061
+ query. Every tool call goes through the dispatcher with transport `"mcp"`,
1062
+ so input validation, access checks and limits apply exactly as on a socket.
1063
+ A method whose input cannot describe itself as JSON Schema stops the
1064
+ registry at startup, naming the method, unless it is excluded.
1065
+
1066
+ <!-- example: apps/api/src/mcp.ts#mcp -->
1067
+
1068
+ ```ts
1069
+ import {
1070
+ createMcpHttpRouter,
1071
+ createMcpRegistry,
1072
+ createMcpStdioServer,
1073
+ } from "@fitzzero/quickdraw-core/server/mcp";
1074
+
1075
+ const summarizeInput = z.object({ projectId: z.string() });
1076
+
1077
+ const registry = createMcpRegistry({
1078
+ services: [projectService, taskService],
1079
+ dispatcher: server.dispatcher,
1080
+ // who a stdio session or an HTTP bearer token stands for; nothing is anonymous
1081
+ principal: (request) =>
1082
+ verifySession(request.transport === "http" ? request.token : process.env.AGENT_TOKEN),
1083
+ // handlers read it as ctx.mcp
1084
+ context: () => ({ scopes: ["tasks"] }),
1085
+ // or include: [...]; name: (service, method) => ...
1086
+ exclude: ["projectService.invite"],
1087
+ customTools: [
1088
+ {
1089
+ name: "summarize",
1090
+ description: "Counts the tasks of a project.",
1091
+ // validated before the handler runs, and types `arguments`
1092
+ inputSchema: summarizeInput,
1093
+ // access: "authenticated" is the default; "public" lets anonymous callers in
1094
+ handler: async ({ arguments: { projectId }, caller }) =>
1095
+ `${String(await caller.taskService.countOnBoard({ projectId }))} tasks`,
1096
+ },
1097
+ ],
1098
+ });
359
1099
 
360
- // e.g. called from a fixed-timestep loop
361
- const onTick = (input: GameInput) => send(input);
362
- }
1100
+ // GET /mcp/tools, POST /mcp/invoke
1101
+ app.use(createMcpHttpRouter({ registry }));
1102
+ // in an MCP client's process
1103
+ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
363
1104
  ```
364
1105
 
365
- **Access model** (all synchronous, in-memory):
366
-
367
- | Check | Behavior |
368
- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
369
- | Authentication | Always required — anonymous messages dropped, even at `"Public"` access |
370
- | `"Public"` / `"Read"` | Any authenticated user passes the service gate |
371
- | `"Moderate"` / `"Admin"` | Requires that level in the socket's `serviceAccess` |
372
- | `requireRoom` | Socket must already be in the resolved room — membership was ACL-checked at subscribe time, so this inherits entry ACL semantics without a DB read per message |
373
-
374
- Channels route as the Socket.io event `channel:<serviceName>:<channelName>`
375
- (helper: `channelEventName(serviceName, channelName)`), which also makes them
376
- easy to speak from non-JS clients (game engines, native apps).
377
-
378
- When to use which:
379
-
380
- | | Method | Channel |
381
- | -------------- | ------------------------- | ------------------------ |
382
- | Response | ack with data/error | none (fire-and-forget) |
383
- | Frequency | occasional (user actions) | tick rate (10-60Hz) |
384
- | Loss tolerance | must not lose | next message supersedes |
385
- | ACL | full async check incl. DB | in-memory only |
386
- | Rate limit | global limiter | per-channel token bucket |
387
-
388
- ## Splitting Large Services
389
-
390
- Real services grow past what one file should hold. The proven pattern —
391
- battle-tested in the framework's largest consumer without import cycles — is
392
- an abstract `*ServiceCore` plus method modules wired by a thin concrete
393
- subclass:
394
-
395
- ```typescript
396
- // services/task/service-core.ts — state, ACL overrides, helpers. No methods.
397
- export abstract class TaskServiceCore extends BaseService<
398
- Task,
399
- Prisma.TaskCreateInput,
400
- Prisma.TaskUpdateInput,
401
- TaskServiceMethods,
402
- TaskChannels,
403
- TaskDTO,
404
- TaskCollections
405
- > {
406
- constructor(protected readonly prisma: PrismaClient) {
407
- super({ serviceName: "taskService" });
408
- this.setDelegate(prisma.task);
409
- }
1106
+ - **stdio** speaks JSON-RPC (MCP protocol version 2024-11-05). One process is
1107
+ one session: its queries share one concurrency lane, and
1108
+ `notifications/cancelled` cancels a call. Start its module through
1109
+ `bootstrapMcpServer(new URL("./mcp-server.js", import.meta.url))`, which
1110
+ sends console output to stderr so only the protocol reaches stdout.
1111
+ - **HTTP**: `GET /mcp/tools` and `POST /mcp/invoke`, which takes
1112
+ `{ name, arguments }` and answers `{ success: true, data }`, or
1113
+ `{ success: false, error, code, data? }` with the code's HTTP status.
1114
+ - An anonymous caller (the `principal` hook returned nothing) may call
1115
+ `"public"` methods, and custom tools that declare `access: "public"`; any
1116
+ other tool answers `UNAUTHENTICATED` before it runs. A failed call reaches
1117
+ the agent as a tool error carrying the code.
1118
+
1119
+ ## The client
1120
+
1121
+ `createQuickdrawClient(contracts)` builds one typed client from the
1122
+ contracts: `qd.<key>.<member>` for each method, collection, stream, channel
1123
+ and event, plus `useEntity` and `useEntities` for a contract with an entity
1124
+ (design: section 11). Nothing is generated: a misspelled method, or a hook
1125
+ the method's kind does not have, is a compile error.
1126
+ `<QuickdrawProvider client={qd} url auth>` owns the socket and the TanStack
1127
+ `QueryClient` (5-minute stale time by default), and works without DOM
1128
+ globals (React Native).
1129
+
1130
+ | Member | Gives |
1131
+ | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
1132
+ | `qd.task.get.useQuery(input, options)` | TanStack's `useQuery`; errors are `QuickdrawError` with a `code` |
1133
+ | `qd.task.rename.useMutation(options)` | TanStack's `useMutation`; `mutate` returns nothing, `mutateAsync` the output |
1134
+ | `qd.task.useEntity(id)`, `useEntities(ids)` | live rows at the user's level: `{ data, isLoading, isRemoved, error }` |
1135
+ | `qd.task.board.useCollection(scope, { view, load, limit })` | a live scope: `{ items, index, byId, totalCount, hasMore, isLoading, loadMore, refresh, ... }` |
1136
+ | `qd.task.get.call(input)`, `.key(input)`, `.prefetch(queryClient, input)` | a call over the mounted provider's connection, the cache key, a prefetch |
1137
+ | `qd.invalidate(qd.task.get, input?)` | invalidates through the coordinator: a read in flight is never cancelled |
1138
+ | `useQuickdraw()` | `{ connection, status, isConnected, userId, serviceAccess, hello, refusal, isRateLimited }` |
1139
+
1140
+ <!-- example: apps/web/src/components/TaskDetail.tsx#detail -->
410
1141
 
411
- public buildCardDTO(taskId: string): Promise<TaskCardDTO> {
412
- /* ... */
1142
+ ```tsx
1143
+ export function TaskDetail({ id }: { readonly id: string }) {
1144
+ // live, at the user's level
1145
+ const { data: task, isRemoved, error } = qd.task.useEntity(id);
1146
+ const rename = qd.task.rename.useMutation({
1147
+ // the default for a mutation with `id` and an "entity" output, written out
1148
+ optimistic: (input, cache) => cache.patchEntity(input.id, { title: input.title }),
1149
+ });
1150
+ if (error?.code === "FORBIDDEN") {
1151
+ return <p>You cannot see this task.</p>;
413
1152
  }
414
- }
415
-
416
- // services/task/methods/create-task.ts — one module per method (or cluster).
417
- // defineMethod is public precisely so modules can register on the instance.
418
- export function registerCreateTask(service: TaskService): void {
419
- service.defineMethod(
420
- "createTask",
421
- "Read",
422
- async (payload, ctx) => {
423
- // ...
424
- },
425
- { schema: createTaskSchema },
1153
+ if (isRemoved) {
1154
+ return <p>This task was deleted.</p>;
1155
+ }
1156
+ // notes reach Admins only (the contract's fields), so its type is optional: guard it
1157
+ return (
1158
+ <div>
1159
+ <h1>{task?.title}</h1>
1160
+ {task?.notes === undefined ? null : <p>{task.notes}</p>}
1161
+ <button type="button" onClick={() => rename.mutate({ id, title: "Renamed" })}>
1162
+ Rename
1163
+ </button>
1164
+ {rename.error === null ? null : <p>{`Refused: ${rename.error.code}`}</p>}
1165
+ </div>
426
1166
  );
427
1167
  }
1168
+ ```
428
1169
 
429
- // services/task/index.ts — the concrete subclass wires the modules.
430
- export class TaskService extends TaskServiceCore {
431
- constructor(prisma: PrismaClient) {
432
- super(prisma);
433
- registerCreateTask(this);
434
- registerUpdateTask(this);
435
- // ...
436
- this.verifyAllMethods(["createTask", "updateTask" /* ... */]);
437
- }
1170
+ - A field the contract's `fields` map tiers (`notes` here) is optional in
1171
+ every row type a reader gets (`useEntity`, collection items, `"entity"`
1172
+ outputs, `EntityOf`, `ItemOf`), because a reader below its level receives
1173
+ the row without it. Handlers still return the full row.
1174
+ - A mutation whose input has `id` and whose output is `"entity"` is
1175
+ optimistic by default: its input's fields show over the cached row and its
1176
+ collection items from the moment it is sent, are dropped if it fails, and
1177
+ give way to the server's frame. `optimistic: false` turns that off;
1178
+ `optimistic: (input, cache) => ...` writes its own layers with
1179
+ `patchEntity`, `removeEntity` and `patchItem`.
1180
+ - Live rows and collections need no refetching: frames keep them current,
1181
+ and after a reconnect they resume by revision. A query whose result
1182
+ follows writes declares `watch` in its contract; the coordinator fetches
1183
+ it again once per change, with at most one read in flight per key. Do not
1184
+ call `refetch` or `invalidateQueries` on quickdraw keys after a mutation.
1185
+ - After a reconnect, the queries that are watched (they missed the changes
1186
+ meanwhile) or stale are refetched, each after a random delay of up to
1187
+ `reconnectJitterMs` (2,000 ms by default), so clients that reconnect
1188
+ together, after a server restart say, do not refetch in one burst; cached
1189
+ data stays on screen until then. `reconnectJitterMs={0}` refetches them at
1190
+ once; a client without React passes `jitterMs` to the coordinator's
1191
+ `refetchAfterReconnect`.
1192
+ - `useCollection` holds one scope: its index (the members, in order), the
1193
+ items loaded, and `loadMore`/`loadItems`; `view` filters the members by a
1194
+ view of the contract, for the user the server's hello names; `load: "all"`
1195
+ keeps every page loaded. A `null` scope holds nothing; `enabled: false`
1196
+ subscribes to nothing.
1197
+ - The cache follows the user: a hello naming another user removes everything
1198
+ quickdraw cached; new credentials for the same user refetch it; new grants
1199
+ (`qd:access`) refetch every query.
1200
+ - A protocol mismatch reloads the page once per session by default
1201
+ (`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
1202
+ kind of work.
1203
+
1204
+ ### Server components and other runtimes
1205
+
1206
+ `./client` begins with `"use client"`, so a React server component imports
1207
+ `createServerCaller` from `@fitzzero/quickdraw-core/utils` instead. It calls
1208
+ over the HTTP transport, and `prefetch` fills the keys the hooks read:
1209
+
1210
+ <!-- example: apps/web/src/app/tasks/page.tsx#page -->
1211
+
1212
+ ```tsx
1213
+ import { createServerCaller } from "@fitzzero/quickdraw-core/utils";
1214
+ import { contracts } from "@project/shared";
1215
+
1216
+ export async function TasksPage({ projectId, cookie }: { projectId: string; cookie: string }) {
1217
+ // forwards the user's session cookie to the API's HTTP transport
1218
+ const caller = createServerCaller(contracts, { url: "http://api:4000", headers: { cookie } });
1219
+ const queryClient = new QueryClient();
1220
+ // the key useQuery reads
1221
+ await caller.task.countOnBoard.prefetch(queryClient, { projectId });
1222
+ return (
1223
+ <HydrationBoundary state={dehydrate(queryClient)}>
1224
+ <TaskBoard projectId={projectId} />
1225
+ </HydrationBoundary>
1226
+ );
438
1227
  }
439
1228
  ```
440
1229
 
441
- Core → modules → concrete class is a DAG: the core never imports the modules,
442
- the modules never import each other. `verifyAllMethods` catches a forgotten
443
- `register*` call at boot. The public choke points (`emitUpdate`,
444
- `emitCollectionUpsert`, `emitToRoom`, `isLevelSufficient`, …) exist so method
445
- modules outside the class stay fully capable.
1230
+ A script, a worker or a React Native module without hooks uses the
1231
+ connection directly:
446
1232
 
447
- ## Package Exports
1233
+ <!-- example: apps/api/scripts/report.ts#script -->
448
1234
 
449
- ```typescript
450
- // Shared types (both server and client)
451
- import {
452
- ServiceResponse,
453
- AccessLevel,
454
- ServiceMethodMap,
455
- // Room helpers + typed events (4.0)
456
- serviceRoom,
457
- collectionRoom,
458
- userRoom,
459
- type QuickdrawEventMap,
460
- type CollectionDelta,
461
- } from "@fitzzero/quickdraw-core";
462
-
463
- // Server
464
- import {
465
- BaseService,
466
- BaseRpcService, // 4.0: method-only services, no delegate/CRUD
467
- ServiceRegistry,
468
- createQuickdrawServer,
469
- type CollectionDefinition, // 4.0
470
- type QuickdrawIdentity, // 4.0: structured authenticate result
471
- createJWT,
472
- verifyJWT,
473
- discordProvider,
474
- googleProvider,
475
- // Auth & security (3.7+)
476
- createMockOAuthProvider,
477
- registerMockOAuthProvider,
478
- isMockOAuthEnabled,
479
- validateRedirectOrigin,
480
- setSessionCookie,
481
- clearSessionCookie,
482
- createRequireAuth,
483
- encrypt,
484
- decrypt,
485
- } from "@fitzzero/quickdraw-core/server";
486
-
487
- // Express rate-limit presets (3.7+, requires the optional express-rate-limit peer)
488
- import {
489
- createAuthLimiter,
490
- createWebhookLimiter,
491
- createPublicApiLimiter,
492
- } from "@fitzzero/quickdraw-core/server/express";
1235
+ ```ts
1236
+ import { callData, createQuickdrawConnection } from "@fitzzero/quickdraw-core/client";
493
1237
 
494
- // Server testing
495
- import {
496
- createTestServer,
497
- connectAsUser,
498
- emitWithAck,
499
- } from "@fitzzero/quickdraw-core/server/testing";
500
-
501
- // Dual-mode Prisma test databases (3.7+, optional peers: @electric-sql/pglite, pg)
502
- import {
503
- createPrismaTestGlobalSetup,
504
- resetDatabase,
505
- workerDatabaseUrl,
506
- } from "@fitzzero/quickdraw-core/server/testing/prisma";
1238
+ const connection = createQuickdrawConnection({
1239
+ url: "http://localhost:4000",
1240
+ // sent as auth.token
1241
+ auth: process.env.API_TOKEN,
1242
+ });
1243
+ connection.open();
1244
+ const count = await callData<number>(connection, {
1245
+ service: "taskService",
1246
+ method: "countOnBoard",
1247
+ input: { projectId: process.argv[2] },
1248
+ });
1249
+ process.stdout.write(`${String(count)} tasks\n`);
1250
+ connection.close();
1251
+ ```
507
1252
 
508
- // Client
509
- import {
510
- QuickdrawProvider,
511
- useQuickdrawSocket,
512
- useService,
513
- useServiceQuery,
514
- useSubscription,
515
- useCollection, // 4.0: live scope-keyed lists
516
- useRoomEvents,
517
- ServiceCallError, // 4.0: hook errors carry the server code
518
- SocketCheckbox,
519
- SocketTextField,
520
- SocketSelect,
521
- SocketSlider,
522
- SocketSwitch,
523
- } from "@fitzzero/quickdraw-core/client";
524
-
525
- // Client testing
526
- import { createMockSocket, createTestWrapper } from "@fitzzero/quickdraw-core/client/testing";
527
- ```
528
-
529
- ## Linting
530
-
531
- The package ships a shared oxlint base config, `oxlint.base.jsonc` — the
532
- framework's lint best practices (strict type-safety, complexity budgets, and
533
- the `quickdraw` plugin rules pre-wired for `services/**` and client code).
534
- Extend it from your root `.oxlintrc.json` so best practices update with the
535
- package:
1253
+ `liveDataOf(connection, queryClient)` holds live rows and collections for
1254
+ such code, and `createInvalidationCoordinator(queryClient)` invalidates as
1255
+ the hooks do.
1256
+
1257
+ A client in another language (a Godot game, a native app) speaks the wire
1258
+ itself: [`docs/protocol-v5.md`](../../docs/protocol-v5.md) is its specification,
1259
+ generated from the protocol's source, and [`examples/godot`](../../examples/godot)
1260
+ holds a GDScript client written from it. [`docs/clients.md`](../../docs/clients.md)
1261
+ compares the three ways in.
1262
+
1263
+ ## Kits
1264
+
1265
+ The methods most services write by hand, as one-line opt-ins (design:
1266
+ section 12). Each kit's contract half comes from the package root and makes
1267
+ ordinary contract entries; its handlers come from `./server`. Lint's
1268
+ `prefer-kit` reports a method written by hand that a kit implements (`get`,
1269
+ `list`, `create`, `getTask`, ...) in a service that uses no kit; one that
1270
+ must stay hand-written says why in a `// quickdraw: hand-written because ...`
1271
+ comment above it.
1272
+
1273
+ ### Read/write kit
1274
+
1275
+ `crud.contract` returns entries for exactly the methods it names, and
1276
+ `crud.handlers` implements exactly those, each with the access form it is
1277
+ given:
1278
+
1279
+ <!-- example: packages/shared/src/kits/crud.ts -->
1280
+
1281
+ ```ts
1282
+ import { crud, defineContract, mutation } from "@fitzzero/quickdraw-core";
1283
+ import { z } from "zod";
1284
+ import { cardSchema, taskSchema } from "../schemas";
1285
+
1286
+ const newTaskSchema = z.object({ projectId: z.string(), title: z.string() });
1287
+ // every field optional
1288
+ const taskPatch = z.object({ title: z.string(), status: z.string() }).partial();
1289
+
1290
+ export const task = defineContract("taskService", {
1291
+ entity: taskSchema,
1292
+ projections: { card: cardSchema },
1293
+ methods: {
1294
+ ...crud.contract({
1295
+ entity: taskSchema,
1296
+ get: true,
1297
+ getMany: true,
1298
+ list: { item: cardSchema, filter: ["projectId", "status"], sort: ["ordinal", "title"] },
1299
+ create: { input: newTaskSchema },
1300
+ // the kit adds `id`
1301
+ update: { input: taskPatch },
1302
+ delete: true,
1303
+ reorder: { column: "ordinal", within: "projectId" },
1304
+ // the kit adds `ids`
1305
+ bulkUpdate: { input: taskPatch },
1306
+ bulkDelete: true,
1307
+ }),
1308
+ archive: mutation({ input: z.object({ id: z.string() }), output: "entity" }),
1309
+ },
1310
+ });
1311
+ ```
536
1312
 
537
- ```jsonc
538
- {
539
- "extends": ["./node_modules/@fitzzero/quickdraw-core/oxlint.base.jsonc"],
540
- // plugins are NOT purely inherited: omitting this array unions oxlint's
541
- // default plugin set into the merge — mirror the base's list.
542
- "plugins": ["typescript", "import", "react", "nextjs", "jsx_a11y"],
543
- // ignorePatterns, env, globals, and settings are not inherited — declare here.
544
- "ignorePatterns": ["**/dist/**", "**/node_modules/**"],
545
- "overrides": [
546
- // Project-specific relaxations win over the base (overrides concatenate,
547
- // consumer last), e.g. allow specific cross-service mutations:
548
- {
549
- "files": ["**/services/**/*.ts"],
550
- "rules": {
551
- "quickdraw/no-cross-service-mutations": [
552
- "error",
553
- { "allowedModels": { "chat": ["chatMember"] } },
554
- ],
1313
+ <!-- example: apps/api/src/services/kits/crud.ts#service -->
1314
+
1315
+ ```ts
1316
+ import { crud, inherit, nextOrdinal } from "@fitzzero/quickdraw-core/server";
1317
+
1318
+ export const taskService = qd.defineService(task, {
1319
+ model: "task",
1320
+ access: inherit({ from: projectContract, via: "projectId" }),
1321
+ methods: {
1322
+ ...crud.handlers(task, {
1323
+ access: {
1324
+ get: { entry: "Read" },
1325
+ getMany: "authenticated",
1326
+ list: "authenticated",
1327
+ create: { scope: "Moderate", of: projectContract, id: "projectId" },
1328
+ update: { entry: "Moderate" },
1329
+ delete: { entry: "Admin" },
1330
+ reorder: { entry: "Moderate" },
1331
+ bulkUpdate: "authenticated",
1332
+ bulkDelete: "authenticated",
555
1333
  },
1334
+ // what `create` writes: columns from the principal, the next ordinal
1335
+ prepare: async (input, ctx, db) => ({
1336
+ ...input,
1337
+ assigneeId: ctx.principal.userId,
1338
+ ordinal: await nextOrdinal(db, "task", { projectId: input.projectId }),
1339
+ }),
1340
+ }),
1341
+ archive: {
1342
+ access: { entry: "Admin" },
1343
+ handler: ({ input, db }) =>
1344
+ db.task.update({ where: { id: input.id }, data: { status: "archived" } }),
556
1345
  },
557
- ],
1346
+ },
1347
+ });
1348
+ ```
1349
+
1350
+ - `prepare` gets the app's database client as `db`; annotate it with the
1351
+ client's type (`db: AppDb`) to use it typed, and the handlers spread into
1352
+ `qd.defineService` refuse a type that is not the service's client (the
1353
+ sharing kit's `resolveUser` and `onChange` take `db` the same way).
1354
+ - Each method needs a form: one missing from `access` does not compile.
1355
+ `get`, `update`, `delete` and `reorder` act on `input.id`, which
1356
+ `{ entry: L }` checks; a write on one row also needs the row level
1357
+ (`Moderate`, or the form's `entry`) on that row whatever its form, so
1358
+ `update: "authenticated"` edits no row. `list`, `getMany`, `bulkUpdate`
1359
+ and `bulkDelete` act on many rows, so they also keep only the rows the
1360
+ service's policy gives the caller at the form's `entry` or `scope` level
1361
+ (else `Read` for reads and `Moderate` for writes): a list never shows a
1362
+ row `get` would refuse. A service-wide `Admin` grant reaches every row,
1363
+ and a grant a `{ service: L }` form names is a row level of the grant on
1364
+ every row (the read is not filtered, and its rows are stripped at the
1365
+ grant). A `"public"` read's rows are not filtered (a public bulk write
1366
+ still needs a level on each row), nor are any on a service without a
1367
+ policy, where the form is the whole check: there these methods (and
1368
+ `search`) must be `"public"` or `{ service }`, or the service is refused
1369
+ when it is defined.
1370
+ - `list({ filter?, sort?, cursor?, limit?, totalCount? })` returns
1371
+ `{ items, nextCursor, totalCount? }`: equality filters and one sort field,
1372
+ limited to the declared fields (anything else is `VALIDATION`; a field
1373
+ above the caller's level is `FORBIDDEN`, and a default sort on one falls
1374
+ back to `id`), keyset cursors that stay put when rows are inserted, 50
1375
+ items by default and at most 200 (a larger `limit` is clamped), and a
1376
+ total only when asked (a second statement). Items are the `item`
1377
+ projection, stripped of fields above the level the page was read at, as
1378
+ collection items are.
1379
+ - `getMany({ ids })` (at most 200) leaves out ids the caller cannot read and
1380
+ ids with no row. Bulk writes skip rows the caller cannot write, run in one
1381
+ transaction and return `{ count }`.
1382
+ - Writes go through the tracked client: `create` sends `added`, `update` a
1383
+ patch and `delete` `removed`, and a bulk write past a scope's
1384
+ `bulkThreshold` sends it one `reset`. A missing row is `NOT_FOUND`, a
1385
+ unique violation `CONFLICT`.
1386
+ - `update` and `bulkUpdate` (and the admin kit's writes) never let a caller
1387
+ without a service-wide `Admin` grant set a column the policy reads (an
1388
+ owner column, an access list), and move a row into another parent (an
1389
+ `inherit` policy's `via`, an anchored collection's scope column) only
1390
+ when the caller's level on the new parent meets the method's row level:
1391
+ `FORBIDDEN` otherwise.
1392
+ - `reorder({ id, beforeId?, afterId? })` puts the row between its new
1393
+ neighbors (`beforeId` comes right before it) with one write, or renumbers
1394
+ the `within` list in steps of 1,024 when no gap is left, in a transaction
1395
+ given 5 s plus 10 ms per row of the list. Moves run SERIALIZABLE: two at
1396
+ once into one gap fail one of them with `CONFLICT` (try again).
1397
+ - The generated inputs carry JSON Schema, so the kit's methods are MCP tools
1398
+ too. For hand-written handlers, `./server` has `requireRow(row, message?)`
1399
+ (`NOT_FOUND` for a missing row) and `nextOrdinal(db, model, where)`.
1400
+
1401
+ The typed client has no hook for infinite scroll. Page through `list` with
1402
+ TanStack's `useInfiniteQuery`, keyed by the list's own `key` (with a suffix,
1403
+ since pages are not one list result) and fetching with its `call`, so
1404
+ `qd.invalidate(qd.task.list)` refetches the pages too. `no-untyped-client`
1405
+ accepts this form for the hooks the typed client has none of
1406
+ (`useInfiniteQuery`, `useSuspenseQuery`, `useQueries`, `queryOptions` and
1407
+ their variants):
1408
+
1409
+ <!-- example: apps/web/src/components/kits/TaskPages.tsx#component -->
1410
+
1411
+ ```tsx
1412
+ export function TaskPages({ projectId }: { readonly projectId: string }) {
1413
+ const filter = { projectId };
1414
+ // the list's own key (plus a suffix: pages are not one list result) and call,
1415
+ // so qd.invalidate(qd.task.list) refetches these pages too
1416
+ const pages = useInfiniteQuery({
1417
+ queryKey: [...qd.task.list.key({ filter }), "pages"],
1418
+ queryFn: ({ pageParam, signal }) =>
1419
+ qd.task.list.call({ filter, cursor: pageParam, limit: 50 }, { signal }),
1420
+ initialPageParam: undefined as string | undefined,
1421
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
1422
+ });
1423
+ const cards = pages.data?.pages.flatMap((page) => page.items) ?? [];
1424
+ return (
1425
+ <>
1426
+ <ul>
1427
+ {cards.map((card) => (
1428
+ <li key={card.id}>{card.title}</li>
1429
+ ))}
1430
+ </ul>
1431
+ {pages.hasNextPage ? (
1432
+ <button type="button" onClick={() => void pages.fetchNextPage()}>
1433
+ More
1434
+ </button>
1435
+ ) : null}
1436
+ </>
1437
+ );
558
1438
  }
559
1439
  ```
560
1440
 
561
- The base config also loads `./eslint-plugin` (the `quickdraw` rules) via
562
- `jsPlugins` — no separate wiring needed. The `./eslint-config` export (ESLint
563
- flat config) is legacy; prefer the oxlint base.
1441
+ ### Search kit
1442
+
1443
+ `search.contract` makes one query, `search`, and `search.handlers`
1444
+ implements it; on the client, its member gets `useSearch`:
1445
+
1446
+ <!-- example: packages/shared/src/kits/search.ts -->
1447
+
1448
+ ```ts
1449
+ import { defineContract, search } from "@fitzzero/quickdraw-core";
1450
+ import { cardSchema, taskSchema } from "../schemas";
1451
+
1452
+ export const task = defineContract("taskService", {
1453
+ entity: taskSchema,
1454
+ projections: { card: cardSchema },
1455
+ methods: {
1456
+ // looks in title and notes; a call may keep to one scope of byProject
1457
+ ...search.contract({
1458
+ entity: taskSchema,
1459
+ // a scoped search's results are its collection's items
1460
+ item: cardSchema,
1461
+ fields: ["title", "notes"],
1462
+ scope: "byProject",
1463
+ }),
1464
+ },
1465
+ collections: {
1466
+ byProject: {
1467
+ scope: "projectId",
1468
+ item: "card",
1469
+ order: [
1470
+ ["ordinal", "asc"],
1471
+ ["id", "asc"],
1472
+ ],
1473
+ },
1474
+ },
1475
+ });
1476
+ ```
1477
+
1478
+ <!-- example: apps/api/src/services/kits/search.ts#service -->
564
1479
 
565
- ## Local Development
1480
+ ```ts
1481
+ import { inherit, search } from "@fitzzero/quickdraw-core/server";
566
1482
 
567
- This package is developed alongside [quickdraw-chat](https://github.com/fitzzero/quickdraw-chat), a reference implementation.
1483
+ export const taskService = qd.defineService(task, {
1484
+ model: "task",
1485
+ access: inherit({ from: projectContract, via: "projectId" }),
1486
+ collections: { byProject: { anchor: projectContract } },
1487
+ methods: { ...search.handlers(task, { access: "authenticated" }) },
1488
+ });
1489
+ ```
568
1490
 
569
- quickdraw-chat consumes the published npm package. For local iteration
570
- against a checkout, use `bun link` (or point lint `extends` at the sibling
571
- path), and always re-verify against a published version before releasing:
1491
+ <!-- example: apps/web/src/components/kits/TaskSearch.tsx#component -->
572
1492
 
573
- ```bash
574
- bun run build # or bun run dev for watch mode
1493
+ ```tsx
1494
+ export function TaskSearch({ projectId }: { readonly projectId: string }) {
1495
+ const [text, setText] = useState("");
1496
+ // debounced, superseded searches cancelled, results live while the board is open
1497
+ const { items, isSearching } = qd.task.search.useSearch(text, { scope: projectId });
1498
+ return (
1499
+ <>
1500
+ <input value={text} onChange={(event) => setText(event.target.value)} />
1501
+ {isSearching ? <p>Searching…</p> : null}
1502
+ <ul>
1503
+ {items.map((card) => (
1504
+ <li key={card.id}>{card.title}</li>
1505
+ ))}
1506
+ </ul>
1507
+ </>
1508
+ );
1509
+ }
575
1510
  ```
576
1511
 
577
- ## Type Definitions
1512
+ - `search({ q, scope?, cursor?, limit? })` returns `{ items, nextCursor }`
1513
+ (and `rev`, below). `q` is trimmed, and one shorter than `minLength`
1514
+ (default 2) finds nothing and reads nothing. 20 results by default, at
1515
+ most 100, with keyset cursors as for `list`. There is no ranking: results
1516
+ come in the scope collection's order, else by id.
1517
+ - By default a row matches when one of the declared `fields` contains `q`,
1518
+ ignoring case; `%`, `_` and `\` in `q` are taken literally. `fields` is an
1519
+ explicit list because an unbounded "contains" over every column is slow. A
1520
+ field the reader's level does not receive (a field tier) is not searched,
1521
+ so whether a row matches never tells what that field holds.
1522
+ - A search finds only rows the caller can read, as `list` does: the policy's
1523
+ `accessWhere` at the form's `entry` or `scope` level, else `Read`; a
1524
+ `"public"` search is not filtered and hides every tiered field. With
1525
+ `scope`, only that scope's members (its column, or its `via` junction's
1526
+ links, and `where`), and only for a caller who may open the scope as
1527
+ `qd:col:sub` decides (`UNAUTHENTICATED` without a principal, `FORBIDDEN`
1528
+ below the collection's `access` on its anchor). A `via` scope is searched
1529
+ among at most its collection's `maxLimit` links, the first by row id.
1530
+ Identical concurrent searches by one caller run once (`share: "caller"`).
1531
+ For a contract with several search methods, `method` names the one a
1532
+ `search.handlers` call implements.
1533
+ - `strategy` replaces how rows are found; the kit still adds the access
1534
+ filter, the scope and paging. `where(q, ctx)` returns a filter;
1535
+ `ids(q, ctx, { limit })` returns ranked ids from an index of your own,
1536
+ which the kit reads, keeps to the rows the caller may read (so a page can
1537
+ hold fewer than `limit`) and returns in that order as one page. Postgres
1538
+ full-text search through a `tsvector` column the app maintains (a
1539
+ generated column or a trigger, with a GIN index):
1540
+
1541
+ <!-- example: apps/api/src/services/kits/search.ts#fulltext -->
1542
+
1543
+ ```ts
1544
+ export const fullTextSearch = search.handlers(task, {
1545
+ access: "authenticated",
1546
+ strategy: {
1547
+ // Prisma cannot filter on a tsvector column: find the ids with SQL. Keep
1548
+ // to the caller's rows (here, their projects' tasks) before LIMIT, so
1549
+ // other users' matches never fill the 1,000; the kit's access filter
1550
+ // still applies to what comes back.
1551
+ where: async (q, ctx) => {
1552
+ const rows = await db.$queryRaw<{ id: string }[]>`
1553
+ SELECT t.id FROM "Task" t
1554
+ JOIN "ProjectMember" m ON m."projectId" = t."projectId"
1555
+ WHERE m."userId" = ${ctx.principal.userId}
1556
+ AND t."searchVector" @@ websearch_to_tsquery('english', ${q})
1557
+ LIMIT 1000`;
1558
+ return { id: { in: rows.map((row) => row.id) } };
1559
+ },
1560
+ },
1561
+ });
1562
+ ```
578
1563
 
579
- Define your service methods in a shared types file:
1564
+ For results by relevance, return the ids from `ids` instead, ordered by
1565
+ `ts_rank("searchVector", query) DESC` and limited to `limit`.
1566
+
1567
+ - `useSearch(q, { scope?, debounceMs?, limit?, enabled? })` sends `q` once
1568
+ typing pauses (200 ms), cancels a search still on its way when the next
1569
+ one is sent (`qd:cancel`), keeps the last results in the same scope shown
1570
+ meanwhile, and returns `{ items, hasMore, isSearching, isLoading, error }`.
1571
+ A scoped search's page carries the revision it was read at (`rev`) when
1572
+ its items are exactly the scope collection's items: while a
1573
+ `useCollection` holds that scope, they are kept in its cache and shown as
1574
+ it holds them, so another user's rename of a result shows at once, with
1575
+ no second search and no subscription of the search's own.
1576
+
1577
+ ### Sharing and membership kit
1578
+
1579
+ `sharing.contract` makes the methods for one of the two ways a policy shares
1580
+ rows, and `sharing.handlers` implements them on the access list or the
1581
+ membership table the service's own policy reads:
1582
+
1583
+ <!-- example: packages/shared/src/kits/sharing.ts -->
1584
+
1585
+ ```ts
1586
+ import { defineContract, sharing, via } from "@fitzzero/quickdraw-core";
1587
+ import { z } from "zod";
1588
+
1589
+ const projectSchema = z.object({ id: z.string(), name: z.string() });
1590
+
1591
+ export const project = defineContract("projectService", {
1592
+ entity: projectSchema,
1593
+ methods: {
1594
+ // the JSON access list jsonAcl reads: share, unshare, setLevel, listShares
1595
+ ...sharing.contract({ mode: "acl" }),
1596
+ // the table members reads: invite, remove, leave, setRole, listMembers; by name too
1597
+ ...sharing.contract({
1598
+ mode: "members",
1599
+ methods: ["invite", "inviteByName", "remove", "leave", "setRole", "listMembers"],
1600
+ }),
1601
+ },
1602
+ collections: {
1603
+ // each user's projects: an invite adds the project, a remove or a leave takes it out
1604
+ mine: {
1605
+ scope: via({ model: "projectMember", entry: "projectId", scope: "userId" }),
1606
+ item: "entity",
1607
+ order: [["id", "asc"]],
1608
+ },
1609
+ },
1610
+ });
1611
+ ```
580
1612
 
581
- ```typescript
582
- // shared/types.ts
583
- import type { ServiceMethodMap } from "@fitzzero/quickdraw-core";
1613
+ <!-- example: apps/api/src/services/kits/sharing.ts#service -->
1614
+
1615
+ ```ts
1616
+ import { anyOf, jsonAcl, members, sharing } from "@fitzzero/quickdraw-core/server";
1617
+
1618
+ export const projectService = qd.defineService(project, {
1619
+ model: "project",
1620
+ access: anyOf(
1621
+ jsonAcl("acl", { owner: "ownerId" }),
1622
+ members({ model: "projectMember", entry: "projectId", user: "userId", level: "role" }),
1623
+ ),
1624
+ collections: { mine: { scopeAccess: "self" } },
1625
+ methods: {
1626
+ ...sharing.handlers(project, {
1627
+ // finds the user inviteByName means; none is NOT_FOUND
1628
+ resolveUser: async ({ name, email }) =>
1629
+ (await db.user.findFirst({ where: name === undefined ? { email } : { name } }))?.id,
1630
+ // runs inside the change's transaction: its writes commit with it, a throw undoes it
1631
+ onChange: (change, ctx) => {
1632
+ ctx.log.info("sharing changed", { kind: change.kind, id: change.id, user: change.userId });
1633
+ },
1634
+ }),
1635
+ },
1636
+ });
1637
+ ```
584
1638
 
585
- export type ChatServiceMethods = ServiceMethodMap<{
586
- createChat: {
587
- payload: { title: string };
588
- response: { id: string };
589
- };
590
- updateTitle: {
591
- payload: { id: string; title: string };
592
- response: { id: string; title: string };
593
- };
594
- inviteUser: {
595
- payload: { id: string; userId: string; level: "Read" | "Moderate" | "Admin" };
596
- response: { id: string };
597
- };
598
- }>;
599
- ```
600
-
601
- ## Architecture
602
-
603
- ```
604
- ┌─────────────────────────────────────────────────────────────────┐
605
- │ Client (React) │
606
- ├─────────────────────────────────────────────────────────────────┤
607
- │ QuickdrawProvider │
608
- │ ├── TanStack QueryClient │
609
- │ └── Socket.io Connection │
610
- │ │
611
- │ useService() ──────────────────────────────────────────────┐ │
612
- │ useSubscription() ─────────────────────────────────────────┤ │
613
- │ useCollection() ───────────────────────────────────────────┤ │
614
- │ SocketTextField, SocketCheckbox, ... ──────────────────────┤ │
615
- │ │ │
616
- └─────────────────────────────────────────────────────────────│───┘
617
- │
618
- Socket.io Events │
619
- ▼
620
- ┌─────────────────────────────────────────────────────────────────┐
621
- │ Server (Node.js) │
622
- ├─────────────────────────────────────────────────────────────────┤
623
- │ createQuickdrawServer() │
624
- │ └── ServiceRegistry │
625
- │ ├── Auto-discovers public methods │
626
- │ └── Wires methods to Socket.io events │
627
- │ │
628
- │ BaseService<Entity, Create, Update, Methods, …, Dto, Colls> │
629
- │ ├── defineMethod() - Type-safe method definition │
630
- │ ├── defineCollection() - Live lists with automatic deltas │
631
- │ ├── subscribe() / unsubscribe() - Real-time subscriptions │
632
- │ ├── create() / update() / delete() - CRUD, auto-emit + hooks │
633
- │ └── checkAccess() - ACL enforcement │
634
- │ │
635
- │ Auth Utilities │
636
- │ ├── createJWT() / verifyJWT() │
637
- │ └── OAuth providers (Discord, Google) │
638
- │ │
639
- └─────────────────────────────────────────────────────────────────┘
640
- ```
641
-
642
- ## Access Control
643
-
644
- Quickdraw provides flexible ACL with two complementary levels:
645
-
646
- ### Service-level ACL
647
-
648
- Blanket permissions across all entries in a service. Stored in `user.serviceAccess`:
649
-
650
- ```typescript
651
- // User model must satisfy QuickdrawUser interface
652
- interface QuickdrawUser {
653
- id: string;
654
- serviceAccess?: Record<string, AccessLevel> | null;
655
- }
1639
+ - Access list methods: `share({ id, userId, level })` gives a user `Read`,
1640
+ `Moderate` or `Admin` (replacing any level they had), `setLevel` changes
1641
+ the level of a user the row is shared with, `unshare({ id, userId })`
1642
+ takes it away, and `listShares({ id })` lists the row's shares; each
1643
+ returns the list as `[{ userId, level }]`, one entry per user. Membership
1644
+ methods: `invite({ entryId, userId, role? })` and
1645
+ `setRole({ entryId, userId, role })` return the member as
1646
+ `{ userId, role, level }`, `remove({ entryId, userId })` and
1647
+ `leave({ entryId })` return `null`, and
1648
+ `listMembers({ entryId, cursor?, limit? })` pages the members in user id
1649
+ order (50 by default, at most 200). `shareByName` and `inviteByName` take
1650
+ `name` or `email` instead of `userId`, and need `resolveUser`; a mode adds
1651
+ every method but those two unless `methods` names its own list.
1652
+ - Who may call: a change needs `{ entry: "Admin" }` on the row, a list
1653
+ `{ entry: "Read" }`, and `leave` a signed-in member (`FORBIDDEN` for
1654
+ anyone else). `access: { share: { entry: "Moderate" } }` replaces one
1655
+ method's form. Whatever the form, no caller shares, sets or invites at a
1656
+ level above their own on the row (`FORBIDDEN`; a service-wide grant
1657
+ counts where the form names `service`), and each change reads the
1658
+ caller's level again inside its transaction, refusing one lowered by a
1659
+ concurrent change (`FORBIDDEN`).
1660
+ - The kit changes the list or the table the service's policy reads, alone
1661
+ or inside `anyOf`, and takes their names from it: a service whose policy
1662
+ has none for a mode the contract uses (or two) fails when it is defined.
1663
+ Roles are the policy's `levels` keys, or the level names `Read`,
1664
+ `Moderate` and `Admin` without it; another role is `VALIDATION`, and
1665
+ `invite` without a role gives the lowest one that can read the row.
1666
+ - The owner's access never changes (`CONFLICT`). A row keeps its last
1667
+ Admin, counted across every policy of an `anyOf`: an owner column, an
1668
+ `Admin` entry of the access list, an `Admin` member (so a project's owner
1669
+ may remove its only Admin member). Taking the last one away, by
1670
+ `unshare`, a lower level, `remove`, `leave` or `setRole`, is `CONFLICT`.
1671
+ An access list the policy cannot read is `CONFLICT` and left as it is; an
1672
+ entry's other keys are kept. Inviting a member is `CONFLICT`, an unknown
1673
+ user `NOT_FOUND`, and a change to the level or role a user has already
1674
+ writes nothing.
1675
+ - Each change reads and writes in one SERIALIZABLE transaction, so two
1676
+ changes to one row at once cannot lose one or both remove the last two
1677
+ Admins: the database fails the second, which answers `CONFLICT` (try
1678
+ again). The writes go through the tracked client, so the flush revokes
1679
+ the live subscriptions of whoever lost access (`qd:revoked`) and sends
1680
+ the `via` collections over the table `added` and `removed`; the kit sends
1681
+ nothing itself. The by-name methods tell a row's owner whether an account
1682
+ exists; list them only where that is acceptable.
1683
+
1684
+ ### Admin kit
1685
+
1686
+ Back-office methods for every row of a service, only for service
1687
+ administrators, with the screen's fields derived from the entity (design:
1688
+ section 12.4). `admin.contract` makes ordinary, typed entries, and
1689
+ `admin.handlers` implements them:
1690
+
1691
+ <!-- example: packages/shared/src/kits/admin.ts -->
1692
+
1693
+ ```ts
1694
+ import { admin, defineContract } from "@fitzzero/quickdraw-core";
1695
+ import { taskSchema } from "../schemas";
1696
+
1697
+ export const task = defineContract("taskService", {
1698
+ // Zod 4.2 or later: the fields come from its JSON Schema
1699
+ entity: taskSchema,
1700
+ methods: {
1701
+ // adminList, adminGet, adminCreate, adminUpdate, adminDelete,
1702
+ // adminMeta, adminSubscribers, adminReemit; `expose` picks fewer
1703
+ ...admin.contract({ entity: taskSchema, filter: ["status"], sort: ["ordinal", "title"] }),
1704
+ },
1705
+ });
1706
+ ```
656
1707
 
657
- // Example: Admin access to all chats
658
- user.serviceAccess = { chatService: "Admin", userService: "Read" };
1708
+ <!-- example: apps/api/src/services/kits/admin.ts#service -->
1709
+
1710
+ ```ts
1711
+ import { admin, inherit } from "@fitzzero/quickdraw-core/server";
1712
+
1713
+ export const taskService = qd.defineService(task, {
1714
+ model: "task",
1715
+ access: inherit({ from: projectContract, via: "projectId" }),
1716
+ methods: {
1717
+ ...admin.handlers(task, {
1718
+ // the default: from the service name
1719
+ displayName: "Tasks",
1720
+ // never shown, returned or written
1721
+ hiddenFields: ["notes"],
1722
+ fieldOverrides: { assigneeId: { type: "relation", relationService: "userService" } },
1723
+ }),
1724
+ },
1725
+ });
659
1726
  ```
660
1727
 
661
- ### Entry-level ACL
1728
+ <!-- example: apps/web/src/components/kits/AdminTasks.tsx#component -->
662
1729
 
663
- Per-entity permissions. Quickdraw supports two patterns:
1730
+ ```tsx
1731
+ export function AdminTasks() {
1732
+ // [{ key: "task", serviceName, displayName }]
1733
+ const { services } = useAdminServices(qd);
1734
+ const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
1735
+ const update = qd.task.admin.adminUpdate.useMutation();
1736
+ return (
1737
+ <table aria-label={services[0]?.displayName}>
1738
+ <tbody>
1739
+ {data?.items.map((row) => (
1740
+ <tr key={row.id}>
1741
+ <td>{row.title}</td>
1742
+ <td>
1743
+ <button
1744
+ type="button"
1745
+ onClick={() => update.mutate({ id: row.id, data: { status: "done" } })}
1746
+ >
1747
+ Done
1748
+ </button>
1749
+ </td>
1750
+ </tr>
1751
+ ))}
1752
+ </tbody>
1753
+ </table>
1754
+ );
1755
+ }
1756
+ ```
664
1757
 
665
- #### Pattern 1: JSON ACL (Simple)
1758
+ - Who may call: every method defaults to `{ service: "Admin" }`, the
1759
+ caller's service-wide grant. A row-level `Admin` (the row's owner, an
1760
+ access list entry, a member) is `FORBIDDEN`, with or without
1761
+ `adminBypass`. `access: { adminList: { service: "Moderate" } }` replaces
1762
+ one method's form; whatever the form, the method reaches every row, and
1763
+ rows go out with only the fields the caller's service-wide grant reaches
1764
+ (a `fields` tier above it is left out, and a filter, sort or write that
1765
+ names such a field is `FORBIDDEN`).
1766
+ - `adminList({ page?, pageSize?, filter?, sort? })` returns
1767
+ `{ items, total, page, pageSize, totalPages }`: page numbers from 1, 20 rows
1768
+ by default and at most 100 (a larger `pageSize` is clamped), in two
1769
+ statements. `filter` is equality on the fields `admin.contract` declares
1770
+ and `sort` one declared field (the first one by default, then `id`):
1771
+ anything else, an operator object, `where` or `orderBy`, is `VALIDATION`.
1772
+ - `adminGet({ id })` returns the row; `adminCreate({ data })` and
1773
+ `adminUpdate({ id, data })` write the entity's fields through the tracked
1774
+ client, so subscribers and collections get the same frames as for any
1775
+ other write; `adminDelete({ id })` returns `null`. `id`, `createdAt` and
1776
+ `updatedAt` are never writable, nor are hidden fields or those an override
1777
+ made read-only (`VALIDATION`); without a service-wide `Admin` grant, nor
1778
+ are the columns the policy reads, and a row moves to another parent only
1779
+ with the row level on it (`FORBIDDEN`, as for the read/write kit); each
1780
+ value is checked by the entity schema itself, and a value the database
1781
+ refuses is `VALIDATION`. A missing row is `NOT_FOUND`.
1782
+ - `adminMeta()` returns `{ serviceName, displayName, fields }`, one
1783
+ `{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService? }`
1784
+ per field: `type` is
1785
+ `string`, `number`, `boolean`, `date` (an ISO string with a date format),
1786
+ `enum` or `json` from the field's JSON Schema, and `relation` by override;
1787
+ `sortable` and `filterable` are the declared fields (`filterable` is
1788
+ optional in the `AdminFieldConfig` type, default `false`, so field
1789
+ configurations written for 4.x still type); `id` and the
1790
+ timestamps come first and are not editable; `acl`, `serviceAccess` and
1791
+ `service_access` are hidden.
1792
+ - `adminSubscribers({ id })` counts the sockets subscribed to a row per
1793
+ access level (`{ id, count, levels, complete }`; behind a Redis adapter
1794
+ the counts are this server's and `complete` is `false`), and
1795
+ `adminReemit({ id })` touches the row so the flush sends it again to every
1796
+ subscriber.
1797
+ - `qd.<service>.admin` holds the kit's members (there is no top-level
1798
+ `qd.admin`, since `admin` is reserved per service). `useAdminServices(qd)`
1799
+ lists the client's services whose `adminMeta` answers the user, with their
1800
+ display names, sharing the cache of `qd.<service>.admin.adminMeta.useQuery()`.
1801
+
1802
+ ### Presence, streams and channels
1803
+
1804
+ Who is online, feeds that start with recent history and then append (logs,
1805
+ metrics), fast one-way input (cursors, typing) and typed room events
1806
+ (design: section 12.5). All four are declared in the contract; they share
1807
+ `qd.<service>.<name>` with the methods and collections:
1808
+
1809
+ <!-- example: packages/shared/src/kits/realtime.ts -->
1810
+
1811
+ ```ts
1812
+ import { defineContract, mutation } from "@fitzzero/quickdraw-core";
1813
+ import { z } from "zod";
1814
+ import { taskSchema } from "../schemas";
1815
+
1816
+ const cursorSchema = z.object({ projectId: z.string(), taskId: z.string(), x: z.number() });
1817
+ const logLineSchema = z.object({ line: z.string() });
1818
+
1819
+ export const task = defineContract("taskService", {
1820
+ entity: taskSchema,
1821
+ methods: {
1822
+ enterBoard: mutation({ input: z.object({ projectId: z.string() }), output: z.boolean() }),
1823
+ },
1824
+ streams: {
1825
+ // one feed per task; a subscriber needs Read on the task, and first gets the latest 50 lines
1826
+ logs: { item: logLineSchema, scope: "taskId", seed: 50, access: { entry: "Read" } },
1827
+ // one feed for everyone
1828
+ load: { item: z.number(), volatile: true, access: "authenticated" },
1829
+ },
1830
+ channels: {
1831
+ // 20 a second per socket; only from a socket in the board's room, which enterBoard joined
1832
+ cursor: {
1833
+ payload: cursorSchema,
1834
+ ratePerSecond: 20,
1835
+ requires: { room: (cursor) => `board:${cursor.projectId}` },
1836
+ },
1837
+ },
1838
+ events: { cursorMoved: { payload: cursorSchema } },
1839
+ });
1840
+ ```
666
1841
 
667
- Store ACL directly on the entity. Best for:
1842
+ <!-- example: apps/api/src/services/kits/realtime.ts#service -->
668
1843
 
669
- - Simple ownership models (owner + collaborators)
670
- - When you don't need to query "all entities user X can access" efficiently
671
- - Minimal schema complexity
1844
+ ```ts
1845
+ import { inherit } from "@fitzzero/quickdraw-core/server";
672
1846
 
673
- ```typescript
674
- // Entity must satisfy ACLEntity interface
675
- interface ACLEntity {
676
- id: string;
677
- acl?: ACL | null; // ACL = Array<{ userId: string; level: AccessLevel }>
678
- }
1847
+ export const taskService = qd.defineService(task, {
1848
+ model: "task",
1849
+ access: inherit({ from: projectContract, via: "projectId" }),
1850
+ methods: {
1851
+ enterBoard: {
1852
+ access: { scope: "Read", of: projectContract, id: "projectId" },
1853
+ handler: ({ input, ctx }) => ctx.rooms.join(`board:${input.projectId}`),
1854
+ },
1855
+ },
1856
+ channels: {
1857
+ // relay each cursor to the board's room
1858
+ cursor: (payload, ctx) => {
1859
+ ctx.rooms.emit(`board:${payload.projectId}`, task, "cursorMoved", payload);
1860
+ },
1861
+ },
1862
+ });
679
1863
 
680
- // Prisma schema
681
- model Document {
682
- id String @id @default(cuid())
683
- acl Json? // Stores [{ userId: "...", level: "Read" }]
1864
+ // in handlers, jobs and timers
1865
+ export function logLine(taskId: string, line: string): void {
1866
+ qd.stream(task, "logs").push(taskId, { line });
684
1867
  }
685
1868
 
686
- // Service - uses default checkEntryACL (no override needed)
687
- class DocumentService extends BaseService<Document, ...> {
688
- constructor(prisma: PrismaClient) {
689
- super({ serviceName: "documentService", hasEntryACL: true });
690
- this.setDelegate(prisma.document);
691
- }
1869
+ export async function isOnline(userId: string): Promise<boolean> {
1870
+ // also ctx.presence and server.presence
1871
+ return await qd.presence.isOnline(userId);
692
1872
  }
693
1873
  ```
694
1874
 
695
- #### Pattern 2: Membership Table (Complex)
1875
+ <!-- example: apps/web/src/components/kits/TaskRoom.tsx#component -->
696
1876
 
697
- Separate table for memberships. Best for:
1877
+ ```tsx
1878
+ export function TaskRoom({
1879
+ projectId,
1880
+ taskId,
1881
+ }: {
1882
+ readonly projectId: string;
1883
+ readonly taskId: string;
1884
+ }) {
1885
+ const { items } = qd.task.logs.useStream(taskId, { max: 200 });
1886
+ const { send, isReady } = qd.task.cursor.useChannel();
1887
+ const [lastX, setLastX] = useState(0);
1888
+ qd.task.cursorMoved.useEvent((cursor) => setLastX(cursor.x));
1889
+ // user ids, after enterBoard joined the room
1890
+ const here = usePresence(`board:${projectId}`);
1891
+ return (
1892
+ <div onMouseMove={(event) => isReady && send({ projectId, taskId, x: event.clientX })}>
1893
+ <p>{`${String(here.length)} here; a cursor at ${String(lastX)}`}</p>
1894
+ <pre>{items.map((item) => item.line).join("\n")}</pre>
1895
+ </div>
1896
+ );
1897
+ }
1898
+ ```
698
1899
 
699
- - Querying "all entities user X can access" efficiently
700
- - Complex role hierarchies
701
- - Additional membership metadata (join date, invited by, etc.)
1900
+ - Streams: `push` checks each item against the stream's schema (a mismatch
1901
+ throws `INTERNAL` and nothing is sent; what is kept and sent is the
1902
+ validated item, so keys the schema does not name are stripped), keeps the
1903
+ latest `seed` items per scope in memory on that process (at most 1,000 per
1904
+ scope and 10,000 scopes per stream; a restart empties them, and durable
1905
+ history is the app's: store the rows and expose a collection), and sends
1906
+ `qd:stream { s, stream, scope?, item }` to the feed's subscribers,
1907
+ volatile when the stream says so. `pushMany(scope, items)`
1908
+ (`pushMany(items)` for a global stream) pushes several items to one feed
1909
+ at once: every item is checked before any is kept or sent, and each goes
1910
+ out as its own frame, in order; use it rather than `push` in a loop
1911
+ (`no-emit-in-loop`). `qd:stream:sub` is authorized with the
1912
+ stream's `access` through the access engine, the scope being the row an
1913
+ `entry` or `scope` form checks; a stream without `access` is closed. The
1914
+ answer is the seed; `useStream` then appends, keeps the latest `max`
1915
+ (default 500), and subscribes again after a reconnect, when the seed
1916
+ replaces what it held. A socket holds at most 500 feeds. A subscriber
1917
+ whose access is lowered is authorized again; one refused leaves the feed
1918
+ and gets `qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`,
1919
+ and `useStream` shows `FORBIDDEN` until the next connect.
1920
+ - Channels: each message is `qd:ch [service, channel, payload]`, sent
1921
+ volatile and never answered. Per socket and channel a token bucket
1922
+ (`ratePerSecond`, default 30; `burst`, default twice that) drops what is
1923
+ over the rate, and a socket whose drops within 10 s pass 100 times the
1924
+ rate is disconnected. A message from an anonymous socket, one that fails
1925
+ its schema, one without the service grant `{ access: { service }, handler }`
1926
+ names, or one whose `requires` the socket does not hold (`{ entity }`: a
1927
+ `qd:sub` of that row; `{ collection, scope }`: a `qd:col:sub` of that
1928
+ scope; `{ room }`: the app room, a name like `"world"` or a function of
1929
+ the payload, which a call over that same socket joined) is dropped.
1930
+ Nothing is logged per message; a handler's error is. The socket rate
1931
+ limiter does not count channels. Every requirement is the sending
1932
+ socket's own: a room another socket of the user joined does not count, a
1933
+ reconnected socket must join again, and behind a cluster the check runs
1934
+ on the node the socket is connected to, with no round trip.
1935
+ - Presence: `isOnline`, `lastSeen` (now while online, else when the user's
1936
+ last socket on this process disconnected), `count` and `users` (each user
1937
+ once, anonymous sockets left out; app rooms only) come from this process's
1938
+ sockets, and from every node's (`fetchSockets`) behind a Redis adapter.
1939
+ `ctx.rooms.join(room)` and `leave` put the calling socket in an app room
1940
+ (calls without a socket get `false`; names starting with `qd:` or `user:`
1941
+ are refused with `VALIDATION`; at most 100 per socket; a method that shares
1942
+ its runs, `share`, may not join or leave: `INTERNAL`), and the room's
1943
+ sockets get `qd:presence` frames: the list on joining, then who joins and
1944
+ who leaves. `usePresence(room)` shows them.
1945
+ - Events: `ctx.rooms.emit(room, contract, event, payload)` and
1946
+ `emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
1947
+ sent, when it fails), then send the validated payload as
1948
+ `qd:event [service, event, payload]`; `useEvent` hears them.
1949
+
1950
+ ### Auth routes kit
1951
+
1952
+ Sign-in for Google, Discord, a development mock and guests, as one Express
1953
+ middleware (design: section 12.6). The app supplies how a provider's profile
1954
+ becomes its user (`onLogin`, returning the user's id) and where sessions are
1955
+ stored (a `SessionStore`); `socketAuth` then authenticates the server's
1956
+ sockets and HTTP calls by those sessions:
1957
+
1958
+ <!-- example: apps/api/src/auth/routes.ts#routes -->
1959
+
1960
+ ```ts
1961
+ import {
1962
+ createAuthRoutes,
1963
+ createMemorySessionStore,
1964
+ discord,
1965
+ google,
1966
+ guest,
1967
+ mock,
1968
+ socketAuth,
1969
+ } from "@fitzzero/quickdraw-core/server/auth";
1970
+ import { createCallLimiter } from "@fitzzero/quickdraw-core/server/express";
1971
+
1972
+ // the web app's origins: one list for both
1973
+ const allowedOrigins = [env.CLIENT_URL];
1974
+ // in production: a store over your database (below)
1975
+ const sessions = createMemorySessionStore();
1976
+
1977
+ export const app: Express = express();
1978
+ // behind a proxy, so the rate limits see the client's IP
1979
+ app.set("trust proxy", 1);
1980
+ // a web app on another origin also needs CORS with credentials on these routes
1981
+ app.use(
1982
+ createAuthRoutes({
1983
+ providers: [
1984
+ google({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
1985
+ discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET }),
1986
+ // served only while isMockOAuthEnabled()
1987
+ mock({ listUsers: listSeededUsers }),
1988
+ guest({ createUser: (input) => createGuestUser(input) }),
1989
+ ],
1990
+ sessions,
1991
+ // 32 characters or more
1992
+ jwtSecret: env.JWT_SECRET,
1993
+ // the user's id, or null to refuse
1994
+ onLogin: (profile) => upsertUser(profile),
1995
+ allowedOrigins,
1996
+ // redirect URIs: {publicUrl}/auth/{provider}/callback
1997
+ publicUrl: env.API_URL,
1998
+ successPath: "/auth/callback",
1999
+ errorPath: "/auth/login",
2000
+ // a revoked session's open sockets: logout ends its own, logout-all every one of the user
2001
+ onRevoke: (userId, sessionId) =>
2002
+ server.access.disconnectUser(userId, sessionId === null ? {} : { sessionId }),
2003
+ }),
2004
+ );
2005
+
2006
+ export const server = qd.createServer({
2007
+ app,
2008
+ services,
2009
+ db,
2010
+ auth: {
2011
+ authenticate: socketAuth({
2012
+ sessions,
2013
+ jwtSecret: env.JWT_SECRET,
2014
+ allowedOrigins,
2015
+ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "user" }),
2016
+ }),
2017
+ loadServiceAccess: (userId) => loadGrants(userId),
2018
+ },
2019
+ // the HTTP transport has no limit of its own
2020
+ http: { rateLimit: createCallLimiter() },
2021
+ });
2022
+ ```
702
2023
 
703
- ```typescript
704
- // Prisma schema
705
- model Chat {
706
- id String @id
707
- members ChatMember[]
2024
+ The routes, under `basePath` (default `/auth`). Each POST needs
2025
+ `Content-Type: application/json`, which a cross-site form cannot send; a
2026
+ failure answers `{ error: <code>, message }` with the code's HTTP status, and
2027
+ nothing is cached:
2028
+
2029
+ | Route | Answer |
2030
+ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
2031
+ | `GET /{provider}/start?returnTo=<url>` | 302 to the provider |
2032
+ | `GET /{provider}/callback` | 302 to `{origin}{successPath}` with the session cookie, or to `{origin}{errorPath}?error=state`, `denied` or `failed` |
2033
+ | `POST /guest` | `createUser(body)`, then `{ userId }` with the session cookie |
2034
+ | `GET /me` | `{ userId }`, or 401 |
2035
+ | `POST /logout` | 204: revokes the session, clears the cookie |
2036
+ | `POST /logout-all` | 204: revokes every session of the user; 401 without a live session |
2037
+ | `/mock/provider/*` | the mock provider's own endpoints, while `isMockOAuthEnabled()` |
2038
+
2039
+ - The OAuth state is 256 random bits, sent to the provider and kept in a
2040
+ 10-minute HttpOnly, SameSite=Lax cookie (`__Host-qd_oauth` on `/` over a
2041
+ secure request, else `qd_oauth` on `basePath`). The callback clears it
2042
+ whatever happens, and accepts only that state, for that provider, within
2043
+ its 10 minutes, once (each redeemed state is remembered by the process),
2044
+ so a sign-in cannot be finished in another browser or replayed.
2045
+ - `returnTo` is an origin or a URL on one; only its origin is kept, and only
2046
+ when `allowedOrigins` lists it (exact origins, or patterns anchored with
2047
+ `^` and `$`; nothing is read from the environment, and localhost or
2048
+ Codespaces origins are allowed only when listed). It is checked before it
2049
+ is stored and again before the redirect. Without `returnTo` the sign-in
2050
+ returns to the first exact origin listed.
2051
+ - The session cookie holds a JWT naming the user and the session (`sid`).
2052
+ It is HttpOnly and SameSite=Lax, Secure in production or over HTTPS, and
2053
+ lasts `cookie.maxAgeMs` (7 days), as do the JWT and the stored session.
2054
+ `cookie.sameSite: "none"` (always Secure) serves a web app on another
2055
+ site; `COOKIE_DOMAIN` (or `cookie.domain`) shares it with subdomains. Its
2056
+ name is `cookie.name`; else `session` when it has a domain; else
2057
+ `__Host-session` on a request that came over HTTPS (`req.secure`,
2058
+ `X-Forwarded-Proto: https`, the `Origin` of an `https:` page, or for an
2059
+ OAuth callback an `https:` return origin), which a browser keeps only as
2060
+ a Secure, host-only cookie on `/`, so no other site under the same parent
2061
+ domain can plant or replace it; else, over plain HTTP, `session`.
2062
+ - A cookie name a request repeats counts as no credential, for the session
2063
+ and the OAuth state alike: a sibling site can plant a second cookie of
2064
+ the same name, and the server cannot tell which is its own.
2065
+ - `me` answers the same 401 whether the request had no credential, a forged
2066
+ or expired one, or one whose session was revoked. It, `logout` and
2067
+ `logout-all` read the cookie, else an `Authorization: Bearer` token.
2068
+ - `socketAuth` reads a socket's `auth.token` (a bearer token, for clients
2069
+ without cookies), else the session cookie from its handshake; an HTTP
2070
+ call's credential is the one the transport found. No credential is
2071
+ anonymous; a credential that does not stand for a live session in the
2072
+ store is refused with `UNAUTHENTICATED` (logged at debug), so a logged
2073
+ out or revoked session stops working at the next handshake or call even
2074
+ though its JWT has not expired. A socket that uses the cookie must also
2075
+ come from an allowed page: its `Origin` must be in `allowedOrigins`
2076
+ (WebSockets are not subject to CORS, and a browser sends the cookie with
2077
+ any page's handshake). A handshake without `Origin` is refused unless it
2078
+ is a browser's same-origin request (`Sec-Fetch-Site: same-origin`) or
2079
+ `allowMissingOrigin: true` is set for native clients that keep cookies.
2080
+ Bearer tokens need no Origin, and HTTP calls are guarded by their JSON
2081
+ content type instead.
2082
+ - Rate limits: the sign-in routes share `createAuthLimiter({ max: 60 })` (60
2083
+ requests per 15 minutes per IP) and the session routes
2084
+ `createAuthStatusLimiter()` (120); pass `rateLimit: { signIn, session }`
2085
+ to replace them (a shared store across instances, say) or `false`. The
2086
+ defaults need the optional peer `express-rate-limit`. The HTTP transport
2087
+ (`/qd`) is not limited unless `http.rateLimit` is set;
2088
+ `createCallLimiter()` (300 calls per minute per IP) refuses in the
2089
+ transport's own `RATE_LIMITED` reply. A web server that prefetches for
2090
+ many users calls from one address: give it its own `keyGenerator` or a
2091
+ higher `max`.
2092
+ - The mock provider is mounted only while `isMockOAuthEnabled()`
2093
+ (`ENABLE_MOCK_OAUTH=true` and `NODE_ENV` other than `production`), and
2094
+ every request checks again. Set `mock({ internalUrl })` where the API
2095
+ cannot reach itself at `publicUrl`.
2096
+ - One rule names the session cookie, written and read: the routes,
2097
+ `setSessionCookie`, `socketAuth` and the HTTP transport give a request
2098
+ the same name, and read first the name they would set on it. Without a
2099
+ domain, a request over HTTPS reads only `__Host-session`, never
2100
+ `session`: another site under the same parent domain can set that cookie
2101
+ for the whole domain, and while the user holds no `__Host-session` it
2102
+ would sign them in as whoever set it. A plain HTTP request reads
2103
+ `session`, then `__Host-session`. A configured name is read as it is, on
2104
+ any request, so a changed name goes in all three places:
2105
+ `createAuthRoutes({ cookie: { name } })`, `socketAuth({ cookieName })`
2106
+ and `createServer({ http: { cookieName } })`. The transports see the
2107
+ cookie's domain through `COOKIE_DOMAIN` only: share the cookie with
2108
+ subdomains by setting `COOKIE_DOMAIN`; a `cookie.domain` given only to
2109
+ the routes needs `cookieName: "session"` on the other two, and the routes
2110
+ warn at startup until the cookie is named. Behind a proxy that ends TLS,
2111
+ have it send `X-Forwarded-Proto`, which every request carries, so the
2112
+ name does not depend on which requests carry an `Origin`.
2113
+ - A socket keeps the principal it authenticated with until it reconnects,
2114
+ so a revoked session's open sockets are ended with
2115
+ `server.access.disconnectUser(userId, { sessionId?, reason? })`: every
2116
+ socket of the user, or those `socketAuth` recorded for one session
2117
+ (`recordSocketSession` on `./server` for an app's own `authenticate`), on
2118
+ every node behind a cluster adapter. `onRevoke(userId, sessionId | null)`
2119
+ tells the app when `logout` (the session's id) or `logout-all` (`null`)
2120
+ revoked; wire it as above. A disconnected client does not reconnect on
2121
+ its own; its next connect is authenticated afresh.
2122
+ - `issueSession({ sessions, jwtSecret }, userId, { provider })` starts a
2123
+ session for an app's own sign-in flow (login codes, an embedded activity),
2124
+ and `liveSession` reads a token back; both work with `socketAuth`.
2125
+
2126
+ `createMemorySessionStore()` keeps sessions in the process, for development
2127
+ and tests. In production, store them in the database. Sessions are not live
2128
+ data, so nothing needs their writes tracked: with the tracked client, the
2129
+ first session write logs one development warning about a write outside a
2130
+ unit of work, which running the store's writes inside `qd.run(...)` (or
2131
+ using the untracked client here) avoids. Delete expired rows now and then.
2132
+
2133
+ ```prisma
2134
+ model Session {
2135
+ id String @id @default(cuid())
2136
+ userId String
2137
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
2138
+ provider String
2139
+ userAgent String?
2140
+ ip String?
2141
+ createdAt DateTime @default(now())
2142
+ expiresAt DateTime
2143
+
2144
+ @@index([userId])
2145
+ @@index([expiresAt])
708
2146
  }
2147
+ ```
709
2148
 
710
- model ChatMember {
711
- chatId String
712
- userId String
713
- level String // "Read" | "Moderate" | "Admin"
2149
+ <!-- example: apps/api/src/auth/sessions.ts#store -->
714
2150
 
715
- @@unique([chatId, userId])
2151
+ ```ts
2152
+ import type { SessionStore } from "@fitzzero/quickdraw-core/server/auth";
2153
+
2154
+ /** The methods of Prisma's `db.session` delegate the store calls. */
2155
+ interface SessionTable {
2156
+ create(args: { data: SessionMeta & { userId: string } }): Promise<AuthSession>;
2157
+ findUnique(args: { where: { id: string } }): Promise<AuthSession | null>;
2158
+ deleteMany(args: { where: { id: string } | { userId: string } }): Promise<unknown>;
716
2159
  }
717
2160
 
718
- // Service - override checkEntryACL to use membership table
719
- class ChatService extends BaseService<Chat, ...> {
720
- protected override async checkEntryACL(
721
- userId: string,
722
- chatId: string,
723
- requiredLevel: AccessLevel
724
- ): Promise<boolean> {
725
- const member = await this.prisma.chatMember.findUnique({
726
- where: { chatId_userId: { chatId, userId } },
727
- });
728
- if (!member) return false;
729
- return this.isLevelSufficient(member.level as AccessLevel, requiredLevel);
730
- }
2161
+ export function prismaSessions(sessions: SessionTable): SessionStore {
2162
+ return {
2163
+ create: (userId, meta) => sessions.create({ data: { userId, ...meta } }),
2164
+ get: (id) => sessions.findUnique({ where: { id } }),
2165
+ revoke: (id) => sessions.deleteMany({ where: { id } }),
2166
+ revokeAll: (userId) => sessions.deleteMany({ where: { userId } }),
2167
+ };
731
2168
  }
732
2169
  ```
733
2170
 
734
- ### Access Check Order
2171
+ ## Testing
735
2172
 
736
- When a method is called, `ensureAccessForMethod` checks in this order:
2173
+ `@fitzzero/quickdraw-core/testing` boots the app's real server on a free
2174
+ port, so a test exercises the same dispatcher, access engine, tracked writes
2175
+ and frames production does (design: section 13):
737
2176
 
738
- 1. **Service-level**: `socket.serviceAccess[serviceName] >= requiredLevel` → Allow
739
- 2. **Custom override**: `checkAccess()` returns true → Allow (use for self-access patterns)
740
- 3. **Entry-level**: `checkEntryACL()` returns true → Allow (JSON ACL or membership table)
741
- 4. **Deny** if none of the above
2177
+ <!-- example: apps/api/src/services/task.test.ts#app -->
2178
+
2179
+ ```ts
2180
+ it("sends a rename to the other members' boards", async () => {
2181
+ const app = await createTestApp({
2182
+ services: [projectService, taskService],
2183
+ db,
2184
+ strictWarnings: true,
2185
+ });
2186
+ const { call } = await app.connect(bo); // a real protocol 5 socket
2187
+ await call.taskService.get({ id: taskId });
2188
+ await app.as(ada).taskService.rename({ id: taskId, title: "Ship it" }); // in process
2189
+ await app.frames.waitFor({ event: "qd:e", userId: bo.userId }); // the entity frame bo receives
2190
+ await app.close();
2191
+ });
2192
+ ```
2193
+
2194
+ - `createTestApp` takes `createServer`'s options, plus `strictWarnings`
2195
+ (below). Its sockets act as the principal they connect with, its rate
2196
+ limiter is off, and its dispatcher becomes the current one of the
2197
+ `initQuickdraw` instance that defined its services, so
2198
+ `qd.stream(...).push`, `qd.presence` and `qd.run` reach the test app (the
2199
+ last one created).
2200
+ - Seed rows with the untracked client (`prisma`), or inside `qd.run` once
2201
+ an app runs: a tracked write outside any unit of work flushes on its own,
2202
+ with an `ambient-write` warning.
2203
+ - `app.as(principal)` calls in process and `app.connect(principal)` over a
2204
+ real socket (`{ call, socket, hello, close }`); both are keyed by service
2205
+ name. `app.frames(match?)` lists every frame the server sent, with its
2206
+ socket and user; `frames.waitFor(match)` waits for one. `emitWithAck` and
2207
+ `waitForEvent` send raw frames and wait for events.
2208
+
2209
+ ### Access matrices
2210
+
2211
+ `describeAccessMatrix(app, { service, principals, cases, via? })` runs each
2212
+ case as each principal, and anonymously, through the app's real dispatcher
2213
+ (in process, or over a socket per principal with `via: "socket"`), and fails
2214
+ listing every cell that differs from the expected table:
2215
+
2216
+ <!-- example: apps/api/src/services/task.test.ts#matrix -->
2217
+
2218
+ ```ts
2219
+ it("lets the owner rename, members read, and nobody else in", async () => {
2220
+ const app = await createTestApp({ services: [projectService, taskService], db });
2221
+ await describeAccessMatrix(app, {
2222
+ service: taskService,
2223
+ principals: { owner: ada, member: bo, stranger: ed },
2224
+ cases: [
2225
+ { method: "get", input: { id: taskId }, allow: ["owner", "member"] }, // everyone else is denied
2226
+ {
2227
+ method: "rename",
2228
+ input: { id: taskId, title: "x" },
2229
+ expect: { owner: "allow", member: "FORBIDDEN" },
2230
+ },
2231
+ ],
2232
+ });
2233
+ await app.close();
2234
+ });
2235
+ ```
742
2236
 
743
- ### Access Levels
2237
+ `"deny"` (the default for everyone `allow` does not name) means
2238
+ `UNAUTHENTICATED` without a principal and `FORBIDDEN` with one. Mutations run
2239
+ for real, once per allowed principal: give inputs that can run again.
744
2240
 
745
- | Level | Value | Typical Use |
746
- | -------- | ----- | -------------------------------- |
747
- | Public | 0 | No authentication required |
748
- | Read | 1 | View data, subscribe to updates |
749
- | Moderate | 2 | Edit content, manage members |
750
- | Admin | 3 | Delete, manage ACL, full control |
2241
+ ### Performance budgets
751
2242
 
752
- ## Testing
2243
+ `expectBudget(run, { name })` makes performance something a test can fail
2244
+ on. It runs one step of a test against the apps `createTestApp` started,
2245
+ records what the step cost, and compares that with the entry `name` in the
2246
+ budget file beside the test, `__budgets__/<test file>.json`. Commit the
2247
+ file.
753
2248
 
754
- ### Server Integration Tests
2249
+ <!-- example: apps/api/src/services/task.test.ts#budget -->
755
2250
 
756
- ```typescript
757
- import {
758
- createTestServer,
759
- connectAsUser,
760
- emitWithAck,
761
- } from "@fitzzero/quickdraw-core/server/testing";
762
-
763
- describe("ChatService", () => {
764
- let server;
765
-
766
- beforeAll(async () => {
767
- server = await createTestServer({
768
- services: { chatService: new ChatService() },
769
- seedDb: async () => {
770
- /* seed test data */
771
- },
772
- });
2251
+ ```ts
2252
+ it("counts a board within its budget", async () => {
2253
+ const app = await createTestApp({ services: [projectService, taskService], db });
2254
+ const result = await expectBudget(() => app.as(ada).taskService.countOnBoard({ projectId }), {
2255
+ name: "count a board",
773
2256
  });
2257
+ expect(result.measured.calls).toHaveLength(1); // what the step cost: calls, statements, bytes
2258
+ await app.close();
2259
+ });
2260
+ ```
2261
+
2262
+ It counts statements and bytes, never time, so a budget is the same on every
2263
+ machine and on PGlite or PostgreSQL. It records:
2264
+
2265
+ - **each call** of the step (through `app.as(...)`, a socket or HTTP): its
2266
+ service and method, the statements its handler ran and its reply's bytes,
2267
+ from its completion record (`CallRecord.sqlStatements` and `bytes`;
2268
+ `app.as(...)` replies count as their JSON). The access check before the
2269
+ handler is not among a call's statements; the reads a kit's handler makes
2270
+ to filter by access are. A query that joined another call's shared run
2271
+ counts none: the run is counted once.
2272
+ - **the whole step**: every statement the apps' tracked database clients ran
2273
+ while `run` did (access checks, handlers, flushes and subscription reads),
2274
+ and every byte the apps' servers wrote to sockets (a frame sent to a room
2275
+ counts once per socket that receives it), plus the in-process replies.
2276
+
2277
+ What it does with them:
2278
+
2279
+ - A missing entry is written. A step that costs less rewrites its entry, so
2280
+ the budget tightens as the code improves; under CI (`CI=1` or `CI=true`)
2281
+ it fails instead ("budget changed; rerun locally to accept"), so removed
2282
+ work is noticed and the lower budget is committed from a local run.
2283
+ - A step that costs more fails, naming every number that grew with its old
2284
+ and new values. Set `QD_ALLOW_BUDGET_GROWTH=1` to accept every new budget,
2285
+ or `QD_ALLOW_BUDGET_GROWTH="list a page,count a board"` for the ones it
2286
+ names, and commit the file. A step whose calls changed (other methods,
2287
+ outcomes or how many) counts as growth.
2288
+ - Each step of a test file has its own name: a name another test of the
2289
+ file already measured is a `TypeError` (the same test measuring it again,
2290
+ a retry, is fine).
2291
+ - Statements must match exactly; bytes may move by up to 5% either way
2292
+ (ids and timestamps vary in length) without counting as a change.
2293
+ - Await, inside `run`, everything the step should cost: the replies and the
2294
+ frames it is about. Measure one step at a time.
2295
+ - A few reads happen once per process (the storage adapter asks once
2296
+ whether an order column may hold null). When a step could be the first to
2297
+ pay for one, run it once before measuring it, so its budget does not
2298
+ depend on the order tests run in.
2299
+
2300
+ ### Development warnings
2301
+
2302
+ While `NODE_ENV` is not `"production"`, a running app warns about the slow
2303
+ and untracked patterns lint cannot see, as they happen. Every warning has
2304
+ one format and names the method call it happened in:
2305
+
2306
+ ```text
2307
+ [quickdraw:n-plus-one] taskService.board: task.findUnique by id ran 10 times in one call, once per item (N+1); ...
2308
+ ```
774
2309
 
775
- afterAll(() => server.stop());
2310
+ | Kind | Raised when |
2311
+ | -------------------- | ---------------------------------------------------------------------------------------------------- |
2312
+ | `n-plus-one` | a call ran 10 statements of one shape (model, operation, `where` keys), outside a `$transaction([])` |
2313
+ | `unbounded-read` | a call ran `findMany` with neither `take` nor ids to read (`id`, `{ in }` or `{ equals }`) |
2314
+ | `oversized-response` | a reply was larger than `maxResponseBytes` (default 1 MiB) |
2315
+ | `nested-write` | a write's `data` wrote a related row, which is not tracked |
2316
+ | `ambient-write` | a tracked write ran outside any unit of work |
2317
+ | `batch-read` | a write in an array-form `$transaction` read its rows outside the batch |
2318
+ | `batch-create-many` | a `createMany` in an array-form `$transaction` could not report its rows |
2319
+ | `repeated-call` | one connection repeated a call or was refused `RATE_LIMITED` again and again (below) |
2320
+
2321
+ Updates and deletes by id inside an interactive transaction are not counted
2322
+ toward `n-plus-one`: that is how per-row writes are written (see tracked
2323
+ writes). Each is logged once per kind, service, method and subject (the
2324
+ model, or the field of a nested write), under `category: "quickdraw.dev"`.
2325
+ Only an app's own statements are checked: the framework's reads and the
2326
+ kits' own reads are not, while the app's callbacks a kit calls (`prepare`,
2327
+ `onChange`, `resolveUser`, a search strategy) are. A statement filtered by
2328
+ an `id` list (`{ id: { in: ids } }`, one per chunk of ids) never counts
2329
+ toward `n-plus-one`. In tests, `createTestApp({ strictWarnings: true })`
2330
+ (under vitest or jest) throws every warning raised in that app's method
2331
+ calls as a `DevWarningError` where it is raised, so the test that caused it
2332
+ fails: the call it happened in fails with `INTERNAL` and the error as its
2333
+ `cause`, and an in-process call whose reply was oversized rejects with it
2334
+ once the reply was recorded (over a socket or HTTP the reply was already
2335
+ sent, so that error is logged, not thrown). Strictness belongs to the app:
2336
+ warnings outside its calls (an ambient write while seeding, another app's
2337
+ calls) are logged as usual, and `app.close()` ends it.
2338
+
2339
+ A `repeated-call` warning names a client caught in a loop (a mutation fired
2340
+ from an effect that its own result runs again, a refetch that triggers
2341
+ itself) before the socket's rate limit answers `RATE_LIMITED` without saying
2342
+ why: when one connection (a socket, an MCP session) calls a method with the
2343
+ same input more than 10 times within a second, once per connection, service
2344
+ and method; and when one is refused `RATE_LIMITED` more than 30 times within
2345
+ a minute, once per connection. Calls without a connection (in-process,
2346
+ HTTP) are not counted.
2347
+ The client names the same loops from its side, in development, with the
2348
+ same format: `repeated-mutation` when one `useMutation` issues its mutation
2349
+ more than 5 times within a second (with the component that holds it), and
2350
+ `repeated-invalidation` when `qd.invalidate` asks for one query key more
2351
+ than 20 times within a second, or the invalidation coordinator refetches
2352
+ or marks it stale that often. The coordinator's work is counted after its
2353
+ coalescing, so a watched topic that changes 25 times a second (a few
2354
+ refetches) is not named a loop.
2355
+
2356
+ ### Components
2357
+
2358
+ `@fitzzero/quickdraw-core/testing/client` renders components against the
2359
+ test app, with the real provider, hooks and socket:
2360
+
2361
+ <!-- example: apps/web/src/components/TaskBoard.test.tsx#render -->
776
2362
 
777
- it("creates chat", async () => {
778
- const client = await server.connectAs("user-id");
779
- const chat = await client.emit("chatService:createChat", { title: "Test" });
780
- expect(chat.id).toBeDefined();
781
- client.close();
2363
+ ```tsx
2364
+ it("shows a task another user adds", async () => {
2365
+ const app = await createTestApp({ services: [projectService, taskService], db });
2366
+ const view = await renderWithQuickdraw(<TaskBoard projectId={projectId} />, {
2367
+ app,
2368
+ as: ada,
2369
+ client: qd,
782
2370
  });
2371
+ await app.as(ada).taskService.create({ projectId, title: "Added elsewhere" });
2372
+ await view.findByText("Added elsewhere"); // the collection delta reached the component
2373
+ await app.close();
783
2374
  });
784
2375
  ```
785
2376
 
786
- ### Client Component Tests
2377
+ `renderWithQuickdraw(ui, { app, as, client, queryClient?, wrapper? })` is
2378
+ async (Testing Library, an optional peer, is loaded lazily) and returns
2379
+ Testing Library's result plus `connection`, `queryClient`, `disconnect()`
2380
+ and `reconnect()`, which drop and restore the socket as a lost network does.
787
2381
 
788
- ```typescript
789
- import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@fitzzero/quickdraw-core/client/testing';
2382
+ For a component test without a server, `createMockClient(contracts)` gives
2383
+ the typed client's shape with stubs; give it to the components in place of
2384
+ the app's client (a module mock of the file that exports `qd`, say):
790
2385
 
791
- test('renders chat', () => {
792
- const mockSocket = createMockSocket();
793
- mockSocket.emit.mockImplementation(mockSuccessEmit({ title: 'Test Chat' }));
2386
+ <!-- example: apps/web/src/components/TaskBoard.test.tsx#mock -->
794
2387
 
795
- const wrapper = createTestWrapper({ socketContext: { socket: mockSocket } });
796
- render(<ChatView chatId="123" />, { wrapper });
2388
+ ```tsx
2389
+ const mock = createMockClient(contracts); // the typed client's members, with stubs
2390
+ mock.task.board.mockScope(projectId, [card]); // what useCollection shows for the scope
2391
+ mock.task.countOnBoard.mockResolvedValue(1); // what the query answers
2392
+ mock.task.useEntity.mockRow({ ...card, notes: null }); // what useEntity shows for t1
2393
+ afterEach(() => mock.$reset()); // forget it all (automatic when the runner has a global afterEach)
2394
+ ```
797
2395
 
798
- expect(screen.getByText('Test Chat')).toBeInTheDocument();
2396
+ Each method member has `mockResolvedValue`, `mockRejectedValue`,
2397
+ `mockImplementation`, `mockReset` and `calls`; streams have `mockItems`,
2398
+ channels `sent` and events `mockEmit`. Everything set is forgotten after
2399
+ each test only when the test runner has a global `afterEach` (vitest with
2400
+ `globals: true`, or jest), where the mock registers its own reset
2401
+ (`resetAfterEach: false` opts out); otherwise call `mock.$reset()` in an
2402
+ `afterEach` of your own, as above. Optimistic updates are not shown.
2403
+
2404
+ ### Test databases
2405
+
2406
+ `@fitzzero/quickdraw-core/testing/prisma` gives each vitest worker a database
2407
+ of its own: `createPrismaTestGlobalSetup` migrates a template once per run
2408
+ and clones a database per worker on PostgreSQL (`TEST_DATABASE_URL`), or
2409
+ boots PGlite from a cached dump without one; `workerDatabaseUrl` and
2410
+ `resetDatabase` (truncates every table) do the rest. Apply `trackPrisma` to
2411
+ the test client exactly as in production.
2412
+
2413
+ ## Observability
2414
+
2415
+ `createServer({ stallWatchdog: true })` watches the event loop. It samples
2416
+ the loop's delay every 20 ms (`perf_hooks.monitorEventLoopDelay`), reads it
2417
+ every 10 s, and logs a warning (`category: "quickdraw.stall"`) when the 99th
2418
+ percentile delay of that window is above 200 ms, or when one delay in it is
2419
+ above 1 s, naming the window's slowest methods. `{ thresholdMs, maxMs,
2420
+ intervalMs, slowest }` change the two thresholds, the window (at least 1 s)
2421
+ and how many methods it names. A percentile needs repeated stalls: one
2422
+ 300 ms block in a 10 s window is one sample of about 500 and does not move
2423
+ it, while a single block over `maxMs` warns on its own. On an idle process
2424
+ the watchdog costs about 0.05% of one CPU.
2425
+
2426
+ `otelOnCall({ meter, tracer })` on `./server/otel` is an `onCall` handler
2427
+ that records every call with OpenTelemetry (`@opentelemetry/api` is an
2428
+ optional peer dependency, and only this entry imports it):
2429
+
2430
+ <!-- example: apps/api/src/observability.ts#otel -->
2431
+
2432
+ ```ts
2433
+ import { metrics, trace } from "@opentelemetry/api";
2434
+ import { otelOnCall } from "@fitzzero/quickdraw-core/server/otel";
2435
+
2436
+ export const server = qd.createServer({
2437
+ app,
2438
+ services,
2439
+ db,
2440
+ // warns when the event loop's p99 delay passes 200 ms
2441
+ stallWatchdog: true,
2442
+ onCall: otelOnCall({ meter: metrics.getMeter("api"), tracer: trace.getTracer("api") }),
799
2443
  });
800
2444
  ```
801
2445
 
802
- ## Contributing
2446
+ | Instrument | Kind | Unit |
2447
+ | ------------------------------- | --------- | ------------- |
2448
+ | `quickdraw.calls` | counter | `{call}` |
2449
+ | `quickdraw.call.duration` | histogram | `s` |
2450
+ | `quickdraw.call.response.size` | histogram | `By` |
2451
+ | `quickdraw.call.sql_statements` | histogram | `{statement}` |
2452
+
2453
+ Every point carries `quickdraw.service`, `quickdraw.method`,
2454
+ `quickdraw.outcome` (`ok`, `not-modified` or the error code) and
2455
+ `quickdraw.transport`; a call to a method that does not exist is recorded as
2456
+ `_unknown`, so no client can add attribute values. With a tracer, each call
2457
+ is also a server span named `service.method`, with an error status for
2458
+ `INTERNAL` and `TIMEOUT`. With or without either, calls slower than `slowMs`
2459
+ (default 1 s) or larger than `maxResponseBytes` (default 1 MiB) log at
2460
+ `warn`.
2461
+
2462
+ ## API docs from contracts
2463
+
2464
+ The `quickdraw-docs` command writes Markdown API docs from the contracts
2465
+ alone: one page per service (its entity and field tiers, projections,
2466
+ methods with their kind, input fields and output, collections, streams,
2467
+ channels and events, from the schemas' JSON Schema where they have one) and
2468
+ a `README.md` index. It reads contracts, never source code.
2469
+
2470
+ ```bash
2471
+ quickdraw-docs packages/shared/src/index.ts --out docs/api # write the pages
2472
+ quickdraw-docs packages/shared/src/index.ts --out docs/api --check # exit 1 when they are stale
2473
+ ```
2474
+
2475
+ The module may export each contract, or a map of them as given to
2476
+ `createQuickdrawClient`. A TypeScript module loads through Node's type
2477
+ stripping, or through `tsx` when the project has it installed (for
2478
+ extensionless imports and `tsconfig` paths, which Node's loader refuses); a
2479
+ module whose own code throws runs once and its error is reported as is. Pages are only ever replaced
2480
+ or removed when they start with the generator's marker, and they are laid
2481
+ out as oxfmt and Prettier format Markdown, so formatting them changes
2482
+ nothing. Run `--check` in CI next to the lint step.
2483
+
2484
+ ## Lint rules and agent guidance
2485
+
2486
+ [`@fitzzero/quickdraw-lint`](../../packages/lint) is the oxlint plugin and base
2487
+ config every 5.0 app extends: it reports untracked and foreign writes, nested
2488
+ and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
2489
+ database calls and emits in loops, layering breaks, bypasses of the typed
2490
+ client, hand-written copies of kit methods (`prefer-kit`, a warning), and
2491
+ every removed 4.x API with its replacement. Each rule supports a baseline,
2492
+ so an app can adopt it before fixing old code.
2493
+
2494
+ The guards follow one order of preference, so an agent moving fast meets a
2495
+ mistake as early as it can be caught: a type error; then a refusal when the
2496
+ service is defined (an access form that checks no row on a method that
2497
+ takes an `id`, unless it says `rowless: true`); then lint; then a
2498
+ development warning as it happens (`repeated-call`, `repeated-mutation` and
2499
+ `repeated-invalidation` name a client loop before the rate limit does).
2500
+
2501
+ [`@fitzzero/quickdraw-skills`](../../packages/skills) ships agent rules and skills
2502
+ for quickdraw apps and links them into `.claude/` with
2503
+ `quickdraw-skills link`, so every app's agents read the same, current
2504
+ guidance:
2505
+
2506
+ ```jsonc
2507
+ // package.json
2508
+ {
2509
+ "scripts": {
2510
+ "prepare": "quickdraw-skills link",
2511
+ },
2512
+ }
2513
+ ```
2514
+
2515
+ ## Migrating from 4.x
2516
+
2517
+ [`@fitzzero/quickdraw-codemod`](../../packages/codemod) moves a 4.x app to 5.0:
2518
+ contracts from the method maps, `defineService` from the service classes,
2519
+ the typed client for the hooks, and a report of everything left to decide.
2520
+ [`MIGRATION.md`](../../MIGRATION.md) explains each step, the access mapping and
2521
+ the defaults that changed; [`UPGRADE-PROMPT.md`](../../UPGRADE-PROMPT.md) is the
2522
+ procedure for an agent. Both ship with the codemod, at
2523
+ `node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` and
2524
+ `node_modules/@fitzzero/quickdraw-codemod/UPGRADE-PROMPT.md`.
2525
+
2526
+ ```bash
2527
+ bunx @fitzzero/quickdraw-codemod@next v5 .
2528
+ ```
2529
+
2530
+ ## Package exports
2531
+
2532
+ | Export | Holds |
2533
+ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2534
+ | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names |
2535
+ | `./server` | `initQuickdraw`, `createServer`, `createDispatcher`, `createHttpRouter`, policies, `custom`, the kits' handlers, `requireRow`, `nextOrdinal`, `storageOf`, dev warnings, the Redis adapter, the socket rate limiter, env and encryption helpers |
2536
+ | `./server/auth` | `createAuthRoutes`, `socketAuth`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
2537
+ | `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
2538
+ | `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
2539
+ | `./server/otel` | `otelOnCall` |
2540
+ | `./prisma` | `trackPrisma`, `storageOf` |
2541
+ | `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
2542
+ | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
2543
+ | `./parser` | the JSON-only Socket.IO parser |
2544
+ | `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
2545
+ | `./testing/client` | `renderWithQuickdraw`, `createMockClient` |
2546
+ | `./testing/prisma` | test databases on PostgreSQL or PGlite |
2547
+
2548
+ The package also ships the `quickdraw-docs` command.
2549
+
2550
+ ## Developing this repository
2551
+
2552
+ A bun workspace with turbo: `packages/core` (this package), `packages/lint`,
2553
+ `packages/skills` and `packages/codemod`. See
2554
+ [CONTRIBUTING.md](../../CONTRIBUTING.md).
2555
+
2556
+ ```bash
2557
+ bun install
2558
+ bun run build && bun run typecheck && bun run lint && bun run test
2559
+ bun run format:check
2560
+ ```
803
2561
 
804
- Contributions are welcome! Please read our contributing guide for details.
2562
+ The README's examples live in `packages/core/test/readme/`: edit them there,
2563
+ then run `bun run readme:sync` in `packages/core` to copy them in here.
805
2564
 
806
2565
  ## License
807
2566