@webda/graphql 4.0.0-beta.6 → 4.0.0-beta.8

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.
@@ -32,10 +32,10 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
- import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields, assertFilterOnly } from "@webda/core";
35
+ import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields, assertFilterOnly, callOperation, canCallOperation, createOperationFilter, useInstanceStorage, runWithInstanceStorage, emitCoreEvent, runWithContext, useRouter } from "@webda/core";
36
36
  import * as WebdaQL from "@webda/ql";
37
37
  import { EventIterator, MergedIterator } from "@webda/runtime";
38
- import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
38
+ import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
39
39
  import { createHandler } from "graphql-http";
40
40
  import { CloseCode, makeServer } from "graphql-ws";
41
41
  import { nextTick } from "process";
@@ -43,7 +43,9 @@ import { WebSocketServer } from "ws";
43
43
  import { AnyScalarType } from "./types/any.js";
44
44
  import { DateScalar } from "./types/date.js";
45
45
  import { GraphQLLong } from "./types/long.js";
46
- import { createFromInput, isInputAttribute, loadForAction, updateFromInput } from "./mutations.js";
46
+ import { graphQLNames, restoreInputKeys, WEBDA_KEY } from "./names.js";
47
+ import { createFromInput, isInputAttribute, loadForAction, operationError, updateFromInput } from "./mutations.js";
48
+ import { GRAPHQL_NAME, GraphQLOperationContext, graphqlPlacement, operationTypeName, publicCopy } from "./operations.js";
47
49
  const GraphIQL = `
48
50
  <!doctype html>
49
51
  <html lang="en">
@@ -140,6 +142,7 @@ export class GraphQLParameters extends DomainServiceParameters {
140
142
  this.userModel ?? (this.userModel = "User");
141
143
  this.exposeMe ?? (this.exposeMe = true);
142
144
  this.globalSubscription ?? (this.globalSubscription = true);
145
+ this.exposeOperations ?? (this.exposeOperations = ["*"]);
143
146
  return this;
144
147
  }
145
148
  }
@@ -161,6 +164,10 @@ let GraphQLService = (() => {
161
164
  constructor() {
162
165
  super(...arguments);
163
166
  this.schema = __runInitializers(this, _instanceExtraInitializers);
167
+ /**
168
+ * Generated named types, by name: a name is generated once per shape (reset by generateSchema)
169
+ */
170
+ this.namedTypes = new Map();
164
171
  }
165
172
  /**
166
173
  * Load and initialize GraphQL service parameters
@@ -170,6 +177,14 @@ let GraphQLService = (() => {
170
177
  loadParameters(params) {
171
178
  return new GraphQLParameters().load(params);
172
179
  }
180
+ /**
181
+ * Parameters with their defaults (the core builds a service configuration through this, not loadParameters)
182
+ * @param params - raw partial configuration
183
+ * @returns initialized GraphQLParameters
184
+ */
185
+ static createConfiguration(params = {}) {
186
+ return new GraphQLParameters().load(params);
187
+ }
173
188
  /**
174
189
  * Resolve a JSON Schema property by following $ref pointers
175
190
  * @param prop - JSON Schema property that may contain $ref
@@ -188,6 +203,41 @@ let GraphQLService = (() => {
188
203
  }
189
204
  return prop || { type: "null" };
190
205
  }
206
+ /**
207
+ * One GraphQL type per name: reuse the type already generated for this name and shape
208
+ * @param name - type name
209
+ * @param shape - what makes two types of this name the same (kind and field names)
210
+ * @param create - builds the type the first time
211
+ * @returns the type
212
+ * @throws Error when the name was generated for another shape
213
+ */
214
+ namedType(name, shape, create) {
215
+ this.namedTypes ?? (this.namedTypes = new Map());
216
+ const known = this.namedTypes.get(name);
217
+ if (known) {
218
+ if (known.shape !== shape) {
219
+ throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
220
+ }
221
+ return known.type;
222
+ }
223
+ const type = create();
224
+ this.namedTypes.set(name, { type, shape });
225
+ return type;
226
+ }
227
+ /**
228
+ * Reserve a type name for a type built later (its entry gets the type once built)
229
+ * @param name - type name
230
+ * @param shape - identity of the reserving type
231
+ * @throws Error when the name is already taken by another shape
232
+ */
233
+ reserveName(name, shape) {
234
+ this.namedTypes ?? (this.namedTypes = new Map());
235
+ const known = this.namedTypes.get(name);
236
+ if (known && known.shape !== shape) {
237
+ throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
238
+ }
239
+ this.namedTypes.set(name, known || { type: undefined, shape });
240
+ }
191
241
  /**
192
242
  * Convert a JSON Schema to a GraphQL type definition
193
243
  * @param schema - JSON Schema to convert
@@ -196,6 +246,17 @@ let GraphQLService = (() => {
196
246
  * @returns object with the GraphQL type and optional description, or undefined for null types
197
247
  */
198
248
  getGraphQLSchemaFromSchema(schema, defaultName, input) {
249
+ // `type: ["string", "null"]`: the nullable form of one type (GraphQL fields are nullable already)
250
+ if (Array.isArray(schema?.type)) {
251
+ const types = schema.type.filter(t => t !== "null");
252
+ if (types.length === 0) {
253
+ return undefined;
254
+ }
255
+ if (types.length > 1) {
256
+ return { type: AnyScalarType, description: schema.description };
257
+ }
258
+ return this.getGraphQLSchemaFromSchema({ ...schema, type: types[0] }, defaultName, input);
259
+ }
199
260
  let type;
200
261
  if (!schema || !schema.type) {
201
262
  type = AnyScalarType;
@@ -228,27 +289,57 @@ let GraphQLService = (() => {
228
289
  if (!schema.properties) {
229
290
  return { type: AnyScalarType, description: "Map" };
230
291
  }
231
- for (const i in schema.properties) {
232
- // Private (`__`) fields are server-only, at any depth
233
- if (i.startsWith("__")) {
234
- continue;
235
- }
236
- const res = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${schema.title || defaultName}_${i}`, input);
292
+ // Private (`__`) fields are server-only, at any depth
293
+ const names = this.graphQLNames(Object.keys(schema.properties).filter(i => !i.startsWith("__")), schema.title || defaultName);
294
+ for (const [i, field] of names) {
295
+ const res = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${schema.title || defaultName}_${field}`, input);
237
296
  if (!res)
238
297
  continue;
239
- fields[i] = res;
298
+ fields[field] = this.renamedField(res, i, field, input);
240
299
  }
241
300
  if (Object.keys(fields).length === 0) {
242
301
  this.log("DEBUG", "Return map for", defaultName, "because no fields");
243
302
  return { type: AnyScalarType, description: "Map" };
244
303
  }
245
- type = new (input ? GraphQLInputObjectType : GraphQLObjectType)({
246
- fields,
247
- name: schema.title || defaultName
248
- });
304
+ // A titled input type gets its own name: the output type of the same title already uses the title
305
+ const name = schema.title ? (input ? `${schema.title}Input` : schema.title) : defaultName;
306
+ type = this.namedType(name, `${input ? "input" : "output"}:${Object.keys(fields)
307
+ .sort()
308
+ .map(k => `${k}:${fields[k].type}`)
309
+ .join(",")}`, () => (input ? new GraphQLInputObjectType({ fields, name }) : new GraphQLObjectType({ fields, name })));
249
310
  }
250
311
  return { type, description: schema.description };
251
312
  }
313
+ /**
314
+ * GraphQL names of the properties of a type, see {@link graphQLNames}: a JSON key that is not a GraphQL name
315
+ * (`available-balance`) is exposed as `availableBalance`, unless that name is taken (logged, left out)
316
+ * @param keys - the property keys
317
+ * @param type - the type holding them, for the log
318
+ * @returns the GraphQL name of each key exposed
319
+ */
320
+ graphQLNames(keys, type) {
321
+ return graphQLNames(keys, (key, name) =>
322
+ // The real property (or the first key renamed so) keeps the name: this key stays REST only
323
+ this.log("INFO", `Property '${key}' of ${type} is left out of GraphQL: its name '${name}' is already used`));
324
+ }
325
+ /**
326
+ * A field renamed from its JSON key reads that key (output), and carries it to be put back on inputs
327
+ * ({@link restoreInputKeys})
328
+ * @param config - the field config
329
+ * @param key - the JSON key
330
+ * @param name - the GraphQL name
331
+ * @param input - an input type field
332
+ * @returns the field config
333
+ */
334
+ renamedField(config, key, name, input) {
335
+ if (key === name)
336
+ return config;
337
+ return {
338
+ ...config,
339
+ extensions: { ...config.extensions, [WEBDA_KEY]: key },
340
+ ...(input ? {} : { resolve: (source) => source?.[key] })
341
+ };
342
+ }
252
343
  /**
253
344
  * Get or create a GraphQL query result wrapper type with results array and continuationToken
254
345
  * @param type - GraphQL object type to wrap in a query result
@@ -359,22 +450,20 @@ let GraphQLService = (() => {
359
450
  };
360
451
  });
361
452
  }
362
- for (const i in schema.properties) {
363
- // Private (`__`) fields are server-only: never part of an output or input type
364
- if (i.startsWith("__")) {
365
- continue;
366
- }
453
+ // Private (`__`) fields are server-only: never part of an output or input type
454
+ const names = this.graphQLNames(Object.keys(schema.properties ?? {}).filter(i => !i.startsWith("__")), defaultName);
455
+ for (const [i, field] of names) {
367
456
  // Was initiated by the known graph; inputs never carry private or behavior attributes
368
- if (fields[i] ||
457
+ if (fields[field] ||
369
458
  skipFields.includes(i) ||
370
459
  (input && (schema.properties[i].readOnly || !isInputAttribute(i, webdaGraph, writable)))) {
371
460
  continue;
372
461
  }
373
- const prop = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${defaultName}_${i}`, input);
462
+ const prop = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema.properties[i], schema.definitions), `${defaultName}_${field}`, input);
374
463
  if (!prop) {
375
464
  continue;
376
465
  }
