@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.
- package/README.md +793 -57
- package/dist/{admin-DWWrT1h6.d.ts → admin-CS7JB_UH.d.ts} +2 -2
- package/dist/{adminSchemas-D4bFKsrb.d.ts → adminSchemas-29cGgrg5.d.ts} +1 -1
- package/dist/{auth-DXiSJZaf.d.ts → auth-DtdYjrZ9.d.ts} +11 -7
- package/dist/{caller-BT-Mv4bD.d.ts → caller-BTwGl7vm.d.ts} +183 -15
- package/dist/chunk-2FDSKLBO.js +82 -0
- package/dist/chunk-2FDSKLBO.js.map +1 -0
- package/dist/{chunk-GSIPUNJL.js → chunk-64I6MYBC.js} +139 -792
- package/dist/chunk-64I6MYBC.js.map +1 -0
- package/dist/chunk-7473HK24.js +17 -0
- package/dist/chunk-7473HK24.js.map +1 -0
- package/dist/chunk-7Y5ROPXL.js +33 -0
- package/dist/chunk-7Y5ROPXL.js.map +1 -0
- package/dist/chunk-A2RBQD66.js +7 -0
- package/dist/chunk-A2RBQD66.js.map +1 -0
- package/dist/chunk-ASAMUGHG.js +651 -0
- package/dist/chunk-ASAMUGHG.js.map +1 -0
- package/dist/{chunk-B2P7AZ25.js → chunk-CYE4UBOB.js} +6 -4
- package/dist/chunk-CYE4UBOB.js.map +1 -0
- package/dist/chunk-DNP65TD5.js +832 -0
- package/dist/chunk-DNP65TD5.js.map +1 -0
- package/dist/{chunk-5CGO426C.js → chunk-GYLWEJHW.js} +5 -1
- package/dist/chunk-GYLWEJHW.js.map +1 -0
- package/dist/{chunk-AONAFOYU.js → chunk-I3JCFS7H.js} +25 -13
- package/dist/chunk-I3JCFS7H.js.map +1 -0
- package/dist/{chunk-X5CWPX7H.js → chunk-IIQ3WMJK.js} +13 -2
- package/dist/chunk-IIQ3WMJK.js.map +1 -0
- package/dist/{chunk-6K5BY47X.js → chunk-JVTONQFI.js} +1277 -277
- package/dist/chunk-JVTONQFI.js.map +1 -0
- package/dist/{chunk-RL4VKLBX.js → chunk-KKQANRLG.js} +2 -2
- package/dist/{chunk-RL4VKLBX.js.map → chunk-KKQANRLG.js.map} +1 -1
- package/dist/{chunk-GKH3LJ7N.js → chunk-MPQAT55F.js} +38 -205
- package/dist/chunk-MPQAT55F.js.map +1 -0
- package/dist/chunk-NX2V6SIL.js +202 -0
- package/dist/chunk-NX2V6SIL.js.map +1 -0
- package/dist/{chunk-7QCUX4TK.js → chunk-NZHA4TZD.js} +72 -175
- package/dist/chunk-NZHA4TZD.js.map +1 -0
- package/dist/{chunk-MBH6IDSM.js → chunk-OCZJ4EYV.js} +40 -17
- package/dist/chunk-OCZJ4EYV.js.map +1 -0
- package/dist/{chunk-RJ5I2VO3.js → chunk-PHCURHC7.js} +6 -6
- package/dist/chunk-PHCURHC7.js.map +1 -0
- package/dist/{chunk-4YO5H5ON.js → chunk-Q4OGQM5I.js} +4 -10
- package/dist/chunk-Q4OGQM5I.js.map +1 -0
- package/dist/chunk-QSU47XEX.js +13 -0
- package/dist/chunk-QSU47XEX.js.map +1 -0
- package/dist/{chunk-LCC7HNFP.js → chunk-RZXJTDH5.js} +2 -2
- package/dist/{chunk-Z2LZ7IZ7.js → chunk-SN3OZVEQ.js} +5 -31
- package/dist/chunk-SN3OZVEQ.js.map +1 -0
- package/dist/{chunk-Z5ILULF2.js → chunk-SQ5VUG77.js} +4 -3
- package/dist/{chunk-Z5ILULF2.js.map → chunk-SQ5VUG77.js.map} +1 -1
- package/dist/chunk-STH5DLQP.js +1192 -0
- package/dist/chunk-STH5DLQP.js.map +1 -0
- package/dist/{chunk-NMXYENH4.js → chunk-VZFJTKIW.js} +6 -6
- package/dist/{chunk-753VJIUP.js → chunk-X4ZU4QH5.js} +43 -9
- package/dist/chunk-X4ZU4QH5.js.map +1 -0
- package/dist/chunk-XD7SRDUY.js +188 -0
- package/dist/chunk-XD7SRDUY.js.map +1 -0
- package/dist/chunk-Z7I6SONO.js +825 -0
- package/dist/chunk-Z7I6SONO.js.map +1 -0
- package/dist/cli/quickdraw-docs.js +131 -113
- package/dist/cli/quickdraw-docs.js.map +1 -1
- package/dist/client/core.d.ts +387 -0
- package/dist/client/core.js +109 -0
- package/dist/client/core.js.map +1 -0
- package/dist/client/index.d.ts +30 -193
- package/dist/client/index.js +56 -217
- package/dist/client/index.js.map +1 -1
- package/dist/{clientTypes-hWcPUVS9.d.ts → clientTypes-CUUNJwWs.d.ts} +3 -3
- package/dist/{connection-CgijdGpT.d.ts → connection-2EeQ_Et0.d.ts} +16 -9
- package/dist/{createServer-Cp-Wk500.d.ts → createServer-Bx5HOcYK.d.ts} +37 -9
- package/dist/{createTestApp-BB_PkqQ7.d.ts → createTestApp-DAMANT79.d.ts} +36 -9
- package/dist/{envelope-DzWyIXjj.d.ts → envelope-BMBaxppV.d.ts} +200 -8
- package/dist/{http-DotQ5bj8.d.ts → http-YzLAar3w.d.ts} +15 -50
- package/dist/index.d.ts +7 -6
- package/dist/index.js +61 -28
- package/dist/index.js.map +1 -1
- package/dist/{infer-BmoZsL9a.d.ts → infer-Br62cnyW.d.ts} +39 -1
- package/dist/{metrics-DW80Exve.d.ts → metrics-CTVpw0gu.d.ts} +2 -2
- package/dist/parser.js +2 -1
- package/dist/prisma/index.d.ts +13 -6
- package/dist/prisma/index.js +155 -9
- package/dist/prisma/index.js.map +1 -1
- package/dist/remote-N7OUAGOW.js +15 -0
- package/dist/remote-N7OUAGOW.js.map +1 -0
- package/dist/server/auth/index.d.ts +3 -3
- package/dist/server/auth/index.js +5 -3
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/index.d.ts +140 -36
- package/dist/server/index.js +162 -55
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/index.d.ts +34 -18
- package/dist/server/mcp/index.js +29 -12
- package/dist/server/mcp/index.js.map +1 -1
- package/dist/server/otel.d.ts +3 -3
- package/dist/{sharing-UDLn1049.d.ts → sharing-BrsmlDv_.d.ts} +3 -3
- package/dist/testing/client.d.ts +13 -13
- package/dist/testing/client.js +17 -10
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +49 -12
- package/dist/testing/index.js +102 -19
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +5 -5
- package/dist/testing/mock.js +15 -9
- package/dist/tracked-BR5b2O-U.d.ts +43 -0
- package/dist/{types-CYliTP65.d.ts → types-CLRCQGnO.d.ts} +4 -3
- package/dist/types-tMagHbjy.d.ts +151 -0
- package/dist/utils/index.d.ts +16 -4
- package/dist/utils/index.js +6 -3
- package/package.json +5 -1
- package/dist/chunk-4YO5H5ON.js.map +0 -1
- package/dist/chunk-5CGO426C.js.map +0 -1
- package/dist/chunk-6K5BY47X.js.map +0 -1
- package/dist/chunk-753VJIUP.js.map +0 -1
- package/dist/chunk-7QCUX4TK.js.map +0 -1
- package/dist/chunk-AONAFOYU.js.map +0 -1
- package/dist/chunk-B2P7AZ25.js.map +0 -1
- package/dist/chunk-GKH3LJ7N.js.map +0 -1
- package/dist/chunk-GSIPUNJL.js.map +0 -1
- package/dist/chunk-MBH6IDSM.js.map +0 -1
- package/dist/chunk-RJ5I2VO3.js.map +0 -1
- package/dist/chunk-X5CWPX7H.js.map +0 -1
- package/dist/chunk-Z2LZ7IZ7.js.map +0 -1
- /package/dist/{chunk-LCC7HNFP.js.map → chunk-RZXJTDH5.js.map} +0 -0
- /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
|
|
623
|
-
|
|
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
|
|
629
|
-
a context field made from them (in development, reading
|
|
630
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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`
|
|
1716
|
-
forgotten: `isKnown` is false, `status` is
|
|
1717
|
-
`"refused"`, and everything quickdraw cached is gone,
|
|
1718
|
-
the connection stopped with nobody known shows the
|
|
1719
|
-
|
|
1720
|
-
|
|
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
|
|
1773
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
-
//
|
|
2373
|
-
|
|
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
|
|
2481
|
-
Each handler `admin.handlers` returns resolves
|
|
2482
|
-
type (`AdminOutputOf<C, M>`), so a wrapper reads
|
|
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
|
|
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
|
|
3257
|
-
|
|
3258
|
-
|
|
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
|
-
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
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.
|
|
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
|
|
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
|
-
| `./
|
|
3809
|
-
| `./
|
|
3810
|
-
| `./
|
|
3811
|
-
| `./testing
|
|
3812
|
-
| `./testing/
|
|
3813
|
-
| `./testing/
|
|
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
|
|