@fitzzero/quickdraw-core 5.0.0-rc.2 → 5.0.0-rc.4
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/README.md +520 -73
- package/dist/{admin-Bl71J4IG.d.ts → admin-ByHvKBth.d.ts} +3 -172
- package/dist/adminSchemas-C1U6_voe.d.ts +229 -0
- package/dist/{auth-DyCIKmTQ.d.ts → auth-Cc_XKBKj.d.ts} +2 -2
- package/dist/{caller-Cx5wuao-.d.ts → caller-B-7Ib98g.d.ts} +253 -21
- package/dist/{chunk-UJQJM7EG.js → chunk-3I7R3DZW.js} +3 -2
- package/dist/chunk-3I7R3DZW.js.map +1 -0
- package/dist/chunk-4PK6O7VL.js +1292 -0
- package/dist/chunk-4PK6O7VL.js.map +1 -0
- package/dist/{chunk-CLPB6IGC.js → chunk-6G663RIR.js} +2 -2
- package/dist/{chunk-I2OMV4AD.js → chunk-7EWHLDGN.js} +428 -90
- package/dist/chunk-7EWHLDGN.js.map +1 -0
- package/dist/{chunk-DXTJNJM5.js → chunk-7MPXURIO.js} +492 -1238
- package/dist/chunk-7MPXURIO.js.map +1 -0
- package/dist/{chunk-5ADGYGC5.js → chunk-B4IOFSM6.js} +2 -2
- package/dist/{chunk-5EZNDES3.js → chunk-DMUGWMNJ.js} +67 -11
- package/dist/chunk-DMUGWMNJ.js.map +1 -0
- package/dist/{chunk-EI57GEMD.js → chunk-H23KEFBA.js} +1 -1
- package/dist/{chunk-EI57GEMD.js.map → chunk-H23KEFBA.js.map} +1 -1
- package/dist/chunk-IJTZQX6Z.js +799 -0
- package/dist/chunk-IJTZQX6Z.js.map +1 -0
- package/dist/{chunk-JK5D3OXC.js → chunk-KR7K4QEY.js} +3 -2
- package/dist/chunk-KR7K4QEY.js.map +1 -0
- package/dist/chunk-OZ7DJUJY.js +805 -0
- package/dist/chunk-OZ7DJUJY.js.map +1 -0
- package/dist/{chunk-7E7STZEO.js → chunk-QMCIKLWC.js} +1 -14
- package/dist/chunk-QMCIKLWC.js.map +1 -0
- package/dist/{chunk-NRM4XAWR.js → chunk-RDCIZR4I.js} +6 -6
- package/dist/{chunk-K35VD4CQ.js → chunk-TDK6S6V7.js} +4 -2
- package/dist/chunk-TDK6S6V7.js.map +1 -0
- package/dist/{chunk-SSDLYYQV.js → chunk-Y5YAG75H.js} +15 -2
- package/dist/chunk-Y5YAG75H.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +351 -58
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/index.d.ts +230 -24
- package/dist/client/index.js +209 -51
- package/dist/client/index.js.map +1 -1
- package/dist/{clientTypes-B62bWHua.d.ts → clientTypes-CKqjWKdP.d.ts} +71 -252
- package/dist/connection-GcBHwKgF.d.ts +271 -0
- package/dist/{createServer-P61KBWro.d.ts → createServer-6ZLgE-Vs.d.ts} +44 -7
- package/dist/{createTestApp-BBm3TakW.d.ts → createTestApp-BTFzRxV8.d.ts} +47 -16
- package/dist/{envelope-CoiZY9KU.d.ts → envelope-B6TUGKsk.d.ts} +32 -12
- package/dist/{http-89lENH2D.d.ts → http-GTJIh3Hm.d.ts} +23 -12
- package/dist/index.d.ts +6 -6
- package/dist/index.js +63 -11
- package/dist/index.js.map +1 -1
- package/dist/{infer-Cwq49ME5.d.ts → infer-Zs7dAWlI.d.ts} +97 -24
- package/dist/{metrics-Ny1dFbyY.d.ts → metrics-C77VTXEa.d.ts} +2 -2
- package/dist/prisma/index.d.ts +4 -4
- package/dist/prisma/index.js +3 -5
- package/dist/prisma/index.js.map +1 -1
- package/dist/server/auth/index.d.ts +136 -12
- package/dist/server/auth/index.js +129 -12
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/index.d.ts +176 -43
- package/dist/server/index.js +264 -64
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +22 -11
- package/dist/server/mcp/index.js +11 -6
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-BHyufgVl.d.ts → sharing-F3blmuVL.d.ts} +3 -3
- package/dist/testing/client.d.ts +25 -164
- package/dist/testing/client.js +49 -578
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +29 -15
- package/dist/testing/index.js +58 -16
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +218 -0
- package/dist/testing/mock.js +16 -0
- package/dist/testing/mock.js.map +1 -0
- package/dist/testing/prisma.d.ts +22 -2
- package/dist/testing/prisma.js +10 -0
- package/dist/testing/prisma.js.map +1 -1
- package/dist/{types-BDmssndG.d.ts → types-BBU3rSbi.d.ts} +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +2 -2
- package/package.json +5 -1
- package/dist/chunk-5EZNDES3.js.map +0 -1
- package/dist/chunk-7E7STZEO.js.map +0 -1
- package/dist/chunk-DXTJNJM5.js.map +0 -1
- package/dist/chunk-GXLJA7AH.js +0 -379
- package/dist/chunk-GXLJA7AH.js.map +0 -1
- package/dist/chunk-I2OMV4AD.js.map +0 -1
- package/dist/chunk-JK5D3OXC.js.map +0 -1
- package/dist/chunk-K35VD4CQ.js.map +0 -1
- package/dist/chunk-SSDLYYQV.js.map +0 -1
- package/dist/chunk-U32GOJRO.js +0 -400
- package/dist/chunk-U32GOJRO.js.map +0 -1
- package/dist/chunk-UJQJM7EG.js.map +0 -1
- package/dist/searchSchemas-CBgKRa1b.d.ts +0 -43
- /package/dist/{chunk-CLPB6IGC.js.map → chunk-6G663RIR.js.map} +0 -0
- /package/dist/{chunk-5ADGYGC5.js.map → chunk-B4IOFSM6.js.map} +0 -0
- /package/dist/{chunk-NRM4XAWR.js.map → chunk-RDCIZR4I.js.map} +0 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ single hand-written event.
|
|
|
25
25
|
- **Testing**: a real test server, access matrices, performance budgets that
|
|
26
26
|
count statements and bytes, strict development warnings.
|
|
27
27
|
|
|
28
|
-
Design record: [`docs/rfcs/0003-v5.md`](
|
|
28
|
+
Design record: [`docs/rfcs/0003-v5.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/rfcs/0003-v5.md).
|
|
29
29
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
@@ -710,13 +710,18 @@ export const taskService = qd.defineService(task, {
|
|
|
710
710
|
(`adminBypass: false` turns that off). A grant below `Admin` counts only
|
|
711
711
|
where the form names `service`: a `Read` grant does not read every row.
|
|
712
712
|
- Grants come from `principal.serviceAccess`, as `authenticate` returns it or
|
|
713
|
-
`createServer({ auth: { loadServiceAccess } })` loads it
|
|
713
|
+
`createServer({ auth: { loadServiceAccess } })` loads it for a principal
|
|
714
|
+
that carries none: at a socket's handshake, for each HTTP call, and for
|
|
715
|
+
an in-process caller (`qd.caller`) at its first call. With
|
|
714
716
|
`auth.serviceAccessSource: { model: "user", column: "serviceAccess" }`, a
|
|
715
717
|
tracked write to that column refreshes the user's open sockets
|
|
716
|
-
(`qd:access`).
|
|
718
|
+
(`qd:access`), and in-process callers load the grants again.
|
|
717
719
|
- Policies: `owner(field)`, `jsonAcl(field, { owner? })`,
|
|
718
720
|
`members({ model, entry, user, level, levels? })`, `inherit({ from, via })`,
|
|
719
|
-
`anyOf(...)
|
|
721
|
+
`anyOf(...)`, `resolver({ levelsFor, where? })` and `everyone(level)`
|
|
722
|
+
(every signed-in user has `level` on every row, reading nothing: public
|
|
723
|
+
profiles are `anyOf(owner("id"), everyone("Read"))`; unlike `rowless: true`
|
|
724
|
+
on a method, it covers subscriptions and lists too). Their column names are
|
|
720
725
|
checked against the Prisma client's models at compile time. A lookup is one
|
|
721
726
|
batched query per table, memoized for the call, so checking 60 ids costs
|
|
722
727
|
what checking one does. `entry` access needs a policy; a service without
|
|
@@ -766,7 +771,15 @@ tracked client as `db`; the server finds the rest on it.
|
|
|
766
771
|
(`writes: ["taskLabel"]`); the `no-foreign-write` lint rule checks it.
|
|
767
772
|
- Jobs, scripts and webhooks wrap their writes in `qd.run(fn)`, which
|
|
768
773
|
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.
|
|
774
|
+
on its own on the next tick, with a development warning. Inside a
|
|
775
|
+
handler `qd.run` joins the handler's unit; background work the reply does
|
|
776
|
+
not wait for (a push sent after a message, pruning what it reports dead)
|
|
777
|
+
runs in `qd.run(fn, { detached: true })`, a unit of its own that flushes
|
|
778
|
+
when `fn` settles (catch what the promise rejects with: nothing awaits it).
|
|
779
|
+
Before any server exists (a seed at boot, before `createServer`),
|
|
780
|
+
`qd.run(fn)` still runs `fn` in a unit of its own, and its writes reach no
|
|
781
|
+
one: no socket can be subscribed yet (behind a cluster, write after
|
|
782
|
+
`createServer` when other nodes' subscribers must hear of it).
|
|
770
783
|
|
|
771
784
|
<!-- example: apps/api/src/jobs/overdue.ts#run -->
|
|
772
785
|
|
|
@@ -828,14 +841,16 @@ export const taskService = qd.defineService(task, {
|
|
|
828
841
|
// a write to a subtask sends its parent again
|
|
829
842
|
affects: [{ service: task, id: "parentTaskId" }],
|
|
830
843
|
project: {
|
|
831
|
-
// a relation count: read with select, built by a pure, synchronous map
|
|
844
|
+
// a relation count: read with select, built by a pure, synchronous map. Read the relation's
|
|
845
|
+
// ids, which Prisma fetches for the rows read only; its _count aggregates the whole TaskLabel
|
|
846
|
+
// table (a GROUP BY over every row) on every snapshot and flush
|
|
832
847
|
card: {
|
|
833
|
-
select: { title: true, status: true,
|
|
834
|
-
map: (row: { id: string; title: string; status: string;
|
|
848
|
+
select: { title: true, status: true, labels: { select: { id: true } } },
|
|
849
|
+
map: (row: { id: string; title: string; status: string; labels: { id: string }[] }) => ({
|
|
835
850
|
id: row.id,
|
|
836
851
|
title: row.title,
|
|
837
852
|
status: row.status,
|
|
838
|
-
labelCount: row.
|
|
853
|
+
labelCount: row.labels.length,
|
|
839
854
|
}),
|
|
840
855
|
},
|
|
841
856
|
},
|
|
@@ -848,13 +863,19 @@ export const taskService = qd.defineService(task, {
|
|
|
848
863
|
handler: ({ input, db }) =>
|
|
849
864
|
db.task.findUniqueOrThrow({
|
|
850
865
|
where: { id: input.id },
|
|
851
|
-
select: { id: true, title: true, status: true,
|
|
866
|
+
select: { id: true, title: true, status: true, labels: { select: { id: true } } },
|
|
852
867
|
}),
|
|
853
868
|
},
|
|
854
869
|
},
|
|
855
870
|
});
|
|
856
871
|
```
|
|
857
872
|
|
|
873
|
+
- A count over a relation reads the relation's ids (`labels: { select: { id: true } }`)
|
|
874
|
+
and counts them in `map`: Prisma reads them for the rows read only (a
|
|
875
|
+
second SQL query, `WHERE "taskId" IN (...)`). Its `_count` compiles to a
|
|
876
|
+
`GROUP BY` over the whole relation table, joined in, on every snapshot,
|
|
877
|
+
page and flush that reads the projection. For a relation too large to read
|
|
878
|
+
(a chat's members), keep a counter column that the writes maintain.
|
|
858
879
|
- A projection's keys come from its schema's JSON Schema (Zod 4.2 or later),
|
|
859
880
|
or from `project: { <name>: { keys } }`; a service whose projection has
|
|
860
881
|
neither fails when it is defined. A handler returning a projection returns
|
|
@@ -893,7 +914,7 @@ export const taskService = qd.defineService(task, {
|
|
|
893
914
|
Valkey's clock in microseconds, so revisions from all nodes are one order;
|
|
894
915
|
its key needs persistence or replication. A node whose Valkey connection
|
|
895
916
|
comes back has its clients reconnect to catch up.
|
|
896
|
-
[docs/deploying.md](
|
|
917
|
+
[docs/deploying.md](https://github.com/fitzzero/quickdraw/blob/dev/docs/deploying.md) has the wiring, what holds across
|
|
897
918
|
nodes, what it costs, and what happens when a node or Valkey stops
|
|
898
919
|
answering.
|
|
899
920
|
|
|
@@ -959,6 +980,11 @@ export const taskService = qd.defineService(task, {
|
|
|
959
980
|
ends in `id`; `limit` (default 100) and `maxLimit` (default 500) size its
|
|
960
981
|
pages; `where` is an equality filter on membership; `access` is the level
|
|
961
982
|
needed on the anchor (default `Read`).
|
|
983
|
+
- A junction write adds or removes its entry in the one scope it links.
|
|
984
|
+
When the item reads the junction (a chat's `memberCount`), declare
|
|
985
|
+
`via({ model, entry, scope, refreshEntry: true })`: every junction write
|
|
986
|
+
then also sends the entry again, `updated`, to each scope that still
|
|
987
|
+
holds it, for one more read of the item per flush.
|
|
962
988
|
- `qd:col:sub { s, c, scope }` authorizes the scope through its anchor's
|
|
963
989
|
policy, then answers a page and joins the scope's room; flushes send `qd:c`
|
|
964
990
|
deltas to it (`added`, `updated`, `patched`, `removed`, or `reset` for more
|
|
@@ -981,7 +1007,9 @@ export const taskService = qd.defineService(task, {
|
|
|
981
1007
|
scope after a change tracked writes cannot describe.
|
|
982
1008
|
- `qd:watch { s, topic }` joins a change topic: `{collection}:{scope}`,
|
|
983
1009
|
authorized like a subscribe to that scope, or `service`, which changes
|
|
984
|
-
whenever any row of the service does
|
|
1010
|
+
whenever any row of the service does, or a row of a model it lists in
|
|
1011
|
+
`writes` (a game's high scores, which no service owns). A query declares
|
|
1012
|
+
`watch: "service"` to be invalidated by it. The service topic is closed
|
|
985
1013
|
(`FORBIDDEN`) unless the service declares `watchAccess` (`"public"`,
|
|
986
1014
|
`"authenticated"` or `{ service: level }`). A watcher that loses access
|
|
987
1015
|
leaves the topic after one last `qd:changed`.
|
|
@@ -998,7 +1026,9 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
998
1026
|
|
|
999
1027
|
- **Socket.IO** (protocol 5): a client connects with
|
|
1000
1028
|
`auth: { token, qd: { protocol: 5, client } }`, receives `qd:hello` with the
|
|
1001
|
-
server's limits
|
|
1029
|
+
server's limits, who it acts for and the server's id (`serverId`, new
|
|
1030
|
+
each time a server starts: a reconnect that brings another one reached a
|
|
1031
|
+
restarted server, or another node), and calls through `qd:call` and
|
|
1002
1032
|
`qd:cancel`. Every socket gets the same few listeners however many methods
|
|
1003
1033
|
the services have. The JSON-only parser is the default; `binary: true`
|
|
1004
1034
|
restores the stock one. The socket rate limiter is on by default (600
|
|
@@ -1020,7 +1050,13 @@ over three transports (design: sections 3, 8 and 10):
|
|
|
1020
1050
|
`./server/express`), which refuses with the `RATE_LIMITED` reply.
|
|
1021
1051
|
- **In process**: `server.dispatcher.caller(principal)` or
|
|
1022
1052
|
`qd.caller(principal)`: `await qd.caller(user).taskService.rename(input)`,
|
|
1023
|
-
typed by the `contracts` of `initQuickdraw`'s types.
|
|
1053
|
+
typed by the `contracts` of `initQuickdraw`'s types. A principal that
|
|
1054
|
+
carries no `serviceAccess` gets the grants `auth.loadServiceAccess`
|
|
1055
|
+
loads, as a socket's handshake and an HTTP call do: at the caller's first
|
|
1056
|
+
call, and again at its next call after the server applied new grants to
|
|
1057
|
+
a user (`server.access.refresh`, a tracked write to
|
|
1058
|
+
`auth.serviceAccessSource`). One that carries grants (even `{}`) keeps
|
|
1059
|
+
exactly those. A load that fails rejects the call with `INTERNAL`.
|
|
1024
1060
|
|
|
1025
1061
|
`authenticate` takes one request (`{ transport, auth, headers, socket | req }`)
|
|
1026
1062
|
for both transports and returns a principal, a user id, or nothing for an
|
|
@@ -1105,7 +1141,9 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1105
1141
|
|
|
1106
1142
|
- **stdio** speaks JSON-RPC (MCP protocol version 2024-11-05). One process is
|
|
1107
1143
|
one session: its queries share one concurrency lane, and
|
|
1108
|
-
`notifications/cancelled` cancels a call.
|
|
1144
|
+
`notifications/cancelled` cancels a call. When stdin ends, the calls still
|
|
1145
|
+
running finish and their replies are written before `closed` resolves
|
|
1146
|
+
(`server.close()` cancels them instead). Start its module through
|
|
1109
1147
|
`bootstrapMcpServer(new URL("./mcp-server.js", import.meta.url))`, which
|
|
1110
1148
|
sends console output to stderr so only the protocol reaches stdout.
|
|
1111
1149
|
- **HTTP**: `GET /mcp/tools` and `POST /mcp/invoke`, which takes
|
|
@@ -1115,6 +1153,11 @@ createMcpStdioServer({ registry, name: "my-app", version: "1.0.0" });
|
|
|
1115
1153
|
`"public"` methods, and custom tools that declare `access: "public"`; any
|
|
1116
1154
|
other tool answers `UNAUTHENTICATED` before it runs. A failed call reaches
|
|
1117
1155
|
the agent as a tool error carrying the code.
|
|
1156
|
+
- The tool list is the registry's, the same for every caller: `tools/list`
|
|
1157
|
+
and `GET /mcp/tools` are not filtered by the principal, so an agent sees
|
|
1158
|
+
tools it may not call (and its call is refused). Keep a tool out of the
|
|
1159
|
+
list with `exclude` or `include`, per registry, and serve agents of
|
|
1160
|
+
different reach from separate registries.
|
|
1118
1161
|
|
|
1119
1162
|
## The client
|
|
1120
1163
|
|
|
@@ -1127,15 +1170,16 @@ the method's kind does not have, is a compile error.
|
|
|
1127
1170
|
`QueryClient` (5-minute stale time by default), and works without DOM
|
|
1128
1171
|
globals (React Native).
|
|
1129
1172
|
|
|
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,
|
|
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
|
-
| `
|
|
1173
|
+
| Member | Gives |
|
|
1174
|
+
| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
1175
|
+
| `qd.task.get.useQuery(input, options)` | TanStack's `useQuery`; errors are `QuickdrawError` with a `code` |
|
|
1176
|
+
| `qd.task.rename.useMutation(options)` | TanStack's `useMutation`; `mutate` returns nothing, `mutateAsync` the output |
|
|
1177
|
+
| `qd.task.useEntity(id)`, `useEntities(ids)` | live rows at the user's level: `{ data, isLoading, isRemoved, error }` |
|
|
1178
|
+
| `qd.task.board.useCollection(scope, { view, load, limit })` | a live scope: `{ items, index, byId, pending, totalCount, hasMore, isLoading, loadMore, ... }` |
|
|
1179
|
+
| `qd.task.get.call(input)`, `.key(input)`, `.prefetch(queryClient, input)` | a call over the mounted provider's connection, the cache key, a prefetch |
|
|
1180
|
+
| `qd.invalidate(qd.task.get, input?)` | invalidates through the coordinator: a read in flight is never cancelled |
|
|
1181
|
+
| `qd.task.get.setData(input, updater)` | writes a cached result an event carries, at once; a read in flight is followed by one more |
|
|
1182
|
+
| `useQuickdraw()` | `{ connection, status, isConnected, isKnown, reconnecting, userId, serviceAccess, hello, ... }` |
|
|
1139
1183
|
|
|
1140
1184
|
<!-- example: apps/web/src/components/TaskDetail.tsx#detail -->
|
|
1141
1185
|
|
|
@@ -1176,12 +1220,18 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1176
1220
|
collection items from the moment it is sent, are dropped if it fails, and
|
|
1177
1221
|
give way to the server's frame. `optimistic: false` turns that off;
|
|
1178
1222
|
`optimistic: (input, cache) => ...` writes its own layers with
|
|
1179
|
-
`patchEntity`, `removeEntity` and `patchItem
|
|
1223
|
+
`patchEntity`, `removeEntity` and `patchItem`, and adds rows with
|
|
1224
|
+
`addItem` and `addEntity` (below).
|
|
1180
1225
|
- Live rows and collections need no refetching: frames keep them current,
|
|
1181
1226
|
and after a reconnect they resume by revision. A query whose result
|
|
1182
1227
|
follows writes declares `watch` in its contract; the coordinator fetches
|
|
1183
1228
|
it again once per change, with at most one read in flight per key. Do not
|
|
1184
1229
|
call `refetch` or `invalidateQueries` on quickdraw keys after a mutation.
|
|
1230
|
+
- An event that carries a query's new result (a room's roster, a score)
|
|
1231
|
+
writes it with `qd.<service>.<query>.setData(input, updater)` rather than
|
|
1232
|
+
refetching: the hooks show it at once, under the overlays of optimistic
|
|
1233
|
+
mutations, and a read of that key already in flight (which may predate
|
|
1234
|
+
the event) is followed by one more, never cancelled.
|
|
1185
1235
|
- After a reconnect, the queries that are watched (they missed the changes
|
|
1186
1236
|
meanwhile) or stale are refetched, each after a random delay of up to
|
|
1187
1237
|
`reconnectJitterMs` (2,000 ms by default), so clients that reconnect
|
|
@@ -1200,6 +1250,87 @@ export function TaskDetail({ id }: { readonly id: string }) {
|
|
|
1200
1250
|
- A protocol mismatch reloads the page once per session by default
|
|
1201
1251
|
(`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
|
|
1202
1252
|
kind of work.
|
|
1253
|
+
- Server rendering: a server never connects, so `useQuickdraw()`,
|
|
1254
|
+
`usePresence`, streams and overlays render the state a new connection has
|
|
1255
|
+
(nobody known, no rooms, nothing loaded), and the browser hydrates that
|
|
1256
|
+
same state even where the provider connected first (a Suspense boundary
|
|
1257
|
+
that hydrates late), then renders the live one.
|
|
1258
|
+
|
|
1259
|
+
A create shows its row before the server answers with `cache.addItem(collection,
|
|
1260
|
+
scope, item)`: the item appears in the scope at once, in its place by the
|
|
1261
|
+
collection's `order` (give it the order's fields; without them it goes
|
|
1262
|
+
last), and `useCollection`'s `pending` names it while the call is in flight.
|
|
1263
|
+
A refused call removes it. The reply's `id` (the created row's) and values
|
|
1264
|
+
replace the item's own, and once the scope's own copy arrives (its `added`
|
|
1265
|
+
delta, or a load) that copy shows in its place: never both, never a gap.
|
|
1266
|
+
`cache.addEntity(row)` adds a row to every collection of entity rows whose
|
|
1267
|
+
scope column (and `where`) it matches; a collection of a projection takes
|
|
1268
|
+
`addItem`. The item may give its own `id`, one the client made and the
|
|
1269
|
+
server keeps:
|
|
1270
|
+
|
|
1271
|
+
<!-- example: apps/web/src/components/AddTask.tsx#add -->
|
|
1272
|
+
|
|
1273
|
+
```tsx
|
|
1274
|
+
export function TaskList({ projectId }: { readonly projectId: string }) {
|
|
1275
|
+
const { items, pending } = qd.task.board.useCollection(projectId);
|
|
1276
|
+
const create = qd.task.create.useMutation({
|
|
1277
|
+
// the new card shows at once, last on the board (its ordinal), until the server's arrives
|
|
1278
|
+
optimistic: (input, cache) =>
|
|
1279
|
+
cache.addItem("board", input.projectId, {
|
|
1280
|
+
projectId: input.projectId,
|
|
1281
|
+
title: input.title,
|
|
1282
|
+
status: "open",
|
|
1283
|
+
ordinal: Number.MAX_SAFE_INTEGER,
|
|
1284
|
+
assigneeId: null,
|
|
1285
|
+
}),
|
|
1286
|
+
});
|
|
1287
|
+
return (
|
|
1288
|
+
<>
|
|
1289
|
+
<ul>
|
|
1290
|
+
{items.map((task) => (
|
|
1291
|
+
// pending: the create is on its way; the card is the server's once it answers
|
|
1292
|
+
<li key={task.id} style={{ opacity: pending.has(task.id) ? 0.5 : 1 }}>
|
|
1293
|
+
{task.title}
|
|
1294
|
+
</li>
|
|
1295
|
+
))}
|
|
1296
|
+
</ul>
|
|
1297
|
+
<button type="button" onClick={() => create.mutate({ projectId, title: "New task" })}>
|
|
1298
|
+
Add
|
|
1299
|
+
</button>
|
|
1300
|
+
{create.error === null ? null : <p>{`Not added: ${create.error.code}`}</p>}
|
|
1301
|
+
</>
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
```
|
|
1305
|
+
|
|
1306
|
+
`useQuickdraw()` says who the connection acts for. `userId` is `null` both
|
|
1307
|
+
for an anonymous socket and before the server's hello, so a gate waits for
|
|
1308
|
+
`isKnown` (the hello on the current credentials arrived; false again from
|
|
1309
|
+
new credentials until theirs). `isConnected` turns true before the hello and
|
|
1310
|
+
false while the connection reconnects; `reconnecting` says the user and the
|
|
1311
|
+
page stay meanwhile:
|
|
1312
|
+
|
|
1313
|
+
<!-- example: apps/web/src/components/AuthGate.tsx#gate -->
|
|
1314
|
+
|
|
1315
|
+
```tsx
|
|
1316
|
+
export function AuthGate({ children }: { readonly children: ReactNode }) {
|
|
1317
|
+
// isKnown: the server's hello named the user, so userId is final (null: signed out)
|
|
1318
|
+
const { isKnown, userId, reconnecting } = useQuickdraw();
|
|
1319
|
+
if (!isKnown) {
|
|
1320
|
+
return <p>Connecting…</p>;
|
|
1321
|
+
}
|
|
1322
|
+
if (userId === null) {
|
|
1323
|
+
return <SignIn />;
|
|
1324
|
+
}
|
|
1325
|
+
// a reconnect keeps the user and the page: say so, unmount nothing
|
|
1326
|
+
return (
|
|
1327
|
+
<>
|
|
1328
|
+
{reconnecting ? <p role="status">Reconnecting…</p> : null}
|
|
1329
|
+
{children}
|
|
1330
|
+
</>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
```
|
|
1203
1334
|
|
|
1204
1335
|
### Server components and other runtimes
|
|
1205
1336
|
|
|
@@ -1255,9 +1386,9 @@ such code, and `createInvalidationCoordinator(queryClient)` invalidates as
|
|
|
1255
1386
|
the hooks do.
|
|
1256
1387
|
|
|
1257
1388
|
A client in another language (a Godot game, a native app) speaks the wire
|
|
1258
|
-
itself: [`docs/protocol-v5.md`](
|
|
1259
|
-
generated from the protocol's source, and [`examples/godot`](
|
|
1260
|
-
holds a GDScript client written from it. [`docs/clients.md`](
|
|
1389
|
+
itself: [`docs/protocol-v5.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/protocol-v5.md) is its specification,
|
|
1390
|
+
generated from the protocol's source, and [`examples/godot`](https://github.com/fitzzero/quickdraw/tree/dev/examples/godot)
|
|
1391
|
+
holds a GDScript client written from it. [`docs/clients.md`](https://github.com/fitzzero/quickdraw/blob/dev/docs/clients.md)
|
|
1261
1392
|
compares the three ways in.
|
|
1262
1393
|
|
|
1263
1394
|
## Kits
|
|
@@ -1732,7 +1863,10 @@ export function AdminTasks() {
|
|
|
1732
1863
|
// [{ key: "task", serviceName, displayName }]
|
|
1733
1864
|
const { services } = useAdminServices(qd);
|
|
1734
1865
|
const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
|
|
1735
|
-
const update = qd.task.admin.adminUpdate.useMutation(
|
|
1866
|
+
const update = qd.task.admin.adminUpdate.useMutation({
|
|
1867
|
+
// adminList is a query, not live data: read the page again after this screen's own write
|
|
1868
|
+
onSuccess: () => qd.invalidate(qd.task.admin.adminList),
|
|
1869
|
+
});
|
|
1736
1870
|
return (
|
|
1737
1871
|
<table aria-label={services[0]?.displayName}>
|
|
1738
1872
|
<tbody>
|
|
@@ -1780,7 +1914,7 @@ export function AdminTasks() {
|
|
|
1780
1914
|
value is checked by the entity schema itself, and a value the database
|
|
1781
1915
|
refuses is `VALIDATION`. A missing row is `NOT_FOUND`.
|
|
1782
1916
|
- `adminMeta()` returns `{ serviceName, displayName, fields }`, one
|
|
1783
|
-
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService? }`
|
|
1917
|
+
`{ name, type, label, required, editable, showInTable, sortable, filterable, enumValues?, relationService?, kind?, showInForm? }`
|
|
1784
1918
|
per field: `type` is
|
|
1785
1919
|
`string`, `number`, `boolean`, `date` (an ISO string with a date format),
|
|
1786
1920
|
`enum` or `json` from the field's JSON Schema, and `relation` by override;
|
|
@@ -1789,6 +1923,29 @@ export function AdminTasks() {
|
|
|
1789
1923
|
configurations written for 4.x still type); `id` and the
|
|
1790
1924
|
timestamps come first and are not editable; `acl`, `serviceAccess` and
|
|
1791
1925
|
`service_access` are hidden.
|
|
1926
|
+
- Grants: on a users service, `admin.handlers(user, { grants: true })`
|
|
1927
|
+
shows and writes the entity's `serviceAccess` (a `json` field the entity
|
|
1928
|
+
schema checks), so the admin screen edits grants through `adminUpdate`.
|
|
1929
|
+
Only a caller whose own service-wide grant on that service is `Admin`
|
|
1930
|
+
reads or writes it, whatever `access` gives the method: anyone else gets
|
|
1931
|
+
rows without it and `FORBIDDEN` for a write, filter or sort naming it.
|
|
1932
|
+
The write is tracked, so with `auth.serviceAccessSource` naming the column
|
|
1933
|
+
the user's open sockets get the new grants at once, on every node. Such
|
|
1934
|
+
an Admin can grant any service, themself included. The field's
|
|
1935
|
+
configuration says `kind: "grants"`, so a screen with a grants editor of
|
|
1936
|
+
its own finds it without its name, and
|
|
1937
|
+
`fieldOverrides: { serviceAccess: { showInForm: false } }` keeps it out
|
|
1938
|
+
of a generic create or edit form (a form shows the fields whose
|
|
1939
|
+
`showInForm` is not `false`); the kit still reads and writes it.
|
|
1940
|
+
- `admin.handlers(contract, { onWrite })` runs `onWrite({ method, id,
|
|
1941
|
+
before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
|
|
1942
|
+
`adminDelete`, in one transaction with the write (`db` is the
|
|
1943
|
+
transaction's tracked client; a throw undoes the write and fails the
|
|
1944
|
+
call): what an admin edit must set off, such as a game reloading its
|
|
1945
|
+
tunables, without wrapping the kit's handlers. The rows are the entity,
|
|
1946
|
+
every field. Without it the kit opens no transaction. Each handler
|
|
1947
|
+
`admin.handlers` returns resolves with its method's output type, so a
|
|
1948
|
+
wrapper reads the row and returns it with no cast.
|
|
1792
1949
|
- `adminSubscribers({ id })` counts the sockets subscribed to a row per
|
|
1793
1950
|
access level (`{ id, count, levels, complete }`; behind a Redis adapter
|
|
1794
1951
|
the counts are this server's and `complete` is `false`), and
|
|
@@ -1798,6 +1955,21 @@ export function AdminTasks() {
|
|
|
1798
1955
|
`qd.admin`, since `admin` is reserved per service). `useAdminServices(qd)`
|
|
1799
1956
|
lists the client's services whose `adminMeta` answers the user, with their
|
|
1800
1957
|
display names, sharing the cache of `qd.<service>.admin.adminMeta.useQuery()`.
|
|
1958
|
+
It asks only the services the user's grants (from the server's hello)
|
|
1959
|
+
allow: `Admin` by default, what the kit's methods require;
|
|
1960
|
+
`useAdminServices(qd, { requires: "Moderate" })` for an `adminMeta` given
|
|
1961
|
+
another form, `requires: null` for every service. A service that refused
|
|
1962
|
+
is not asked again until the user's grant on it changes, reconnects
|
|
1963
|
+
included.
|
|
1964
|
+
- The kit's rows are not live: `adminList` and `adminGet` are queries that
|
|
1965
|
+
watch nothing, so a screen reads them again after its own writes (the
|
|
1966
|
+
`onSuccess` above), and sees other admins' writes when it next reads.
|
|
1967
|
+
- A screen that serves every service from its metadata (the route names the
|
|
1968
|
+
service) takes `adminOf(qd, key)`: the same members, keyed by what the kit
|
|
1969
|
+
made them for and typed by field name (`AdminScreen`: rows are
|
|
1970
|
+
`AdminRow`, `adminList` takes `{ page, pageSize, filter, sort: { field, direction } }`
|
|
1971
|
+
with names read from `adminMeta`), so no cast is needed over a union of
|
|
1972
|
+
keys; a method the contract does not expose is absent.
|
|
1801
1973
|
|
|
1802
1974
|
### Presence, streams and channels
|
|
1803
1975
|
|
|
@@ -1835,7 +2007,11 @@ export const task = defineContract("taskService", {
|
|
|
1835
2007
|
requires: { room: (cursor) => `board:${cursor.projectId}` },
|
|
1836
2008
|
},
|
|
1837
2009
|
},
|
|
1838
|
-
events: {
|
|
2010
|
+
events: {
|
|
2011
|
+
cursorMoved: { payload: cursorSchema },
|
|
2012
|
+
// a user's last socket left a board: `onRoomLeave` sends it
|
|
2013
|
+
leftBoard: { payload: z.object({ projectId: z.string(), userId: z.string() }) },
|
|
2014
|
+
},
|
|
1839
2015
|
});
|
|
1840
2016
|
```
|
|
1841
2017
|
|
|
@@ -1859,6 +2035,16 @@ export const taskService = qd.defineService(task, {
|
|
|
1859
2035
|
ctx.rooms.emit(`board:${payload.projectId}`, task, "cursorMoved", payload);
|
|
1860
2036
|
},
|
|
1861
2037
|
},
|
|
2038
|
+
// once per socket that leaves app rooms, in every server this service runs in, in a unit of its own
|
|
2039
|
+
onRoomLeave: ({ principal, rooms }) => {
|
|
2040
|
+
for (const { room, last } of rooms) {
|
|
2041
|
+
// last: no socket of the user is in the room any more (a second tab keeps it false)
|
|
2042
|
+
if (principal !== null && last && room.startsWith("board:")) {
|
|
2043
|
+
const projectId = room.slice("board:".length);
|
|
2044
|
+
qd.rooms.emit(room, task, "leftBoard", { projectId, userId: principal.userId });
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
},
|
|
1862
2048
|
});
|
|
1863
2049
|
|
|
1864
2050
|
// in handlers, jobs and timers
|
|
@@ -1882,14 +2068,17 @@ export function TaskRoom({
|
|
|
1882
2068
|
readonly projectId: string;
|
|
1883
2069
|
readonly taskId: string;
|
|
1884
2070
|
}) {
|
|
2071
|
+
// the socket is in the board's room on every connection: a reconnect is a new socket in no room
|
|
2072
|
+
const board = useJoin(qd.task.enterBoard, { projectId });
|
|
1885
2073
|
const { items } = qd.task.logs.useStream(taskId, { max: 200 });
|
|
1886
2074
|
const { send, isReady } = qd.task.cursor.useChannel();
|
|
1887
2075
|
const [lastX, setLastX] = useState(0);
|
|
1888
2076
|
qd.task.cursorMoved.useEvent((cursor) => setLastX(cursor.x));
|
|
1889
|
-
// user ids,
|
|
2077
|
+
// user ids, once enterBoard joined the room
|
|
1890
2078
|
const here = usePresence(`board:${projectId}`);
|
|
2079
|
+
const move = (x: number) => isReady && board.isJoined && send({ projectId, taskId, x });
|
|
1891
2080
|
return (
|
|
1892
|
-
<div onMouseMove={(event) =>
|
|
2081
|
+
<div onMouseMove={(event) => move(event.clientX)}>
|
|
1893
2082
|
<p>{`${String(here.length)} here; a cursor at ${String(lastX)}`}</p>
|
|
1894
2083
|
<pre>{items.map((item) => item.line).join("\n")}</pre>
|
|
1895
2084
|
</div>
|
|
@@ -1903,20 +2092,33 @@ export function TaskRoom({
|
|
|
1903
2092
|
latest `seed` items per scope in memory on that process (at most 1,000 per
|
|
1904
2093
|
scope and 10,000 scopes per stream; a restart empties them, and durable
|
|
1905
2094
|
history is the app's: store the rows and expose a collection), and sends
|
|
1906
|
-
`qd:stream
|
|
1907
|
-
|
|
2095
|
+
`qd:stream [service, stream, scope, item]` (`scope` null for a global
|
|
2096
|
+
stream; positional, so a fast stream's frames carry no key names) to the
|
|
2097
|
+
feed's subscribers, volatile when the stream says so. `pushMany(scope, items)`
|
|
1908
2098
|
(`pushMany(items)` for a global stream) pushes several items to one feed
|
|
1909
2099
|
at once: every item is checked before any is kept or sent, and each goes
|
|
1910
2100
|
out as its own frame, in order; use it rather than `push` in a loop
|
|
1911
2101
|
(`no-emit-in-loop`). `qd:stream:sub` is authorized with the
|
|
1912
2102
|
stream's `access` through the access engine, the scope being the row an
|
|
1913
|
-
`entry` or `scope` form checks;
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2103
|
+
`entry` or `scope` form checks; `access: { room }` (a name, `{ prefix }`,
|
|
2104
|
+
or for a scoped stream a function of the scope) opens it to the sockets
|
|
2105
|
+
in that app room instead, signed in or not, and a socket that leaves the
|
|
2106
|
+
room, or is taken out, leaves the feed; a stream without `access` is
|
|
2107
|
+
closed. The answer is the seed; `useStream` then appends, keeps the
|
|
2108
|
+
latest `max` (default 500), and subscribes again after a reconnect, when
|
|
2109
|
+
the seed replaces what it held. A socket holds at most 500 feeds. A
|
|
2110
|
+
subscriber whose access is lowered is authorized again; one refused
|
|
2111
|
+
leaves the feed and gets
|
|
2112
|
+
`qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
|
|
2113
|
+
`useStream` shows `FORBIDDEN` until the next connect.
|
|
2114
|
+
- A service's `streams: { <name>: { seed, validate } }` computes a stream's
|
|
2115
|
+
seed per subscriber, `seed: (scope, ctx) => items` (the current state,
|
|
2116
|
+
where the items that follow are deltas; on whichever node the subscriber
|
|
2117
|
+
is on, under its principal, in the tick it joins the feed; a contract
|
|
2118
|
+
`seed: n` and a seed function cannot both be declared), and
|
|
2119
|
+
`validate: "development"` checks pushed items only while the dispatcher
|
|
2120
|
+
checks outputs (`outputValidation`, off in production), for a hot stream:
|
|
2121
|
+
unchecked, an item goes out as pushed, extra keys included.
|
|
1920
2122
|
- Channels: each message is `qd:ch [service, channel, payload]`, sent
|
|
1921
2123
|
volatile and never answered. Per socket and channel a token bucket
|
|
1922
2124
|
(`ratePerSecond`, default 30; `burst`, default twice that) drops what is
|
|
@@ -1928,10 +2130,13 @@ export function TaskRoom({
|
|
|
1928
2130
|
scope; `{ room }`: the app room, a name like `"world"` or a function of
|
|
1929
2131
|
the payload, which a call over that same socket joined) is dropped.
|
|
1930
2132
|
Nothing is logged per message; a handler's error is. The socket rate
|
|
1931
|
-
limiter does not count channels.
|
|
1932
|
-
socket
|
|
1933
|
-
|
|
1934
|
-
|
|
2133
|
+
limiter does not count channels. `{ room: { prefix: "world:" } }` takes
|
|
2134
|
+
a socket in any app room whose name starts with the prefix (a game of many
|
|
2135
|
+
worlds), and every room form gives the handler the room it matched as
|
|
2136
|
+
`ctx.room`, so the payload need not repeat it. Every requirement is the
|
|
2137
|
+
sending socket's own: a room another socket of the user joined does not
|
|
2138
|
+
count, a reconnected socket must join again, and behind a cluster the
|
|
2139
|
+
check runs on the node the socket is connected to, with no round trip.
|
|
1935
2140
|
- Presence: `isOnline`, `lastSeen` (now while online, else when the user's
|
|
1936
2141
|
last socket on this process disconnected), `count` and `users` (each user
|
|
1937
2142
|
once, anonymous sockets left out; app rooms only) come from this process's
|
|
@@ -1939,13 +2144,81 @@ export function TaskRoom({
|
|
|
1939
2144
|
`ctx.rooms.join(room)` and `leave` put the calling socket in an app room
|
|
1940
2145
|
(calls without a socket get `false`; names starting with `qd:` or `user:`
|
|
1941
2146
|
are refused with `VALIDATION`; at most 100 per socket; a method that shares
|
|
1942
|
-
its runs, `share`, may not join or leave
|
|
1943
|
-
sockets get `qd:presence` frames: the list on
|
|
1944
|
-
who leaves. `usePresence(room)` shows them.
|
|
2147
|
+
its runs, `share`, may not join or leave with its caller's socket:
|
|
2148
|
+
`INTERNAL`), and the room's sockets get `qd:presence` frames: the list on
|
|
2149
|
+
joining, then who joins and who leaves. `usePresence(room)` shows them.
|
|
1945
2150
|
- Events: `ctx.rooms.emit(room, contract, event, payload)` and
|
|
1946
2151
|
`emitToUser(userId, ...)` check the payload first (`INTERNAL`, nothing
|
|
1947
2152
|
sent, when it fails), then send the validated payload as
|
|
1948
2153
|
`qd:event [service, event, payload]`; `useEvent` hears them.
|
|
2154
|
+
- Rooms belong to a socket: a reconnect (a lost network, `qd:rotate`, new
|
|
2155
|
+
credentials) is a new socket in no room, which hears none of its events
|
|
2156
|
+
and whose channel messages requiring it are dropped. `useJoin(member,
|
|
2157
|
+
input, { enabled?, onJoined? })` (from `./client`) runs the joining call
|
|
2158
|
+
(`enterBoard` above) on every `qd:hello` and when its input changes by
|
|
2159
|
+
value, never on a re-render, and shows `status` (`idle` with no socket to
|
|
2160
|
+
join with, `joining`, `joined`, `error`), `isJoined`, `data` and `error`;
|
|
2161
|
+
a refusal stands until the next hello, `RATE_LIMITED` is tried again after
|
|
2162
|
+
its backoff, and it never leaves the room itself.
|
|
2163
|
+
`connection.onHello(listener)` is the same hook without React.
|
|
2164
|
+
|
|
2165
|
+
Code that is not a handler (a game loop, a timer, a job) reaches rooms
|
|
2166
|
+
through `qd.rooms` (also `server.rooms`); the service's `onRoomLeave` (above)
|
|
2167
|
+
hears sockets leave:
|
|
2168
|
+
|
|
2169
|
+
<!-- example: apps/api/src/services/kits/rooms.ts#rooms -->
|
|
2170
|
+
|
|
2171
|
+
```ts
|
|
2172
|
+
const boardRoom = (projectId: string): string => `board:${projectId}`;
|
|
2173
|
+
|
|
2174
|
+
// a timer or a game loop's tick, outside any handler: every socket in the room, on every node
|
|
2175
|
+
export function showCursor(projectId: string, taskId: string, x: number): void {
|
|
2176
|
+
qd.rooms.emit(boardRoom(projectId), task, "cursorMoved", { projectId, taskId, x });
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// a member removed from the project: their sockets leave its board on every node, so they hear
|
|
2180
|
+
// nothing more of it and its cursor channel drops their messages (also ctx.rooms.leave)
|
|
2181
|
+
export async function removeFromBoard(projectId: string, userId: string): Promise<void> {
|
|
2182
|
+
await qd.rooms.leave(boardRoom(projectId), { userId });
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// a tick loop's "is anyone watching?", at its tick rate: this node's sockets in the room,
|
|
2186
|
+
// anonymous ones too, with no promise (presence.count asks every node for users)
|
|
2187
|
+
export function hasAudience(projectId: string): boolean {
|
|
2188
|
+
return qd.rooms.size(boardRoom(projectId)) > 0;
|
|
2189
|
+
}
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
- `qd.rooms.emit` and `emitToUser` are `ctx.rooms`' own, from anywhere,
|
|
2193
|
+
reaching every node behind a cluster adapter.
|
|
2194
|
+
- `rooms.size(room)` (on `qd.rooms`, `server.rooms` and `ctx.rooms`) counts
|
|
2195
|
+
the sockets in an app room on this node, anonymous ones included, at once:
|
|
2196
|
+
a game loop can ask it every tick. It is local by design; `presence.count`
|
|
2197
|
+
counts users on every node. A handler's `ctx.socketId` names the socket
|
|
2198
|
+
its call arrived on (`undefined` over HTTP, MCP or in process).
|
|
2199
|
+
- `leave(room, { userId })` (on `qd.rooms`, and on `ctx.rooms` beside the
|
|
2200
|
+
calling socket's own `leave(room)`) takes every socket of the user out of
|
|
2201
|
+
an app room, on every node: they hear nothing more of it, a channel that
|
|
2202
|
+
`requires` the room drops their messages, each gets
|
|
2203
|
+
`qd:presence { room, users: [] }`, and the room hears `left`. Behind a
|
|
2204
|
+
cluster it is broadcast and answered, so await it before sending what the
|
|
2205
|
+
user must not receive. Joining again is the app's to refuse.
|
|
2206
|
+
- A service's `onRoomLeave` runs once per socket that leaves app rooms, in
|
|
2207
|
+
every server the service runs in (`createTestApp` and a benchmark's
|
|
2208
|
+
server included, with nothing to wire), beside every other service's and
|
|
2209
|
+
`createServer({ onRoomLeave })`, the app's own; each runs in a unit of
|
|
2210
|
+
work of its own, and one that throws stops none of the others. It hears
|
|
2211
|
+
every app room a socket leaves, so check the room's name. It runs on
|
|
2212
|
+
the node that held the socket: its own `leave` (`reason: "leave"`), a
|
|
2213
|
+
removal (`"removed"`), or a disconnect, which leaves every app room it was
|
|
2214
|
+
in (`"disconnect"`; `qd:rotate` reconnects with a new socket). Each room
|
|
2215
|
+
comes with `last`: true when no socket of that user is in the room any
|
|
2216
|
+
more, on any node, which is what a game's `playerLeft` waits for. It runs
|
|
2217
|
+
in a unit of work of its own (never the unit of the handler that left), a
|
|
2218
|
+
throw is logged, and `close()` waits for it. Behind a cluster `last` is
|
|
2219
|
+
decided by asking every node once the socket left, so two sockets of one
|
|
2220
|
+
user leaving two nodes at the same moment may both see the other (a
|
|
2221
|
+
removal never misses: each node reports its own last socket).
|
|
1949
2222
|
|
|
1950
2223
|
### Auth routes kit
|
|
1951
2224
|
|
|
@@ -1981,7 +2254,8 @@ app.set("trust proxy", 1);
|
|
|
1981
2254
|
app.use(
|
|
1982
2255
|
createAuthRoutes({
|
|
1983
2256
|
providers: [
|
|
1984
|
-
|
|
2257
|
+
// nothing without its credentials: the routes skip it
|
|
2258
|
+
google.optional({ clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET }),
|
|
1985
2259
|
discord({ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET }),
|
|
1986
2260
|
// served only while isMockOAuthEnabled()
|
|
1987
2261
|
mock({ listUsers: listSeededUsers }),
|
|
@@ -2030,7 +2304,7 @@ nothing is cached:
|
|
|
2030
2304
|
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
2031
2305
|
| `GET /{provider}/start?returnTo=<url>` | 302 to the provider |
|
|
2032
2306
|
| `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
|
|
2307
|
+
| `POST /guest` | `createUser(body)`, then `{ userId, name? }` with the session cookie (and `token` with `guest({ token: true })`) |
|
|
2034
2308
|
| `GET /me` | `{ userId }`, or 401 |
|
|
2035
2309
|
| `POST /logout` | 204: revokes the session, clears the cookie |
|
|
2036
2310
|
| `POST /logout-all` | 204: revokes every session of the user; 401 without a live session |
|
|
@@ -2079,6 +2353,27 @@ nothing is cached:
|
|
|
2079
2353
|
`allowMissingOrigin: true` is set for native clients that keep cookies.
|
|
2080
2354
|
Bearer tokens need no Origin, and HTTP calls are guarded by their JSON
|
|
2081
2355
|
content type instead.
|
|
2356
|
+
- `socketAuth({ devCredentials })` signs a socket in by the user id its
|
|
2357
|
+
handshake names (`auth: { userId }`, no token), as the function answers
|
|
2358
|
+
(the principal, or `null` to refuse): for a game editor or load-test bots
|
|
2359
|
+
in development. `socketAuth` throws when it is given while `NODE_ENV` is
|
|
2360
|
+
`production`, and refuses such a handshake there anyway; pass it only
|
|
2361
|
+
behind the app's own flag.
|
|
2362
|
+
- `google.optional(...)` and `discord.optional(...)` build nothing when
|
|
2363
|
+
neither credential is set (only one is a misconfiguration, refused), and
|
|
2364
|
+
`providers` skips `undefined`, `null` and `false` entries, so an
|
|
2365
|
+
environment without a provider's app leaves it out in place.
|
|
2366
|
+
- A guest's `createUser` may return `{ userId, name }` when the name it gave
|
|
2367
|
+
differs from the one asked (a numbered one after a collision), and the
|
|
2368
|
+
route answers it; `guest({ createUser, token: true })` also answers the
|
|
2369
|
+
session's token, for clients that keep no cookies (a game engine, a page
|
|
2370
|
+
in a third-party iframe), at the cost of the token being readable by the
|
|
2371
|
+
page's scripts.
|
|
2372
|
+
- `requireSession({ sessions, jwtSecret }, { loadPrincipal? })` guards the
|
|
2373
|
+
app's own REST routes (below): the credential is read as `/me` reads it,
|
|
2374
|
+
the JWT verified once and the session checked in the store; otherwise
|
|
2375
|
+
401 `{ error: "UNAUTHENTICATED", message }`. 4.1's `createRequireAuth`
|
|
2376
|
+
stays for token-keyed sessions.
|
|
2082
2377
|
- Rate limits: the sign-in routes share `createAuthLimiter({ max: 60 })` (60
|
|
2083
2378
|
requests per 15 minutes per IP) and the session routes
|
|
2084
2379
|
`createAuthStatusLimiter()` (120); pass `rateLimit: { signIn, session }`
|
|
@@ -2123,6 +2418,83 @@ nothing is cached:
|
|
|
2123
2418
|
session for an app's own sign-in flow (login codes, an embedded activity),
|
|
2124
2419
|
and `liveSession` reads a token back; both work with `socketAuth`.
|
|
2125
2420
|
|
|
2421
|
+
From the browser, `./client` speaks to these routes: `signInUrl(provider,
|
|
2422
|
+
{ apiUrl, returnTo })` is the start route's URL (to navigate to; `returnTo`
|
|
2423
|
+
defaults to the current page's origin), and `signOut()` and
|
|
2424
|
+
`signOutEverywhere()` post to `logout` and `logout-all`. Each POST sends the
|
|
2425
|
+
session cookie (`credentials: "include"`, so the API's CORS must allow the
|
|
2426
|
+
web app's origin with credentials) with `Content-Type: application/json`,
|
|
2427
|
+
and the token `setAuthToken` stored, if any, as a bearer token; the stored
|
|
2428
|
+
token is forgotten either way, and a refusal or an unreachable server
|
|
2429
|
+
rejects with a `QuickdrawError`. A socket keeps the user it signed in as
|
|
2430
|
+
until it connects again, so reconnect the provider's connection after
|
|
2431
|
+
signing out (with a token in `auth`, clearing it does that):
|
|
2432
|
+
|
|
2433
|
+
<!-- example: apps/web/src/components/SignIn.tsx#browser -->
|
|
2434
|
+
|
|
2435
|
+
```tsx
|
|
2436
|
+
export function SignIn() {
|
|
2437
|
+
// the kit's GET /auth/google/start: back to this page's origin with the session cookie
|
|
2438
|
+
return <a href={signInUrl("google", { apiUrl: API_URL })}>Sign in with Google</a>;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
export function SignOut() {
|
|
2442
|
+
const { connection } = useQuickdraw();
|
|
2443
|
+
const leave = async (): Promise<void> => {
|
|
2444
|
+
// POST /auth/logout with the cookie: the session is revoked, the cookie cleared
|
|
2445
|
+
await signOut({ apiUrl: API_URL });
|
|
2446
|
+
// the socket keeps its user until it connects again, as nobody now
|
|
2447
|
+
connection.close();
|
|
2448
|
+
connection.open();
|
|
2449
|
+
};
|
|
2450
|
+
return (
|
|
2451
|
+
<button type="button" onClick={() => void leave()}>
|
|
2452
|
+
Sign out
|
|
2453
|
+
</button>
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
```
|
|
2457
|
+
|
|
2458
|
+
An app's own REST route (a service worker's renewal, a webhook) signs the
|
|
2459
|
+
user in with `requireSession` and calls the services in process, so the
|
|
2460
|
+
method's validation, access check and tracked writes are a socket call's.
|
|
2461
|
+
`sessionOf(req)` gives the route `{ userId, sessionId, principal }`, typed,
|
|
2462
|
+
with no cast of `req` (`sessionOf<AppPrincipal>(req)` when `loadPrincipal`,
|
|
2463
|
+
like `socketAuth`'s, builds the app's own principal; by default it is
|
|
2464
|
+
`{ userId, kind: "user" }`); they are also set as `req.userId`,
|
|
2465
|
+
`req.sessionId` and `req.principal`. `qd.caller(principal)` then loads the
|
|
2466
|
+
user's grants as the user's sockets get them, so a method behind
|
|
2467
|
+
`{ service: L }` passes or answers `FORBIDDEN` as it would over a socket:
|
|
2468
|
+
|
|
2469
|
+
<!-- example: apps/api/src/auth/routes.ts#rest -->
|
|
2470
|
+
|
|
2471
|
+
```ts
|
|
2472
|
+
import { httpStatus, toWire } from "@fitzzero/quickdraw-core";
|
|
2473
|
+
import { requireSession, sessionOf } from "@fitzzero/quickdraw-core/server/auth";
|
|
2474
|
+
|
|
2475
|
+
// 401 without a live session; the principal built as socketAuth builds a socket's
|
|
2476
|
+
const signedIn = requireSession(
|
|
2477
|
+
{ sessions, jwtSecret: env.JWT_SECRET },
|
|
2478
|
+
{ loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "user" }) },
|
|
2479
|
+
);
|
|
2480
|
+
|
|
2481
|
+
app.get("/api/projects/:projectId/task-count", signedIn, (req, res) => {
|
|
2482
|
+
void (async () => {
|
|
2483
|
+
const { principal } = sessionOf<AppPrincipal>(req);
|
|
2484
|
+
try {
|
|
2485
|
+
// the method's validation, access check (with the user's grants) and writes, as over a socket
|
|
2486
|
+
const count = await qd.caller(principal).taskService.countOnBoard({
|
|
2487
|
+
projectId: req.params.projectId,
|
|
2488
|
+
});
|
|
2489
|
+
res.json({ count });
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
const failure = toWire(error);
|
|
2492
|
+
res.status(httpStatus(failure.code)).json(failure);
|
|
2493
|
+
}
|
|
2494
|
+
})();
|
|
2495
|
+
});
|
|
2496
|
+
```
|
|
2497
|
+
|
|
2126
2498
|
`createMemorySessionStore()` keeps sessions in the process, for development
|
|
2127
2499
|
and tests. In production, store them in the database. Sessions are not live
|
|
2128
2500
|
data, so nothing needs their writes tracked: with the tracked client, the
|
|
@@ -2203,8 +2575,14 @@ it("sends a rename to the other members' boards", async () => {
|
|
|
2203
2575
|
- `app.as(principal)` calls in process and `app.connect(principal)` over a
|
|
2204
2576
|
real socket (`{ call, socket, hello, close }`); both are keyed by service
|
|
2205
2577
|
name. `app.frames(match?)` lists every frame the server sent, with its
|
|
2206
|
-
socket and user; `frames.waitFor(match)` waits for one.
|
|
2207
|
-
`
|
|
2578
|
+
socket and user; `frames.waitFor(match)` waits for one. A query of one
|
|
2579
|
+
event takes `where`, a predicate over its frames typed by the event
|
|
2580
|
+
(`{ event: "qd:presence", where: ({ data }) => data.joined === id }`), and
|
|
2581
|
+
`streamFrames(contract, stream, where?, scope?)` and
|
|
2582
|
+
`eventFrames(contract, event, where?)` match one stream's items or one
|
|
2583
|
+
event's payloads, typed by the contract, to spread beside `socketId` or
|
|
2584
|
+
`userId`. `emitWithAck` and `waitForEvent` send raw frames and wait for
|
|
2585
|
+
events.
|
|
2208
2586
|
|
|
2209
2587
|
### Access matrices
|
|
2210
2588
|
|
|
@@ -2236,7 +2614,11 @@ it("lets the owner rename, members read, and nobody else in", async () => {
|
|
|
2236
2614
|
|
|
2237
2615
|
`"deny"` (the default for everyone `allow` does not name) means
|
|
2238
2616
|
`UNAUTHENTICATED` without a principal and `FORBIDDEN` with one. Mutations run
|
|
2239
|
-
for real, once per allowed principal: give inputs that can run again
|
|
2617
|
+
for real, once per allowed principal: give inputs that can run again, or
|
|
2618
|
+
`input` as a function of the cell (`({ name, principal }) => input`, sync or
|
|
2619
|
+
async), called before each cell's call, that makes a row of its own (a task
|
|
2620
|
+
to delete, an unused name), so no cell depends on the order of the
|
|
2621
|
+
principals.
|
|
2240
2622
|
|
|
2241
2623
|
### Performance budgets
|
|
2242
2624
|
|
|
@@ -2379,9 +2761,22 @@ async (Testing Library, an optional peer, is loaded lazily) and returns
|
|
|
2379
2761
|
Testing Library's result plus `connection`, `queryClient`, `disconnect()`
|
|
2380
2762
|
and `reconnect()`, which drop and restore the socket as a lost network does.
|
|
2381
2763
|
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2764
|
+
The web app's test imports the API's services across apps, as above, and
|
|
2765
|
+
needs a database: run such tests in a jsdom project of their own, with the
|
|
2766
|
+
API's global setup (the template its test databases boot from, below) and a
|
|
2767
|
+
setup file that calls `installJsdomShims()` from `./testing/client`. That
|
|
2768
|
+
adds what jsdom lacks, and keeps what it has: `scrollTo`, `scrollBy` and
|
|
2769
|
+
`scrollIntoView` on elements (they do nothing; a list that follows its newest
|
|
2770
|
+
item calls them) and `Blob.prototype.arrayBuffer` (PGlite reads a database
|
|
2771
|
+
dump through it). This repository runs the README's example app that way:
|
|
2772
|
+
`packages/core/vitest.config.ts`, project `readme`, with
|
|
2773
|
+
`test/readme/globalSetup.ts` and `test/readme/workerSetup.ts`.
|
|
2774
|
+
|
|
2775
|
+
For a component test or a story without a server, `createMockClient(contracts)`
|
|
2776
|
+
gives the typed client's shape with stubs; give it to the components in place
|
|
2777
|
+
of the app's client (a module mock of the file that exports `qd`, say). It
|
|
2778
|
+
comes from `./testing/client`, or from `@fitzzero/quickdraw-core/testing/mock`,
|
|
2779
|
+
which names no Testing Library, for a browser bundle (Storybook):
|
|
2385
2780
|
|
|
2386
2781
|
<!-- example: apps/web/src/components/TaskBoard.test.tsx#mock -->
|
|
2387
2782
|
|
|
@@ -2401,14 +2796,52 @@ each test only when the test runner has a global `afterEach` (vitest with
|
|
|
2401
2796
|
(`resetAfterEach: false` opts out); otherwise call `mock.$reset()` in an
|
|
2402
2797
|
`afterEach` of your own, as above. Optimistic updates are not shown.
|
|
2403
2798
|
|
|
2799
|
+
The members' hooks need no provider. A component that reads the connection
|
|
2800
|
+
(`useQuickdraw()`, `usePresence(room)`) renders inside the mock's own
|
|
2801
|
+
provider, `mock.$Provider`, where those real hooks read the mock's session:
|
|
2802
|
+
who it acts for, set with `createMockClient(contracts, { session })` and
|
|
2803
|
+
`mock.$session({ userId, serviceAccess, isConnected, isKnown })` (each field
|
|
2804
|
+
left out keeps its starting value, and the reset after each test puts the
|
|
2805
|
+
starting session back), and the rooms `mock.$presence(room, userIds)` sets.
|
|
2806
|
+
Its views select members for the session's user:
|
|
2807
|
+
|
|
2808
|
+
<!-- example: apps/web/src/components/TaskBoard.test.tsx#session -->
|
|
2809
|
+
|
|
2810
|
+
```tsx
|
|
2811
|
+
it("lets a signed-in user through the gate", () => {
|
|
2812
|
+
mock.$session({ userId: "ada", serviceAccess: { taskService: "Admin" } }); // useQuickdraw() shows it
|
|
2813
|
+
render(<AuthGate>Board</AuthGate>, { wrapper: mock.$Provider });
|
|
2814
|
+
expect(screen.getByText("Board")).toBeTruthy();
|
|
2815
|
+
});
|
|
2816
|
+
```
|
|
2817
|
+
|
|
2818
|
+
In Storybook, a decorator renders every story inside `qd.$Provider`, and a
|
|
2819
|
+
story's `beforeEach` sets its session (`qd.$session(...)`) beside its data.
|
|
2820
|
+
|
|
2404
2821
|
### Test databases
|
|
2405
2822
|
|
|
2406
2823
|
`@fitzzero/quickdraw-core/testing/prisma` gives each vitest worker a database
|
|
2407
2824
|
of its own: `createPrismaTestGlobalSetup` migrates a template once per run
|
|
2408
2825
|
and clones a database per worker on PostgreSQL (`TEST_DATABASE_URL`), or
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
the
|
|
2826
|
+
builds a PGlite dump without one; `workerDatabaseUrl` and `resetDatabase`
|
|
2827
|
+
(truncates every table) do the rest. On PGlite each worker boots its own
|
|
2828
|
+
database from the dump with `openPgliteFromTemplate`, in milliseconds and
|
|
2829
|
+
under jsdom too (it reads the dump through Node's `Blob`); the app's db
|
|
2830
|
+
package gives its test client from it while tests run:
|
|
2831
|
+
|
|
2832
|
+
<!-- example: packages/db/src/testing.ts#worker -->
|
|
2833
|
+
|
|
2834
|
+
```ts
|
|
2835
|
+
// this worker's own database: the migrated template, loaded in milliseconds
|
|
2836
|
+
const pglite = await openPgliteFromTemplate(TEST_TEMPLATE);
|
|
2837
|
+
export const prisma = new PrismaClient({ adapter: new PrismaPGlite(pglite) });
|
|
2838
|
+
```
|
|
2839
|
+
|
|
2840
|
+
`TEST_TEMPLATE` is `{ migrationsDir, cacheDir, templateName }`, the same the
|
|
2841
|
+
global setup builds with (`buildPgliteTemplate(TEST_TEMPLATE)`, or
|
|
2842
|
+
`createPrismaTestGlobalSetup`), and a setup file empties the database before
|
|
2843
|
+
each test (`beforeEach(() => resetDatabase(prisma))`). Apply `trackPrisma`
|
|
2844
|
+
to the test client exactly as in production.
|
|
2412
2845
|
|
|
2413
2846
|
## Observability
|
|
2414
2847
|
|
|
@@ -2461,17 +2894,30 @@ is also a server span named `service.method`, with an error status for
|
|
|
2461
2894
|
|
|
2462
2895
|
## API docs from contracts
|
|
2463
2896
|
|
|
2464
|
-
The `quickdraw-docs` command writes Markdown API docs from the contracts
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2897
|
+
The `quickdraw-docs` command writes Markdown API docs from the contracts:
|
|
2898
|
+
one page per service (its entity and field tiers, projections, methods
|
|
2899
|
+
with their kind, input fields and output, collections, streams, channels
|
|
2900
|
+
and events, from the schemas' JSON Schema where they have one) and a
|
|
2901
|
+
`README.md` index. It reads contracts (and, with `--services`, the
|
|
2902
|
+
services' definitions), never source code.
|
|
2469
2903
|
|
|
2470
2904
|
```bash
|
|
2471
2905
|
quickdraw-docs packages/shared/src/index.ts --out docs/api # write the pages
|
|
2472
2906
|
quickdraw-docs packages/shared/src/index.ts --out docs/api --check # exit 1 when they are stale
|
|
2907
|
+
quickdraw-docs packages/shared/src/index.ts --services apps/api/src/services/index.ts --out docs/api
|
|
2473
2908
|
```
|
|
2474
2909
|
|
|
2910
|
+
With `--services <module>` (a module exporting the services, each or in a
|
|
2911
|
+
list, as the server takes them; importing it must not start the server),
|
|
2912
|
+
each page also says who may call what, read from the services'
|
|
2913
|
+
definitions: an "Access" section with the row policy, whether a
|
|
2914
|
+
service-wide `Admin` grant passes every check, who may watch the change
|
|
2915
|
+
topic (`watchAccess`) and the field levels; each method's access form, in
|
|
2916
|
+
words, and its `rowless`; who may open a collection's scope; a channel's
|
|
2917
|
+
access; a stream's computed seed and when its items are checked. A
|
|
2918
|
+
contract the services module has no service for says so, and a service
|
|
2919
|
+
without a contract is an error. Pass the same flag to `--check`.
|
|
2920
|
+
|
|
2475
2921
|
The module may export each contract, or a map of them as given to
|
|
2476
2922
|
`createQuickdrawClient`. A TypeScript module loads through Node's type
|
|
2477
2923
|
stripping, or through `tsx` when the project has it installed (for
|
|
@@ -2483,7 +2929,7 @@ nothing. Run `--check` in CI next to the lint step.
|
|
|
2483
2929
|
|
|
2484
2930
|
## Lint rules and agent guidance
|
|
2485
2931
|
|
|
2486
|
-
[`@fitzzero/quickdraw-lint`](
|
|
2932
|
+
[`@fitzzero/quickdraw-lint`](https://github.com/fitzzero/quickdraw/tree/dev/packages/lint) is the oxlint plugin and base
|
|
2487
2933
|
config every 5.0 app extends: it reports untracked and foreign writes, nested
|
|
2488
2934
|
and raw SQL writes, hand-sent frames, inline auth guards, unbounded reads,
|
|
2489
2935
|
database calls and emits in loops, layering breaks, bypasses of the typed
|
|
@@ -2498,7 +2944,7 @@ takes an `id`, unless it says `rowless: true`); then lint; then a
|
|
|
2498
2944
|
development warning as it happens (`repeated-call`, `repeated-mutation` and
|
|
2499
2945
|
`repeated-invalidation` name a client loop before the rate limit does).
|
|
2500
2946
|
|
|
2501
|
-
[`@fitzzero/quickdraw-skills`](
|
|
2947
|
+
[`@fitzzero/quickdraw-skills`](https://github.com/fitzzero/quickdraw/tree/dev/packages/skills) ships agent rules and skills
|
|
2502
2948
|
for quickdraw apps and links them into `.claude/` with
|
|
2503
2949
|
`quickdraw-skills link`, so every app's agents read the same, current
|
|
2504
2950
|
guidance:
|
|
@@ -2514,11 +2960,11 @@ guidance:
|
|
|
2514
2960
|
|
|
2515
2961
|
## Migrating from 4.x
|
|
2516
2962
|
|
|
2517
|
-
[`@fitzzero/quickdraw-codemod`](
|
|
2963
|
+
[`@fitzzero/quickdraw-codemod`](https://github.com/fitzzero/quickdraw/tree/dev/packages/codemod) moves a 4.x app to 5.0:
|
|
2518
2964
|
contracts from the method maps, `defineService` from the service classes,
|
|
2519
2965
|
the typed client for the hooks, and a report of everything left to decide.
|
|
2520
|
-
[`MIGRATION.md`](
|
|
2521
|
-
the defaults that changed; [`UPGRADE-PROMPT.md`](
|
|
2966
|
+
[`MIGRATION.md`](https://github.com/fitzzero/quickdraw/blob/dev/MIGRATION.md) explains each step, the access mapping and
|
|
2967
|
+
the defaults that changed; [`UPGRADE-PROMPT.md`](https://github.com/fitzzero/quickdraw/blob/dev/UPGRADE-PROMPT.md) is the
|
|
2522
2968
|
procedure for an agent. Both ship with the codemod, at
|
|
2523
2969
|
`node_modules/@fitzzero/quickdraw-codemod/MIGRATION.md` and
|
|
2524
2970
|
`node_modules/@fitzzero/quickdraw-codemod/UPGRADE-PROMPT.md`.
|
|
@@ -2533,7 +2979,7 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
|
|
|
2533
2979
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2534
2980
|
| `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names |
|
|
2535
2981
|
| `./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
|
|
2982
|
+
| `./server/auth` | `createAuthRoutes`, `socketAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
|
|
2537
2983
|
| `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
|
|
2538
2984
|
| `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
|
|
2539
2985
|
| `./server/otel` | `otelOnCall` |
|
|
@@ -2542,7 +2988,8 @@ bunx @fitzzero/quickdraw-codemod@next v5 .
|
|
|
2542
2988
|
| `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
|
|
2543
2989
|
| `./parser` | the JSON-only Socket.IO parser |
|
|
2544
2990
|
| `./testing` | `createTestApp`, `describeAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
|
|
2545
|
-
| `./testing/client` | `renderWithQuickdraw`, `
|
|
2991
|
+
| `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
|
|
2992
|
+
| `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
|
|
2546
2993
|
| `./testing/prisma` | test databases on PostgreSQL or PGlite |
|
|
2547
2994
|
|
|
2548
2995
|
The package also ships the `quickdraw-docs` command.
|
|
@@ -2551,7 +2998,7 @@ The package also ships the `quickdraw-docs` command.
|
|
|
2551
2998
|
|
|
2552
2999
|
A bun workspace with turbo: `packages/core` (this package), `packages/lint`,
|
|
2553
3000
|
`packages/skills` and `packages/codemod`. See
|
|
2554
|
-
[CONTRIBUTING.md](
|
|
3001
|
+
[CONTRIBUTING.md](https://github.com/fitzzero/quickdraw/blob/dev/CONTRIBUTING.md).
|
|
2555
3002
|
|
|
2556
3003
|
```bash
|
|
2557
3004
|
bun install
|