@m1212e/rumble 0.23.18 → 0.23.20

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/out/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { t as lazy } from "./lazy-C7IyM4M4.mjs";
2
2
  import { t as generateFromSchema } from "./generate-0QFVc3Sg.mjs";
3
- import { GraphQLError, GraphQLSchema, execute as defaultExecute, getOperationAST, print, subscribe as defaultSubscribe } from "graphql";
3
+ import { GraphQLError, GraphQLScalarType, GraphQLSchema, Kind, execute as defaultExecute, getOperationAST, print, subscribe as defaultSubscribe } from "graphql";
4
4
  import { EnvelopArmorPlugin } from "@escape.tech/graphql-armor";
5
5
  import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection";
6
6
  import { SpanStatusCode, isSpanContextValid, trace } from "@opentelemetry/api";
@@ -18,8 +18,12 @@ import SchemaBuilder, { BasePlugin } from "@pothos/core";
18
18
  import DrizzlePlugin from "@pothos/plugin-drizzle";
19
19
  import SmartSubscriptionsPlugin, { subscribeOptionsFromIterator } from "@pothos/plugin-smart-subscriptions";
20
20
  import TracingPlugin, { isRootField, wrapResolver } from "@pothos/plugin-tracing";
21
- import { BigIntResolver, ByteResolver, DateResolver, DateTimeISOResolver, JSONResolver } from "graphql-scalars";
21
+ import ValidationPlugin from "@pothos/plugin-validation";
22
+ import { BigIntResolver, ByteResolver, DateResolver, DateTimeISOResolver, EmailAddressResolver, JSONResolver, LocaleResolver } from "graphql-scalars";
22
23
  import DataLoader from "dataloader";
24
+ import { AddressValidationError, CountryMissingError, InvalidStateError, InvalidZipError, MissingFieldError, isValidCountryCode, validateAddress } from "lib-address";
25
+ import { z } from "zod";
26
+ import { parsePhoneNumberFromString } from "libphonenumber-js";
23
27
  //#region lib/types/rumbleError.ts
24
28
  /**
25
29
  * An error that gets raised by rumble whenever something does not go according to plan.
@@ -182,7 +186,7 @@ function mapNullFieldsToUndefined(obj) {
182
186
  }
183
187
  //#endregion
184
188
  //#region package.json
185
- var version = "0.23.18";
189
+ var version = "0.23.20";
186
190
  //#endregion
187
191
  //#region lib/helpers/errorLogging.ts
188
192
  function hasMessage(error) {
@@ -563,6 +567,24 @@ function recordSpanErrors(span, errors) {
563
567
  function recordSpanError(span, error) {
564
568
  recordSpanErrors(span, [error]);
565
569
  }
570
+ /**
571
+ * Errors the operation wrapper has already reported.
572
+ *
573
+ * A transport can see the same failure twice: sofa, for instance, hands
574
+ * `result.errors` to its error handler *after* execute has returned, so without
575
+ * this the very same error would produce two log lines and two exception events,
576
+ * which quietly doubles error counts in dashboards. A WeakSet keeps this free of
577
+ * bookkeeping — entries disappear with the errors themselves.
578
+ */
579
+ const reportedErrors = /* @__PURE__ */ new WeakSet();
580
+ function markErrorsReported(errors) {
581
+ for (const error of errors) if (typeof error === "object" && error !== null) reportedErrors.add(error);
582
+ }
583
+ /** True when every error was already reported by the operation wrapper. */
584
+ function errorsAlreadyReported(errors) {
585
+ if (errors.length === 0) return false;
586
+ return errors.every((error) => typeof error === "object" && error !== null && reportedErrors.has(error));
587
+ }
566
588
  function safeJsonStringify(value) {
567
589
  try {
568
590
  return JSON.stringify(value, (_key, inner) => typeof inner === "bigint" ? inner.toString() : inner) ?? "null";
@@ -1603,6 +1625,7 @@ function buildTracedExecute(executeFn, rumbleInput, transport) {
1603
1625
  ...errorsLogField(result.errors)
1604
1626
  }, "graphql execute completed with errors");
1605
1627
  recordSpanErrors(span, result.errors);
1628
+ markErrorsReported(result.errors);
1606
1629
  } else log?.info({
1607
1630
  ...telemetry.logFields,
1608
1631
  [FIELD_DURATION_MS]: durationMs(start)
@@ -1615,6 +1638,7 @@ function buildTracedExecute(executeFn, rumbleInput, transport) {
1615
1638
  ...errorLogField(error)
1616
1639
  }, "graphql execute threw");
1617
1640
  recordSpanError(span, error);
1641
+ markErrorsReported([error]);
1618
1642
  throw error;
1619
1643
  }
