@fitzzero/quickdraw-skills 5.1.2 → 5.1.3
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/package.json
CHANGED
|
@@ -109,6 +109,53 @@ triage: {
|
|
|
109
109
|
it; elsewhere it acts with its user's grants (`kinds` keeps a kind of
|
|
110
110
|
token out).
|
|
111
111
|
|
|
112
|
+
## A row the form cannot name
|
|
113
|
+
|
|
114
|
+
A check of the caller's level on a row no declared form names (a parent
|
|
115
|
+
the input names, a key that is optional) is a `custom` form that asks
|
|
116
|
+
`ctx.access`: never a check in the handler, and never a module that holds
|
|
117
|
+
the dispatcher.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
assigned: {
|
|
121
|
+
// Read on the project the input names, asked through ctx.access; none named, the caller's
|
|
122
|
+
// own that they may still read
|
|
123
|
+
access: custom(
|
|
124
|
+
async (ctx, input) =>
|
|
125
|
+
input.projectId === undefined || (await ctx.access.can(project, input.projectId, "Read")),
|
|
126
|
+
),
|
|
127
|
+
handler: async ({ input, ctx, db }) => {
|
|
128
|
+
const readable = await ctx.access.where(task, "Read");
|
|
129
|
+
if (readable === "none") return [];
|
|
130
|
+
return db.task.findMany({
|
|
131
|
+
where: { AND: [{ assigneeId: ctx.principal.userId, projectId: input.projectId }, readable] },
|
|
132
|
+
take: 100,
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- `ctx.access.can(service, id, level)`, `levelsFor(service, ids)` and
|
|
139
|
+
`where(service, level)` count levels as `{ entry }` does, for
|
|
140
|
+
`ctx.principal` with the grants the call runs with: never load the user's
|
|
141
|
+
grants again. `service` is a contract or a service name.
|
|
142
|
+
- A list the form lets through without its key still follows the row
|
|
143
|
+
policy: add `ctx.access.where(service, "Read")` to its `findMany` with
|
|
144
|
+
`AND` (`"none"` lists nothing). Never spread it into `where`: the filter
|
|
145
|
+
and the input share a column (`inherit`'s `projectId`), so whichever
|
|
146
|
+
comes last replaces the other. The filter's widens the keyed list to
|
|
147
|
+
every project the caller reads; the input's `undefined` drops the policy
|
|
148
|
+
from the list without a key.
|
|
149
|
+
- Prefer a declared form when one says it: `{ entry: L }` for the method's
|
|
150
|
+
own row, `{ scope: L, of, id }` for a parent whose key is always there.
|
|
151
|
+
A `ctx.access` form counts a service-wide `Admin` grant on the service it
|
|
152
|
+
asks (the parent's); `{ scope, of }` counts only one on the method's own
|
|
153
|
+
service. `custom` replaces the row check and a
|
|
154
|
+
service-wide `Admin` grant skips it, so a method whose input has `id`
|
|
155
|
+
still needs `rowless: true` beside it (below).
|
|
156
|
+
- An anonymous caller has no level anywhere (`false`, an empty map,
|
|
157
|
+
`"none"`). `qd` has no `access`: a job asks `server.dispatcher.access`.
|
|
158
|
+
|
|
112
159
|
## A method that takes a row id
|
|
113
160
|
|
|
114
161
|
On a service with a policy, a method whose input has `id` reads or writes
|
|
@@ -231,6 +278,12 @@ channel `requires`.
|
|
|
231
278
|
item service `inherit` from the anchor, or use separate scopes. A self
|
|
232
279
|
scope (`scopeAccess: "self"`) is the exception: its items follow the item
|
|
233
280
|
service's policy, so its user gets only the rows they may read.
|
|
281
|
+
- A list whose methods need a service-wide grant (`{ service: "Read" }`)
|
|
282
|
+
needs it too: `requires: { service: "Read" }` beside its `anchor` or
|
|
283
|
+
`scopeAccess`, in `defineService`'s `collections`. Both checks apply (a
|
|
284
|
+
service-wide `Admin` passes either), a scope without the grant is
|
|
285
|
+
`FORBIDDEN`, and a reload of the user's grants below it revokes the scope
|
|
286
|
+
on their sockets. Without it a self list opens for any signed-in user.
|
|
234
287
|
- A hand-written method that reads many rows authorizes the parent
|
|
235
288
|
(`{ scope: "Read", of: project, id: "projectId" }`) and filters by it, or
|
|
236
289
|
uses the read/write kit's `list` or a collection, which filter by policy.
|
|
@@ -34,7 +34,9 @@ export function Providers({ children }: { readonly children: React.ReactNode })
|
|
|
34
34
|
(`qd.taskService.board`); the examples here key theirs short (`qd.task`).
|
|
35
35
|
- `auth` is a token (sent as `auth.token`) or handshake fields; leave it out
|
|
36
36
|
for cookie sessions. Changing it reconnects, and a hello naming another
|
|
37
|
-
user empties everything quickdraw cached
|
|
37
|
+
user empties everything quickdraw cached, the placeholder a member's
|
|
38
|
+
`useQuery` or `useSearch` would make from it (`keepPreviousData`, the
|
|
39
|
+
last results) included.
|
|
38
40
|
- `useQuickdraw()` gives
|
|
39
41
|
`{ connection, status, isConnected, isKnown, reconnecting, userId, serviceAccess, hello, refusal, isRateLimited }`.
|
|
40
42
|
Gate on `isKnown` (the server's hello named the user; `userId` is then
|
|
@@ -45,7 +47,14 @@ export function Providers({ children }: { readonly children: React.ReactNode })
|
|
|
45
47
|
signed out: the server ended the session (signed out elsewhere, revoked,
|
|
46
48
|
expired) or refused a connect for it, the hello is gone and quickdraw's
|
|
47
49
|
cache is empty; any other status is still connecting. Never keep showing
|
|
48
|
-
data the app holds itself once `isKnown` turns false.
|
|
50
|
+
data the app holds itself once `isKnown` turns false. A server that could
|
|
51
|
+
not check the credentials (its session store down: the handshake is
|
|
52
|
+
refused `INTERNAL`) signs nobody out: the connection keeps the user and
|
|
53
|
+
the cache, stays `connecting` (`reconnecting` once it had connected) and
|
|
54
|
+
connects again by itself, so never treat that as a sign-out. On the
|
|
55
|
+
server, `authenticate` refuses credentials only by throwing
|
|
56
|
+
`QuickdrawError("UNAUTHENTICATED")` (or `"FORBIDDEN"`); anything else it
|
|
57
|
+
throws is an outage.
|
|
49
58
|
`hello.serverId` changes when the server restarts (or the socket lands
|
|
50
59
|
on another node): compare it to tell a new server from a network blip.
|
|
51
60
|
- Sign-in and out in the browser go through the auth routes kit's routes:
|
|
@@ -157,6 +166,35 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
|
|
|
157
166
|
the user's say). Never join from a plain `useQuery` or a
|
|
158
167
|
mount effect: a reconnect leaves the socket out of the room while the
|
|
159
168
|
cached answer still looks fine. Without React, `connection.onHello`.
|
|
169
|
+
- Peer links: on a server created with `peers: true`,
|
|
170
|
+
`createPeerLink(connection, { room, to, offerer })` (in React,
|
|
171
|
+
`useQuickdraw().connection`) links two sockets of an app room over
|
|
172
|
+
WebRTC data channels, or over the server's relay when those cannot form.
|
|
173
|
+
The peer's socket id comes from the app (an event carrying
|
|
174
|
+
`ctx.socketId`, a method's answer); `onPeerRequest(connection, listener)`
|
|
175
|
+
answers a peer that linked first. Send input at a tick rate on `fast`
|
|
176
|
+
(unordered, never resent) and state on `rel` (ordered, reliable). A link
|
|
177
|
+
dies with its socket: open it again on every connection, as `useJoin`
|
|
178
|
+
joins again. What a peer sends is the peer's word, never checked by the
|
|
179
|
+
server: parse each message with its schema as untrusted input. Never
|
|
180
|
+
send `qd:peer` or `qd:relay` by hand (`no-raw-socket`). To play over a
|
|
181
|
+
bad network in a browser test or a bench, `link.setLag({ ms, jitter,
|
|
182
|
+
loss })` lags the link's own messages both ways on its real path (`rel`
|
|
183
|
+
never lost, `stats.simulatedLoss` for the rest); never a proxy or a
|
|
184
|
+
hand-made delay around `send`.
|
|
185
|
+
- Host mode: a player's `useStream` and `useChannel` of a host-served
|
|
186
|
+
member (`serve: "host"`) reach the room's host over a link, with no
|
|
187
|
+
change in the app; a channel's messages go on `fast`, so send input
|
|
188
|
+
every frame, never once. The host's client serves the members with
|
|
189
|
+
`defineHost(contract, { channels, streams }).serve(connection, { room })`
|
|
190
|
+
while `hostOf(connection, room)` is its own socket's id
|
|
191
|
+
(`onHostChange(connection, listener)` hears it change), and calls
|
|
192
|
+
`close()` when it is not. A host checks what players send it against the
|
|
193
|
+
contract, but each message stays that player's word, and what the host
|
|
194
|
+
sends is the host's word. Each of `host.peers` (and a handler's
|
|
195
|
+
`ctx.peer`) has `link`: read a player's round trip from
|
|
196
|
+
`link.clock.rtt`, and give the host's own play its players' lag with
|
|
197
|
+
`setLag` on the host's own entry, whose link is in process.
|
|
160
198
|
- Admin screens: `qd.task.admin.adminList.useQuery(input)` and the other
|
|
161
199
|
admin kit members; `useAdminServices(qd)` lists the services whose
|
|
162
200
|
`adminMeta` answers the user, asking only those the hello's grants allow
|
|
@@ -164,9 +202,14 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
|
|
|
164
202
|
`adminMeta` wider). A screen served from metadata for every service takes
|
|
165
203
|
`adminOf(qd, key)`, one shape typed by field name: never cast
|
|
166
204
|
`qd[key].admin`. The kit's rows are not live: after the screen's own
|
|
167
|
-
write,
|
|
168
|
-
|
|
169
|
-
|
|
205
|
+
write, read the page again (`qd.invalidate(qd.task.admin.adminList)`)
|
|
206
|
+
and put the row the write returned in the cache
|
|
207
|
+
(`qd.task.admin.adminGet.setData({ id: row.id }, row)`; `adminOf`'s
|
|
208
|
+
query members have `setData` too), never in React state. Its reads of
|
|
209
|
+
rows default to `staleTime: 0`, read again on every mount (`adminMeta`
|
|
210
|
+
stays cached): pass a `staleTime` only to keep a page longer. A generic
|
|
211
|
+
form shows the fields whose `showInForm` is not `false`; a user's grants
|
|
212
|
+
field says `kind: "grants"`: find it by that, never by its name.
|
|
170
213
|
|
|
171
214
|
## Server components and other runtimes
|
|
172
215
|
|
|
@@ -177,10 +220,13 @@ await rename.mutateAsync({ id, title }); // resolves with the output, rejects wi
|
|
|
177
220
|
`caller.task.get.prefetch(queryClient, input)` fills the keys the hooks
|
|
178
221
|
read, for `dehydrate` and `HydrationBoundary`.
|
|
179
222
|
- React Native uses the same client and provider (no DOM is needed).
|
|
180
|
-
React-free code (a Node script, a
|
|
223
|
+
React-free code (a Node script, a CLI, a worker) imports
|
|
224
|
+
`@fitzzero/quickdraw-core/client/core`, which needs neither React nor
|
|
225
|
+
TanStack Query installed: it makes a connection with
|
|
181
226
|
`createQuickdrawConnection({ url, auth })`, calls `open()`, and calls
|
|
182
|
-
`callData(connection, { service: "taskService", method: "get", input })
|
|
183
|
-
|
|
227
|
+
`callData(connection, { service: "taskService", method: "get", input })`.
|
|
228
|
+
`./client` re-exports the same functions. Live rows need a TanStack
|
|
229
|
+
`QueryClient`: `liveDataOf(connection, queryClient)` from `./client`.
|
|
184
230
|
- A client in another language (a Godot game, a native app) speaks the wire
|
|
185
231
|
itself: `docs/protocol-v5.md` in the quickdraw repository is the
|
|
186
232
|
specification, `examples/godot` a GDScript client written from it, and
|
|
@@ -86,8 +86,8 @@ export const taskService = qd.defineService(task, {
|
|
|
86
86
|
- `share: "all"` runs one handler for every caller, with the first caller's
|
|
87
87
|
`ctx`, and gives every caller its result: use it only for a result that
|
|
88
88
|
depends on the input alone. Its handler reads nothing of the caller
|
|
89
|
-
(`ctx.principal`, `ctx.services`, `ctx.mcp`, or a context
|
|
90
|
-
them); in development a read of one of the first
|
|
89
|
+
(`ctx.principal`, `ctx.services`, `ctx.mcp`, `ctx.access`, or a context
|
|
90
|
+
field made from them); in development a read of one of the first four warns
|
|
91
91
|
`[quickdraw:shared-principal]`. It is refused with `custom` access and on
|
|
92
92
|
the kit methods whose result depends on the caller (the read/write kit's
|
|
93
93
|
`list` and `getMany`, `search`, the sharing kit's `listShares` and
|
|
@@ -111,6 +111,10 @@ export const taskService = qd.defineService(task, {
|
|
|
111
111
|
and is cancelled with `ctx.signal`; it is typed by the app's `contracts`
|
|
112
112
|
(`initQuickdraw<{ ...; contracts }>()`). Never write another service's
|
|
113
113
|
model directly to skip its access checks.
|
|
114
|
+
- A call through `ctx.services` carries this call's `ctx.mcp`, nested calls
|
|
115
|
+
too, so in an MCP call the method it reaches sees the token's fields. Its
|
|
116
|
+
`ctx.transport` is `"internal"`: tell a token's call apart by `ctx.mcp`,
|
|
117
|
+
never by `ctx.transport === "mcp"`.
|
|
114
118
|
|
|
115
119
|
## REST routes call the services
|
|
116
120
|
|
|
@@ -155,12 +159,25 @@ once:
|
|
|
155
159
|
- `requireSession(keys)` guards the app's own REST routes.
|
|
156
160
|
- `bearerAuth(...)` is for a hook handed a bearer token alone: the MCP
|
|
157
161
|
bridge's `principal`, an `authenticate` that reads `auth.token`.
|
|
162
|
+
- An app's own MCP transport, or an in-process assistant, that signs the
|
|
163
|
+
caller in itself (with `requireSession`, say) passes that caller to the
|
|
164
|
+
registry: `registry.callMethod(service, method, input, { request, principal })`.
|
|
165
|
+
The registry's `principal` hook is not asked (`null` calls anonymously),
|
|
166
|
+
the `context` hook and bound arguments see that principal, and its grants
|
|
167
|
+
load as `qd.caller`'s do. Never take it from a request body, and never
|
|
168
|
+
smuggle the user to the hook some other way (a map keyed by the request).
|
|
158
169
|
- Never `verifyJWT` alone: it checks the signature and expiry, not the
|
|
159
170
|
session store.
|
|
160
171
|
- A custom `authenticate` takes the session cookie only from
|
|
161
172
|
`request.sessionCookie` (HTTP), after `cookieOriginAllowed(request,
|
|
162
173
|
allowedOrigins)`, and checks it with `liveSession`; `auth.token` holds
|
|
163
174
|
only a bearer token for it.
|
|
175
|
+
- `authenticate` refuses credentials only by throwing
|
|
176
|
+
`QuickdrawError("UNAUTHENTICATED")` (or `"FORBIDDEN"`). Anything else it
|
|
177
|
+
or `loadServiceAccess` throws is an outage (the store or the database
|
|
178
|
+
down): a socket's handshake is refused `INTERNAL`, and the client keeps
|
|
179
|
+
the user and connects again by itself. Never throw a plain `Error` for
|
|
180
|
+
bad credentials, which the client would retry for ever.
|
|
164
181
|
|
|
165
182
|
## Writes are tracked; frames are derived
|
|
166
183
|
|
|
@@ -172,6 +189,16 @@ writes made through `db`, after the response is sent:
|
|
|
172
189
|
wrap the work in `qd.run(async (ctx) => ...)`, which flushes before it
|
|
173
190
|
returns; a boot-time seed may run it before `createServer` too (its writes
|
|
174
191
|
then reach no subscriber). Never write through the untracked client.
|
|
192
|
+
- A helper that writes takes the tracked client by its type:
|
|
193
|
+
`client: Tracked<PrismaClient>`, or `TrackedTransaction<PrismaClient>`
|
|
194
|
+
when it may run inside `db.$transaction(async (tx) => ...)` (`tx` is
|
|
195
|
+
one, and so is `db`). `trackPrisma` returns a `Tracked<PrismaClient>`;
|
|
196
|
+
the untracked client is none, so passing it is a type error. Both types
|
|
197
|
+
are on `@fitzzero/quickdraw-core/prisma` and the root export. Never type
|
|
198
|
+
such a parameter `PrismaClient`, which the untracked client is too;
|
|
199
|
+
`no-untracked-write` reads the type, whatever the parameter's name. A
|
|
200
|
+
`db` declared `PrismaClient` (a lazy `Proxy`, say) carries no mark:
|
|
201
|
+
declare it `Tracked<PrismaClient>` to pass it to such a helper.
|
|
175
202
|
- Background work a handler starts and does not await (a push sent after
|
|
176
203
|
the reply) runs in `qd.run(fn, { detached: true })`: a unit of its own,
|
|
177
204
|
flushed when `fn` settles. Without `detached` it joins the handler's unit,
|
|
@@ -184,7 +211,10 @@ writes made through `db`, after the response is sent:
|
|
|
184
211
|
`db.$transaction(async (tx) => ...)` when they must commit together.
|
|
185
212
|
- Raw SQL writes and database cascades are not seen: record them with
|
|
186
213
|
`ctx.touch("task", ids)` (`{ removed: true }` for deleted rows); `qd.run`
|
|
187
|
-
passes `fn` the same `touch`.
|
|
214
|
+
passes `fn` the same `touch`. Before deleting a row whose dependents the
|
|
215
|
+
database deletes or clears (`onDelete: Cascade`, `SetNull`), delete or
|
|
216
|
+
clear them through `db` first, in the same transaction, so the lists
|
|
217
|
+
holding them are told; for the admin kit, in its `prepare`.
|
|
188
218
|
- A write to one row that changes how another service's row looks (a
|
|
189
219
|
parent's counts) declares `affects: [{ service: task, id: "parentTaskId" }]`.
|
|
190
220
|
|
|
@@ -196,7 +226,11 @@ in `id`), and optionally `where`, `limit`, `maxLimit`, `index` (small fields
|
|
|
196
226
|
sent for the whole scope), `views` (`(row, who) => boolean` over index rows)
|
|
197
227
|
and `access`. The service says who may open a scope: `{ anchor: project }`
|
|
198
228
|
(the level on the row the scope value names) or `{ scopeAccess: "self" }`
|
|
199
|
-
(the subscriber's own user id)
|
|
229
|
+
(the subscriber's own user id), plus `requires: { service: "Read" }` when
|
|
230
|
+
the list must need the service-wide grant its methods ask for
|
|
231
|
+
(`{ scopeAccess: "self", requires: { service: "Read" } }`: the user's own
|
|
232
|
+
rows, for holders of the Read grant); both checks then apply, and a socket
|
|
233
|
+
whose grants reload below it leaves the scope. Everyone in an anchored scope sees every
|
|
200
234
|
item, so derive the item service's access from the anchor (`inherit`); a
|
|
201
235
|
self scope's items follow the item service's policy, so its user gets only
|
|
202
236
|
the rows they may read. A `via` scope over the item service's own `members`
|
|
@@ -250,12 +284,23 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
|
|
|
250
284
|
`onCommitted`, which runs once it committed, in a unit of its own (a
|
|
251
285
|
game applying an edited definition to its running simulation, so a
|
|
252
286
|
rolled-back edit is never applied); never wrap the kit's handlers.
|
|
287
|
+
What must happen before an edit goes in `prepare(call, ctx, db)`, in
|
|
288
|
+
the same transaction: a column the server computes (a content hash) is
|
|
289
|
+
returned from it and written over the caller's data, even a hidden or
|
|
290
|
+
read-only one (never `id`, a timestamp or a relation; a parent it sets
|
|
291
|
+
needs the caller's row level on it, as in the read/write kit), so it
|
|
292
|
+
need not be an editable field of the entity; and before an `adminDelete`
|
|
293
|
+
it deletes or clears through `db` the rows the database would cascade,
|
|
294
|
+
so their subscribers and lists are told.
|
|
253
295
|
|
|
254
296
|
## Realtime
|
|
255
297
|
|
|
256
298
|
- Streams: `streams: { logs: { item, scope: "taskId", seed: 50, access } }`;
|
|
257
299
|
push with `qd.stream(task, "logs").push(taskId, item)`, and several items
|
|
258
|
-
at once with `pushMany(taskId, items)`, never `push` in a loop.
|
|
300
|
+
at once with `pushMany(taskId, items)`, never `push` in a loop. A job that
|
|
301
|
+
skips feeds nobody watches asks
|
|
302
|
+
`await qd.stream(task, "logs").subscribers(taskId)` (the sockets on every
|
|
303
|
+
node), never Socket.IO's adapter rooms, which hold one node's. When the
|
|
259
304
|
items are deltas (a game's ticks), give the service a computed seed,
|
|
260
305
|
`streams: { world: { seed: (worldId, ctx) => [snapshotOf(worldId)] } }`,
|
|
261
306
|
never a bootstrap call beside the stream; `validate: "development"` there
|
|
@@ -290,6 +335,38 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
|
|
|
290
335
|
- A game loop asks `qd.rooms.size(room)` (this node's sockets, spectators
|
|
291
336
|
included, synchronous) at its tick rate, never Socket.IO's adapter;
|
|
292
337
|
`ctx.socketId` names the calling socket in a method.
|
|
338
|
+
- Peer links: `createServer({ peers: true })` lets two clients of an app
|
|
339
|
+
room link directly (`createPeerLink` on the client). The server forwards
|
|
340
|
+
their signals and relays their messages only between signed-in sockets
|
|
341
|
+
of the room, and ends a player's links when they leave it or
|
|
342
|
+
`rooms.leave(room, { userId })` takes them out. Tell players each other's
|
|
343
|
+
socket ids with an event or a method's answer (`ctx.socketId`). The
|
|
344
|
+
server never sees what a link carries: a method that records what a
|
|
345
|
+
client reports from a link (a score, a winner) checks it as untrusted
|
|
346
|
+
input, and its contract's `describe` says so.
|
|
347
|
+
- Host mode: a channel or stream declared `serve: "host"` is served by the
|
|
348
|
+
room's host, one player's client (`defineHost` on the client), over peer
|
|
349
|
+
links; the server needs `peers: true` and never handles it. A
|
|
350
|
+
host-served channel declares `requires: { room }` and a host-served
|
|
351
|
+
stream a scope and `access: { room: (scope) => room }`. Give a
|
|
352
|
+
host-served channel no handler and a host-served stream no options or
|
|
353
|
+
seed: `defineService` refuses them. Pick the host in a method,
|
|
354
|
+
`ctx.rooms.setHost(room)` for the calling socket (after a check of the
|
|
355
|
+
app's own: a seat, a role), never by election; read it with
|
|
356
|
+
`ctx.rooms.hostOf(room)`, clear it with `clearHost(room)`, and decide
|
|
357
|
+
what follows in `onHostLeave({ room, socketId, principal, reason })`.
|
|
358
|
+
Behind a cluster adapter, a node that dies without closing its sockets
|
|
359
|
+
leaves the hosts it held recorded on the other nodes until each room's
|
|
360
|
+
host is set or cleared again, and no `onHostLeave` runs for them. So
|
|
361
|
+
when the app picks a host only while `hostOf(room)` is `null`, also let
|
|
362
|
+
a player take over a host that stops answering: a method that calls
|
|
363
|
+
`setHost(room)` or `clearHost(room)` after a timeout of the app's own.
|
|
364
|
+
The server never sees what a host and its players send each other: what
|
|
365
|
+
a host reports to a method (a result, a score) is one player's word,
|
|
366
|
+
checked as untrusted input, and the contract's `describe` says so. The
|
|
367
|
+
service's `kinds` does not reach host-served members: any signed-in
|
|
368
|
+
socket of the room can link to the host, so guard the method that joins
|
|
369
|
+
the room.
|
|
293
370
|
- Events: `events: { moved: { payload } }`, sent with
|
|
294
371
|
`ctx.rooms.emit(room, task, "moved", payload)` to an app room
|
|
295
372
|
(`ctx.rooms.join(room)` in a method puts the caller's socket in one), and
|
|
@@ -322,8 +399,10 @@ Contract halves come from `@fitzzero/quickdraw-core`, handlers from
|
|
|
322
399
|
|
|
323
400
|
Bound every `findMany` with `take`; batch per-item reads with `in:` filters;
|
|
324
401
|
filter in `where`, not after loading. Write many rows in one statement when
|
|
325
|
-
they all get the same data (`updateMany`, `createMany`)
|
|
326
|
-
|
|
402
|
+
they all get the same data (`updateMany`, `createMany`); a large insert may
|
|
403
|
+
go in fixed-size `createMany` chunks, which neither `no-db-call-in-loop` nor
|
|
404
|
+
the N+1 warning counts. When each row's data differs, loop over the rows
|
|
405
|
+
inside an interactive
|
|
327
406
|
`db.$transaction(async (tx) => ...)` and await one `tx.task.update(...)` by
|
|
328
407
|
id per row, not an array-form `$transaction([...])`, which cannot read a
|
|
329
408
|
moved row inside its batch. A projection's relation count selects the
|
|
@@ -51,6 +51,16 @@ await app.close();
|
|
|
51
51
|
`{ call, socket, hello, close }` once the server said hello. Pass `null`
|
|
52
52
|
for an anonymous caller. Both callers are keyed by service name
|
|
53
53
|
(`taskService`), where the web client uses the contract map's keys.
|
|
54
|
+
- Test a guard that reads `ctx.mcp` (an agent token's scopes) or
|
|
55
|
+
`ctx.transport` in process too, never through `app.server.dispatcher.call`:
|
|
56
|
+
`app.as(agent, { transport: "mcp", mcp: { scopes: [] } })` calls as an
|
|
57
|
+
MCP tool call arrives, its `mcp` typed by the app's
|
|
58
|
+
`QuickdrawTypes["mcp"]`, and `{ mcp }` alone as a call an MCP call made
|
|
59
|
+
through `ctx.services`. `transport` takes `"internal"` (the default),
|
|
60
|
+
`"http"` or `"mcp"`; a socket's call is `app.connect`'s. Bound arguments
|
|
61
|
+
and the registry's `principal` and `context` hooks belong to the MCP
|
|
62
|
+
registry: test them through
|
|
63
|
+
`registry.callMethod(service, method, input, { request })`.
|
|
54
64
|
- `app.frames(match?)` lists every frame the server sent, each with its
|
|
55
65
|
`event`, `data`, `socketId`, `userId` and `at`;
|
|
56
66
|
`frames.waitFor(match, timeoutMs?)` waits for one, `frames.clear()`
|
|
@@ -106,7 +106,10 @@ guide's sections as the report sends you to them.
|
|
|
106
106
|
"Hand-built auth to the auth routes kit"). Until then the server's
|
|
107
107
|
interim `authenticate` checks the app's session store on every handshake
|
|
108
108
|
and call, and the session cookie's `Origin` (`cookieOriginAllowed`);
|
|
109
|
-
never `verifyJWT` alone, which a signed-out session still passes.
|
|
109
|
+
never `verifyJWT` alone, which a signed-out session still passes. It
|
|
110
|
+
refuses by throwing `QuickdrawError("UNAUTHENTICATED")`, never a plain
|
|
111
|
+
`Error`, which a socket's handshake answers `INTERNAL` (an outage, which
|
|
112
|
+
the client retries, keeping the user).
|
|
110
113
|
|
|
111
114
|
4. **Delete each marker when its item is done**, and run the codemod again:
|
|
112
115
|
it changes no code a second time and rewrites the report from the markers
|