377
- fields[i] = prop;
466
+ fields[field] = this.renamedField(prop, i, field, input);
378
467
  }
379
468
  return fields;
380
469
  }
@@ -490,17 +579,40 @@ let GraphQLService = (() => {
490
579
  super.resolve();
491
580
  // Set-up ws server
492
581
  this.wss = new WebSocketServer({ noServer: true });
582
+ // The HTTP server events fire outside the instance storage: re-enter it
583
+ const storage = useInstanceStorage();
493
584
  useCoreEvents("Webda.Init.Http", (http) => {
494
585
  http.on("upgrade", (req, socket, head) => {
495
- if (req.url === this.parameters.url) {
586
+ runWithInstanceStorage(storage, () => {
587
+ if (req.url !== this.parameters.url)
588
+ return;
496
589
  (async () => {
497
- req.webdaContext ?? (req.webdaContext = await useCore().getContextFromRequest(req));
498
- await req.webdaContext.init();
590
+ const httpServer = Object.values(useCore().getServices()).find(s => s.server === http && typeof s.getContextFromRequest === "function");
591
+ req.webdaContext ?? (req.webdaContext = await (httpServer ?? useCore()).getContextFromRequest(req));
592
+ const context = req.webdaContext;
593
+ await context.init();
594
+ // Same checks as a normal request: Webda.Request listeners and the router request filters
595
+ const allowed = await runWithContext(context, async () => {
596
+ try {
597
+ emitCoreEvent("Webda.Request", { context });
598
+ }
599
+ catch {
600
+ // listener error
601
+ }
602
+ return useRouter().checkRequest(context);
603
+ });
604
+ if (!allowed) {
605
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
606
+ return;
607
+ }
499
608
  this.wss.handleUpgrade(req, socket, head, ws => {
500
609
  this.wss.emit("connection", ws, req);
501
610
  });
502
- })();
503
- }
611
+ })().catch(err => {
612
+ this.log("ERROR", "GraphQL WebSocket upgrade failed", err);
613
+ socket.destroy();
614
+ });
615
+ });
504
616
  });
505
617
  });