1620
1644
  };
@@ -1656,6 +1680,7 @@ function buildTracedSubscribe(subscribeFn, rumbleInput, transport) {
1656
1680
  ...errorsLogField(execResult.errors)
1657
1681
  }, "graphql subscribe completed with errors");
1658
1682
  recordSpanErrors(span, execResult.errors);
1683
+ markErrorsReported(execResult.errors);
1659
1684
  }
1660
1685
  return execResult;
1661
1686
  }
@@ -1677,6 +1702,7 @@ function buildTracedSubscribe(subscribeFn, rumbleInput, transport) {
1677
1702
  ...errorLogField(error)
1678
1703
  }, "graphql subscribe threw");
1679
1704
  recordSpanError(span, error);
1705
+ markErrorsReported([error]);
1680
1706
  throw error;
1681
1707
  }
1682
1708
  };
@@ -2584,8 +2610,191 @@ function registerRuntimeFiltersPlugin() {
2584
2610
  }
2585
2611
  }
2586
2612
  //#endregion
2613
+ //#region lib/scalars/addressValidation.ts
2614
+ const FIELD_PATH_MAP = {
2615
+ addressLine1: "streetAddress",
2616
+ addressLine2: "streetAddress",
2617
+ addressLine3: "streetAddress",
2618
+ city: "locality",
2619
+ dependentLocality: "locality",
2620
+ state: "region",
2621
+ zip: "postalCode",
2622
+ sortingCode: "postalCode"
2623
+ };
2624
+ /**
2625
+ * Fixes the unambiguous, purely-cosmetic issues (stray surrounding whitespace, wrong case)
2626
+ * before validation runs, rather than rejecting a request over them. This runs as a Zod
2627
+ * `transform`, and `@pothos/plugin-validation` uses a transform's *output* as the field's
2628
+ * actual value — so the corrected value, not the original one, is what resolvers receive.
2629
+ *
2630
+ * `countryCode` and `postalCode` are uppercased since their canonical forms always are (ISO
2631
+ * 3166-1 codes, and postal codes in every country that mixes letters and digits, e.g. GB/CA/NL).
2632
+ * `region`/`locality` are only trimmed, not case-changed: unlike country/postal codes, a region
2633
+ * is sometimes a free-text place name rather than a code (lib-address's own subdivision lookup
2634
+ * is already case-insensitive, so case coercion there wouldn't fix anything a rejection-worthy
2635
+ * case wouldn't already pass).
2636
+ */
2637
+ const normalizeAddress = (address) => ({
2638
+ streetAddress: address.streetAddress.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n") || null,
2639
+ locality: address.locality?.trim() || null,
2640
+ region: address.region?.trim() || null,
2641
+ postalCode: address.postalCode?.trim().toUpperCase() || null,
2642
+ countryCode: address.countryCode.trim().toUpperCase()
2643
+ });
2644
+ const addressValidationSchema = z.object({
2645
+ streetAddress: z.string(),
2646
+ locality: z.string().nullable().optional(),
2647
+ region: z.string().nullable().optional(),
2648
+ postalCode: z.string().nullable().optional(),
2649
+ countryCode: z.string()
2650
+ }).transform(normalizeAddress).superRefine((address, ctx) => {
2651
+ if (!address.countryCode) {
2652
+ ctx.addIssue({
2653
+ code: "custom",
2654
+ path: ["countryCode"],
2655
+ message: "countryCode is required."
2656
+ });
2657
+ return;
2658
+ }
2659
+ if (!isValidCountryCode(address.countryCode)) {
2660
+ ctx.addIssue({
2661
+ code: "custom",
2662
+ path: ["countryCode"],
2663
+ message: `Unknown country code: ${address.countryCode}`
2664
+ });
2665
+ return;
2666
+ }
2667
+ const [addressLine1, addressLine2, addressLine3] = address.streetAddress ? address.streetAddress.split("\n") : [];
2668
+ try {
2669
+ validateAddress({
2670
+ country: address.countryCode,
2671
+ addressLine1,
2672
+ addressLine2,
2673
+ addressLine3,
2674
+ city: address.locality ?? void 0,
2675
+ state: address.region?.toUpperCase() ?? void 0,
2676
+ zip: address.postalCode ?? void 0
2677
+ });
2678
+ } catch (error) {
2679
+ if (error instanceof CountryMissingError) {
2680
+ ctx.addIssue({
2681
+ code: "custom",
2682
+ path: ["countryCode"],
2683
+ message: error.message
2684
+ });
2685
+ return;
2686
+ }
2687
+ if (!(error instanceof AddressValidationError)) throw error;
2688
+ for (const subError of error.errors) if (subError instanceof MissingFieldError) {
2689
+ const path = FIELD_PATH_MAP[subError.field];
2690
+ if (!path) continue;
2691
+ ctx.addIssue({
2692
+ code: "custom",
2693
+ path: [path],
2694
+ message: `${path} is required for country ${address.countryCode}.`
2695
+ });
2696
+ } else if (subError instanceof InvalidZipError) ctx.addIssue({
2697
+ code: "custom",
2698
+ path: ["postalCode"],
2699
+ message: `Invalid postal code for country ${address.countryCode}.`
2700
+ });
2701
+ else if (subError instanceof InvalidStateError) ctx.addIssue({
2702
+ code: "custom",
2703
+ path: ["region"],
2704
+ message: `Invalid region for country ${address.countryCode}.`
2705
+ });
2706
+ }
2707
+ });
2708
+ //#endregion
2709
+ //#region lib/scalars/address.ts
2710
+ function implementDefaultAddressTypes(schemaBuilder) {
2711
+ return {
2712
+ Address: schemaBuilder.objectRef("Address").implement({
2713
+ description: "A postal address, modeled after the OIDC `address` claim and schema.org's PostalAddress.",
2714
+ fields: (t) => ({
2715
+ streetAddress: t.exposeString("streetAddress", { nullable: true }),
2716
+ locality: t.exposeString("locality", { nullable: true }),
2717
+ region: t.exposeString("region", { nullable: true }),
2718
+ postalCode: t.exposeString("postalCode", { nullable: true }),
2719
+ countryCode: t.exposeString("countryCode", { nullable: true })
2720
+ })
2721
+ }),
2722
+ AddressInput: schemaBuilder.inputRef("AddressInput").implement({
2723
+ description: "Input counterpart of Address. countryCode and streetAddress are required — countryCode because there is nothing to validate the other fields against without it, and streetAddress because it's the one field required in every country. The remaining fields are optional since their requiredness genuinely varies by country; validated as a whole using Google's libaddressinput metadata (offline).",
2724
+ validate: addressValidationSchema,
2725
+ fields: (t) => ({
2726
+ streetAddress: t.string({ required: true }),
2727
+ locality: t.string({ required: false }),
2728
+ region: t.string({ required: false }),
2729
+ postalCode: t.string({ required: false }),
2730
+ countryCode: t.string({ required: true })
2731
+ })
2732
+ })
2733
+ };
2734
+ }
2735
+ //#endregion
2736
+ //#region lib/scalars/personName.ts
2737
+ const MAX_LENGTH = 200;
2738
+ const NAME_REGEX = /^[\p{L}\p{M}]+(?:(?:[ '-]|\.\x20?)[\p{L}\p{M}]+)*\.?$/u;
2739
+ /**
2740
+ * Normalizes and validates a person's name.
2741
+ */
2742
+ const normalize = (value) => {
2743
+ if (typeof value !== "string") throw new RumbleErrorSafe(`Name value is not a string: ${value}`);
2744
+ const normalized = value.normalize("NFC").replace(/[\p{Cc}\p{Cf}]/gu, "").replace(/\p{Zs}+/gu, " ").trim().replace(/([-'])\1+/gu, "$1").replace(/^[-']+|[-']+$/gu, "").trim();
2745
+ if (normalized.length === 0) throw new RumbleErrorSafe("Name must not be empty.");
2746
+ if (normalized.length > MAX_LENGTH) throw new RumbleErrorSafe(`Name must not be longer than ${MAX_LENGTH} characters.`);
2747
+ if (!NAME_REGEX.test(normalized)) throw new RumbleErrorSafe(`Invalid name: ${value}. Names may only contain letters and the separators space, apostrophe, hyphen and period, and may not start with or repeat a separator.`);
2748
+ return normalized;
2749
+ };
2750
+ const PersonNameResolver = new GraphQLScalarType({
2751
+ name: "PersonName",
2752
+ description: "A person's name. Unicode letters from any script are allowed, along with space, apostrophe, hyphen and period as separators between name parts. The value is Unicode NFC normalized, has invisible/control characters stripped, has its whitespace collapsed, and has unambiguous typos (a doubled or stray leading/trailing hyphen/apostrophe) silently corrected rather than rejected.",
2753
+ serialize: normalize,
2754
+ parseValue: normalize,
2755
+ parseLiteral(ast) {
2756
+ if (ast.kind !== Kind.STRING) throw new RumbleErrorSafe(`Can only validate strings as names but got a: ${ast.kind}`);
2757
+ return normalize(ast.value);
2758
+ }
2759
+ });
2760
+ //#endregion
2761
+ //#region lib/scalars/phoneNumber.ts
2762
+ /**
2763
+ * Parses and validates a phone number using libphonenumber-js (Google's phone number metadata),
2764
+ * which checks real per-country validity (length, area code plausibility, ...) rather than just
2765
+ * the E.164 shape.
2766
+ */
2767
+ const parse = (value) => {
2768
+ if (typeof value !== "string") throw new RumbleErrorSafe(`Phone number value is not a string: ${value}`);
2769
+ const phoneNumber = parsePhoneNumberFromString(value);
2770
+ if (!phoneNumber?.isValid()) throw new RumbleErrorSafe(`Invalid phone number: ${value}. Please provide a valid phone number including its country calling code, e.g. +4915123456789.`);
2771
+ return phoneNumber;
2772
+ };
2773
+ /**
2774
+ * Resolvers may either return an already-parsed PhoneNumber (e.g. one obtained via `parse`
2775
+ * above) or a raw string, which is parsed and validated the same way input values are.
2776
+ */
2777
+ const serialize = (value) => {
2778
+ if (value && typeof value === "object" && typeof value.isValid === "function") {
2779
+ const phoneNumber = value;
2780
+ if (!phoneNumber.isValid()) throw new RumbleErrorSafe(`Invalid phone number: ${phoneNumber.number}`);
2781
+ return phoneNumber.number;
2782
+ }
2783
+ return parse(value).number;
2784
+ };
2785
+ const PhoneNumberResolver = new GraphQLScalarType({
2786
+ name: "PhoneNumber",
2787
+ description: "A phone number, validated using real per-country validity rules and represented as a parsed libphonenumber-js PhoneNumber. On the wire it is transported as the E.164 format (https://en.wikipedia.org/wiki/E.164), e.g. +4915123456789. The country calling code is required.",
2788
+ serialize,
2789
+ parseValue: parse,
2790
+ parseLiteral(ast) {
2791
+ if (ast.kind !== Kind.STRING) throw new RumbleErrorSafe(`Can only validate strings as phone numbers but got a: ${ast.kind}`);
2792
+ return parse(ast.value);
2793
+ }
2794
+ });
2795
+ //#endregion
2587
2796
  //#region lib/schemaBuilder.ts
2588
- const createSchemaBuilder = ({ db, pubsub, pothosConfig, otel, logger }) => {
2797
+ const createSchemaBuilder = ({ db, pubsub, pothosConfig, otel, logger, validation }) => {
2589
2798
  const createSpan = otel?.enabled && otel.tracer ? createOpenTelemetryWrapper(otel.tracer, otel.options) : void 0;
2590
2799
  registerRuntimeFiltersPlugin();
2591
2800
  const schemaBuilder = new SchemaBuilder({
@@ -2595,6 +2804,7 @@ const createSchemaBuilder = ({ db, pubsub, pothosConfig, otel, logger }) => {
2595
2804
  DrizzlePlugin,
2596
2805
  SmartSubscriptionsPlugin,
2597
2806
  TracingPlugin,
2807
+ ValidationPlugin,
2598
2808
  ...pothosConfig?.plugins ?? []
2599
2809
  ],
2600
2810
  drizzle: {
@@ -2640,14 +2850,20 @@ const createSchemaBuilder = ({ db, pubsub, pothosConfig, otel, logger }) => {
2640
2850
  }
2641
2851
  },
2642
2852
  otel,
2643
- logger
2853
+ logger,
2854
+ validation
2644
2855
  });
2645
2856
  schemaBuilder.addScalarType("JSON", JSONResolver);
2646
2857
  schemaBuilder.addScalarType("Date", DateResolver);
2647
2858
  schemaBuilder.addScalarType("DateTime", DateTimeISOResolver);
2648
2859
  schemaBuilder.addScalarType("BigInt", BigIntResolver);
2649
2860
  schemaBuilder.addScalarType("Bytes", ByteResolver);
2861
+ schemaBuilder.addScalarType("EmailAddress", EmailAddressResolver);
2862
+ schemaBuilder.addScalarType("PhoneNumber", PhoneNumberResolver);
2863
+ schemaBuilder.addScalarType("Locale", LocaleResolver);
2864
+ schemaBuilder.addScalarType("PersonName", PersonNameResolver);
2650
2865
  implementDefaultWhereInputArgs(schemaBuilder);
2866
+ implementDefaultAddressTypes(schemaBuilder);
2651
2867
  schemaBuilder.queryType({});
2652
2868
  schemaBuilder.subscriptionType({});
2653
2869
  schemaBuilder.mutationType({});
@@ -2841,12 +3057,14 @@ export const r = rumble({
2841
3057
  execute: buildTracedExecute(args.execute ?? defaultExecute, rumbleInput, "rest"),
2842
3058
  subscribe: buildTracedSubscribe(args.subscribe ?? defaultSubscribe, rumbleInput, "rest"),
2843
3059
  errorHandler(errors) {
2844
- recordSpanErrors(trace.getActiveSpan(), errors);
2845
- telemetryLogger(rumbleInput)?.error({
2846
- [ATTR_TRANSPORT]: "rest",
2847
- ...traceCorrelationFields(rumbleInput),
2848
- ...errorsLogField(errors)
2849
- }, "rest request failed");
3060
+ if (!errorsAlreadyReported(errors)) {
3061
+ recordSpanErrors(trace.getActiveSpan(), errors);
3062
+ telemetryLogger(rumbleInput)?.error({
3063
+ [ATTR_TRANSPORT]: "rest",
3064
+ ...traceCorrelationFields(rumbleInput),
3065
+ ...errorsLogField(errors)
3066
+ }, "rest request failed");
3067
+ }
2850
3068
  return userErrorHandler?.(errors) ?? sofaErrorResponse(errors);
2851
3069
  }
2852
3070
  });