tina4-nodejs 3.11.1 → 3.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.11.1",
6
+ "version": "3.11.2",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
@@ -480,8 +480,9 @@ export class GraphQL {
480
480
  /**
481
481
  * Execute a GraphQL query string.
482
482
  */
483
- execute(query: string, variables?: Record<string, unknown>): GraphQLResult {
483
+ execute(query: string, variables?: Record<string, unknown>, context?: Record<string, unknown>): GraphQLResult {
484
484
  const vars = variables ?? {};
485
+ const ctx = context ?? {};
485
486
  const errors: Array<{ message: string; path?: string[] }> = [];
486
487
 
487
488
  let doc: { definitions: ParsedOperation[] };
@@ -511,7 +512,10 @@ export class GraphQL {
511
512
  const data: Record<string, unknown> = {};
512
513
 
513
514
  for (const sel of op.selections) {
514
- const [value, errs] = this.resolveField(sel, resolvers, null, vars);
515
+ // Check directives (@skip, @include, @auth, @role, @guest)
516
+ if (!this.checkDirectives(sel.directives ?? [], vars, ctx)) continue;
517
+
518
+ const [value, errs] = this.resolveField(sel, resolvers, null, vars, ctx);
515
519
  errors.push(...errs);
516
520
  const key = sel.alias ?? sel.name;
517
521
  data[key] = value;
@@ -841,22 +845,36 @@ export class GraphQL {
841
845
  resolvers: Map<string, QueryConfig>,
842
846
  parent: unknown,
843
847
  variables: Record<string, unknown>,
848
+ context: Record<string, unknown> = {},
844
849
  ): [unknown, Array<{ message: string; path?: string[] }>] {
845
850
  const errors: Array<{ message: string; path?: string[] }> = [];
846
851
  const name = sel.name;
847
852
  const args = this.resolveArgs(sel.args, variables);
848
853
 
854
+ // Check directives (@auth, @role, @guest, @skip, @include)
855
+ if (!this.checkDirectives(sel.directives ?? [], variables, context)) {
856
+ return [null, errors];
857
+ }
858
+
849
859
  let value: unknown = undefined;
850
860
 
851
861
  if (parent !== null && parent !== undefined) {
852
- // Resolve from parent object
853
862
  if (typeof parent === "object" && parent !== null) {
854
863
  value = (parent as Record<string, unknown>)[name];
855
864
  }
856
865
  } else if (resolvers.has(name)) {
857
866
  const config = resolvers.get(name)!;
867
+
868
+ // Input validation
869
+ const validationErrors = this.validateArgs(args, config.args ?? {}, name);
870
+ if (validationErrors.length > 0) {
871
+ return [null, validationErrors];
872
+ }
873
+
874
+ // Inject sub-selections into context for DataLoader/eager-loading
875
+ const ctx = { ...context, __selections: sel.selections ?? [] };
858
876
  try {
859
- value = config.resolver(null, args, {});
877
+ value = config.resolver(null, args, ctx);
860
878
  } catch (e: unknown) {
861
879
  const message = e instanceof Error ? e.message : String(e);
862
880
  errors.push({ message, path: [name] });
@@ -864,45 +882,30 @@ export class GraphQL {
864
882
  }
865
883
  }
866
884
 
867
- // If no sub-selections, return the scalar value
868
885
  if (!sel.selections || sel.selections.length === 0) {
869
886
  return [value, errors];
870
887
  }
871
888
 
872
- // Handle list types
873
889
  if (Array.isArray(value)) {
874
890
  const result: Record<string, unknown>[] = [];
875
891
  for (const item of value) {
876
892
  const obj: Record<string, unknown> = {};
877
893
  for (const subSel of sel.selections) {
878
- const [subVal, subErrs] = this.resolveField(
879
- subSel,
880
- new Map(),
881
- item,
882
- variables,
883
- );
894
+ const [subVal, subErrs] = this.resolveField(subSel, new Map(), item, variables, context);
884
895
  errors.push(...subErrs);
885
- const key = subSel.alias ?? subSel.name;
886
- obj[key] = subVal;
896
+ obj[subSel.alias ?? subSel.name] = subVal;
887
897
  }
888
898
  result.push(obj);
889
899
  }
890
900
  return [result, errors];
891
901
  }
892
902
 
893
- // Handle object types
894
903
  if (value !== null && value !== undefined) {
895
904
  const obj: Record<string, unknown> = {};
896
905
  for (const subSel of sel.selections) {
897
- const [subVal, subErrs] = this.resolveField(
898
- subSel,
899
- new Map(),
900
- value,
901
- variables,
902
- );
906
+ const [subVal, subErrs] = this.resolveField(subSel, new Map(), value, variables, context);
903
907
  errors.push(...subErrs);
904
- const key = subSel.alias ?? subSel.name;
905
- obj[key] = subVal;
908
+ obj[subSel.alias ?? subSel.name] = subVal;
906
909
  }
907
910
  return [obj, errors];
908
911
  }
@@ -931,6 +934,120 @@ export class GraphQL {
931
934
  }
932
935
  return resolved;
933
936
  }
937
+
938
+ /**
939
+ * Check directives: @skip, @include, @auth, @role, @guest.
940
+ * Returns true if the field should be included, false to skip.
941
+ */
942
+ private checkDirectives(
943
+ directives: Array<{ name: string; args: Record<string, unknown> }>,
944
+ variables: Record<string, unknown>,
945
+ context: Record<string, unknown> = {},
946
+ ): boolean {
947
+ for (const d of directives) {
948
+ let val = d.args?.if;
949
+ if (typeof val === "object" && val !== null && "$var" in val) {
950
+ val = variables[(val as { $var: string }).$var];
951
+ }
952
+
953
+ if (d.name === "skip" && val) return false;
954
+ if (d.name === "include" && !val) return false;
955
+
956
+ // Auth: @auth — requires any authenticated user
957
+ if (d.name === "auth" && !context.user) return false;
958
+
959
+ // Auth: @role(role: "admin") — requires specific role
960
+ if (d.name === "role") {
961
+ const required = d.args?.role;
962
+ const user = context.user as Record<string, unknown> | undefined;
963
+ const actual = user?.role ?? context.role;
964
+ if (!required || actual !== required) return false;
965
+ }
966
+
967
+ // Auth: @guest — only for unauthenticated
968
+ if (d.name === "guest" && context.user) return false;
969
+ }
970
+ return true;
971
+ }
972
+
973
+ /**
974
+ * Validate resolved args against declared types.
975
+ */
976
+ private validateArgs(
977
+ args: Record<string, unknown>,
978
+ argConfigs: Record<string, string>,
979
+ fieldName: string,
980
+ ): Array<{ message: string; path?: string[] }> {
981
+ const errors: Array<{ message: string; path?: string[] }> = [];
982
+
983
+ for (const [argName, declaredType] of Object.entries(argConfigs)) {
984
+ const parsed = GraphQLType.parse(declaredType);
985
+ const value = args[argName];
986
+ const isNonNull = parsed.kind === "non_null";
987
+ const innerType = isNonNull ? parsed.ofType! : parsed;
988
+ const baseName = innerType.kind === "list" ? "list" : innerType.name;
989
+
990
+ if (isNonNull && (value === null || value === undefined || value === "")) {
991
+ errors.push({
992
+ message: `Argument '${argName}' on field '${fieldName}' is required (type: ${declaredType})`,
993
+ path: [fieldName],
994
+ });
995
+ continue;
996
+ }
997
+
998
+ if (value === null || value === undefined) continue;
999
+
1000
+ if (baseName === "list" && Array.isArray(value)) {
1001
+ const itemType = innerType.ofType;
1002
+ if (itemType) {
1003
+ const itemName = itemType.kind === "non_null" ? (itemType.ofType?.name ?? "String") : itemType.name;
1004
+ for (let i = 0; i < value.length; i++) {
1005
+ if (!this.coerceValue(value[i], itemName)) {
1006
+ errors.push({
1007
+ message: `Argument '${argName}[${i}]' on field '${fieldName}' expected ${itemName}, got ${typeof value[i]}`,
1008
+ path: [fieldName],
1009
+ });
1010
+ }
1011
+ }
1012
+ }
1013
+ continue;
1014
+ }
1015
+
1016
+ if (GraphQLType.SCALARS.includes(baseName)) {
1017
+ if (!this.coerceValue(value, baseName)) {
1018
+ errors.push({
1019
+ message: `Argument '${argName}' on field '${fieldName}' expected type ${baseName}, got ${typeof value}`,
1020
+ path: [fieldName],
1021
+ });
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ return errors;
1027
+ }
1028
+
1029
+ private coerceValue(value: unknown, typeName: string): boolean {
1030
+ if (typeName === "String" || typeName === "ID") {
1031
+ return typeof value === "string" || typeof value === "number";
1032
+ }
1033
+ if (typeName === "Int") {
1034
+ if (typeof value === "boolean") return false;
1035
+ if (typeof value === "number") return Number.isInteger(value);
1036
+ if (typeof value === "string") return /^-?\d+$/.test(value);
1037
+ return false;
1038
+ }
1039
+ if (typeName === "Float") {
1040
+ if (typeof value === "boolean") return false;
1041
+ if (typeof value === "number") return true;
1042
+ if (typeof value === "string") return !isNaN(parseFloat(value));
1043
+ return false;
1044
+ }
1045
+ if (typeName === "Boolean") {
1046
+ return typeof value === "boolean" || value === 0 || value === 1
1047
+ || value === "true" || value === "false";
1048
+ }
1049
+ return true;
1050
+ }
934
1051
  }
935
1052
 
936
1053
  /**