506
618
  return this;
@@ -514,6 +626,10 @@ let GraphQLService = (() => {
514
626
  const subscriptions = {};
515
627
  const models = this.app.getModels();
516
628
  this.modelsMap = {};
629
+ this.namedTypes = new Map();
630
+ // First pass: reserve every exposed model name (and its Input) before any type is built, so a nested
631
+ // titled type of the same name fails whatever the model order
632
+ const exposed = [];
517
633
  for (const i in models) {
518
634
  const model = models[i];
519
635
  const metadata = useModelMetadata(model);
@@ -534,6 +650,11 @@ let GraphQLService = (() => {
534
650
  if (!schema)
535
651
  continue;
536
652
  const name = (metadata.ShortName || i.split("/").pop()).replace("/", "_");
653
+ this.reserveName(name, `model ${i}`);
654
+ this.reserveName(`${name}Input`, `model input ${i}`);
655
+ exposed.push({ i, model, metadata, schema, name });
656
+ }
657
+ for (const { i, model, metadata, schema, name } of exposed) {
537
658
  this.log("INFO", "Add GraphQL type", name);
538
659
  const modelGraph = metadata.Relations;
539
660
  this.modelsMap[i] = new GraphQLObjectType({
@@ -544,6 +665,8 @@ let GraphQLService = (() => {
544
665
  fields: this.getGraphQLFieldsFromSchema(schema, name + "Input", modelGraph, true, getClientWritableAttributes(model)),
545
666
  name: name + "Input"
546
667
  });
668
+ this.namedTypes.get(name).type = this.modelsMap[i];
669
+ this.namedTypes.get(`${name}Input`).type = input;
547
670
  const actionsName = Object.keys(metadata.Actions);
548
671
  if (!actionsName.includes("create")) {
549
672
  mutations[`create${name}`] = {
@@ -553,7 +676,7 @@ let GraphQLService = (() => {
553
676
  },
554
677
  resolve: async (_, args, context) => {
555
678
  this.log("INFO", "Create", name, context.getCurrentUserId());
556
- return createFromInput(model, args[name], context);
679
+ return createFromInput(model, restoreInputKeys(input, args[name]), context);
557
680
  }
558
681
  };
559
682
  }
@@ -566,7 +689,7 @@ let GraphQLService = (() => {
566
689
  type: GraphQLString
567
690
  }
568
691
  },
569
- resolve: async (_, args, context) => updateFromInput(model, args.uuid, args[name], context)
692
+ resolve: async (_, args, context) => updateFromInput(model, args.uuid, restoreInputKeys(input, args[name]), context)
570
693
  };
571
694
  }
572
695
  // check for actions
@@ -619,7 +742,8 @@ let GraphQLService = (() => {
619
742
  },
620
743
  subscribe: async (_source, args, context) => {
621
744
  this.log("DEBUG", "Subscription called on", args);
622
- return this.registerAsyncIterator(model, args[uuidField], context);
745
+ // The payload is keyed by the field name: the default resolver reads it from there
746
+ return this.registerAsyncIterator(model, args[uuidField], context, this.transformName(i.split("/").pop()));
623
747
  }
624
748
  };
625
749
  const events = metadata.Events || [];
@@ -776,6 +900,9 @@ let GraphQLService = (() => {
776
900
  for (const i in rootFields) {
777
901
  subscriptions[i] ?? (subscriptions[i] = { ...rootFields[i], resolve: undefined });
778
902
  }
903
+ // Operations (service operations, model and behaviour actions) next to the model schema; added after the copy
904
+ // above on purpose: a query operation is not a subscription
905
+ this.addOperationFields(rootFields, mutations, subscriptions);
779
906
  if (this.parameters.globalSubscription) {
780
907
  // Create global type based on all previous subscriptions
781
908
  subscriptions["Aggregate"] = {
@@ -811,6 +938,153 @@ let GraphQLService = (() => {
811
938
  }
812
939
  this.schema = this.getGraphQLSchema(rootFields, mutations, subscriptions);
813
940
  }
941
+ /**
942
+ * Expose the registered operations, except the model CRUD the model schema already serves: read-only ones as
943
+ * queries, the others as mutations, server-streaming ones as subscriptions
944
+ * @param rootFields - query fields
945
+ * @param mutations - mutation fields
946
+ * @param subscriptions - subscription fields
947
+ * @throws Error when a field name is already defined
948
+ */
949
+ addOperationFields(rootFields, mutations, subscriptions) {
950
+ const exposed = createOperationFilter(this.parameters.exposeOperations);
951
+ const targets = { query: rootFields, mutation: mutations, subscription: subscriptions };
952
+ const owners = new Map();
953
+ const operations = useInstanceStorage().operations;
954
+ for (const id of Object.keys(operations).sort()) {
955
+ const op = operations[id];
956
+ if (!exposed(id))
957
+ continue;
958
+ const placement = graphqlPlacement(id, op);
959
+ if (!placement)
960
+ continue;
961
+ const target = targets[placement.kind];
962
+ const key = `${placement.kind}.${placement.name}`;
963
+ if (placement.kind === "subscription" && placement.name === "Aggregate" && this.parameters.globalSubscription) {
964
+ throw new Error(`GraphQL subscription field Aggregate of operation ${id} is already defined by the global Aggregate subscription (globalSubscription): rename it with @Operation({ graphql: { subscription: "…" } })`);
965
+ }
966
+ if (target[placement.name]) {
967
+ throw new Error(`GraphQL ${placement.kind} field ${placement.name} of operation ${id} is already defined by ${owners.get(key) ?? "the model schema"}: rename it with @Operation({ graphql: { ${placement.kind}: "…" } })`);
968
+ }
969
+ owners.set(key, `operation ${id}`);
970
+ const base = operationTypeName(id);
971
+ const { args, toInput } = this.operationArgs(op, base);
972
+ const type = this.operationOutputType(op, base);
973
+ const description = op.description || op.summary;
974
+ target[placement.name] =
975
+ placement.kind === "subscription"
976
+ ? {
977
+ type,
978
+ args,
979
+ description,
980
+ subscribe: async (_source, fieldArgs, context) => this.subscribeOperation(id, op, toInput(fieldArgs), context),
981
+ resolve: (chunk) => chunk
982
+ }
983
+ : {
984
+ type,
985
+ args,
986
+ description,
987
+ resolve: async (_source, fieldArgs, context) => this.runOperation(id, op, toInput(fieldArgs), context)
988
+ };
989
+ this.log("INFO", `Add GraphQL ${placement.kind}`, placement.name, "for operation", id);
990
+ }
991
+ }
992
+ /**
993
+ * GraphQL arguments of an operation: one per input schema property, or a single `input` argument
994
+ * @param op - operation
995
+ * @param base - type name prefix
996
+ * @returns the arguments and how to turn them back into the operation input
997
+ */
998
+ operationArgs(op, base) {
999
+ const schema = op.input && op.input !== "void" ? this.app.getSchema(op.input) : undefined;
1000
+ if (!schema) {
1001
+ return { args: {}, toInput: () => undefined };
1002
+ }
1003
+ const resolved = this.getJsonSchemaDefinition(schema, schema.definitions);
1004
+ const names = Object.keys(resolved.properties ?? {}).filter(name => !name.startsWith("__"));
1005
+ if (resolved.type === "object" && names.length > 0 && names.every(name => GRAPHQL_NAME.test(name))) {
1006
+ const required = new Set(resolved.required ?? []);
1007
+ const args = {};
1008
+ for (const name of names) {
1009
+ const converted = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(resolved.properties[name], resolved.definitions ?? schema.definitions), `${base}Input_${name}`, true);
1010
+ // A property the converter cannot map still is an argument: the Object scalar takes anything
1011
+ const type = (converted?.type ?? AnyScalarType);
1012
+ args[name] = {
1013
+ type: required.has(name) ? new GraphQLNonNull(type) : type,
1014
+ description: converted?.description
1015
+ };
1016
+ }
1017
+ return {
1018
+ args,
1019
+ toInput: fieldArgs => Object.fromEntries(Object.entries(fieldArgs).map(([k, v]) => [k, args[k] ? restoreInputKeys(args[k].type, v) : v]))
1020
+ };
1021
+ }
1022
+ const converted = this.getGraphQLSchemaFromSchema(resolved, `${base}Input`, true);
1023
+ const type = (converted?.type ?? AnyScalarType);
1024
+ return {
1025
+ args: { input: { type } },
1026
+ toInput: fieldArgs => restoreInputKeys(type, fieldArgs.input)
1027
+ };
1028
+ }
1029
+ /**
1030
+ * GraphQL type of an operation result
1031
+ * @param op - operation
1032
+ * @param base - type name prefix
1033
+ * @returns the model type for a model output, Boolean for void, else the converted output schema
1034
+ */
1035
+ operationOutputType(op, base) {
1036
+ if (!op.output || op.output === "void") {
1037
+ return GraphQLBoolean;
1038
+ }
1039
+ if (this.modelsMap[op.output]) {
1040
+ return this.modelsMap[op.output];
1041
+ }
1042
+ const schema = this.app.getSchema(op.output);
1043
+ const converted = schema &&
1044
+ this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema, schema.definitions), `${base}Output`);
1045
+ return (converted?.type ?? AnyScalarType);
1046
+ }
1047
+ /**
1048
+ * Run an operation for a query or mutation field
1049
+ * @param id - operation id
1050
+ * @param op - operation
1051
+ * @param input - operation input
1052
+ * @param context - the GraphQL request context
1053
+ * @returns the field value
1054
+ */
1055
+ async runOperation(id, op, input, context) {
1056
+ this.countOperation(context);
1057
+ const ctx = new GraphQLOperationContext(context, input);
1058
+ await ctx.init();
1059
+ try {
1060
+ await callOperation(ctx, id);
1061
+ }
1062
+ catch (err) {
1063
+ throw operationError(err);
1064
+ }
1065
+ if (!op.output || op.output === "void") {
1066
+ return true;
1067
+ }
1068
+ return this.modelsMap[op.output] ? ctx.result : publicCopy(ctx.result);
1069
+ }
1070
+ /**
1071
+ * Start a server-streaming operation for a subscription field
1072
+ * @param id - operation id
1073
+ * @param op - operation
1074
+ * @param input - operation input
1075
+ * @param context - the GraphQL request context
1076
+ * @returns the chunks; leaving the subscription cancels the operation
1077
+ */
1078
+ async subscribeOperation(id, op, input, context) {
1079
+ this.countOperation(context);
1080
+ const ctx = new GraphQLOperationContext(context, input, this.modelsMap[op.output] ? chunk => chunk : publicCopy);
1081
+ await ctx.init();
1082
+ if (!(await canCallOperation(ctx, id, { input }))) {
1083
+ throw operationError(new WebdaError.Forbidden(`${id} PermissionDenied`));
1084
+ }
1085
+ callOperation(ctx, id).then(() => ctx.finish(), err => ctx.fail(err));
1086
+ return ctx.chunks();
1087
+ }
814
1088
  /**
815
1089
  * Extract the list of event names from the GraphQL selection set (excluding latestEventTime)
816
1090
  * @param info - GraphQL resolve info containing field selections
@@ -980,11 +1254,24 @@ let GraphQLService = (() => {
980
1254
  */
981
1255
  async registerAsyncEventIterator(model, uuid, events, context, identifier) {
982
1256
  const updatedCallback = eventName => async (evt) => {
983
- if (uuid === null || evt.object_id !== uuid)
1257
+ // A class-wide subscription (uuid null) receives the events of every object of the model
1258
+ if (uuid !== null && evt.object_id !== uuid)
984
1259
  return;
985
- // We rely on the cache of the store to get the full object
986
- // We let the other listeners finish before returning the object
987
- return { latestEventTime: Date.now(), [eventName]: evt };
1260
+ if (uuid === null) {
1261
+ // Only the events of objects the subscriber can read: an object that is gone or unreadable is skipped
1262
+ let object = evt.object;
1263
+ if (!object) {
1264
+ try {
1265
+ object = await model.ref(evt.object_id).get();
1266
+ }
1267
+ catch {
1268
+ return;
1269
+ }
1270
+ }
1271
+ if (!object || !(await isModelActionAllowed(object, context, "get", model)))
1272
+ return;
1273
+ }
1274
+ return { latestEventTime: Date.now(), [eventName]: publicCopy(evt) };
988
1275
  };
989
1276
  const eventsMap = {};
990
1277
  if (uuid !== null) {
@@ -1008,6 +1295,30 @@ let GraphQLService = (() => {
1008
1295
  logout: { evt: "nok?" }
1009
1296
  }).iterate();
1010
1297
  }
1298
+ /**
1299
+ * Hand a WebSocket message to graphql-ws; a failure is logged and closes the socket without leaking details
1300
+ * @param socket - the WebSocket
1301
+ * @param cb - the graphql-ws message handler
1302
+ * @param data - the message
1303
+ * @param storage - the instance storage to re-enter for the resolvers
1304
+ * @returns a promise settled once the message is handled
1305
+ */
1306
+ async handleSocketMessage(socket, cb, data, storage) {
1307
+ try {
1308
+ // wait for the the operation to complete
1309
+ // - if init message, waits for connect
1310
+ // - if query/mutation, waits for result
1311
+ // - if subscription, waits for complete
1312
+ // The socket events fire outside the instance storage: re-enter it for the resolvers
1313
+ await runWithInstanceStorage(storage, () => cb(data));
1314
+ }
1315
+ catch (err) {
1316
+ // all errors that could be thrown during the execution of operations will be caught here
1317
+ this.log("ERROR", "GraphQL websocket message failed", err);
1318
+ // A close reason is limited to 123 bytes and must not leak internal details
1319
+ socket.close(CloseCode.InternalServerError, "Internal server error");
1320
+ }
1321
+ }
1011
1322
  /**
1012
1323
  * Assemble the final GraphQLSchema from root query fields, mutations, and subscriptions
1013
1324
  * @param rootFields - top-level query fields
@@ -1059,6 +1370,7 @@ let GraphQLService = (() => {
1059
1370
  return req.extra?.context || {};
1060
1371
  }
1061
1372
  });
1373
+ const storage = useInstanceStorage();
1062
1374
  this.wss.on("connection", async (socket, request) => {
1063
1375
  // a new socket opened, let graphql-ws take over
1064
1376
  const closed = this.wsHandler.opened({
@@ -1067,20 +1379,7 @@ let GraphQLService = (() => {
1067
1379
  socket.send(data, err => (err ? reject(err) : resolve()));
1068
1380
  }), // control your data flow by timing the promise resolve
1069
1381
  close: (code, reason) => socket.close(code, reason), // there are protocol standard closures
1070
- onMessage: cb => socket.on("message", async (event) => {
1071
- try {
1072
- // wait for the the operation to complete
1073
- // - if init message, waits for connect
1074
- // - if query/mutation, waits for result
1075
- // - if subscription, waits for complete
1076
- await cb(event.toString());
1077
- }
1078
- catch (err) {
1079
- // all errors that could be thrown during the
1080
- // execution of operations will be caught here
1081
- socket.close(CloseCode.InternalServerError, err.message);
1082
- }
1083
- })
1382
+ onMessage: cb => socket.on("message", event => this.handleSocketMessage(socket, cb, event.toString(), storage))
1084
1383
  },
1085
1384
  // pass values to the `extra` field in the context
1086
1385
  { socket, request, context: request.webdaContext });