@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.
- package/LICENSE +21 -0
- package/README.md +2402 -643
- package/dist/admin-Bl71J4IG.d.ts +445 -0
- package/dist/auth-DyCIKmTQ.d.ts +80 -0
- package/dist/caller-Cx5wuao-.d.ts +975 -0
- package/dist/chunk-47AEXVDM.js +44 -0
- package/dist/chunk-47AEXVDM.js.map +1 -0
- package/dist/chunk-5ADGYGC5.js +478 -0
- package/dist/chunk-5ADGYGC5.js.map +1 -0
- package/dist/chunk-5EZNDES3.js +269 -0
- package/dist/chunk-5EZNDES3.js.map +1 -0
- package/dist/chunk-7E7STZEO.js +37 -0
- package/dist/chunk-7E7STZEO.js.map +1 -0
- package/dist/chunk-CLPB6IGC.js +275 -0
- package/dist/chunk-CLPB6IGC.js.map +1 -0
- package/dist/chunk-DXTJNJM5.js +4850 -0
- package/dist/chunk-DXTJNJM5.js.map +1 -0
- package/dist/chunk-EI57GEMD.js +53 -0
- package/dist/chunk-EI57GEMD.js.map +1 -0
- package/dist/chunk-G47OHQYB.js +13 -0
- package/dist/chunk-G47OHQYB.js.map +1 -0
- package/dist/chunk-GXLJA7AH.js +379 -0
- package/dist/chunk-GXLJA7AH.js.map +1 -0
- package/dist/chunk-I2OMV4AD.js +8296 -0
- package/dist/chunk-I2OMV4AD.js.map +1 -0
- package/dist/chunk-JK5D3OXC.js +76 -0
- package/dist/chunk-JK5D3OXC.js.map +1 -0
- package/dist/chunk-K35VD4CQ.js +1302 -0
- package/dist/chunk-K35VD4CQ.js.map +1 -0
- package/dist/chunk-NRM4XAWR.js +516 -0
- package/dist/chunk-NRM4XAWR.js.map +1 -0
- package/dist/chunk-SSDLYYQV.js +117 -0
- package/dist/chunk-SSDLYYQV.js.map +1 -0
- package/dist/chunk-TBMKJ26R.js +90 -0
- package/dist/chunk-TBMKJ26R.js.map +1 -0
- package/dist/chunk-U32GOJRO.js +400 -0
- package/dist/chunk-U32GOJRO.js.map +1 -0
- package/dist/chunk-UJQJM7EG.js +30 -0
- package/dist/chunk-UJQJM7EG.js.map +1 -0
- package/dist/chunk-V3KNCTXJ.js +79 -0
- package/dist/chunk-V3KNCTXJ.js.map +1 -0
- package/dist/chunk-XPTZFIWZ.js +86 -0
- package/dist/chunk-XPTZFIWZ.js.map +1 -0
- package/dist/chunk-Z2LZ7IZ7.js +130 -0
- package/dist/chunk-Z2LZ7IZ7.js.map +1 -0
- package/dist/cli/quickdraw-docs.d.ts +1 -0
- package/dist/cli/quickdraw-docs.js +760 -0
- package/dist/cli/quickdraw-docs.js.map +1 -0
- package/dist/client/index.d.ts +627 -595
- package/dist/client/index.js +172 -1638
- package/dist/client/index.js.map +1 -1
- package/dist/clientTypes-B62bWHua.d.ts +684 -0
- package/dist/createServer-P61KBWro.d.ts +524 -0
- package/dist/createTestApp-BBm3TakW.d.ts +157 -0
- package/dist/envelope-CoiZY9KU.d.ts +557 -0
- package/dist/errors-S-bxEIBC.d.ts +72 -0
- package/dist/http-89lENH2D.d.ts +431 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +663 -0
- package/dist/index.js.map +1 -0
- package/dist/infer-Cwq49ME5.d.ts +841 -0
- package/dist/keys-CqKkljVN.d.ts +46 -0
- package/dist/logger-BY28acW-.d.ts +16 -0
- package/dist/metrics-Ny1dFbyY.d.ts +35 -0
- package/dist/parser.d.ts +31 -0
- package/dist/parser.js +7 -0
- package/dist/parser.js.map +1 -0
- package/dist/prisma/index.d.ts +64 -0
- package/dist/prisma/index.js +492 -0
- package/dist/prisma/index.js.map +1 -0
- package/dist/rateLimit-ZMIZJUZF.js +21 -0
- package/dist/rateLimit-ZMIZJUZF.js.map +1 -0
- package/dist/searchSchemas-CBgKRa1b.d.ts +43 -0
- package/dist/server/auth/index.d.ts +723 -0
- package/dist/server/auth/index.js +1398 -0
- package/dist/server/auth/index.js.map +1 -0
- package/dist/server/express/index.d.ts +10 -1
- package/dist/server/express/index.js +19 -58
- package/dist/server/express/index.js.map +1 -1
- package/dist/server/index.d.ts +1046 -1519
- package/dist/server/index.js +3611 -3271
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +358 -0
- package/dist/server/mcp/index.js +936 -0
- package/dist/server/mcp/index.js.map +1 -0
- package/dist/server/otel.d.ts +30 -0
- package/dist/server/otel.js +108 -0
- package/dist/server/otel.js.map +1 -0
- package/dist/sharing-BHyufgVl.d.ts +252 -0
- package/dist/testing/client.d.ts +241 -0
- package/dist/testing/client.js +730 -0
- package/dist/testing/client.js.map +1 -0
- package/dist/testing/index.d.ts +168 -0
- package/dist/testing/index.js +776 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/{server/testing-prisma.d.ts → testing/prisma.d.ts} +1 -1
- package/dist/{server/testing-prisma.js → testing/prisma.js} +17 -11
- package/dist/testing/prisma.js.map +1 -0
- package/dist/types-BDmssndG.d.ts +66 -0
- package/dist/utils/index.d.ts +140 -0
- package/dist/utils/index.js +44 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +113 -79
- package/dist/client/testing.d.ts +0 -73
- package/dist/client/testing.js +0 -115
- package/dist/client/testing.js.map +0 -1
- package/dist/server/testing-prisma.js.map +0 -1
- package/dist/server/testing.d.ts +0 -70
- package/dist/server/testing.js +0 -695
- package/dist/server/testing.js.map +0 -1
- package/dist/shared/index.d.ts +0 -467
- package/dist/shared/index.js +0 -31
- package/dist/shared/index.js.map +0 -1
- package/dist/types-87lWXyNP.d.ts +0 -185
- package/dist/types-BbnMKDA5.d.ts +0 -420
- package/eslint-config/index.mjs +0 -163
- package/eslint-plugin-quickdraw/index.mjs +0 -30
- package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +0 -112
- package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +0 -48
- package/eslint-plugin-quickdraw/rules/no-manual-collection-events.mjs +0 -68
- package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +0 -52
- package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +0 -140
- package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +0 -93
- package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +0 -79
- package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +0 -59
- package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +0 -52
- package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +0 -35
- package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +0 -41
- package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +0 -55
- package/oxlint.base.jsonc +0 -126
package/README.md
CHANGED
|
@@ -1,807 +1,2566 @@
|
|
|
1
1
|
# @fitzzero/quickdraw-core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
123
|
+
### 2. The service
|
|
23
124
|
|
|
24
|
-
|
|
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
|
-
|
|
127
|
+
<!-- example: apps/api/src/db.ts -->
|
|
29
128
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
258
|
+
<!-- example: apps/web/src/app/providers.tsx -->
|
|
68
259
|
|
|
69
260
|
```tsx
|
|
70
|
-
|
|
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
|
|
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
|
|
270
|
+
<QuickdrawProvider client={qd} url="http://localhost:4000">
|
|
76
271
|
{children}
|
|
77
272
|
</QuickdrawProvider>
|
|
78
273
|
);
|
|
79
274
|
}
|
|
275
|
+
```
|
|
80
276
|
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
onSuccess: () => console.log("Title updated!"),
|
|
91
|
-
});
|
|
279
|
+
```tsx
|
|
280
|
+
"use client";
|
|
92
281
|
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
105
|
-
<
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
re-snapshot-on-reconnect are built in:
|
|
315
|
+
### The example app
|
|
157
316
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
`
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
+
```ts
|
|
355
|
+
// The app's sessions, token check and grants, for the README's server examples.
|
|
189
356
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
`
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
253
|
-
that's what collections automate; the shipped
|
|
254
|
-
`quickdraw/no-manual-collection-events` lint rule flags them.
|
|
474
|
+
## Contracts
|
|
255
475
|
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
that should refresh when related events fire, use `invalidateOn`:
|
|
479
|
+
<!-- example: packages/shared/src/contracts/examples.ts#outputs -->
|
|
260
480
|
|
|
261
|
-
```
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
347
|
-
`useRoomEvents` (volatile room emits arrive as ordinary events):
|
|
936
|
+
<!-- example: apps/api/src/services/examples/collections.ts#service -->
|
|
348
937
|
|
|
349
|
-
```
|
|
350
|
-
import {
|
|
938
|
+
```ts
|
|
939
|
+
import { crud, inherit } from "@fitzzero/quickdraw-core/server";
|
|
351
940
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
|
|
1233
|
+
<!-- example: apps/api/scripts/report.ts#script -->
|
|
448
1234
|
|
|
449
|
-
```
|
|
450
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
"
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
1480
|
+
```ts
|
|
1481
|
+
import { inherit, search } from "@fitzzero/quickdraw-core/server";
|
|
566
1482
|
|
|
567
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
1728
|
+
<!-- example: apps/web/src/components/kits/AdminTasks.tsx#component -->
|
|
662
1729
|
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1842
|
+
<!-- example: apps/api/src/services/kits/realtime.ts#service -->
|
|
668
1843
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
- Minimal schema complexity
|
|
1844
|
+
```ts
|
|
1845
|
+
import { inherit } from "@fitzzero/quickdraw-core/server";
|
|
672
1846
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
1875
|
+
<!-- example: apps/web/src/components/kits/TaskRoom.tsx#component -->
|
|
696
1876
|
|
|
697
|
-
|
|
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
|
-
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
711
|
-
chatId String
|
|
712
|
-
userId String
|
|
713
|
-
level String // "Read" | "Moderate" | "Admin"
|
|
2149
|
+
<!-- example: apps/api/src/auth/sessions.ts#store -->
|
|
714
2150
|
|
|
715
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
2171
|
+
## Testing
|
|
735
2172
|
|
|
736
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2249
|
+
<!-- example: apps/api/src/services/task.test.ts#budget -->
|
|
755
2250
|
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|