@fitzzero/quickdraw-core 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.
Files changed (124) hide show
  1. package/README.md +793 -57
  2. package/dist/{admin-DWWrT1h6.d.ts → admin-CS7JB_UH.d.ts} +2 -2
  3. package/dist/{adminSchemas-D4bFKsrb.d.ts → adminSchemas-29cGgrg5.d.ts} +1 -1
  4. package/dist/{auth-DXiSJZaf.d.ts → auth-DtdYjrZ9.d.ts} +11 -7
  5. package/dist/{caller-BT-Mv4bD.d.ts → caller-BTwGl7vm.d.ts} +183 -15
  6. package/dist/chunk-2FDSKLBO.js +82 -0
  7. package/dist/chunk-2FDSKLBO.js.map +1 -0
  8. package/dist/{chunk-GSIPUNJL.js → chunk-64I6MYBC.js} +139 -792
  9. package/dist/chunk-64I6MYBC.js.map +1 -0
  10. package/dist/chunk-7473HK24.js +17 -0
  11. package/dist/chunk-7473HK24.js.map +1 -0
  12. package/dist/chunk-7Y5ROPXL.js +33 -0
  13. package/dist/chunk-7Y5ROPXL.js.map +1 -0
  14. package/dist/chunk-A2RBQD66.js +7 -0
  15. package/dist/chunk-A2RBQD66.js.map +1 -0
  16. package/dist/chunk-ASAMUGHG.js +651 -0
  17. package/dist/chunk-ASAMUGHG.js.map +1 -0
  18. package/dist/{chunk-B2P7AZ25.js → chunk-CYE4UBOB.js} +6 -4
  19. package/dist/chunk-CYE4UBOB.js.map +1 -0
  20. package/dist/chunk-DNP65TD5.js +832 -0
  21. package/dist/chunk-DNP65TD5.js.map +1 -0
  22. package/dist/{chunk-5CGO426C.js → chunk-GYLWEJHW.js} +5 -1
  23. package/dist/chunk-GYLWEJHW.js.map +1 -0
  24. package/dist/{chunk-AONAFOYU.js → chunk-I3JCFS7H.js} +25 -13
  25. package/dist/chunk-I3JCFS7H.js.map +1 -0
  26. package/dist/{chunk-X5CWPX7H.js → chunk-IIQ3WMJK.js} +13 -2
  27. package/dist/chunk-IIQ3WMJK.js.map +1 -0
  28. package/dist/{chunk-6K5BY47X.js → chunk-JVTONQFI.js} +1277 -277
  29. package/dist/chunk-JVTONQFI.js.map +1 -0
  30. package/dist/{chunk-RL4VKLBX.js → chunk-KKQANRLG.js} +2 -2
  31. package/dist/{chunk-RL4VKLBX.js.map → chunk-KKQANRLG.js.map} +1 -1
  32. package/dist/{chunk-GKH3LJ7N.js → chunk-MPQAT55F.js} +38 -205
  33. package/dist/chunk-MPQAT55F.js.map +1 -0
  34. package/dist/chunk-NX2V6SIL.js +202 -0
  35. package/dist/chunk-NX2V6SIL.js.map +1 -0
  36. package/dist/{chunk-7QCUX4TK.js → chunk-NZHA4TZD.js} +72 -175
  37. package/dist/chunk-NZHA4TZD.js.map +1 -0
  38. package/dist/{chunk-MBH6IDSM.js → chunk-OCZJ4EYV.js} +40 -17
  39. package/dist/chunk-OCZJ4EYV.js.map +1 -0
  40. package/dist/{chunk-RJ5I2VO3.js → chunk-PHCURHC7.js} +6 -6
  41. package/dist/chunk-PHCURHC7.js.map +1 -0
  42. package/dist/{chunk-4YO5H5ON.js → chunk-Q4OGQM5I.js} +4 -10
  43. package/dist/chunk-Q4OGQM5I.js.map +1 -0
  44. package/dist/chunk-QSU47XEX.js +13 -0
  45. package/dist/chunk-QSU47XEX.js.map +1 -0
  46. package/dist/{chunk-LCC7HNFP.js → chunk-RZXJTDH5.js} +2 -2
  47. package/dist/{chunk-Z2LZ7IZ7.js → chunk-SN3OZVEQ.js} +5 -31
  48. package/dist/chunk-SN3OZVEQ.js.map +1 -0
  49. package/dist/{chunk-Z5ILULF2.js → chunk-SQ5VUG77.js} +4 -3
  50. package/dist/{chunk-Z5ILULF2.js.map → chunk-SQ5VUG77.js.map} +1 -1
  51. package/dist/chunk-STH5DLQP.js +1192 -0
  52. package/dist/chunk-STH5DLQP.js.map +1 -0
  53. package/dist/{chunk-NMXYENH4.js → chunk-VZFJTKIW.js} +6 -6
  54. package/dist/{chunk-753VJIUP.js → chunk-X4ZU4QH5.js} +43 -9
  55. package/dist/chunk-X4ZU4QH5.js.map +1 -0
  56. package/dist/chunk-XD7SRDUY.js +188 -0
  57. package/dist/chunk-XD7SRDUY.js.map +1 -0
  58. package/dist/chunk-Z7I6SONO.js +825 -0
  59. package/dist/chunk-Z7I6SONO.js.map +1 -0
  60. package/dist/cli/quickdraw-docs.js +131 -113
  61. package/dist/cli/quickdraw-docs.js.map +1 -1
  62. package/dist/client/core.d.ts +387 -0
  63. package/dist/client/core.js +109 -0
  64. package/dist/client/core.js.map +1 -0
  65. package/dist/client/index.d.ts +30 -193
  66. package/dist/client/index.js +56 -217
  67. package/dist/client/index.js.map +1 -1
  68. package/dist/{clientTypes-hWcPUVS9.d.ts → clientTypes-CUUNJwWs.d.ts} +3 -3
  69. package/dist/{connection-CgijdGpT.d.ts → connection-2EeQ_Et0.d.ts} +16 -9
  70. package/dist/{createServer-Cp-Wk500.d.ts → createServer-Bx5HOcYK.d.ts} +37 -9
  71. package/dist/{createTestApp-BB_PkqQ7.d.ts → createTestApp-DAMANT79.d.ts} +36 -9
  72. package/dist/{envelope-DzWyIXjj.d.ts → envelope-BMBaxppV.d.ts} +200 -8
  73. package/dist/{http-DotQ5bj8.d.ts → http-YzLAar3w.d.ts} +15 -50
  74. package/dist/index.d.ts +7 -6
  75. package/dist/index.js +61 -28
  76. package/dist/index.js.map +1 -1
  77. package/dist/{infer-BmoZsL9a.d.ts → infer-Br62cnyW.d.ts} +39 -1
  78. package/dist/{metrics-DW80Exve.d.ts → metrics-CTVpw0gu.d.ts} +2 -2
  79. package/dist/parser.js +2 -1
  80. package/dist/prisma/index.d.ts +13 -6
  81. package/dist/prisma/index.js +155 -9
  82. package/dist/prisma/index.js.map +1 -1
  83. package/dist/remote-N7OUAGOW.js +15 -0
  84. package/dist/remote-N7OUAGOW.js.map +1 -0
  85. package/dist/server/auth/index.d.ts +3 -3
  86. package/dist/server/auth/index.js +5 -3
  87. package/dist/server/auth/index.js.map +1 -1
  88. package/dist/server/index.d.ts +140 -36
  89. package/dist/server/index.js +162 -55
  90. package/dist/server/index.js.map +1 -1
  91. package/dist/server/mcp/index.d.ts +34 -18
  92. package/dist/server/mcp/index.js +29 -12
  93. package/dist/server/mcp/index.js.map +1 -1
  94. package/dist/server/otel.d.ts +3 -3
  95. package/dist/{sharing-UDLn1049.d.ts → sharing-BrsmlDv_.d.ts} +3 -3
  96. package/dist/testing/client.d.ts +13 -13
  97. package/dist/testing/client.js +17 -10
  98. package/dist/testing/client.js.map +1 -1
  99. package/dist/testing/index.d.ts +49 -12
  100. package/dist/testing/index.js +102 -19
  101. package/dist/testing/index.js.map +1 -1
  102. package/dist/testing/mock.d.ts +5 -5
  103. package/dist/testing/mock.js +15 -9
  104. package/dist/tracked-BR5b2O-U.d.ts +43 -0
  105. package/dist/{types-CYliTP65.d.ts → types-CLRCQGnO.d.ts} +4 -3
  106. package/dist/types-tMagHbjy.d.ts +151 -0
  107. package/dist/utils/index.d.ts +16 -4
  108. package/dist/utils/index.js +6 -3
  109. package/package.json +5 -1
  110. package/dist/chunk-4YO5H5ON.js.map +0 -1
  111. package/dist/chunk-5CGO426C.js.map +0 -1
  112. package/dist/chunk-6K5BY47X.js.map +0 -1
  113. package/dist/chunk-753VJIUP.js.map +0 -1
  114. package/dist/chunk-7QCUX4TK.js.map +0 -1
  115. package/dist/chunk-AONAFOYU.js.map +0 -1
  116. package/dist/chunk-B2P7AZ25.js.map +0 -1
  117. package/dist/chunk-GKH3LJ7N.js.map +0 -1
  118. package/dist/chunk-GSIPUNJL.js.map +0 -1
  119. package/dist/chunk-MBH6IDSM.js.map +0 -1
  120. package/dist/chunk-RJ5I2VO3.js.map +0 -1
  121. package/dist/chunk-X5CWPX7H.js.map +0 -1
  122. package/dist/chunk-Z2LZ7IZ7.js.map +0 -1
  123. /package/dist/{chunk-LCC7HNFP.js.map → chunk-RZXJTDH5.js.map} +0 -0
  124. /package/dist/{chunk-NMXYENH4.js.map → chunk-VZFJTKIW.js.map} +0 -0
