@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitzzero/quickdraw-skills",
3
- "version": "5.1.2",
3
+ "version": "5.1.3",
4
4
  "description": "Agent rules and skills for quickdraw 5.0 apps, linked into .claude/ by the quickdraw-skills command",
5
5
  "keywords": [
6
6
  "agents",
@@ -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, `onSuccess: () => qd.invalidate(qd.task.admin.adminList)`. A
168
- generic form shows the fields whose `showInForm` is not `false`; a user's
169
- grants field says `kind: "grants"`: find it by that, never by its name.
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 worker) makes a connection with
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
- from `./client`; `liveDataOf(connection, queryClient)` holds live rows.
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 field made from
90
- them); in development a read of one of the first three warns
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). Everyone in an anchored scope sees every
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. When the
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`). When each row's
326
- data differs, loop over the rows inside an interactive
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