package/README.md CHANGED
@@ -48,6 +48,7 @@ entries you import need.
48
48
  | `./server/otel` | `@opentelemetry/api` |
49
49
  | `./prisma` | `@prisma/client` 7 |
50
50
  | `./client` | `socket.io-client`, `@tanstack/react-query` 5, `react` 19 |
51
+ | `./client/core` | `socket.io-client` |
51
52
  | `./utils`, `./parser` | nothing more |
52
53
  | `./testing` | `socket.io`, `socket.io-client` |
53
54
  | `./testing/client` | the `./client` peers, and `@testing-library/react` |
@@ -157,6 +158,8 @@ export interface AppPrincipal extends Principal {
157
158
  export const qd = initQuickdraw<{
158
159
  db: typeof db;
159
160
  principal: AppPrincipal;
161
+ // what the MCP bridge's context hook gives an agent's calls, as ctx.mcp
162
+ mcp: { readonly scopes: readonly string[] };
160
163
  contracts: typeof contracts;
161
164
  }>();
162
165
  ```
@@ -594,6 +597,10 @@ export const taskService = qd.defineService(task, {
594
597
  // instead of the dispatcher's callTimeoutMs (30 s)
595
598
  timeoutMs: 5_000,
596
599
  handler: async ({ input, ctx, db }) => {
600
+ // an agent's call carries its token's scopes as ctx.mcp; a user's call has none
601
+ if (ctx.mcp !== undefined && !ctx.mcp.scopes.includes("tasks")) {
602
+ throw new QuickdrawError("FORBIDDEN", "This token may not assign tasks");
603
+ }
597
604
  const found = await db.task.findUnique({ where: { id: input.id } });
598
605
  if (found === null) {
599
606
  // the caller receives the code
@@ -619,16 +626,20 @@ export const taskService = qd.defineService(task, {
619
626
  Each call runs the whole pipeline with transport `"internal"` (input
620
627
  check, access, handler, output check), so access is checked on the inner
621
628
  call too; its writes join the calling method's unit of work, so both
622
- flush together once; and it is cancelled with `ctx.signal`. It is typed
623
- by the `contracts` of `initQuickdraw`'s types, as `qd.caller` is.
629
+ flush together once; and it is cancelled with `ctx.signal`. It carries
630
+ the calling method's `ctx.mcp` as well: every call an MCP call makes
631
+ through `ctx.services`, nested ones included, sees the MCP call's fields
632
+ with transport `"internal"`, so tell a token's call apart by `ctx.mcp`,
633
+ never by `ctx.transport`. It is typed by the `contracts` of
634
+ `initQuickdraw`'s types, as `qd.caller` is.
624
635
  - `share: "caller"` runs identical concurrent calls of one principal once,
625
636
  `share: "all"` across principals; `ttlMs` keeps a shared result. A
626
637
  `"all"` run's handler sees the first caller's `ctx` and every caller gets
627
638
  its result, so a result shared across principals must depend on the
628
- input only: it never reads `ctx.principal`, `ctx.services`, `ctx.mcp` or
629
- a context field made from them (in development, reading one of the first
630
- three warns `[quickdraw:shared-principal]`; copying or logging the whole
631
- `ctx` reads them too). `"all"` is refused with
639
+ input only: it never reads `ctx.principal`, `ctx.services`, `ctx.mcp`,
640
+ `ctx.access` or a context field made from them (in development, reading
641
+ one of the first four warns `[quickdraw:shared-principal]`; copying or
642
+ logging the whole `ctx` reads them too). `"all"` is refused with
632
643
  `custom` access and on the kit methods whose result depends on the caller
633
644
  (the read/write kit's `list` and `getMany`, `search`, the sharing kit's
634
645
  `listShares` and `listMembers`, the admin kit's `adminList` and
@@ -700,7 +711,7 @@ not exist or a malformed access list denies.
700
711
  <!-- example: apps/api/src/services/examples/access.ts#access -->
701
712
 
702
713
  ```ts
703
- import { anyOf, crud, inherit, jsonAcl, members } from "@fitzzero/quickdraw-core/server";
714
+ import { anyOf, crud, custom, inherit, jsonAcl, members } from "@fitzzero/quickdraw-core/server";
704
715
 
705
716
  export const projectService = qd.defineService(project, {
706
717
  // the Prisma model the rows live in
@@ -755,6 +766,24 @@ export const taskService = qd.defineService(task, {
755
766
  handler: ({ input, db }) =>
756
767
  db.task.update({ where: { id: input.id }, data: { status: input.status } }),
757
768
  },
769
+ assigned: {
770
+ // Read on the project the input names, asked through ctx.access; none named, the caller's
771
+ // own that they may still read
772
+ access: custom(
773
+ async (ctx, input) =>
774
+ input.projectId === undefined || (await ctx.access.can(project, input.projectId, "Read")),
775
+ ),
776
+ handler: async ({ input, ctx, db }) => {
777
+ const readable = await ctx.access.where(task, "Read");
778
+ if (readable === "none") return [];
779
+ return db.task.findMany({
780
+ where: {
781
+ AND: [{ assigneeId: ctx.principal.userId, projectId: input.projectId }, readable],
782
+ },
783
+ take: 100,
784
+ });
785
+ },
786
+ },
758
787
  },
759
788
  });
760
789
  ```
@@ -767,7 +796,8 @@ export const taskService = qd.defineService(task, {
767
796
  `UNAUTHENTICATED`; a principal that fails gets `FORBIDDEN`. The levels,
768
797
  lowest first, are `Public`, `Read`, `Moderate` and `Admin`. A `custom`
769
798
  form replaces the row check, and a service-wide `Admin` grant skips it: a
770
- check on the caller's claims is `claims` (below).
799
+ check on the caller's claims is `claims` (below), and one that needs the
800
+ caller's level on a row asks `ctx.access` (`assigned` above, and below).
771
801
  - `kinds`, beside `access`, says which kinds of principal
772
802
  (`principal.kind`) may call: `initQuickdraw({ kinds: ["user", "agent"] })`
773
803
  for every service the app defines, `defineService(contract, { kinds })`
@@ -857,6 +887,31 @@ export const taskService = qd.defineService(task, {
857
887
  - `server.dispatcher.access` gives the same answers to other code:
858
888
  `levelsFor(service, principal, ids)`, `accessWhere(service, principal, level)`
859
889
  (a `where` filter for `findMany`, or `"none"`) and `onAccessChanged(listener)`.
890
+ - `ctx.access` gives them to a handler or a `custom` form, as the caller:
891
+ `levelsFor(service, ids)`, `can(service, id, level)` and
892
+ `where(service, level)` are those bound to `ctx.principal`, with the
893
+ grants the call runs with (a socket's, or those an in-process caller
894
+ loaded); `service` is a contract or a service name. A form can then check
895
+ a row no `{ entry }` or `{ scope }` form names: a parent the input
896
+ names, or a key that is optional, as in `assigned` above, which passes
897
+ without a project and needs Read on one it names. Its handler adds
898
+ `where(task, "Read")` to its query with `AND`, so a list without a
899
+ project still follows the row policy. Never spread the filter into
900
+ `where`: it and the input both set `projectId`, and whichever comes last
901
+ wins, widening a list with a project or dropping the policy from one
902
+ without. `ctx.access` counts levels as
903
+ `{ entry }` does: a service-wide `Admin` grant reaches every row (with
904
+ `adminBypass`), a row that is not found gives anyone else no level
905
+ (`can` is `false`), and an anonymous caller has none anywhere (an empty
906
+ map, `false`, `"none"`). So a form that asks it about a parent counts a
907
+ service-wide `Admin` grant on the parent's service, which
908
+ `{ scope, of }` does not: prefer `{ scope: L, of, id }` when the input
909
+ always names the parent. A `custom` form is still no row check of the
910
+ method's own `id`: a method whose input has one needs `rowless: true`
911
+ beside it (above). Every method call has it, MCP and `ctx.services` calls
912
+ included, and so do the `ctx` the kits pass to their forms and callbacks;
913
+ a context no dispatcher built throws `INTERNAL` on its use, and `qd` has
914
+ none: a job asks `server.dispatcher.access`.
860
915
  - `createServer({ access: { cacheMs: 30_000 } })` keeps policy lookups across
861
916
  requests; tracked writes to the columns and membership tables the policies
862
917
  read evict them. Writes the tracked client cannot see are picked up only
@@ -928,12 +983,53 @@ export async function markStale(before: Date): Promise<number> {
928
983
  }
929
984
  ```
930
985
 
986
+ - A helper that writes takes the tracked client by its type. `trackPrisma`
987
+ returns `Tracked<C>`: still a `C` wherever one is asked for, while the
988
+ untracked client is no `Tracked<C>`. Type the helper's client
989
+ `Tracked<PrismaClient>`, or `TrackedTransaction<PrismaClient>` when it
990
+ may also run inside `db.$transaction(async (tx) => ...)`, whose `tx` is
991
+ one (`db` is one too), and the untracked client passed to it is a type
992
+ error. A helper typed `PrismaClient` takes either client. Both types are
993
+ type-only, on `./prisma` and on the root export, so a shared package
994
+ names them without Prisma. `no-untracked-write` reads the type too: a
995
+ parameter so typed may be named `prisma`.
996
+
997
+ <!-- example: apps/api/src/jobs/overdue.ts#helper -->
998
+
999
+ ```ts
1000
+ import type { TrackedTransaction } from "@fitzzero/quickdraw-core/prisma";
1001
+ import type { PrismaClient } from "@project/db";
1002
+
1003
+ // takes the tracked client, or a transaction's: passing the untracked `prisma` is a type error
1004
+ export async function reopenStale(
1005
+ client: TrackedTransaction<PrismaClient>,
1006
+ projectId: string,
1007
+ ): Promise<number> {
1008
+ const { count } = await client.task.updateMany({
1009
+ where: { projectId, status: "stale" },
1010
+ data: { status: "open" },
1011
+ });
1012
+ return count;
1013
+ }
1014
+
1015
+ export async function restoreProject(projectId: string): Promise<number> {
1016
+ return await qd.run(() =>
1017
+ db.$transaction(async (tx) => {
1018
+ await tx.project.update({ where: { id: projectId }, data: { archived: false } });
1019
+ return await reopenStale(tx, projectId);
1020
+ }),
1021
+ );
1022
+ }
1023
+ ```
1024
+
931
1025
  - Not seen: the related rows of a nested write
932
1026
  (`{ labels: { create: [...] } }`, which warns in development), raw SQL and
933
1027
  database cascades. A foreign key a nested write sets on the written row
934
1028
  (`project: { connect }`, `parent: { disconnect: true }`) is seen: such an
935
1029
  `update` or `upsert` reads the row's interested columns first, one more
936
- statement. Record raw SQL with
1030
+ statement. Before deleting a row, delete or clear through `db` the rows
1031
+ a cascade would take with it (for the admin kit's `adminDelete`, in its
1032
+ `prepare`). Record raw SQL with
937
1033
  `ctx.touch("task", ids)`, or `{ removed: true }` for deleted rows. A job
938
1034
  gets the same `touch` from `qd.run`, whose `fn` receives
939
1035
  `{ touch, log, principal: null }`:
@@ -1180,6 +1276,20 @@ export const taskService = qd.defineService(task, {
1180
1276
  resumes the list after such a change reads a page. A change to a parent
1181
1277
  row's own access columns (a project's owner or access list) reaches the
1182
1278
  list only at the rows' next write or the list's next load.
1279
+ - `requires: { service: L }`, beside `anchor` or `scopeAccess`, also asks
1280
+ for a service-wide grant of at least `L` on the collection's service, as
1281
+ its methods under `{ service: L }` do: both checks must pass, and a
1282
+ service-wide `Admin` passes either. It is for a list whose methods need
1283
+ the grant, `mine: { scopeAccess: "self", requires: { service: "Read" } }`
1284
+ beside methods under `{ service: "Read" }`; a list whose rows its users
1285
+ read by their level on each row (`assigned` above) takes none. Without
1286
+ the grant, `qd:col:sub` (cursor pages included), the scope's change
1287
+ topic and a search kept to the scope are `FORBIDDEN`. The grant comes
1288
+ from the socket's principal, never a read: when the user's grants reload
1289
+ below it (`server.access.refresh`, a tracked write of the grants column,
1290
+ on any node), each of their sockets leaves the scope with `qd:revoked`
1291
+ (`reason: "access"`) and gets nothing more of it. A collection without
1292
+ `requires` is unchanged.
1183
1293
  - Items are visible to everyone in an anchored scope: no per-row policy or
1184
1294
  field tier applies inside it. Derive the item service's own access from
1185
1295
  the anchor (`inherit` from it, as above): a per-row policy on the item
@@ -1248,6 +1358,8 @@ over three transports (design: sections 3, 8 and 10):
1248
1358
  events per minute per socket; channels, cancels and subscription events not
1249
1359
  counted); configure it with `rateLimit`, or turn it off with
1250
1360
  `rateLimit: false`. There is no default CORS origin: pass `cors`.
1361
+ `peers: true` serves peer links between the clients of an app room
1362
+ ([Peer links](#peer-links)).
1251
1363
  Socket.IO's connection state recovery is refused: `createServer` throws
1252
1364
  for `socket: { connectionStateRecovery }`, since a recovered socket would
1253
1365
  come back with its principal and rooms without authenticating again, so a
@@ -1291,7 +1403,11 @@ over three transports (design: sections 3, 8 and 10):
1291
1403
  `authenticate` takes one request (`{ transport, auth, headers, socket | req }`,
1292
1404
  plus `credential` and `sessionCookie` on HTTP) for both transports and
1293
1405
  returns a principal, a user id, or nothing for an anonymous caller; throwing
1294
- `QuickdrawError("UNAUTHENTICATED", ...)` refuses. On HTTP the session cookie
1406
+ `QuickdrawError("UNAUTHENTICATED", ...)` (or `"FORBIDDEN"`) refuses. Anything
1407
+ else it, or `loadServiceAccess`, throws is the server's failure (its session
1408
+ store or database down): a socket's handshake is refused `INTERNAL`, and a
1409
+ quickdraw client keeps the user and connects again by itself, while an HTTP
1410
+ call is answered `UNAUTHENTICATED`, as before. On HTTP the session cookie
1295
1411
  is in `auth.token` only for `socketAuth`'s `authenticate`.
1296
1412
  Pass your own HTTP server as `httpServer` together with the `app` it was
1297
1413
  created from (or with `http: false`).
@@ -1499,6 +1615,53 @@ app.use(createMcpHttpRouter({ registry: projectAgents, path: "/agents" }));
1499
1615
  socket or HTTP with nothing bound, so the method's access must still
1500
1616
  confine it to its project.
1501
1617
 
1618
+ An app that signs the caller in itself, such as its own MCP transport over
1619
+ `callMethod` or an in-process assistant, passes the caller as `principal`
1620
+ in the call's options, and the registry's `principal` hook is not asked:
1621
+
1622
+ <!-- example: apps/api/src/mcp.ts#signed-in -->
1623
+
1624
+ ```ts
1625
+ import { requireSession, sessionOf } from "@fitzzero/quickdraw-core/server/auth";
1626
+ import { toToolResult } from "@fitzzero/quickdraw-core/server/mcp";
1627
+
1628
+ // the app's own MCP route: its sessions sign the caller in, as an agent of their user
1629
+ const signedIn = requireSession(
1630
+ { sessions, jwtSecret },
1631
+ { loadPrincipal: (userId): AppPrincipal => ({ userId, kind: "agent" }) },
1632
+ );
1633
+
1634
+ app.post("/assistant/:service/:method", signedIn, (req, res) => {
1635
+ void (async () => {
1636
+ const result = await registry.callMethod(req.params.service, req.params.method, req.body, {
1637
+ // no bearer token: the call's queries share its user's lane
1638
+ request: {
1639
+ transport: "http",
1640
+ sessionId: crypto.randomUUID(),
1641
+ token: null,
1642
+ headers: req.headers,
1643
+ req,
1644
+ },
1645
+ // the registry's principal hook is not asked; the user's grants load as qd.caller's do
1646
+ principal: sessionOf<AppPrincipal>(req).principal,
1647
+ });
1648
+ res.json(toToolResult(result));
1649
+ })();
1650
+ });
1651
+ ```
1652
+
1653
+ - `principal: null` calls anonymously; without the key, the hook says who
1654
+ calls, as before. `registry.call` takes it as `callMethod` does. The
1655
+ `context` hook, bound arguments and the call's concurrency lane see the
1656
+ given principal, and `request` is still needed: the lane and `context`
1657
+ read it.
1658
+ - A principal that carries no `serviceAccess` gets the grants the server's
1659
+ `auth.loadServiceAccess` loads, at each call, as `qd.caller` gives them;
1660
+ one that carries grants (`{}` included) keeps exactly those. The `principal`
1661
+ hook's principal is used as the hook returns it.
1662
+ - It is an in-process option: `POST /mcp/invoke` never reads a principal
1663
+ from its body.
1664
+
1502
1665
  Two more needs are met by the options above, with nothing added:
1503
1666
 
1504
1667
  - **A tool set per kind of caller.** Make one registry per kind, each with its
@@ -1512,7 +1675,9 @@ Two more needs are met by the options above, with nothing added:
1512
1675
  that must never change, give that surface a contract of its own: its
1513
1676
  methods' inputs and `describe` texts are the frozen shapes, its handlers
1514
1677
  call the app's services through `ctx.services`, and a registry with
1515
- `name: (_service, method) => method` serves it. The bridge never reshapes a
1678
+ `name: (_service, method) => method` serves it. Those calls carry the
1679
+ tool call's `ctx.mcp`, so a guard that reads it in a method they reach
1680
+ holds as it does on that method's own tool. The bridge never reshapes a
1516
1681
  tool's arguments on the way in: such a tool would be invisible to the API
1517
1682
  docs and to lint, which read the contracts.
1518
1683
 
@@ -1621,7 +1786,9 @@ export function TaskDetail({ id }: { readonly id: string }) {
1621
1786
  the chat's messages open). A row-level grant with neither sends nothing
1622
1787
  the client could notice: remount the view, or call `refresh()`.
1623
1788
  - The cache follows the user: a hello naming another user removes everything
1624
- quickdraw cached; new credentials for the same user refetch it; new grants
1789
+ quickdraw cached, and no `useQuery` of the client shows a placeholder made
1790
+ from it (`placeholderData` such as `keepPreviousData`), nor `useSearch` its
1791
+ last results; new credentials for the same user refetch it; new grants
1625
1792
  (`qd:access`) refetch every query.
1626
1793
  - A protocol mismatch reloads the page once per session by default
1627
1794
  (`onProtocolMismatch`); `RATE_LIMITED` answers back off with jitter per
@@ -1712,12 +1879,18 @@ new credentials until theirs). `isConnected` turns true before the hello and
1712
1879
  false while the connection reconnects; `reconnecting` says the user and the
1713
1880
  page stay meanwhile. When the server ends the session (signed out on
1714
1881
  another device, revoked, expired) or refuses a connect for it (its
1715
- `authenticate` failed, or the handshake was `FORBIDDEN`), the hello is
1716
- forgotten: `isKnown` is false, `status` is `"disconnected"` or
1717
- `"refused"`, and everything quickdraw cached is gone, so a gate that finds
1718
- the connection stopped with nobody known shows the sign-in. Another
1719
- protocol (`refusal.code` `"PROTOCOL_MISMATCH"`) keeps the hello; the
1720
- provider reloads the page once for it:
1882
+ `authenticate` refused the credentials, or the handshake was
1883
+ `FORBIDDEN`), the hello is forgotten: `isKnown` is false, `status` is
1884
+ `"disconnected"` or `"refused"`, and everything quickdraw cached is gone,
1885
+ so a gate that finds the connection stopped with nobody known shows the
1886
+ sign-in. A server that could not check the credentials (its session store
1887
+ down: the handshake is refused `INTERNAL`) signs nobody out: the
1888
+ connection keeps the hello and the cache, stays `"connecting"`
1889
+ (`reconnecting` after a connect on these credentials) and connects again
1890
+ by itself after Socket.IO's reconnection delays, so the gate below shows
1891
+ the page with "Reconnecting…", or "Connecting…" before a first connect.
1892
+ Another protocol (`refusal.code` `"PROTOCOL_MISMATCH"`) keeps the hello;
1893
+ the provider reloads the page once for it:
1721
1894
 
1722
1895
  <!-- example: apps/web/src/components/AuthGate.tsx#gate -->
1723
1896
 
@@ -1769,13 +1942,18 @@ export async function TasksPage({ projectId, cookie }: { projectId: string; cook
1769
1942
  }
1770
1943
  ```
1771
1944
 
1772
- A script, a worker or a React Native module without hooks uses the
1773
- connection directly:
1945
+ A script, a CLI or a worker uses the connection directly, from
1946
+ `@fitzzero/quickdraw-core/client/core`: the client without React, for a
1947
+ process that installs neither React nor TanStack Query. It holds the
1948
+ connection, `call` and `callData`, `newId`, the auth helpers, peer links,
1949
+ host mode and everything on `./utils`. Nothing it imports, in its code or
1950
+ its types, is React or TanStack, and it carries no `"use client"`.
1951
+ `./client` re-exports all of it, the same functions:
1774
1952
 
1775
1953
  <!-- example: apps/api/scripts/report.ts#script -->
1776
1954
 
1777
1955
  ```ts
1778
- import { callData, createQuickdrawConnection } from "@fitzzero/quickdraw-core/client";
1956
+ import { callData, createQuickdrawConnection } from "@fitzzero/quickdraw-core/client/core";
1779
1957
 
1780
1958
  const connection = createQuickdrawConnection({
1781
1959
  url: "http://localhost:4000",
@@ -1792,9 +1970,11 @@ process.stdout.write(`${String(count)} tasks\n`);
1792
1970
  connection.close();
1793
1971
  ```
1794
1972
 
1795
- `liveDataOf(connection, queryClient)` holds live rows and collections for
1796
- such code, and `createInvalidationCoordinator(queryClient)` invalidates as
1797
- the hooks do.
1973
+ Live rows and collections take a TanStack `QueryClient`, so they are on
1974
+ `./client` only, still without React: `liveDataOf(connection, queryClient)`
1975
+ holds them for code without hooks (a React Native module, a Node client
1976
+ that keeps a `QueryClient`), and `createInvalidationCoordinator(queryClient)`
1977
+ invalidates as the hooks do.
1798
1978
 
1799
1979
  A client in another language (a Godot game, a native app) speaks the wire
1800
1980
  itself: [`docs/protocol-v5.md`](https://github.com/fitzzero/quickdraw/blob/main/docs/protocol-v5.md) is its specification,
@@ -2369,8 +2549,12 @@ export function AdminTasks() {
2369
2549
  const { services } = useAdminServices(qd);
2370
2550
  const { data } = qd.task.admin.adminList.useQuery({ page: 1, sort: { field: "title" } });
2371
2551
  const update = qd.task.admin.adminUpdate.useMutation({
2372
- // adminList is a query, not live data: read the page again after this screen's own write
2373
- onSuccess: () => qd.invalidate(qd.task.admin.adminList),
2552
+ // the kit's reads are queries, not live data: after this screen's own write,
2553
+ // read the page again and put the row the write returned in adminGet's cache
2554
+ onSuccess: (row) => {
2555
+ qd.invalidate(qd.task.admin.adminList);
2556
+ qd.task.admin.adminGet.setData({ id: row.id }, row);
2557
+ },
2374
2558
  });
2375
2559
  return (
2376
2560
  <table aria-label={services[0]?.displayName}>
@@ -2477,10 +2661,61 @@ before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
2477
2661
  unit of work of its own: the reply does not wait for it, its writes flush
2478
2662
  on their own, and a throw is logged, the write standing. Apply an edit to
2479
2663
  what runs in memory (a game's simulation) there, so an edit that rolled
2480
- back is never applied. Without either hook the kit opens no transaction.
2481
- Each handler `admin.handlers` returns resolves with its method's output
2482
- type (`AdminOutputOf<C, M>`), so a wrapper reads the row and returns it
2483
- with no cast.
2664
+ back is never applied. Without a hook (these two or `prepare`, below) the
2665
+ kit opens no transaction. Each handler `admin.handlers` returns resolves
2666
+ with its method's output type (`AdminOutputOf<C, M>`), so a wrapper reads
2667
+ the row and returns it with no cast.
2668
+ - `admin.handlers(contract, { prepare })` runs `prepare(call, ctx, db)`
2669
+ before each `adminCreate`, `adminUpdate` and `adminDelete`, once the
2670
+ caller's access and data were checked, in one transaction with the write
2671
+ and `onWrite` (`db` is the transaction's tracked client; a throw undoes
2672
+ its writes and the write, and fails the call). `call` is
2673
+ `{ method: "adminCreate", data }`, `{ method: "adminUpdate", id, data, before }`
2674
+ or `{ method: "adminDelete", id, before }`: `data` is the caller's, and
2675
+ `before` the row, every field, as `onWrite` hears it. Before a create or
2676
+ an update it may return column values, written over the caller's data:
2677
+ a column the server computes (a hash, a slug, the next ordinal), even one
2678
+ the caller may not write (hidden, not editable, above their level), but
2679
+ never `id`, a timestamp, a relation or a nested write (`VALIDATION`). An
2680
+ anchor column it sets (a parent's id, an anchored collection's scope)
2681
+ needs the method's row level on the row it names, as the caller's data
2682
+ and the read/write kit's `prepare` do (`FORBIDDEN`), so a lowered form
2683
+ never moves a row into a parent its caller cannot reach. Returning
2684
+ nothing, or `undefined` for a key, keeps the caller's data, and what it
2685
+ returns before a delete is ignored. The tracked client never sees
2686
+ what the database deletes or clears with a row (`onDelete: Cascade`,
2687
+ `SetNull`), so those rows stay in the lists and entities open on them;
2688
+ delete or clear them through `db` in `prepare`, and their subscribers are
2689
+ told. `prepare` shares the write's transaction, and so its time limit
2690
+ (Prisma's default is 5 seconds; a client's `transactionOptions` sets
2691
+ another):
2692
+
2693
+ <!-- example: apps/api/src/services/kits/admin.ts#prepare -->
2694
+
2695
+ ```ts
2696
+ import type { TrackedTransaction } from "@fitzzero/quickdraw-core/prisma";
2697
+ import { nextOrdinal } from "@fitzzero/quickdraw-core/server";
2698
+ import type { PrismaClient } from "@project/db";
2699
+
2700
+ export const taskAdmin = admin.handlers(task, {
2701
+ editable: ["projectId", "title", "status"],
2702
+ // runs before each write, in its transaction: `db` is that transaction's tracked client
2703
+ prepare: async (call, _ctx, db: TrackedTransaction<PrismaClient>) => {
2704
+ if (call.method === "adminCreate" && call.data.projectId !== undefined) {
2705
+ // written over the caller's data, a read-only column too: the new task goes last
2706
+ return { ordinal: await nextOrdinal(db, "task", { projectId: call.data.projectId }) };
2707
+ }
2708
+ if (call.method === "adminDelete") {
2709
+ // the database deletes the task's label links and clears its subtasks' parent,
2710
+ // unseen: do both here, tracked, so the rows and lists open on them are told
2711
+ await db.taskLabel.deleteMany({ where: { taskId: call.id } });
2712
+ await db.task.updateMany({ where: { parentTaskId: call.id }, data: { parentTaskId: null } });
2713
+ }
2714
+ return undefined;
2715
+ },
2716
+ });
2717
+ ```
2718
+
2484
2719
  - `adminSubscribers({ id })` counts the sockets subscribed to a row per
2485
2720
  access level (`{ id, count, levels, complete }`; behind a Redis adapter
2486
2721
  the counts are this server's and `complete` is `false`), and
@@ -2497,14 +2732,20 @@ before?, after }, ctx, db)` after each `adminCreate`, `adminUpdate` and
2497
2732
  is not asked again until the user's grant on it changes, reconnects
2498
2733
  included.
2499
2734
  - The kit's rows are not live: `adminList` and `adminGet` are queries that
2500
- watch nothing, so a screen reads them again after its own writes (the
2735
+ watch nothing, so after its own write a screen reads the page again and
2736
+ puts the row the write returned in `adminGet`'s cache with `setData` (the
2501
2737
  `onSuccess` above), and sees other admins' writes when it next reads.
2738
+ Their `useQuery`, and `adminSubscribers`', defaults to `staleTime: 0`, so a
2739
+ screen reads them again whenever it mounts (pass `staleTime` to keep a
2740
+ result longer; `adminMeta` keeps the provider's five minutes).
2502
2741
  - A screen that serves every service from its metadata (the route names the
2503
2742
  service) takes `adminOf(qd, key)`: the same members, keyed by what the kit
2504
2743
  made them for and typed by field name (`AdminScreen`: rows are
2505
2744
  `AdminRow`, `adminList` takes `{ page, pageSize, filter, sort: { field, direction } }`
2506
2745
  with names read from `adminMeta`), so no cast is needed over a union of
2507
- keys; a method the contract does not expose is absent.
2746
+ keys; a method the contract does not expose is absent. Its query members
2747
+ have `setData` too: `admin.adminGet?.setData({ id: row.id }, row)` shows
2748
+ the row `adminUpdate` or `adminCreate` returned, with no read.
2508
2749
 
2509
2750
  ### Presence, streams and channels
2510
2751
 
@@ -2607,6 +2848,17 @@ export function logLine(taskId: string, line: string): void {
2607
2848
  qd.stream(task, "logs").push(taskId, { line });
2608
2849
  }
2609
2850
 
2851
+ // a job skips the work nobody watches: the feed's subscribers on every node
2852
+ export async function tailLogs(taskId: string, read: () => Promise<string[]>): Promise<void> {
2853
+ if ((await qd.stream(task, "logs").subscribers(taskId)) > 0) {
2854
+ const lines = await read();
2855
+ qd.stream(task, "logs").pushMany(
2856
+ taskId,
2857
+ lines.map((line) => ({ line })),
2858
+ );
2859
+ }
2860
+ }
2861
+
2610
2862
  export async function isOnline(userId: string): Promise<boolean> {
2611
2863
  // also ctx.presence and server.presence
2612
2864
  return await qd.presence.isOnline(userId);
@@ -2674,6 +2926,18 @@ export function TaskRoom({
2674
2926
  leaves the feed and gets
2675
2927
  `qd:revoked { kind: "stream", reason: "access", s, stream, scope? }`, and
2676
2928
  `useStream` shows `FORBIDDEN` until the next connect.
2929
+ - A job skips the work nobody watches (`tailLogs` above):
2930
+ `await qd.stream(task, "logs").subscribers(taskId)` (`subscribers()` for
2931
+ a global stream; also on `server.stream` and `dispatcher.stream`) is how
2932
+ many sockets subscribe to the feed, on every node, anonymous ones
2933
+ included: the sockets a push reaches. Behind a cluster adapter the other
2934
+ nodes answer one request with a count each, never their sockets; an
2935
+ answer that does not arrive within `cluster.timeoutMs` counts as 0
2936
+ (warned at most once every 10 minutes), and so does a node of an earlier
2937
+ version. `qd.rooms` refuses the stream rooms' reserved `qd:` names, and
2938
+ Socket.IO's own rooms hold one node's sockets: count with `subscribers`.
2939
+ A host-served stream's handle refuses it, as it refuses `push`: only the
2940
+ host's client knows who subscribes.
2677
2941
  - A service's `streams: { <name>: { seed, validate } }` (type
2678
2942
  `StreamImplementation`) computes a stream's seed per subscriber, `seed: (scope, ctx) => items` (the current state,
2679
2943
  where the items that follow are deltas; on whichever node the subscriber
@@ -2808,6 +3072,440 @@ export function hasAudience(projectId: string): boolean {
2808
3072
  user leaving two nodes at the same moment may both see the other (a
2809
3073
  removal never misses: each node reports its own last socket).
2810
3074
 
3075
+ ### Peer links
3076
+
3077
+ Two players of one app room can link their clients: WebRTC data channels
3078
+ between them, or the server's relay when those cannot form (a browser
3079
+ behind a symmetric NAT, Node, a headless game client), with the same
3080
+ messages either way (design: section 12.5). A server opts in with
3081
+ `createServer({ peers: true })`: it forwards the signals that set a link
3082
+ up and relays its messages, only between two signed-in sockets of the
3083
+ room, and never reads what a link carries. A link has two channels: `rel`,
3084
+ ordered and reliable (state, chat), and `fast`, unordered and never resent
3085
+ (input, positions). The app tells players each other's socket ids, here
3086
+ with an event:
3087
+
3088
+ <!-- example: apps/api/src/services/kits/peers.ts#service -->
3089
+
3090
+ ```ts
3091
+ // on a server created with peers: true, which forwards the links' signals and relays their messages
3092
+ export const matchService = qd.defineService(match, {
3093
+ methods: {
3094
+ enterMatch: {
3095
+ // any signed-in user here: a real game checks the caller's seat in the match first
3096
+ access: "authenticated",
3097
+ handler: ({ input, ctx }) => {
3098
+ const room = `match:${input.matchId}`;
3099
+ const joined = ctx.rooms.join(room);
3100
+ if (joined && ctx.socketId !== undefined) {
3101
+ // the socket's own id, which a peer links to
3102
+ ctx.rooms.emit(room, match, "playerJoined", {
3103
+ matchId: input.matchId,
3104
+ userId: ctx.principal.userId,
3105
+ socketId: ctx.socketId,
3106
+ });
3107
+ }
3108
+ return joined;
3109
+ },
3110
+ },
3111
+ },
3112
+ });
3113
+ ```
3114
+
3115
+ <!-- example: apps/web/src/components/kits/MatchLinks.tsx#hook -->
3116
+
3117
+ ```tsx
3118
+ function track(links: Map<string, PeerLink>, link: PeerLink, onMove: { current: OnMove }): void {
3119
+ links.set(link.peer, link);
3120
+ link.on("message", (data) => {
3121
+ // a peer's message is its word, never checked by the server: parse it as any input
3122
+ const move = moveSchema.safeParse(data);
3123
+ // userId: the peer's user, as the server named it (known once the link is open)
3124
+ if (move.success && link.userId !== undefined) {
3125
+ onMove.current(link.userId, move.data);
3126
+ }
3127
+ });
3128
+ link.on("close", () => links.delete(link.peer));
3129
+ }
3130
+
3131
+ // each player of a match linked to every other: send a move to all of them, hear theirs
3132
+ export function useMatchLinks(matchId: string, onMove: OnMove): (move: Move) => void {
3133
+ const { connection } = useQuickdraw();
3134
+ const room = `match:${matchId}`;
3135
+ const [links] = useState(() => new Map<string, PeerLink>());
3136
+ const heard = useRef(onMove);
3137
+ useEffect(() => {
3138
+ heard.current = onMove;
3139
+ });
3140
+ // on every connection: a link lives as long as its socket, and a reconnect is a new socket
3141
+ useJoin(qd.match.enterMatch, { matchId });
3142
+ // a player who joins later: this side offers (when both offer, the link settles who answers)
3143
+ qd.match.playerJoined.useEvent((player) => {
3144
+ const self = connection.socket.id;
3145
+ if (player.matchId === matchId && player.socketId !== self && !links.has(player.socketId)) {
3146
+ track(links, createPeerLink(connection, { room, to: player.socketId, offerer: true }), heard);
3147
+ }
3148
+ });
3149
+ // the players here before: they offer, this side answers
3150
+ useEffect(
3151
+ () =>
3152
+ onPeerRequest(connection, (request) => {
3153
+ if (request.room === room) {
3154
+ track(links, request.accept(), heard);
3155
+ }
3156
+ }),
3157
+ [connection, room, links],
3158
+ );
3159
+ useEffect(
3160
+ () => () => {
3161
+ for (const link of links.values()) {
3162
+ link.close();
3163
+ }
3164
+ },
3165
+ [connection, room, links],
3166
+ );
3167
+ // direct when WebRTC connects, the server's relay when it cannot; fast: unordered, never resent
3168
+ return (move) => {
3169
+ for (const link of links.values()) {
3170
+ link.send("fast", move);
3171
+ }
3172
+ };
3173
+ }
3174
+ ```
3175
+
3176
+ - `createPeerLink(connection, { room, to, offerer })` (from `./client`, or
3177
+ `./client/core` without React) opens a link to socket `to` in app room `room`. One side
3178
+ offers; when both do (each heard of the other at once), the side whose
3179
+ socket id sorts after the other's answers instead.
3180
+ `onPeerRequest(connection, listener)` hears a socket of the room that
3181
+ signals this one with no link yet (`{ room, from, userId }`):
3182
+ `request.accept(options?)` answers with a link, `request.reject()`
3183
+ refuses it, and a request nobody answers is dropped after 15 s.
3184
+ - A link has `state` (`connecting`, `open`, `closed`), `path` (`direct` or
3185
+ `relay`), `peer` and `userId` (the peer's socket and user, as the server
3186
+ named them), `on("open" | "message" | "state" | "close", listener)` and
3187
+ `close()`, which tells the peer. `send(label, data)` takes any JSON value
3188
+ of at most 64 KiB as UTF-8 JSON (a `RangeError` above) and returns false
3189
+ when it dropped it: the link is not open, `fast` is backed up, or the
3190
+ relay's rate is spent (`stats` counts what was sent, received and
3191
+ dropped). `closeReason` says why it closed: `closed` (this side), `bye`
3192
+ (the peer closed it, or left the room, was taken out of it or
3193
+ disconnected: the server says so at once), `left` (this socket left the
3194
+ room), `disconnected` (a link lives as long as its socket: open it again
3195
+ after a reconnect), `timeout` (nothing opened within 15 s), `lost`
3196
+ (nothing heard for 8 s, or a `relay: "never"` link's direct path
3197
+ dropped), `failed`, `refused` (the server refused a
3198
+ signal) or `unsupported` (a server without `peers`).
3199
+ - The direct path comes first, over the platform's `RTCPeerConnection`
3200
+ (`peerConnection` takes another, such as react-native-webrtc's;
3201
+ `iceServers` defaults to Google's and Cloudflare's public STUN servers,
3202
+ with no TURN). The relay takes over when ICE fails, when the direct path
3203
+ does not open within 5 s or drops after it opened (`rel` messages in
3204
+ flight on it may be lost: the link emits `state`, so the app can
3205
+ resync; when a data channel closes, the link first waits 2 s for the
3206
+ peer's bye, since a peer that closes its link closes its channels too),
3207
+ and at once on a side without WebRTC or with
3208
+ `relay: "always"`; `relay: "never"` closes the link instead. A link never
3209
+ moves back from the relay.
3210
+ - `link.clock` reads the peer's clock from pings on `fast` (six quick ones
3211
+ as the link opens, then one every 500 ms): `now()` (the peer's clock, in
3212
+ its milliseconds), `offset`, `rtt`, `jitter` and `synced`.
3213
+ - The server's limits: a socket signals at most 64 peers at once
3214
+ (`CONFLICT`) and 20 signals a second (a burst of 40; `RATE_LIMITED`).
3215
+ It relays only between two sockets that signalled each other, at most
3216
+ 120 messages a second to each peer (a burst of 240) and 1 MiB a second
3217
+ in all (a burst of 2 MiB), and drops the rest silently; the client keeps
3218
+ under these itself, from the hello's `limits.peers`. A socket that keeps
3219
+ flooding either is disconnected, as for channels. A socket that leaves a
3220
+ room, is taken out of it or disconnects ends its links there: the server
3221
+ tells each peer it signalled, and each that signalled it. Behind a
3222
+ cluster adapter the node that holds a socket checks it, and the relay
3223
+ crosses nodes.
3224
+ - What a peer sends is the peer's word: the server checks who may link,
3225
+ never what a link carries. Parse a link's messages as any input, and
3226
+ check on the server whatever a client reports there.
3227
+ - Tests: `createLoopbackPeerLink({ ms, jitter, loss })` (from `./testing`)
3228
+ links two ends in one process, with a delay and jitter per message and
3229
+ loss on `fast` alone; its ends take `setLag` too.
3230
+
3231
+ A browser test, a bench or a host plays a link over a bad network on its
3232
+ real path, direct or relay, with `setLag`:
3233
+
3234
+ <!-- example: apps/web/src/components/kits/MatchLinks.tsx#lag -->
3235
+
3236
+ ```tsx
3237
+ // a browser test or a bench plays the match over a bad network, on the links' real paths: each
3238
+ // holds every message 150 ms plus up to 40 ms more, each way, and loses 5% of fast ones
3239
+ export function playOverBadNetwork(links: readonly PeerLink[]): () => void {
3240
+ for (const link of links) {
3241
+ link.setLag({ ms: 150, jitter: 40, loss: 0.05 });
3242
+ }
3243
+ // rel messages are never lost and keep their order; clock.rtt now reads about 300 ms more
3244
+ return () => {
3245
+ for (const link of links) {
3246
+ // what the lag holds goes on the next task, in order
3247
+ link.setLag(null);
3248
+ }
3249
+ };
3250
+ }
3251
+ ```
3252
+
3253
+ - `link.setLag({ ms, jitter, loss })` holds every message of the link,
3254
+ both ways, `ms` plus up to `jitter` milliseconds, and loses a `fast` one
3255
+ with probability `loss`, from 0 to 1: `stats.simulatedLoss` counts the
3256
+ app's, and `dropped` still counts only what the link dropped. `rel`
3257
+ messages are never lost and keep their order; a held one its path
3258
+ refuses as it comes due (the relay's rate spent) goes again a little
3259
+ later, and `send` on `rel` returns false meanwhile. `send` returns true
3260
+ for a message it holds, and `stats.sent` counts it once a path took it.
3261
+ The clock's pings and host mode's messages wait too, so `link.clock.rtt`
3262
+ reads the lagged round trip. A new lag leaves what is held on its time;
3263
+ `setLag(null)` (or all zeros) ends it, and what is held goes on the next
3264
+ task, in order. `link.lag` reads it, and `random` takes a seeded
3265
+ generator, so a run repeats. The server never sees the lag.
3266
+
3267
+ ### Host mode
3268
+
3269
+ One player's client can run a room's authoritative simulation as the
3270
+ room's host (design: section 12.5). A contract declares the channels and
3271
+ streams the host serves (`serve: "host"`): a player's messages and feeds
3272
+ of them go to the room's host over the player's link, and never reach the
3273
+ server. The server only says who hosts: a method makes the calling socket
3274
+ the room's host (`ctx.rooms.setHost(room)`), and each socket of the room
3275
+ hears it (`qd:host`). Host mode runs over peer links, so the server needs
3276
+ `peers: true` (`createServer` throws without it):
3277
+
3278
+ <!-- example: packages/shared/src/kits/host.ts#contract -->
3279
+
3280
+ ```ts
3281
+ export const arena = defineContract("arenaService", {
3282
+ describe: "Arenas whose simulation one player's client runs, as the arena's host.",
3283
+ methods: {
3284
+ enterArena: mutation({
3285
+ input: z.object({ arenaId: z.string() }),
3286
+ output: z.boolean(),
3287
+ describe: "Joins the caller's socket to an arena's room.",
3288
+ }),
3289
+ hostArena: mutation({
3290
+ input: z.object({ arenaId: z.string() }),
3291
+ output: z.boolean(),
3292
+ describe: "Makes the caller's socket the arena's host, while it has none.",
3293
+ }),
3294
+ },
3295
+ channels: {
3296
+ // a player's input: to the arena's host over the player's link, never to the server
3297
+ steer: {
3298
+ payload: z.object({
3299
+ arenaId: z.string(),
3300
+ dx: z.number().min(-1).max(1),
3301
+ dy: z.number().min(-1).max(1),
3302
+ }),
3303
+ ratePerSecond: 30,
3304
+ requires: { room: (steer) => arenaRoom(steer.arenaId) },
3305
+ serve: "host",
3306
+ },
3307
+ },
3308
+ streams: {
3309
+ // the host's world, to each player of the arena: the latest snapshot seeds a newcomer
3310
+ world: {
3311
+ item: snapshotSchema,
3312
+ scope: "arenaId",
3313
+ seed: 1,
3314
+ volatile: true,
3315
+ access: { room: arenaRoom },
3316
+ serve: "host",
3317
+ },
3318
+ },
3319
+ });
3320
+ ```
3321
+
3322
+ <!-- example: apps/api/src/services/kits/host.ts#service -->
3323
+
3324
+ ```ts
3325
+ // on a server created with peers: true, as host mode runs over peer links
3326
+ export const arenaService = qd.defineService(arena, {
3327
+ methods: {
3328
+ enterArena: {
3329
+ access: "authenticated",
3330
+ handler: ({ input, ctx }) => ctx.rooms.join(arenaRoom(input.arenaId)),
3331
+ },
3332
+ hostArena: {
3333
+ // the app picks the host, never the server: here the first player who asks
3334
+ access: "authenticated",
3335
+ handler: ({ input, ctx }) => {
3336
+ const room = arenaRoom(input.arenaId);
3337
+ return ctx.rooms.hostOf(room) === null && ctx.rooms.setHost(room);
3338
+ },
3339
+ },
3340
+ },
3341
+ // no handler for steer and no options for world: the arena's host serves both
3342
+ });
3343
+ ```
3344
+
3345
+ A player reads and sends as with a server's stream and channel:
3346
+
3347
+ <!-- example: apps/web/src/components/kits/ArenaHost.tsx#player -->
3348
+
3349
+ ```tsx
3350
+ // a player reads the world and sends input as with any stream and channel: both reach the host
3351
+ export function useArena(arenaId: string) {
3352
+ useJoin(qd.arena.enterArena, { arenaId });
3353
+ const { items } = qd.arena.world.useStream(arenaId);
3354
+ const { send } = qd.arena.steer.useChannel();
3355
+ return {
3356
+ world: items.at(-1),
3357
+ // on the link's fast: never resent, so send input every frame, not once
3358
+ steer: (dx: number, dy: number) => send({ arenaId, dx, dy }),
3359
+ };
3360
+ }
3361
+ ```
3362
+
3363
+ The host's client serves the contract's host-served members with
3364
+ `defineHost`, while the server names its socket the room's host:
3365
+
3366
+ <!-- example: apps/web/src/components/kits/ArenaHost.tsx#host -->
3367
+
3368
+ ```tsx
3369
+ // the arenas this client simulates as their host: each player's position, by user
3370
+ const arenas = new Map<string, { tick: number; players: Map<string, { x: number; y: number }> }>();
3371
+
3372
+ // a peer's input arrives checked against the contract (schema, room, rate), but it is still
3373
+ // that player's word: the simulation applies it as it would any input
3374
+ const arenaHost = defineHost(arena, {
3375
+ channels: {
3376
+ steer: (steer, { peer }) => {
3377
+ const players = arenas.get(steer.arenaId)?.players;
3378
+ const at = players?.get(peer.userId) ?? { x: 0, y: 0 };
3379
+ players?.set(peer.userId, { x: at.x + steer.dx, y: at.y + steer.dy });
3380
+ },
3381
+ },
3382
+ });
3383
+
3384
+ // serves the arena while the server names this socket its host (qd:host), and stops when not
3385
+ export function useArenaHost(arenaId: string): void {
3386
+ const { connection } = useQuickdraw();
3387
+ useEffect(() => {
3388
+ const room = arenaRoom(arenaId);
3389
+ let host: Host<typeof arena> | undefined;
3390
+ const settle = (): void => {
3391
+ const hosting = hostOf(connection, room) === connection.socket.id;
3392
+ if (hosting && host === undefined) {
3393
+ arenas.set(arenaId, { tick: 0, players: new Map() });
3394
+ host = arenaHost.serve(connection, { room });
3395
+ } else if (!hosting && host !== undefined) {
3396
+ host.close();
3397
+ host = undefined;
3398
+ }
3399
+ };
3400
+ const stop = onHostChange(connection, (changed) => {
3401
+ if (changed === room) {
3402
+ settle();
3403
+ }
3404
+ });
3405
+ settle();
3406
+ // the host's loop: twenty snapshots a second to every player of the arena
3407
+ const loop = setInterval(() => {
3408
+ const state = arenas.get(arenaId);
3409
+ if (host !== undefined && state !== undefined) {
3410
+ state.tick += 1;
3411
+ host.stream("world").push(arenaId, {
3412
+ tick: state.tick,
3413
+ players: Object.fromEntries(state.players),
3414
+ });
3415
+ }
3416
+ }, 50);
3417
+ return () => {
3418
+ stop();
3419
+ clearInterval(loop);
3420
+ host?.close();
3421
+ };
3422
+ }, [connection, arenaId]);
3423
+ }
3424
+ ```
3425
+
3426
+ - A host-served channel declares `requires: { room }`, the room whose
3427
+ host takes its messages (a name, a function of the payload, or
3428
+ `{ prefix }`); a host-served stream declares a scope and
3429
+ `access: { room: (scope) => room }`, and its `seed: n` is how many items
3430
+ the host keeps per scope for a newcomer. The service gives a host-served
3431
+ channel no handler and a host-served stream no options (`defineService`
3432
+ refuses them). The server drops a `qd:ch` to one (with a development
3433
+ warning), refuses a `qd:stream:sub` to one (`VALIDATION`), and
3434
+ `qd.stream(...).push` throws for one.
3435
+ - `ctx.rooms.setHost(room)` makes the calling socket the room's host and
3436
+ returns true (false for a call without a socket); the socket must be in
3437
+ the room (`FORBIDDEN`). A new `setHost` replaces the host. `hostOf(room)`
3438
+ (on `ctx.rooms`, `qd.rooms` and `server.rooms`) returns the host's
3439
+ socket id or `null`, and `clearHost(room)` clears it.
3440
+ - When the host leaves the room, is taken out of it or disconnects, the
3441
+ room has no host: its sockets hear `qd:host` with `null`, and
3442
+ `onHostLeave({ room, socketId, principal, reason })` (the service's and
3443
+ `createServer`'s) runs once. The server never picks the next host; the
3444
+ app does, as `hostArena` above lets the next player who asks. Behind a
3445
+ cluster adapter each node knows each room's host. A node that dies
3446
+ without closing its sockets leaves the hosts it held recorded on the
3447
+ other nodes until each room's host is set or cleared again, and no
3448
+ `onHostLeave` runs for them. So an app that picks a host only while
3449
+ `hostOf(room)` is `null`, as `hostArena` does, also needs a way for a
3450
+ player to take over a host that stops answering: a method that calls
3451
+ `ctx.rooms.setHost(room)` or `clearHost(room)` after a timeout of the
3452
+ app's own.
3453
+ - `defineHost(contract, { channels, streams })` (from `./client`, or
3454
+ `./client/core` without React) takes one handler per host-served channel, whose `ctx` is
3455
+ `{ peer: { socketId, userId, link }, room }` (the user as the server
3456
+ named it, and the host's end of the player's link), and
3457
+ `streams.<name>.seed(scope)` to compute a newcomer's seed
3458
+ (default: the latest items pushed, as many as the contract's `seed`).
3459
+ `serve(connection, { room })` answers the links of the room's players and
3460
+ returns a `Host` with `stream(name).push(scope, item)` (checked against
3461
+ the stream's schema), `peers` and `close()`. The host checks each message
3462
+ as the server checks a channel's: its schema, its room, and a bucket per
3463
+ player and channel; it ends the link of a player who floods. A player
3464
+ subscribes at most 500 times at once and then 50 times a second
3465
+ (`RATE_LIMITED`). A seed goes whole or not at all: one with an item too
3466
+ large for a message of its own is refused (`INTERNAL`), and what the
3467
+ relay drops on `rel` is sent again, in order. The host's own
3468
+ `useChannel` and `useStream` for its room reach it over an in-process
3469
+ link, so the host's own client is one of its `peers` too. One
3470
+ connection serves several contracts in a room, one `serve` each, over
3471
+ the same links; serving one contract twice in a room throws.
3472
+ - `hostOf(connection, room)` and `onHostChange(connection, listener)`
3473
+ (from `./client`) say who hosts a room the socket is in. A player's feed
3474
+ subscribes again each time a link to the room's host opens (a new host,
3475
+ a reconnect), and its seed replaces what it showed. A channel message is
3476
+ dropped while no link is open, and goes on the link's `fast`, never
3477
+ resent: send input every frame. After a link to the host
3478
+ closed, the next waits 1 s, doubling to 15 s while links keep closing
3479
+ unopened.
3480
+ - The server never sees what a host and its players send each other. A
3481
+ host is one player's client: trust what it reports no more than any
3482
+ client's word, and check on the server whatever a service keeps of it.
3483
+ A service's `kinds` does not reach its host-served members either: the
3484
+ host knows each player's user, not its kind, so any signed-in socket of
3485
+ the room can link to it. Guard who joins the room.
3486
+
3487
+ Each of a host's `peers` comes with `link`, the host's end of that
3488
+ player's link: `link.clock.rtt` is the player's round trip, and
3489
+ `link.setLag(...)` lags that player's messages both ways. The host's own
3490
+ client is one of them, over its in-process link, so a host can play over
3491
+ a link as laggy as its players':
3492
+
3493
+ <!-- example: apps/web/src/components/kits/ArenaHost.tsx#fair -->
3494
+
3495
+ ```tsx
3496
+ // the host's own play goes through an in-process link, one of host.peers: lag it to the median
3497
+ // player's one-way delay, at most 100 ms, so the host plays over a link as laggy as its players'
3498
+ export function matchPlayersLag(host: Host<typeof arena>, self: string | undefined): void {
3499
+ const oneWay = host.peers
3500
+ .filter((peer) => peer.socketId !== self)
3501
+ .map((peer) => peer.link.clock.rtt / 2)
3502
+ .sort((a, b) => a - b);
3503
+ const median = oneWay[Math.floor(oneWay.length / 2)] ?? 0;
3504
+ // call it now and then, as the players' round trips change: what is held keeps its time
3505
+ host.peers.find((peer) => peer.socketId === self)?.link.setLag({ ms: Math.min(100, median) });
3506
+ }
3507
+ ```
3508
+
2811
3509
  ### Auth routes kit
2812
3510
 
2813
3511
  Sign-in for Google, Discord, a development mock and guests, as one Express
@@ -3253,9 +3951,10 @@ it("sends a rename to the other members' boards", async () => {
3253
3951
  - Seed rows with the untracked client (`prisma`), or inside `qd.run` once
3254
3952
  an app runs: a tracked write outside any unit of work flushes on its own,
3255
3953
  with an `ambient-write` warning.
3256
- - `app.as(principal)` calls in process and `app.connect(principal)` over a
3257
- real socket (`{ call, socket, hello, close }`); both are keyed by service
3258
- name. `app.frames(match?)` lists every frame the server sent, with its
3954
+ - `app.as(principal)` calls in process (with transport `"internal"`, or
3955
+ another, below) and `app.connect(principal)` over a real socket
3956
+ (`{ call, socket, hello, close }`); both are keyed by service name.
3957
+ `app.frames(match?)` lists every frame the server sent, with its
3259
3958
  socket and user; `frames.waitFor(match)` waits for one. A query of one
3260
3959
  event (`EventQuery`) takes `where`, a predicate over its frames typed by
3261
3960
  the event (`{ event: "qd:presence", where: ({ data }) => data.joined === id }`), and
@@ -3265,6 +3964,40 @@ it("sends a rename to the other members' boards", async () => {
3265
3964
  `userId`. `emitWithAck` and `waitForEvent` send raw frames and wait for
3266
3965
  events.
3267
3966
 
3967
+ `app.as(principal, { transport, mcp })` calls in process as another
3968
+ transport would, so a guard that reads `ctx.mcp` or `ctx.transport` is
3969
+ tested like any other access. Here the handler example's `assign` (under
3970
+ Services), which refuses an agent's token without the `tasks` scope:
3971
+
3972
+ <!-- example: apps/api/src/services/task.test.ts#token -->
3973
+
3974
+ ```ts
3975
+ it("lets an agent's token assign tasks only with the tasks scope", async () => {
3976
+ // the handler example's assign, which reads the token's scopes from ctx.mcp
3977
+ const app = await createTestApp({ services: [assigningService], db });
3978
+ const agent: AppPrincipal = { ...ada, kind: "agent", serviceAccess: { taskService: "Moderate" } };
3979
+ const input = { id: taskId, assigneeId: bo.userId };
3980
+ // in process, as an MCP tool call arrives: transport "mcp", and the context hook's fields as ctx.mcp
3981
+ const reader = app.as(agent, { transport: "mcp", mcp: { scopes: [] } });
3982
+ await expect(reader.taskService.assign(input)).rejects.toMatchObject({ code: "FORBIDDEN" });
3983
+ const writer = app.as(agent, { transport: "mcp", mcp: { scopes: ["tasks"] } });
3984
+ expect(await writer.taskService.assign(input)).toMatchObject({ assigneeId: bo.userId });
3985
+ await app.close();
3986
+ });
3987
+ ```
3988
+
3989
+ - `transport` is `"internal"` (the default), `"http"` or `"mcp"`; a
3990
+ socket's call is `app.connect`'s. `mcp` is the call's `ctx.mcp`, typed by
3991
+ the app's `QuickdrawTypes["mcp"]`: with `transport: "mcp"` the call is an
3992
+ MCP tool call's, and with the default transport it is a call an MCP call
3993
+ made through `ctx.services`.
3994
+ - Bound arguments and the registry's `principal` and `context` hooks belong
3995
+ to the MCP registry, not the dispatcher, so test them through the
3996
+ registry: `registry.callMethod(service, method, input, { request })`
3997
+ calls a method's tool as an agent would, with the input as the method
3998
+ takes it, and resolves with `{ ok: true, data }` or
3999
+ `{ ok: false, error }`.
4000
+
3268
4001
  ### Access matrices
3269
4002
 
3270
4003
  `describeAccessMatrix(app, { service, principals, cases, via? })` runs each
@@ -3432,10 +4165,11 @@ What it does with them:
3432
4165
  (ids and timestamps vary in length) without counting as a change.
3433
4166
  - Await, inside `run`, everything the step should cost: the replies and the
3434
4167
  frames it is about. Measure one step at a time.
3435
- - A few reads happen once per process (the storage adapter asks once
3436
- whether an order column may hold null). When a step could be the first to
3437
- pay for one, run it once before measuring it, so its budget does not
3438
- depend on the order tests run in.
4168
+ - Whether an order column may hold null comes from the client's own schema,
4169
+ with no statement; only a client that carries no schema text makes the
4170
+ storage adapter ask the database, once per column and process. When a step
4171
+ could be the first to pay for such a read, run it once before measuring it,
4172
+ so its budget does not depend on the order tests run in.
3439
4173
 
3440
4174
  ### Development warnings
3441
4175
 
@@ -3463,7 +4197,7 @@ start):
3463
4197
  | `tiered-field-in-output` | a method's own output schema declares a field the contract tiers, which only projection outputs strip; raised when the server is made (for an output without JSON Schema, when a reply carries it) |
3464
4198
  | `resolver-without-reads` | a service's policy is, or combines in `anyOf`, a `resolver` without `reads`, so no tracked write re-checks it; raised when the server is made |
3465
4199
  | `untracked-access-model` | a model a policy, a membership table or a `via` junction depends on has no `id` the write tracker can key its rows by, so no write to it revokes; checked when the server starts, logged as an error in every environment |
3466
- | `shared-principal` | the handler of a `share: "all"` run (an app's, not a kit's) read `ctx.principal`, `ctx.services` or `ctx.mcp`: the run has the first caller's, and every caller gets its result |
4200
+ | `shared-principal` | the handler of a `share: "all"` run (an app's, not a kit's) read `ctx.principal`, `ctx.services`, `ctx.mcp` or `ctx.access`: the run has the first caller's, and every caller gets its result |
3467
4201
  | `channel-without-requires` | a channel's contract declares no `requires` and its service no `access`, so every signed-in user may send it; raised when the server is made |
3468
4202
 
3469
4203
  Updates and deletes by id inside an interactive transaction are not counted
@@ -3475,7 +4209,8 @@ Only an app's own statements are checked: the framework's reads and the
3475
4209
  kits' own reads are not, while the app's callbacks a kit calls (`prepare`,
3476
4210
  `onChange`, `resolveUser`, a search strategy) are. A statement filtered by
3477
4211
  an `id` list (`{ id: { in: ids } }`, one per chunk of ids) never counts
3478
- toward `n-plus-one`. In tests, `createTestApp({ strictWarnings: true })`
4212
+ toward `n-plus-one`, and neither does a `createMany` (one per chunk of
4213
+ rows, as a large insert is split). In tests, `createTestApp({ strictWarnings: true })`
3479
4214
  (under vitest or jest) throws every warning raised in that app's method
3480
4215
  calls as a `DevWarningError` where it is raised, so the test that caused it
3481
4216
  fails: the call it happened in fails with `INTERNAL` and the error as its
@@ -3795,22 +4530,23 @@ bunx @fitzzero/quickdraw-codemod v5 .
3795
4530
 
3796
4531
  ## Package exports
3797
4532
 
3798
- | Export | Holds |
3799
- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3800
- | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names, `consoleLogger` and `createConsoleLogger` |
3801
- | `./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 |
3802
- | `./server/auth` | `createAuthRoutes`, `socketAuth`, `bearerAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
3803
- | `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
3804
- | `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
3805
- | `./server/otel` | `otelOnCall` |
3806
- | `./prisma` | `trackPrisma`, `storageOf` |
3807
- | `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
3808
- | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
3809
- | `./parser` | the JSON-only Socket.IO parser |
3810
- | `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
3811
- | `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
3812
- | `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
3813
- | `./testing/prisma` | test databases on PostgreSQL or PGlite |
4533
+ | Export | Holds |
4534
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
4535
+ | `.` | `defineContract`, `query`, `mutation`, `nullable`, `listOf`, `via`, the kits' contract halves, inference types, `QuickdrawError`, error codes, protocol types, room and topic names, `consoleLogger` and `createConsoleLogger`, the types `Tracked` and `TrackedTransaction` |
4536
+ | `./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 |
4537
+ | `./server/auth` | `createAuthRoutes`, `socketAuth`, `bearerAuth`, `requireSession` and `sessionOf`, providers (`google`, `discord`, `mock`, `guest`), session stores, `issueSession`, `liveSession`, JWT, cookie and origin helpers |
4538
+ | `./server/express` | Express rate limits: `createAuthLimiter`, `createAuthStatusLimiter`, `createCallLimiter`, `createPublicApiLimiter`, `createWebhookLimiter` |
4539
+ | `./server/mcp` | `createMcpRegistry`, `describeTools`, `createMcpStdioServer`, `createMcpHttpRouter`, `bootstrapMcpServer` |
4540
+ | `./server/otel` | `otelOnCall` |
4541
+ | `./prisma` | `trackPrisma`, `storageOf`, the types `Tracked` and `TrackedTransaction` |
4542
+ | `./client` | `createQuickdrawClient`, `QuickdrawProvider`, `useQuickdraw`, `usePresence`, `useAdminServices`, `createQuickdrawConnection`, `call`, `callData`, `liveDataOf`, the coordinator; everything in `./utils` |
4543
+ | `./client/core` | the client without React or TanStack: `createQuickdrawConnection`, `call`, `callData`, `newId`, the auth helpers, `createPeerLink`, `defineHost`; everything in `./utils` |
4544
+ | `./utils` | `createServerCaller`, cache keys (`methodKey`, `entityKey`, `collectionKey`), formatting, navigation, `parseJWTPayload` |
4545
+ | `./parser` | the JSON-only Socket.IO parser |
4546
+ | `./testing` | `createTestApp`, `describeAccessMatrix`, `snapshotAccessMatrix`, `expectBudget`, `createRecordingSink`, `DevWarningError` |
4547
+ | `./testing/client` | `renderWithQuickdraw`, and everything in `./testing/mock` |
4548
+ | `./testing/mock` | `createMockClient` alone, without Testing Library: for browser bundles such as Storybook |
4549
+ | `./testing/prisma` | test databases on PostgreSQL or PGlite |
3814
4550
 
3815
4551
  The package also ships the `quickdraw-docs` command.
3816
4552