From 841625e2f901ebd3a9748338b36a1d3af772d9b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Tue, 2 Sep 2025 15:45:41 +0100 Subject: [PATCH 001/123] fix: runtime setup error (#1520) --- config/runtime.exs | 4 ++-- mix.exs | 2 +- run.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 39310f093..ac0a2569b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,12 +3,12 @@ import Config defmodule Env do def get_integer(env, default) do value = System.get_env(env) - if value, do: String.to_integer(env), else: default + if value, do: String.to_integer(value), else: default end def get_charlist(env, default) do value = System.get_env(env) - if value, do: String.to_charlist(env), else: default + if value, do: String.to_charlist(value), else: default end def get_boolean(env, default) do diff --git a/mix.exs b/mix.exs index d0f8a267b..13ffe985a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.46.2", + version: "2.46.3", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/run.sh b/run.sh index 2dddbc1b8..66585dc2b 100755 --- a/run.sh +++ b/run.sh @@ -90,7 +90,7 @@ if [ "${ENABLE_ERL_CRASH_DUMP:-false}" = true ]; then trap upload_crash_dump_to_s3 INT TERM KILL EXIT fi -if [[ -n "${GENERATE_CLUSTER_CERTS}" ]] ; then +if [[ -n "${GENERATE_CLUSTER_CERTS:-}" ]] ; then generate_certs fi From 1b63b4fe2d34f063b6b0afbe7e6133df42e95e93 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 3 Sep 2025 16:49:57 +1200 Subject: [PATCH 002/123] fix: use primary instead of replica on rename_settings_field (#1521) --- lib/realtime/api.ex | 9 +++------ mix.exs | 2 +- test/realtime/api_test.exs | 4 ---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/lib/realtime/api.ex b/lib/realtime/api.ex index 23e28feab..c504d0187 100644 --- a/lib/realtime/api.ex +++ b/lib/realtime/api.ex @@ -186,12 +186,9 @@ defmodule Realtime.Api do |> repo.preload(:extensions) end - def list_extensions(type \\ "postgres_cdc_rls") do - from(e in Extensions, - where: e.type == ^type, - select: e - ) - |> Replica.replica().all() + defp list_extensions(type \\ "postgres_cdc_rls") do + from(e in Extensions, where: e.type == ^type, select: e) + |> Repo.all() end def rename_settings_field(from, to) do diff --git a/mix.exs b/mix.exs index 13ffe985a..c0d4e1516 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.46.3", + version: "2.46.4", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/api_test.exs b/test/realtime/api_test.exs index 1c4a816b0..55dc609eb 100644 --- a/test/realtime/api_test.exs +++ b/test/realtime/api_test.exs @@ -236,10 +236,6 @@ defmodule Realtime.ApiTest do end end - test "list_extensions/1 ", %{tenants: tenants} do - assert length(Api.list_extensions()) == length(tenants) - end - describe "preload_counters/1" do test "preloads counters for a given tenant ", %{tenants: [tenant | _]} do tenant = Repo.reload!(tenant) From da3404aec8da76c1c3a617d9b7e5185e25806416 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Fri, 5 Sep 2025 09:20:47 +1200 Subject: [PATCH 003/123] feat: upgrade cowboy & ranch (#1523) --- lib/realtime/api.ex | 2 +- .../monitoring/prom_ex/plugins/phoenix.ex | 13 ++++--------- mix.exs | 2 +- mix.lock | 6 +++--- .../monitoring/prom_ex/plugins/phoenix_test.exs | 17 +++++++++++------ 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/realtime/api.ex b/lib/realtime/api.ex index c504d0187..16dc2bcd0 100644 --- a/lib/realtime/api.ex +++ b/lib/realtime/api.ex @@ -186,7 +186,7 @@ defmodule Realtime.Api do |> repo.preload(:extensions) end - defp list_extensions(type \\ "postgres_cdc_rls") do + defp list_extensions(type) do from(e in Extensions, where: e.type == ^type, select: e) |> Repo.all() end diff --git a/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex b/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex index d3f64afbe..6cc3709d2 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/phoenix.ex @@ -57,15 +57,10 @@ if Code.ensure_loaded?(Phoenix) do def execute_metrics do active_conn = - case :ets.lookup(:ranch_server, {:listener_sup, HTTP}) do - [] -> - -1 - - _ -> - HTTP - |> :ranch_server.get_connections_sup() - |> :supervisor.count_children() - |> Keyword.get(:active) + if :ranch.info()[HTTP] do + :ranch.info(HTTP)[:all_connections] + else + -1 end :telemetry.execute(@event_all_connections, %{active: active_conn}, %{}) diff --git a/mix.exs b/mix.exs index c0d4e1516..f39513c99 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.46.4", + version: "2.47.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/mix.lock b/mix.lock index 76eb0d980..dd95486b6 100644 --- a/mix.lock +++ b/mix.lock @@ -7,9 +7,9 @@ "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, - "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, + "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, @@ -82,7 +82,7 @@ "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "69129221f0263aa13faa5fbb8af97c28aeb4f71c", []}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, diff --git a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs index a73e6e2f5..ad9198c97 100644 --- a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs @@ -1,6 +1,7 @@ defmodule Realtime.PromEx.Plugins.PhoenixTest do use Realtime.DataCase, async: false alias Realtime.PromEx.Plugins + alias Realtime.Integration.WebsocketClient defmodule MetricsTest do use PromEx, otp_app: :realtime_test_phoenix @@ -13,16 +14,20 @@ defmodule Realtime.PromEx.Plugins.PhoenixTest do describe "pooling metrics" do setup do start_supervised!(MetricsTest) - :ok + %{tenant: Containers.checkout_tenant(run_migrations: true)} end - test "number of connections" do - # Trigger a connection by making a request to the endpoint - url = RealtimeWeb.Endpoint.url() <> "/healthcheck" - Req.get!(url) + test "number of connections", %{tenant: tenant} do + {:ok, token} = token_valid(tenant, "anon", %{}) + + {:ok, _} = + WebsocketClient.connect(self(), uri(tenant, 4002), Phoenix.Socket.V1.JSONSerializer, [{"x-api-key", token}]) + + {:ok, _} = + WebsocketClient.connect(self(), uri(tenant, 4002), Phoenix.Socket.V1.JSONSerializer, [{"x-api-key", token}]) Process.sleep(200) - assert metric_value() > 0 + assert metric_value() >= 2 end end From bd2c141386d88f328749f80b0904e8261a4a78bd Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 8 Sep 2025 20:57:43 +1200 Subject: [PATCH 004/123] fix: Fix GenRpc to not try to connect to nodes that are not alive (#1525) --- lib/realtime/gen_rpc.ex | 17 +++++++++++++++++ mix.exs | 2 +- test/realtime/gen_rpc_test.exs | 12 ++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/realtime/gen_rpc.ex b/lib/realtime/gen_rpc.ex index bb7099242..3487cc933 100644 --- a/lib/realtime/gen_rpc.ex +++ b/lib/realtime/gen_rpc.ex @@ -41,6 +41,23 @@ defmodule Realtime.GenRpc do @spec call(node, module, atom, list(any), keyword()) :: result def call(node, mod, func, args, opts) when is_atom(node) and is_atom(mod) and is_atom(func) and is_list(args) and is_list(opts) do + if node == node() or node in Node.list() do + do_call(node, mod, func, args, opts) + else + tenant_id = Keyword.get(opts, :tenant_id) + + log_error( + "ErrorOnRpcCall", + %{target: node, mod: mod, func: func, error: :badnode}, + project: tenant_id, + external_id: tenant_id + ) + + {:error, :rpc_error, :badnode} + end + end + + defp do_call(node, mod, func, args, opts) do timeout = Keyword.get(opts, :timeout, default_rpc_timeout()) tenant_id = Keyword.get(opts, :tenant_id) key = Keyword.get(opts, :key, nil) diff --git a/mix.exs b/mix.exs index f39513c99..67d1f7706 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.47.0", + version: "2.47.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs index dd837aaf8..e14d2d054 100644 --- a/test/realtime/gen_rpc_test.exs +++ b/test/realtime/gen_rpc_test.exs @@ -172,6 +172,18 @@ defmodule Realtime.GenRpcTest do mechanism: :gen_rpc }} end + + test "bad node" do + node = :"unknown@1.1.1.1" + + log = + capture_log(fn -> + assert GenRpc.call(node, Map, :fetch, [%{a: 1}, :a], tenant_id: 123) == {:error, :rpc_error, :badnode} + end) + + assert log =~ + ~r/project=123 external_id=123 \[error\] ErrorOnRpcCall: %{+error: :badnode, mod: Map, func: :fetch, target: :"#{node}"/ + end end describe "multicast/4" do From 6cfe6e18ecb37bc87049feecdac640b04484313e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 8 Sep 2025 23:32:18 +0100 Subject: [PATCH 005/123] fix: enable presence on track message (#1527) currently the user would need to have enabled from the beginning of the channel. this will enable users to enable presence later in the flow by sending a track message which will enable presence messages for them --- lib/realtime/api.ex | 5 +- lib/realtime_web/channels/realtime_channel.ex | 2 +- .../realtime_channel/presence_handler.ex | 16 ++-- mix.exs | 2 +- test/integration/rt_channel_test.exs | 50 +++++++++++ .../presence_handler_test.exs | 82 +++++++++++++++++-- 6 files changed, 137 insertions(+), 20 deletions(-) diff --git a/lib/realtime/api.ex b/lib/realtime/api.ex index 16dc2bcd0..f612a5c1e 100644 --- a/lib/realtime/api.ex +++ b/lib/realtime/api.ex @@ -187,8 +187,9 @@ defmodule Realtime.Api do end defp list_extensions(type) do - from(e in Extensions, where: e.type == ^type, select: e) - |> Repo.all() + query = from(e in Extensions, where: e.type == ^type, select: e) + + Repo.all(query) end def rename_settings_field(from, to) do diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 26c033f5c..03bd91347 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -376,7 +376,7 @@ defmodule RealtimeWeb.RealtimeChannel do end def handle_in("presence", payload, %{assigns: %{private?: false}} = socket) do - with {:ok, socket} <- PresenceHandler.handle(payload, socket) do + with {:ok, socket} <- PresenceHandler.handle(payload, nil, socket) do {:reply, :ok, socket} else {:error, :rate_limit_exceeded} -> diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 00ce77c02..9dc23d219 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -52,28 +52,22 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end end - @spec handle(map(), Socket.t()) :: - {:ok, Socket.t()} | {:error, :rls_policy_error | :unable_to_set_policies | :rate_limit_exceeded} - def handle(_, %{assigns: %{presence_enabled?: false}} = socket), do: {:ok, socket} - def handle(payload, socket) when not is_private?(socket), do: handle(payload, nil, socket) - @spec handle(map(), pid() | nil, Socket.t()) :: {:ok, Socket.t()} | {:error, :rls_policy_error | :unable_to_set_policies | :rate_limit_exceeded | :unable_to_track_presence} - def handle(_, _, %{assigns: %{presence_enabled?: false}} = socket), do: {:ok, socket} - def handle(%{"event" => event} = payload, db_conn, socket) do event = String.downcase(event, :ascii) handle_presence_event(event, payload, db_conn, socket) end - def handle(_payload, _db_conn, socket), do: {:ok, socket} + def handle(_, _, socket), do: {:ok, socket} - defp handle_presence_event("track", payload, _db_conn, socket) when not is_private?(socket) do + defp handle_presence_event("track", payload, _, socket) when not is_private?(socket) do track(socket, payload) end - defp handle_presence_event("track", payload, db_conn, socket) when is_nil(socket.assigns.policies.presence.write) do + defp handle_presence_event("track", payload, db_conn, socket) + when is_private?(socket) and is_nil(socket.assigns.policies.presence.write) do %{assigns: %{authorization_context: authorization_context, policies: policies}} = socket case Authorization.get_write_authorizations(policies, db_conn, authorization_context) do @@ -111,6 +105,8 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end defp track(socket, payload) do + socket = assign(socket, :presence_enabled?, true) + %{assigns: %{presence_key: presence_key, tenant_topic: tenant_topic}} = socket payload = Map.get(payload, "payload", %{}) diff --git a/mix.exs b/mix.exs index 67d1f7706..f4beca664 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.47.1", + version: "2.47.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index 806a5ad7e..36955e5b8 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -909,6 +909,56 @@ defmodule Realtime.Integration.RtChannelTest do assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 refute_receive %Message{event: "presence_state"}, 500 end + + test "presence automatically enabled when user sends track message for public channel", %{tenant: tenant} do + {socket, _} = get_connection(tenant) + config = %{presence: %{key: "", enabled: false}, private: false} + topic = "realtime:any" + + WebsocketClient.join(socket, topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + refute_receive %Message{event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic} + + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end + + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] + test "presence automatically enabled when user sends track message for private channel", + %{tenant: tenant, topic: topic} do + {socket, _} = get_connection(tenant, "authenticated") + config = %{presence: %{key: "", enabled: false}, private: true} + topic = "realtime:#{topic}" + + WebsocketClient.join(socket, topic, %{config: config}) + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300 + refute_receive %Message{event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802} + } + + WebsocketClient.send_event(socket, topic, "presence", payload) + + assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500 + join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd() + assert get_in(join_payload, ["name"]) == payload.payload.name + assert get_in(join_payload, ["t"]) == payload.payload.t + end end describe "token handling" do diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index e5ecd32ad..0cdf422e2 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -99,7 +99,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end end - describe "handle/2" do + describe "handle/3" do test "with true policy and is private, user can track their presence and changes", %{ tenant: tenant, topic: topic, @@ -142,7 +142,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies = %Policies{presence: %PresencePolicies{read: false, write: false}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket) topic = socket.assigns.tenant_topic assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} @@ -229,6 +229,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert {:ok, socket} = PresenceHandler.handle( %{"event" => "track", "payload" => %{"metadata" => random_string()}}, + nil, socket ) @@ -248,7 +249,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert log =~ "UnknownPresenceEvent" end - test "socket with presence enabled false will ignore presence events in public channel", %{ + test "socket with presence enabled false will ignore non-track presence events in public channel", %{ tenant: tenant, topic: topic } do @@ -256,12 +257,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, nil, socket) topic = socket.assigns.tenant_topic refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} end - test "socket with presence enabled false will ignore presence events in private channel", %{ + test "socket with presence enabled false will ignore non-track presence events in private channel", %{ tenant: tenant, topic: topic, db_conn: db_conn @@ -270,11 +271,80 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) - assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket) topic = socket.assigns.tenant_topic refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} end + test "socket with presence disabled will enable presence on track message for public channel", %{ + tenant: tenant, + topic: topic + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket) + + assert updated_socket.assigns.presence_enabled? + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert Map.has_key?(joins, key) + end + + test "socket with presence disabled will enable presence on track message for private channel", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, private?: true, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + + assert updated_socket.assigns.presence_enabled? + topic = socket.assigns.tenant_topic + assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} + assert Map.has_key?(joins, key) + end + + test "socket with presence disabled will not enable presence on untrack message", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket) + + refute updated_socket.assigns.presence_enabled? + topic = socket.assigns.tenant_topic + refute_receive %Broadcast{topic: ^topic, event: "presence_diff"} + end + + test "socket with presence disabled will not enable presence on unknown event", %{ + tenant: tenant, + topic: topic, + db_conn: db_conn + } do + key = random_string() + policies = %Policies{presence: %PresencePolicies{read: true, write: true}} + socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false) + + refute socket.assigns.presence_enabled? + + assert {:error, :unknown_presence_event} = PresenceHandler.handle(%{"event" => "unknown"}, db_conn, socket) + end + @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] test "rate limit is checked on private channel", %{tenant: tenant, topic: topic, db_conn: db_conn} do key = random_string() From b13bb214ca7abe5988d122854994c7963f844416 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 11 Sep 2025 08:58:42 +1200 Subject: [PATCH 006/123] fix: set cowboy active_n=100 as cowboy 2.12.0 (#1530) cowboy 2.13.0 set the default active_n=1 --- lib/realtime_web/endpoint.ex | 6 ++++++ mix.exs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/realtime_web/endpoint.ex b/lib/realtime_web/endpoint.ex index 917ab65b9..17ee13747 100644 --- a/lib/realtime_web/endpoint.ex +++ b/lib/realtime_web/endpoint.ex @@ -16,6 +16,12 @@ defmodule RealtimeWeb.Endpoint do connect_info: [:peer_data, :uri, :x_headers], fullsweep_after: 20, max_frame_size: 8_000_000, + # https://github.com/ninenines/cowboy/blob/24d32de931a0c985ff7939077463fc8be939f0e9/doc/src/manual/cowboy_websocket.asciidoc#L228 + # active_n: The number of packets Cowboy will request from the socket at once. + # This can be used to tweak the performance of the server. Higher values reduce + # the number of times Cowboy need to request more packets from the port driver at + # the expense of potentially higher memory being used. + active_n: 100, serializer: [ {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} diff --git a/mix.exs b/mix.exs index f4beca664..41f81567e 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.47.2", + version: "2.47.3", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From a17ce3e59aa73ba73816c923917d7e5f838f0e88 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Fri, 12 Sep 2025 14:36:53 +1200 Subject: [PATCH 007/123] fix: provide error_code metadata on RealtimeChannel.Logging (#1531) --- config/test.exs | 2 +- .../channels/realtime_channel/logging.ex | 10 +++---- mix.exs | 2 +- .../realtime_channel/logging_test.exs | 27 ++++++++++++------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/config/test.exs b/config/test.exs index 4c7c66ae8..a69c51701 100644 --- a/config/test.exs +++ b/config/test.exs @@ -47,7 +47,7 @@ config :logger, # Configures Elixir's Logger config :logger, :console, format: "$time $metadata[$level] $message\n", - metadata: [:request_id, :project, :external_id, :application_name, :sub, :iss, :exp] + metadata: [:error_code, :request_id, :project, :external_id, :application_name, :sub, :iss, :exp] config :opentelemetry, span_processor: :simple, diff --git a/lib/realtime_web/channels/realtime_channel/logging.ex b/lib/realtime_web/channels/realtime_channel/logging.ex index 296dce1bc..2f6c91fdb 100644 --- a/lib/realtime_web/channels/realtime_channel/logging.ex +++ b/lib/realtime_web/channels/realtime_channel/logging.ex @@ -21,7 +21,7 @@ defmodule RealtimeWeb.RealtimeChannel.Logging do def log_error(socket, code, msg) do msg = build_msg(code, msg) emit_system_error(:error, code) - log(socket, :error, msg) + log(socket, :error, code, msg) {:error, %{reason: msg}} end @@ -32,7 +32,7 @@ defmodule RealtimeWeb.RealtimeChannel.Logging do {:error, %{reason: binary}} def log_warning(socket, code, msg) do msg = build_msg(code, msg) - log(socket, :warning, msg) + log(socket, :warning, code, msg) {:error, %{reason: msg}} end @@ -59,16 +59,16 @@ defmodule RealtimeWeb.RealtimeChannel.Logging do if code, do: "#{code}: #{msg}", else: msg end - defp log(%{assigns: %{tenant: tenant, access_token: access_token}}, level, msg) do + defp log(%{assigns: %{tenant: tenant, access_token: access_token}}, level, code, msg) do Logger.metadata(external_id: tenant, project: tenant) if level in [:error, :warning], do: update_metadata_with_token_claims(access_token) - Logger.log(level, msg) + Logger.log(level, msg, error_code: code) end defp maybe_log(%{assigns: %{log_level: log_level}} = socket, level, code, msg) do msg = build_msg(code, msg) emit_system_error(level, code) - if Logger.compare_levels(log_level, level) != :gt, do: log(socket, level, msg) + if Logger.compare_levels(log_level, level) != :gt, do: log(socket, level, code, msg) if level in [:error, :warning], do: {:error, %{reason: msg}}, else: :ok end diff --git a/mix.exs b/mix.exs index 41f81567e..d12783f2a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.47.3", + version: "2.47.4", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/realtime_channel/logging_test.exs b/test/realtime_web/channels/realtime_channel/logging_test.exs index 92634daef..cd131d16e 100644 --- a/test/realtime_web/channels/realtime_channel/logging_test.exs +++ b/test/realtime_web/channels/realtime_channel/logging_test.exs @@ -37,6 +37,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do assert log =~ "sub=#{sub}" assert log =~ "exp=#{exp}" assert log =~ "iss=#{iss}" + assert log =~ "error_code=TestError" end end @@ -57,6 +58,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do assert log =~ "sub=#{sub}" assert log =~ "exp=#{exp}" assert log =~ "iss=#{iss}" + assert log =~ "error_code=TestWarning" end end @@ -67,10 +69,14 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do for log_level <- log_levels do socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}} - assert capture_log(fn -> - assert Logging.maybe_log_error(socket, "TestCode", "test message") == - {:error, %{reason: "TestCode: test message"}} - end) =~ "TestCode: test message" + log = + capture_log(fn -> + assert Logging.maybe_log_error(socket, "TestCode", "test message") == + {:error, %{reason: "TestCode: test message"}} + end) + + assert log =~ "TestCode: test message" + assert log =~ "error_code=TestCode" assert capture_log(fn -> assert Logging.maybe_log_error(socket, "TestCode", %{a: "b"}) == @@ -103,11 +109,14 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do for log_level <- log_levels do socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}} - assert capture_log(fn -> - assert Logging.maybe_log_warning(socket, "TestCode", "test message") == - {:error, %{reason: "TestCode: test message"}} - end) =~ - "TestCode: test message" + log = + capture_log(fn -> + assert Logging.maybe_log_warning(socket, "TestCode", "test message") == + {:error, %{reason: "TestCode: test message"}} + end) + + assert log =~ "TestCode: test message" + assert log =~ "error_code=TestCode" assert capture_log(fn -> assert Logging.maybe_log_warning(socket, "TestCode", %{a: "b"}) == From eeba3067b269bf0e316f3d18e484688007b5ea51 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 15 Sep 2025 09:58:32 +1200 Subject: [PATCH 008/123] feat: disable UTF8 validation on websocket frames (#1532) Currently all text frames as handled only with JSON which already requires UTF-8 --- lib/realtime_web/endpoint.ex | 3 +++ mix.exs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/realtime_web/endpoint.ex b/lib/realtime_web/endpoint.ex index 17ee13747..190e1a917 100644 --- a/lib/realtime_web/endpoint.ex +++ b/lib/realtime_web/endpoint.ex @@ -22,6 +22,9 @@ defmodule RealtimeWeb.Endpoint do # the number of times Cowboy need to request more packets from the port driver at # the expense of potentially higher memory being used. active_n: 100, + # Skip validating UTF8 for faster frame processing. + # Currently all text frames as handled only with JSON which already requires UTF-8 + validate_utf8: false, serializer: [ {Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"}, {Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"} diff --git a/mix.exs b/mix.exs index d12783f2a..849a97b7b 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.47.4", + version: "2.48.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 70339c737f54855c200ab1c8ae671bc6171f480a Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 16 Sep 2025 09:42:22 +1200 Subject: [PATCH 009/123] fix: move DB setup to happen after Connect.init (#1533) This change reduces the impact of slow DB setup impacting other tenants trying to connect at the same time that landed on the same partition --- lib/realtime/syn_handler.ex | 6 +- lib/realtime/tenants/connect.ex | 47 ++++++++------- .../tenants/connect/check_connection.ex | 4 +- .../tenants/connect/start_counters.ex | 60 ------------------- mix.exs | 2 +- test/integration/rt_channel_test.exs | 6 +- test/realtime/syn_handler_test.exs | 16 +++-- test/realtime/tenants/connect_test.exs | 20 +++---- 8 files changed, 52 insertions(+), 109 deletions(-) delete mode 100644 lib/realtime/tenants/connect/start_counters.ex diff --git a/lib/realtime/syn_handler.ex b/lib/realtime/syn_handler.ex index 397c8cf8f..d2fa5541c 100644 --- a/lib/realtime/syn_handler.ex +++ b/lib/realtime/syn_handler.ex @@ -10,9 +10,9 @@ defmodule Realtime.SynHandler do @behaviour :syn_event_handler @impl true - def on_registry_process_updated(Connect, tenant_id, _pid, %{conn: conn}, :normal) when is_pid(conn) do + def on_registry_process_updated(Connect, tenant_id, pid, %{conn: conn}, :normal) when is_pid(conn) do # Update that a database connection is ready - Endpoint.local_broadcast(Connect.syn_topic(tenant_id), "ready", %{conn: conn}) + Endpoint.local_broadcast(Connect.syn_topic(tenant_id), "ready", %{pid: pid, conn: conn}) end def on_registry_process_updated(PostgresCdcRls, tenant_id, _pid, meta, _reason) do @@ -38,7 +38,7 @@ defmodule Realtime.SynHandler do end topic = topic(mod) - Endpoint.local_broadcast(topic <> ":" <> name, topic <> "_down", nil) + Endpoint.local_broadcast(topic <> ":" <> name, topic <> "_down", %{pid: pid, reason: reason}) :ok end diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index b9bf00eb4..3c206a785 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -19,7 +19,6 @@ defmodule Realtime.Tenants.Connect do alias Realtime.Tenants.Connect.GetTenant alias Realtime.Tenants.Connect.Piper alias Realtime.Tenants.Connect.RegisterProcess - alias Realtime.Tenants.Connect.StartCounters alias Realtime.Tenants.Migrations alias Realtime.Tenants.ReplicationConnection alias Realtime.UsersCounter @@ -83,14 +82,13 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_connection_initializing} def get_status(tenant_id) do case :syn.lookup(__MODULE__, tenant_id) do - {_pid, %{conn: nil}} -> - wait_for_connection(tenant_id) + {pid, %{conn: nil}} -> + wait_for_connection(pid, tenant_id) {_, %{conn: conn}} -> {:ok, conn} :undefined -> - Logger.warning("Connection process starting up") {:error, :tenant_database_connection_initializing} error -> @@ -101,7 +99,7 @@ defmodule Realtime.Tenants.Connect do def syn_topic(tenant_id), do: "connect:#{tenant_id}" - defp wait_for_connection(tenant_id) do + defp wait_for_connection(pid, tenant_id) do RealtimeWeb.Endpoint.subscribe(syn_topic(tenant_id)) # We do a lookup after subscribing because we could've missed a message while subscribing @@ -112,9 +110,18 @@ defmodule Realtime.Tenants.Connect do _ -> # Wait for up to 5 seconds for the ready event receive do - %{event: "ready", payload: %{conn: conn}} -> {:ok, conn} + %{event: "ready", payload: %{pid: ^pid, conn: conn}} -> + {:ok, conn} + + %{event: "connect_down", payload: %{pid: ^pid, reason: {:shutdown, :tenant_db_too_many_connections}}} -> + {:error, :tenant_db_too_many_connections} + + %{event: "connect_down", payload: %{pid: ^pid, reason: _reason}} -> + metadata = [external_id: tenant_id, project: tenant_id] + log_error("UnableToConnectToTenantDatabase", "Unable to connect to tenant database", metadata) + {:error, :tenant_database_unavailable} after - 5_000 -> {:error, :initializing} + 15_000 -> {:error, :initializing} end end after @@ -139,16 +146,6 @@ defmodule Realtime.Tenants.Connect do {:error, {:already_started, _}} -> get_status(tenant_id) - {:error, {:shutdown, :tenant_db_too_many_connections}} -> - {:error, :tenant_db_too_many_connections} - - {:error, {:shutdown, :tenant_not_found}} -> - {:error, :tenant_not_found} - - {:error, :shutdown} -> - log_error("UnableToConnectToTenantDatabase", "Unable to connect to tenant database", metadata) - {:error, :tenant_database_unavailable} - {:error, error} -> log_error("UnableToConnectToTenantDatabase", error, metadata) {:error, :tenant_database_unavailable} @@ -209,30 +206,33 @@ defmodule Realtime.Tenants.Connect do def init(%{tenant_id: tenant_id} = state) do Logger.metadata(external_id: tenant_id, project: tenant_id) + {:ok, state, {:continue, :db_connect}} + end + + @impl true + def handle_continue(:db_connect, state) do pipes = [ GetTenant, CheckConnection, - StartCounters, RegisterProcess ] case Piper.run(pipes, state) do {:ok, acc} -> - {:ok, acc, {:continue, :run_migrations}} + {:noreply, acc, {:continue, :run_migrations}} {:error, :tenant_not_found} -> - {:stop, {:shutdown, :tenant_not_found}} + {:stop, {:shutdown, :tenant_not_found}, state} {:error, :tenant_db_too_many_connections} -> - {:stop, {:shutdown, :tenant_db_too_many_connections}} + {:stop, {:shutdown, :tenant_db_too_many_connections}, state} {:error, error} -> log_error("UnableToConnectToTenantDatabase", error) - {:stop, :shutdown} + {:stop, :shutdown, state} end end - @impl true def handle_continue(:run_migrations, state) do %{tenant: tenant, db_conn_pid: db_conn_pid} = state Logger.warning("Tenant #{tenant.external_id} is initializing: #{inspect(node())}") @@ -375,6 +375,7 @@ defmodule Realtime.Tenants.Connect do ## Private functions defp call_external_node(tenant_id, opts) do + Logger.warning("Connection process starting up") rpc_timeout = Keyword.get(opts, :rpc_timeout, @rpc_timeout_default) with tenant <- Tenants.Cache.get_tenant_by_external_id(tenant_id), diff --git a/lib/realtime/tenants/connect/check_connection.ex b/lib/realtime/tenants/connect/check_connection.ex index 697c08b6c..53cd8e480 100644 --- a/lib/realtime/tenants/connect/check_connection.ex +++ b/lib/realtime/tenants/connect/check_connection.ex @@ -2,16 +2,14 @@ defmodule Realtime.Tenants.Connect.CheckConnection do @moduledoc """ Check tenant database connection. """ - alias Realtime.Database @behaviour Realtime.Tenants.Connect.Piper @impl true def run(acc) do %{tenant: tenant} = acc - case Database.check_tenant_connection(tenant) do + case Realtime.Database.check_tenant_connection(tenant) do {:ok, conn} -> - Process.link(conn) db_conn_reference = Process.monitor(conn) {:ok, %{acc | db_conn_pid: conn, db_conn_reference: db_conn_reference}} diff --git a/lib/realtime/tenants/connect/start_counters.ex b/lib/realtime/tenants/connect/start_counters.ex deleted file mode 100644 index f8ce6c378..000000000 --- a/lib/realtime/tenants/connect/start_counters.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule Realtime.Tenants.Connect.StartCounters do - @moduledoc """ - Start tenant counters. - """ - - alias Realtime.RateCounter - alias Realtime.Tenants - - @behaviour Realtime.Tenants.Connect.Piper - - @impl true - def run(acc) do - %{tenant: tenant} = acc - - with :ok <- start_joins_per_second_counter(tenant), - :ok <- start_max_events_counter(tenant), - :ok <- start_db_events_counter(tenant) do - {:ok, acc} - end - end - - def start_joins_per_second_counter(tenant) do - res = - tenant - |> Tenants.joins_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end - - def start_max_events_counter(tenant) do - res = - tenant - |> Tenants.events_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end - - def start_db_events_counter(tenant) do - res = - tenant - |> Tenants.db_events_per_second_rate() - |> RateCounter.new() - - case res do - {:ok, _} -> :ok - {:error, {:already_started, _}} -> :ok - {:error, reason} -> {:error, reason} - end - end -end diff --git a/mix.exs b/mix.exs index 849a97b7b..75a7bbd6f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.48.0", + version: "2.48.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index 36955e5b8..2ae4cd449 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -653,8 +653,8 @@ defmodule Realtime.Integration.RtChannelTest do :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end) payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload) - # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready - refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 6000 + # Waiting more than 15 seconds as this is the amount of time we will wait for the Connection to be ready + refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 16000 end) assert log =~ "UnableToHandleBroadcast" @@ -831,7 +831,7 @@ defmodule Realtime.Integration.RtChannelTest do refute_receive %Message{event: "presence_diff"}, 500 # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready - refute_receive %Message{event: "phx_leave", topic: ^topic}, 6000 + refute_receive %Message{event: "phx_leave", topic: ^topic}, 16000 end) assert log =~ "UnableToHandlePresence" diff --git a/test/realtime/syn_handler_test.exs b/test/realtime/syn_handler_test.exs index 2b27cf322..1cf0d3bad 100644 --- a/test/realtime/syn_handler_test.exs +++ b/test/realtime/syn_handler_test.exs @@ -168,32 +168,40 @@ defmodule Realtime.SynHandlerTest do test "it handles :syn_conflict_resolution reason" do reason = :syn_conflict_resolution + pid = self() log = capture_log(fn -> - assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok + assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok end) topic = "#{@topic}:#{@name}" event = "#{@topic}_down" assert log =~ "#{@mod} terminated due to syn conflict resolution: #{inspect(@name)} #{inspect(self())}" - assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil} + assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: %{reason: ^reason, pid: ^pid}} end test "it handles other reasons" do reason = :other_reason + pid = self() log = capture_log(fn -> - assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok + assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok end) topic = "#{@topic}:#{@name}" event = "#{@topic}_down" refute log =~ "#{@mod} terminated: #{inspect(@name)} #{node()}" - assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil}, 500 + + assert_receive %Phoenix.Socket.Broadcast{ + topic: ^topic, + event: ^event, + payload: %{reason: ^reason, pid: ^pid} + }, + 500 end end end diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index 290fb1c8d..18cb6e7f7 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -78,31 +78,27 @@ defmodule Realtime.Tenants.ConnectTest do assert_receive {:ok, ^pid} end - test "more than 5 seconds passed error out", %{tenant: tenant} do + test "more than 15 seconds passed error out", %{tenant: tenant} do parent = self() # Let's slow down Connect starting expect(Database, :check_tenant_connection, fn t -> - :timer.sleep(5500) + Process.sleep(15500) call_original(Database, :check_tenant_connection, [t]) end) connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end - # Start an early connect - spawn(connect) - :timer.sleep(100) - - # Start others spawn(connect) spawn(connect) - {:error, :tenant_database_unavailable} = Connect.lookup_or_start_connection(tenant.external_id) + {:error, :initializing} = Connect.lookup_or_start_connection(tenant.external_id) + # The above call waited 15 seconds + assert_receive {:error, :initializing} + assert_receive {:error, :initializing} - # Only one will succeed the others timed out waiting - assert_receive {:error, :tenant_database_unavailable} - assert_receive {:error, :tenant_database_unavailable} - assert_receive {:ok, _pid}, 7000 + # This one will succeed + {:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id) end end From 50891cd8b9ca8fd7c0760d8badbb8de48c4be770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 15 Sep 2025 23:11:25 +0100 Subject: [PATCH 010/123] fix: handle wal bloat (#1528) Verify that replication connection is able to reconnect when faced with WAL bloat issues --- lib/realtime/tenants/connect.ex | 76 ++++++---- .../tenants/replication_connection.ex | 2 +- mix.exs | 2 +- test/integration/rt_channel_test.exs | 130 ++++++++++++++++++ test/realtime/tenants/connect_test.exs | 26 +++- .../tenants/replication_connection_test.exs | 36 +++++ test/support/containers.ex | 8 +- 7 files changed, 245 insertions(+), 35 deletions(-) diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index 3c206a785..920205e95 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -252,31 +252,10 @@ defmodule Realtime.Tenants.Connect do end def handle_continue(:start_replication, state) do - %{tenant: tenant} = state - - with {:ok, replication_connection_pid} <- ReplicationConnection.start(tenant, self()) do - replication_connection_reference = Process.monitor(replication_connection_pid) - - state = %{ - state - | replication_connection_pid: replication_connection_pid, - replication_connection_reference: replication_connection_reference - } - - {:noreply, state, {:continue, :setup_connected_user_events}} - else - {:error, :max_wal_senders_reached} -> - log_error("ReplicationMaxWalSendersReached", "Tenant database has reached the maximum number of WAL senders") - {:stop, :shutdown, state} - - {:error, error} -> - log_error("StartReplicationFailed", error) - {:stop, :shutdown, state} + case start_replication_connection(state) do + {:ok, state} -> {:noreply, state, {:continue, :setup_connected_user_events}} + {:error, state} -> {:stop, :shutdown, state} end - rescue - error -> - log_error("StartReplicationFailed", error) - {:stop, :shutdown, state} end def handle_continue(:setup_connected_user_events, state) do @@ -348,13 +327,30 @@ defmodule Realtime.Tenants.Connect do {:stop, :shutdown, state} end + @replication_recovery_backoff 1000 + # Handle replication connection termination def handle_info( {:DOWN, replication_connection_reference, _, _, _}, %{replication_connection_reference: replication_connection_reference} = state ) do - Logger.warning("Replication connection has died") - {:stop, :shutdown, state} + log_warning("ReplicationConnectionDown", "Replication connection has been terminated") + Process.send_after(self(), :recover_replication_connection, @replication_recovery_backoff) + state = %{state | replication_connection_pid: nil, replication_connection_reference: nil} + {:noreply, state} + end + + @replication_connection_query "SELECT 1 from pg_stat_activity where application_name='realtime_replication_connection'" + def handle_info(:recover_replication_connection, state) do + with %{num_rows: 0} <- Postgrex.query!(state.db_conn_pid, @replication_connection_query, []), + {:ok, state} <- start_replication_connection(state) do + {:noreply, state} + else + _ -> + log_error("ReplicationConnectionRecoveryFailed", "Replication connection recovery failed") + Process.send_after(self(), :recover_replication_connection, @replication_recovery_backoff) + {:noreply, state} + end end def handle_info(_, state), do: {:noreply, state} @@ -414,4 +410,32 @@ defmodule Realtime.Tenants.Connect do defp tenant_suspended?(_), do: :ok defp rebalance_check_interval_in_ms(), do: Application.fetch_env!(:realtime, :rebalance_check_interval_in_ms) + + defp start_replication_connection(state) do + %{tenant: tenant} = state + + with {:ok, replication_connection_pid} <- ReplicationConnection.start(tenant, self()) do + replication_connection_reference = Process.monitor(replication_connection_pid) + + state = %{ + state + | replication_connection_pid: replication_connection_pid, + replication_connection_reference: replication_connection_reference + } + + {:ok, state} + else + {:error, :max_wal_senders_reached} -> + log_error("ReplicationMaxWalSendersReached", "Tenant database has reached the maximum number of WAL senders") + {:error, state} + + {:error, error} -> + log_error("StartReplicationFailed", error) + {:error, state} + end + rescue + error -> + log_error("StartReplicationFailed", error) + {:error, state} + end end diff --git a/lib/realtime/tenants/replication_connection.ex b/lib/realtime/tenants/replication_connection.ex index 45e03c66e..58b1de191 100644 --- a/lib/realtime/tenants/replication_connection.ex +++ b/lib/realtime/tenants/replication_connection.ex @@ -144,8 +144,8 @@ defmodule Realtime.Tenants.ReplicationConnection do port: connection_opts.port, socket_options: connection_opts.socket_options, ssl: connection_opts.ssl, - backoff_type: :stop, sync_connect: true, + auto_reconnect: false, parameters: [application_name: "realtime_replication_connection"] ] diff --git a/mix.exs b/mix.exs index 75a7bbd6f..372ff12c4 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.48.1", + version: "2.48.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index 2ae4cd449..23b1a3a7f 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -25,6 +25,7 @@ defmodule Realtime.Integration.RtChannelTest do alias Realtime.Tenants alias Realtime.Tenants.Authorization alias Realtime.Tenants.Connect + alias Realtime.Tenants.ReplicationConnection alias RealtimeWeb.RealtimeChannel.Tracker alias RealtimeWeb.SocketDisconnect @@ -2354,6 +2355,135 @@ defmodule Realtime.Integration.RtChannelTest do assert count == 2 end + describe "WAL bloat handling" do + setup %{tenant: tenant} do + topic = random_string() + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + %{rows: [[max_wal_size]]} = Postgrex.query!(db_conn, "SHOW max_wal_size", []) + %{rows: [[wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW wal_keep_size", []) + %{rows: [[max_slot_wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW max_slot_wal_keep_size", []) + + assert max_wal_size == "32MB" + assert wal_keep_size == "32MB" + assert max_slot_wal_keep_size == "32MB" + + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS wal_test (id INT, data TEXT)", []) + + Postgrex.query!( + db_conn, + """ + CREATE OR REPLACE FUNCTION wal_test_trigger_func() RETURNS TRIGGER AS $$ + BEGIN + PERFORM realtime.send(json_build_object ('value', 'test' :: text)::jsonb, 'test', '#{topic}', false); + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + """, + [] + ) + + Postgrex.query!(db_conn, "DROP TRIGGER IF EXISTS wal_test_trigger ON wal_test", []) + + Postgrex.query!( + db_conn, + """ + CREATE TRIGGER wal_test_trigger + AFTER INSERT OR UPDATE OR DELETE ON wal_test + FOR EACH ROW + EXECUTE FUNCTION wal_test_trigger_func() + """, + [] + ) + + GenServer.stop(db_conn) + + on_exit(fn -> + {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) + + Postgrex.query!(db_conn, "DROP TABLE IF EXISTS wal_test CASCADE", []) + end) + + %{topic: topic} + end + + test "track PID changes during WAL bloat creation", %{tenant: tenant, topic: topic} do + {socket, _} = get_connection(tenant, "authenticated") + config = %{broadcast: %{self: true}, private: false} + full_topic = "realtime:#{topic}" + + active_slot_query = + "SELECT active_pid FROM pg_replication_slots where active_pid is not null and slot_name = 'supabase_realtime_messages_replication_slot_'" + + WebsocketClient.join(socket, full_topic, %{config: config}) + + assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500 + assert_receive %Message{event: "presence_state"}, 500 + + assert Connect.ready?(tenant.external_id) + + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + + original_connect_pid = Connect.whereis(tenant.external_id) + original_replication_pid = ReplicationConnection.whereis(tenant.external_id) + %{rows: [[original_db_pid]]} = Postgrex.query!(db_conn, active_slot_query, []) + + tasks = + for _ <- 1..5 do + Task.async(fn -> + {:ok, bloat_conn} = Database.connect(tenant, "realtime_bloat", :stop) + + Postgrex.transaction(bloat_conn, fn conn -> + Postgrex.query(conn, "INSERT INTO wal_test SELECT generate_series(1, 100000), repeat('x', 2000)", []) + {:error, "test"} + end) + + Process.exit(bloat_conn, :normal) + end) + end + + Task.await_many(tasks, 20000) + + # Kill all pending transactions still running + Postgrex.query!( + db_conn, + "SELECT pg_terminate_backend(pid) from pg_stat_activity where application_name='realtime_bloat'", + [] + ) + + # Does it recover? + assert Connect.ready?(tenant.external_id) + {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) + Process.sleep(1000) + %{rows: [[new_db_pid]]} = Postgrex.query!(db_conn, active_slot_query, []) + + assert new_db_pid != original_db_pid + assert ^original_connect_pid = Connect.whereis(tenant.external_id) + assert original_replication_pid != ReplicationConnection.whereis(tenant.external_id) + + # Check if socket is still connected + payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"} + WebsocketClient.send_event(socket, full_topic, "broadcast", payload) + assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^full_topic}, 500 + + # Check if we are receiving the message from replication connection + Postgrex.query!(db_conn, "INSERT INTO wal_test VALUES (1, 'test')", []) + + assert_receive %Phoenix.Socket.Message{ + event: "broadcast", + payload: %{ + "event" => "test", + "payload" => %{"value" => "test"}, + "type" => "broadcast" + }, + join_ref: nil, + ref: nil, + topic: ^full_topic + }, + 5000 + end + end + defp mode(%{mode: :distributed}) do tenant = Api.get_tenant_by_external_id("dev_tenant") diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index 18cb6e7f7..fdc3d6385 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -348,11 +348,13 @@ defmodule Realtime.Tenants.ConnectTest do assert replication_connection_before == replication_connection_after end - test "on replication connection postgres pid being stopped, also kills the Connect module", %{tenant: tenant} do + test "on replication connection postgres pid being stopped, Connect module recovers it", %{tenant: tenant} do assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + Process.monitor(replication_connection_pid) + assert Process.alive?(replication_connection_pid) pid = Connect.whereis(tenant.external_id) @@ -362,21 +364,33 @@ defmodule Realtime.Tenants.ConnectTest do [] ) - assert_process_down(replication_connection_pid) - assert_process_down(pid) + assert_receive {:DOWN, _, :process, ^replication_connection_pid, _} + + Process.sleep(1500) + new_replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + + assert replication_connection_pid != new_replication_connection_pid + assert Process.alive?(new_replication_connection_pid) + assert Process.alive?(pid) end - test "on replication connection exit, also kills the Connect module", %{tenant: tenant} do + test "on replication connection exit, Connect module recovers it", %{tenant: tenant} do assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + Process.monitor(replication_connection_pid) assert Process.alive?(replication_connection_pid) pid = Connect.whereis(tenant.external_id) Process.exit(replication_connection_pid, :kill) + assert_receive {:DOWN, _, :process, ^replication_connection_pid, _} - assert_process_down(replication_connection_pid) - assert_process_down(pid) + Process.sleep(1500) + new_replication_connection_pid = ReplicationConnection.whereis(tenant.external_id) + + assert replication_connection_pid != new_replication_connection_pid + assert Process.alive?(new_replication_connection_pid) + assert Process.alive?(pid) end test "handles max_wal_senders by logging the correct operational code", %{tenant: tenant} do diff --git a/test/realtime/tenants/replication_connection_test.exs b/test/realtime/tenants/replication_connection_test.exs index 783270313..2d367a846 100644 --- a/test/realtime/tenants/replication_connection_test.exs +++ b/test/realtime/tenants/replication_connection_test.exs @@ -331,6 +331,26 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do assert {:error, :max_wal_senders_reached} = ReplicationConnection.start(tenant, self()) end + + test "handles WAL pressure gracefully", %{tenant: tenant} do + {:ok, replication_pid} = ReplicationConnection.start(tenant, self()) + + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + on_exit(fn -> Process.exit(conn, :normal) end) + + large_payload = String.duplicate("x", 10 * 1024 * 1024) + + for i <- 1..5 do + message_fixture_with_conn(tenant, conn, %{ + "topic" => "stress_#{i}", + "private" => true, + "event" => "INSERT", + "payload" => %{"data" => large_payload} + }) + end + + assert Process.alive?(replication_pid) + end end describe "whereis/1" do @@ -409,4 +429,20 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout end + + defp message_fixture_with_conn(_tenant, conn, override) do + create_attrs = %{ + "topic" => random_string(), + "extension" => "broadcast" + } + + override = override |> Enum.map(fn {k, v} -> {"#{k}", v} end) |> Map.new() + + {:ok, message} = + create_attrs + |> Map.merge(override) + |> TenantConnection.create_message(conn) + + message + end end diff --git a/test/support/containers.ex b/test/support/containers.ex index cd66f2699..bc49fa275 100644 --- a/test/support/containers.ex +++ b/test/support/containers.ex @@ -267,7 +267,13 @@ defmodule Containers do @image, "postgres", "-c", - "config_file=/etc/postgresql/postgresql.conf" + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "wal_keep_size=32MB", + "-c", + "max_wal_size=32MB", + "-c", + "max_slot_wal_keep_size=32MB" ]) end end From 5ccea17be1a7c6220b8f742aa7b8fb3dede22e53 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 16 Sep 2025 12:16:32 +1200 Subject: [PATCH 011/123] feat: replay realtime.messages (#1526) A new index was created on inserted_at DESC, topic WHERE private IS TRUE AND extension = "broadast" The hardcoded limit is 25 for now. --- README.md | 3 +- lib/realtime/api/message.ex | 4 +- lib/realtime/messages.ex | 55 +++++ lib/realtime/tenants/batch_broadcast.ex | 38 +-- lib/realtime/tenants/migrations.ex | 6 +- .../tenants/replication_connection.ex | 8 +- ...0905041441_create_messages_replay_index.ex | 11 + .../channels/payloads/broadcast.ex | 2 + .../channels/payloads/broadcast/replay.ex | 17 ++ lib/realtime_web/channels/realtime_channel.ex | 51 +++- .../realtime_channel/message_dispatcher.ex | 41 ++- mix.exs | 2 +- test/realtime/messages_test.exs | 233 ++++++++++++++++-- .../tenants/janitor/maintenance_task_test.exs | 11 +- test/realtime/tenants/janitor_test.exs | 14 +- .../tenants/replication_connection_test.exs | 37 ++- .../channels/payloads/join_test.exs | 17 +- .../message_dispatcher_test.exs | 47 +++- .../channels/realtime_channel_test.exs | 162 ++++++++++++ 19 files changed, 678 insertions(+), 81 deletions(-) create mode 100644 lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex create mode 100644 lib/realtime_web/channels/payloads/broadcast/replay.ex diff --git a/README.md b/README.md index 2235bf388..6a16a79ba 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ You can add your own by making a `POST` request to the server. You must change b "region": "us-west-1", "poll_interval_ms": 100, "poll_max_record_bytes": 1048576, - "ssl_enforced": false + "ssl_enforced": false } } ] @@ -284,6 +284,7 @@ This is the list of operational codes that can help you understand your deployme | UnknownErrorOnController | An error we are not handling correctly was triggered on a controller | | UnknownErrorOnChannel | An error we are not handling correctly was triggered on a channel | | PresenceRateLimitReached | Limit of presence events reached | +| UnableToReplayMessages | An error while replaying messages | ## License diff --git a/lib/realtime/api/message.ex b/lib/realtime/api/message.ex index 90ebc5bc9..18bbc9a87 100644 --- a/lib/realtime/api/message.ex +++ b/lib/realtime/api/message.ex @@ -8,6 +8,8 @@ defmodule Realtime.Api.Message do @primary_key {:id, Ecto.UUID, autogenerate: true} @schema_prefix "realtime" + @type t :: %__MODULE__{} + schema "messages" do field(:topic, :string) field(:extension, Ecto.Enum, values: [:broadcast, :presence]) @@ -39,7 +41,7 @@ defmodule Realtime.Api.Message do end defp maybe_put_timestamp(changeset, field) do - case Map.get(changeset.data, field) do + case get_field(changeset, field) do nil -> put_timestamp(changeset, field) _ -> changeset end diff --git a/lib/realtime/messages.ex b/lib/realtime/messages.ex index c6d571db7..804a48d66 100644 --- a/lib/realtime/messages.ex +++ b/lib/realtime/messages.ex @@ -3,6 +3,61 @@ defmodule Realtime.Messages do Handles `realtime.messages` table operations """ + alias Realtime.Api.Message + + import Ecto.Query, only: [from: 2] + + @hard_limit 25 + @default_timeout 5_000 + + @doc """ + Fetch last `limit ` messages for a given `topic` inserted after `since` + + Automatically uses RPC if the database connection is not in the same node + + Only allowed for private channels + """ + @spec replay(pid, String.t(), non_neg_integer, non_neg_integer) :: + {:ok, Message.t(), [String.t()]} | {:error, term} | {:error, :rpc_error, term} + def replay(conn, topic, since, limit) when node(conn) == node() and is_integer(since) and is_integer(limit) do + limit = max(min(limit, @hard_limit), 1) + + with {:ok, since} <- DateTime.from_unix(since, :millisecond), + {:ok, messages} <- messages(conn, topic, since, limit) do + {:ok, Enum.reverse(messages), MapSet.new(messages, & &1.id)} + else + {:error, :postgrex_exception} -> {:error, :failed_to_replay_messages} + {:error, :invalid_unix_time} -> {:error, :invalid_replay_params} + error -> error + end + end + + def replay(conn, topic, since, limit) when is_integer(since) and is_integer(limit) do + Realtime.GenRpc.call(node(conn), __MODULE__, :replay, [conn, topic, since, limit], key: topic) + end + + def replay(_, _, _, _), do: {:error, :invalid_replay_params} + + defp messages(conn, topic, since, limit) do + since = DateTime.to_naive(since) + # We want to avoid searching partitions in the future as they should be empty + # so we limit to 1 minute in the future to account for any potential drift + now = NaiveDateTime.utc_now() |> NaiveDateTime.add(1, :minute) + + query = + from m in Message, + where: + m.topic == ^topic and + m.private == true and + m.extension == :broadcast and + m.inserted_at >= ^since and + m.inserted_at < ^now, + limit: ^limit, + order_by: [desc: m.inserted_at] + + Realtime.Repo.all(conn, query, Message, timeout: @default_timeout) + end + @doc """ Deletes messages older than 72 hours for a given tenant connection """ diff --git a/lib/realtime/tenants/batch_broadcast.ex b/lib/realtime/tenants/batch_broadcast.ex index 4fc31aa0f..98427621b 100644 --- a/lib/realtime/tenants/batch_broadcast.ex +++ b/lib/realtime/tenants/batch_broadcast.ex @@ -29,7 +29,9 @@ defmodule Realtime.Tenants.BatchBroadcast do @spec broadcast( auth_params :: map() | nil, tenant :: Tenant.t(), - messages :: %{messages: list(%{topic: String.t(), payload: map(), event: String.t(), private: boolean()})}, + messages :: %{ + messages: list(%{id: String.t(), topic: String.t(), payload: map(), event: String.t(), private: boolean()}) + }, super_user :: boolean() ) :: :ok | {:error, atom()} def broadcast(auth_params, tenant, messages, super_user \\ false) @@ -59,8 +61,8 @@ defmodule Realtime.Tenants.BatchBroadcast do # Handle events for public channel events |> Map.get(false, []) - |> Enum.each(fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, true) + |> Enum.each(fn message -> + send_message_and_count(tenant, events_per_second_rate, message, true) end) # Handle events for private channel @@ -69,14 +71,14 @@ defmodule Realtime.Tenants.BatchBroadcast do |> Enum.group_by(fn event -> Map.get(event, :topic) end) |> Enum.each(fn {topic, events} -> if super_user do - Enum.each(events, fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, false) + Enum.each(events, fn message -> + send_message_and_count(tenant, events_per_second_rate, message, false) end) else case permissions_for_message(tenant, auth_params, topic) do %Policies{broadcast: %BroadcastPolicies{write: true}} -> - Enum.each(events, fn %{topic: sub_topic, payload: payload, event: event} -> - send_message_and_count(tenant, events_per_second_rate, sub_topic, event, payload, false) + Enum.each(events, fn message -> + send_message_and_count(tenant, events_per_second_rate, message, false) end) _ -> @@ -91,15 +93,15 @@ defmodule Realtime.Tenants.BatchBroadcast do def broadcast(_, nil, _, _), do: {:error, :tenant_not_found} - def changeset(payload, attrs) do + defp changeset(payload, attrs) do payload |> cast(attrs, []) |> cast_embed(:messages, required: true, with: &message_changeset/2) end - def message_changeset(message, attrs) do + defp message_changeset(message, attrs) do message - |> cast(attrs, [:topic, :payload, :event, :private]) + |> cast(attrs, [:id, :topic, :payload, :event, :private]) |> maybe_put_private_change() |> validate_required([:topic, :payload, :event]) end @@ -112,11 +114,19 @@ defmodule Realtime.Tenants.BatchBroadcast do end @event_type "broadcast" - defp send_message_and_count(tenant, events_per_second_rate, topic, event, payload, public?) do - tenant_topic = Tenants.tenant_topic(tenant, topic, public?) - payload = %{"payload" => payload, "event" => event, "type" => "broadcast"} + defp send_message_and_count(tenant, events_per_second_rate, message, public?) do + tenant_topic = Tenants.tenant_topic(tenant, message.topic, public?) - broadcast = %Phoenix.Socket.Broadcast{topic: topic, event: @event_type, payload: payload} + payload = %{"payload" => message.payload, "event" => message.event, "type" => "broadcast"} + + payload = + if message[:id] do + Map.put(payload, "meta", %{"id" => message.id}) + else + payload + end + + broadcast = %Phoenix.Socket.Broadcast{topic: message.topic, event: @event_type, payload: payload} GenCounter.add(events_per_second_rate.id) TenantBroadcaster.pubsub_broadcast(tenant.external_id, tenant_topic, broadcast, RealtimeChannel.MessageDispatcher) diff --git a/lib/realtime/tenants/migrations.ex b/lib/realtime/tenants/migrations.ex index 04475c2b7..a5fa1eb8b 100644 --- a/lib/realtime/tenants/migrations.ex +++ b/lib/realtime/tenants/migrations.ex @@ -74,7 +74,8 @@ defmodule Realtime.Tenants.Migrations do RealtimeSendSetsTopicConfig, SubscriptionIndexBridgingDisabled, RunSubscriptionIndexBridgingDisabled, - BroadcastSendErrorLogging + BroadcastSendErrorLogging, + CreateMessagesReplayIndex } @migrations [ @@ -140,7 +141,8 @@ defmodule Realtime.Tenants.Migrations do {20_250_128_220_012, RealtimeSendSetsTopicConfig}, {20_250_506_224_012, SubscriptionIndexBridgingDisabled}, {20_250_523_164_012, RunSubscriptionIndexBridgingDisabled}, - {20_250_714_121_412, BroadcastSendErrorLogging} + {20_250_714_121_412, BroadcastSendErrorLogging}, + {20_250_905_041_441, CreateMessagesReplayIndex} ] defstruct [:tenant_external_id, :settings] diff --git a/lib/realtime/tenants/replication_connection.ex b/lib/realtime/tenants/replication_connection.ex index 58b1de191..4ebb1f8e8 100644 --- a/lib/realtime/tenants/replication_connection.ex +++ b/lib/realtime/tenants/replication_connection.ex @@ -310,7 +310,13 @@ defmodule Realtime.Tenants.ReplicationConnection do {:ok, topic} <- get_or_error(to_broadcast, "topic", :topic_missing), {:ok, private} <- get_or_error(to_broadcast, "private", :private_missing), %Tenant{} = tenant <- Cache.get_tenant_by_external_id(tenant_id), - broadcast_message = %{topic: topic, event: event, private: private, payload: Map.put_new(payload, "id", id)}, + broadcast_message = %{ + id: id, + topic: topic, + event: event, + private: private, + payload: Map.put_new(payload, "id", id) + }, :ok <- BatchBroadcast.broadcast(nil, tenant, %{messages: [broadcast_message]}, true) do inserted_at = NaiveDateTime.from_iso8601!(inserted_at) latency_inserted_at = NaiveDateTime.utc_now() |> NaiveDateTime.diff(inserted_at) diff --git a/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex b/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex new file mode 100644 index 000000000..77afde6e0 --- /dev/null +++ b/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex @@ -0,0 +1,11 @@ +defmodule Realtime.Tenants.Migrations.CreateMessagesReplayIndex do + @moduledoc false + + use Ecto.Migration + + def change do + create_if_not_exists index(:messages, [{:desc, :inserted_at}, :topic], + where: "extension = 'broadcast' and private IS TRUE" + ) + end +end diff --git a/lib/realtime_web/channels/payloads/broadcast.ex b/lib/realtime_web/channels/payloads/broadcast.ex index 7feddb043..e2881fd54 100644 --- a/lib/realtime_web/channels/payloads/broadcast.ex +++ b/lib/realtime_web/channels/payloads/broadcast.ex @@ -9,9 +9,11 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do embedded_schema do field :ack, :boolean, default: false field :self, :boolean, default: false + embeds_one :replay, RealtimeWeb.Channels.Payloads.Broadcast.Replay end def changeset(broadcast, attrs) do cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2) + |> cast_embed(:replay, invalid_message: "unable to parse, expected a map") end end diff --git a/lib/realtime_web/channels/payloads/broadcast/replay.ex b/lib/realtime_web/channels/payloads/broadcast/replay.ex new file mode 100644 index 000000000..b0a5804a2 --- /dev/null +++ b/lib/realtime_web/channels/payloads/broadcast/replay.ex @@ -0,0 +1,17 @@ +defmodule RealtimeWeb.Channels.Payloads.Broadcast.Replay do + @moduledoc """ + Validate broadcast replay field of the join payload. + """ + use Ecto.Schema + import Ecto.Changeset + alias RealtimeWeb.Channels.Payloads.Join + + embedded_schema do + field :limit, :integer, default: 10 + field :since, :integer, default: 0 + end + + def changeset(broadcast, attrs) do + cast(broadcast, attrs, [:limit, :since], message: &Join.error_message/2) + end +end diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 03bd91347..1d58d9da7 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -72,12 +72,21 @@ defmodule RealtimeWeb.RealtimeChannel do {:ok, claims, confirm_token_ref} <- confirm_token(socket), socket = assign_authorization_context(socket, sub_topic, claims), {:ok, db_conn} <- Connect.lookup_or_start_connection(tenant_id), - {:ok, socket} <- maybe_assign_policies(sub_topic, db_conn, socket) do + {:ok, socket} <- maybe_assign_policies(sub_topic, db_conn, socket), + {:ok, replayed_message_ids} <- + maybe_replay_messages(params["config"], sub_topic, db_conn, socket.assigns.private?) do tenant_topic = Tenants.tenant_topic(tenant_id, sub_topic, !socket.assigns.private?) # fastlane subscription metadata = - MessageDispatcher.fastlane_metadata(transport_pid, serializer, topic, socket.assigns.log_level, tenant_id) + MessageDispatcher.fastlane_metadata( + transport_pid, + serializer, + topic, + log_level, + tenant_id, + replayed_message_ids + ) RealtimeWeb.Endpoint.subscribe(tenant_topic, metadata: metadata) @@ -198,6 +207,12 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :shutdown_in_progress} -> log_error(socket, "RealtimeRestarting", "Realtime is restarting, please standby") + {:error, :failed_to_replay_messages} -> + log_error(socket, "UnableToReplayMessages", "Realtime was unable to replay messages") + + {:error, :invalid_replay_params} -> + log_error(socket, "UnableToReplayMessages", "Replay params are not valid") + {:error, error} -> log_error(socket, "UnknownErrorOnChannel", error) {:error, %{reason: "Unknown Error on Channel"}} @@ -205,6 +220,17 @@ defmodule RealtimeWeb.RealtimeChannel do end @impl true + def handle_info({:replay, messages}, socket) do + for message <- messages do + meta = %{"replayed" => true, "id" => message.id} + payload = %{"payload" => message.payload, "event" => message.event, "type" => "broadcast", "meta" => meta} + + push(socket, "broadcast", payload) + end + + {:noreply, socket} + end + def handle_info(:update_rate_counter, socket) do count(socket) @@ -762,4 +788,25 @@ defmodule RealtimeWeb.RealtimeChannel do do: {:error, :private_only}, else: :ok end + + defp maybe_replay_messages(%{"broadcast" => %{"replay" => _}}, _sub_topic, _db_conn, false = _private?) do + {:error, :invalid_replay_params} + end + + defp maybe_replay_messages(%{"broadcast" => %{"replay" => replay_params}}, sub_topic, db_conn, true = _private?) + when is_map(replay_params) do + with {:ok, messages, message_ids} <- + Realtime.Messages.replay( + db_conn, + sub_topic, + replay_params["since"], + replay_params["limit"] || 25 + ) do + # Send to self because we can't write to the socket before finishing the join process + send(self(), {:replay, messages}) + {:ok, message_ids} + end + end + + defp maybe_replay_messages(_, _, _, _), do: {:ok, MapSet.new()} end diff --git a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex index b5db97f95..ef486c4e8 100644 --- a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex +++ b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex @@ -5,12 +5,14 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do require Logger - def fastlane_metadata(fastlane_pid, serializer, topic, :info, tenant_id) do - {:realtime_channel_fastlane, fastlane_pid, serializer, topic, {:log, tenant_id}} + def fastlane_metadata(fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids \\ MapSet.new()) + + def fastlane_metadata(fastlane_pid, serializer, topic, :info, tenant_id, replayed_message_ids) do + {:rc_fastlane, fastlane_pid, serializer, topic, {:log, tenant_id}, replayed_message_ids} end - def fastlane_metadata(fastlane_pid, serializer, topic, _log_level, _tenant_id) do - {:realtime_channel_fastlane, fastlane_pid, serializer, topic} + def fastlane_metadata(fastlane_pid, serializer, topic, _log_level, _tenant_id, replayed_message_ids) do + {:rc_fastlane, fastlane_pid, serializer, topic, replayed_message_ids} end @doc """ @@ -23,22 +25,34 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do # This reduce caches the serialization and bypasses the channel process going straight to the # transport process + message_id = msg.payload["meta"]["id"] + # Credo doesn't like that we don't use the result aggregation _ = Enum.reduce(subscribers, %{}, fn {pid, _}, cache when pid == from -> cache - {pid, {:realtime_channel_fastlane, fastlane_pid, serializer, join_topic}}, cache -> - send(pid, :update_rate_counter) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, replayed_message_ids}}, cache -> + if already_replayed?(message_id, replayed_message_ids) do + # skip already replayed message + cache + else + send(pid, :update_rate_counter) + do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + end - {pid, {:realtime_channel_fastlane, fastlane_pid, serializer, join_topic, {:log, tenant_id}}}, cache -> - send(pid, :update_rate_counter) - log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" - Logger.info(log, external_id: tenant_id, project: tenant_id) + {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, {:log, tenant_id}, replayed_message_ids}}, cache -> + if already_replayed?(message_id, replayed_message_ids) do + # skip already replayed message + cache + else + send(pid, :update_rate_counter) + log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" + Logger.info(log, external_id: tenant_id, project: tenant_id) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + end {pid, _}, cache -> send(pid, msg) @@ -48,6 +62,9 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do :ok end + defp already_replayed?(nil, _replayed_message_ids), do: false + defp already_replayed?(message_id, replayed_message_ids), do: MapSet.member?(replayed_message_ids, message_id) + defp do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) do case cache do %{^serializer => encoded_msg} -> diff --git a/mix.exs b/mix.exs index 372ff12c4..1e17ec551 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.48.2", + version: "2.49.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/messages_test.exs b/test/realtime/messages_test.exs index 3bef9a5e0..cca0ce742 100644 --- a/test/realtime/messages_test.exs +++ b/test/realtime/messages_test.exs @@ -16,32 +16,221 @@ defmodule Realtime.MessagesTest do %{conn: conn, tenant: tenant, date_start: date_start, date_end: date_end} end - test "delete_old_messages/1 deletes messages older than 72 hours", %{ - conn: conn, - tenant: tenant, - date_start: date_start, - date_end: date_end - } do - utc_now = NaiveDateTime.utc_now() - limit = NaiveDateTime.add(utc_now, -72, :hour) - - messages = - for date <- Date.range(date_start, date_end) do - inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0)) - message_fixture(tenant, %{inserted_at: inserted_at}) + describe "replay/5" do + test "invalid replay params" do + assert Messages.replay(self(), "a topic", "not a number", 123) == + {:error, :invalid_replay_params} + + assert Messages.replay(self(), "a topic", 123, "not a number") == + {:error, :invalid_replay_params} + + assert Messages.replay(self(), "a topic", 253_402_300_800_000, 10) == + {:error, :invalid_replay_params} + end + + test "empty replay", %{conn: conn} do + assert Messages.replay(conn, "test", 0, 10) == {:ok, [], MapSet.new()} + end + + test "replay respects limit", %{conn: conn, tenant: tenant} do + m1 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, "test", 0, 1) == {:ok, [m1], MapSet.new([m1.id])} + end + + test "replay private topic only", %{conn: conn, tenant: tenant} do + privatem = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "private" => false, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])} + end + + test "replay extension=broadcast", %{conn: conn, tenant: tenant} do + privatem = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "new", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "new"} + }) + + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "old", + "extension" => "presence", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + assert Messages.replay(conn, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])} + end + + test "replay respects since", %{conn: conn, tenant: tenant} do + m1 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "first", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "first"} + }) + + m2 = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "second", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "second"} + }) + + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-10, :minute), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "old"} + }) + + since = DateTime.utc_now() |> DateTime.add(-3, :minute) |> DateTime.to_unix(:millisecond) + + assert Messages.replay(conn, "test", since, 10) == {:ok, [m1, m2], MapSet.new([m1.id, m2.id])} + end + + test "replay respects hard max limit of 25", %{conn: conn, tenant: tenant} do + for _i <- 1..30 do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) end - assert length(messages) == 11 + assert {:ok, messages, set} = Messages.replay(conn, "test", 0, 30) + assert length(messages) == 25 + assert MapSet.size(set) == 25 + end + + test "replay respects hard min limit of 1", %{conn: conn, tenant: tenant} do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) + + assert {:ok, messages, set} = Messages.replay(conn, "test", 0, 0) + assert length(messages) == 1 + assert MapSet.size(set) == 1 + end + + test "distributed replay", %{conn: conn, tenant: tenant} do + m = + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) + + {:ok, node} = Clustered.start() + + # Call remote node passing the database connection that is local to this node + assert :erpc.call(node, Messages, :replay, [conn, "test", 0, 30]) == {:ok, [m], MapSet.new([m.id])} + end + + test "distributed replay error", %{tenant: tenant} do + message_fixture(tenant, %{ + "inserted_at" => NaiveDateTime.utc_now(), + "event" => "event", + "extension" => "broadcast", + "topic" => "test", + "private" => true, + "payload" => %{"value" => "message"} + }) + + {:ok, node} = Clustered.start() + + # Call remote node passing the database connection that is local to this node + pid = spawn(fn -> :ok end) + assert :erpc.call(node, Messages, :replay, [pid, "test", 0, 30]) == {:error, :failed_to_replay_messages} + end + end + + describe "delete_old_messages/1" do + test "delete_old_messages/1 deletes messages older than 72 hours", %{ + conn: conn, + tenant: tenant, + date_start: date_start, + date_end: date_end + } do + utc_now = NaiveDateTime.utc_now() + limit = NaiveDateTime.add(utc_now, -72, :hour) + + messages = + for date <- Date.range(date_start, date_end) do + inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0)) + message_fixture(tenant, %{inserted_at: inserted_at}) + end + + assert length(messages) == 11 - to_keep = - Enum.reject( - messages, - &(NaiveDateTime.compare(limit, &1.inserted_at) == :gt) - ) + to_keep = + Enum.reject( + messages, + &(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt) + ) - assert :ok = Messages.delete_old_messages(conn) - {:ok, current} = Repo.all(conn, from(m in Message), Message) + assert :ok = Messages.delete_old_messages(conn) + {:ok, current} = Repo.all(conn, from(m in Message), Message) - assert Enum.sort(current) == Enum.sort(to_keep) + assert Enum.sort(current) == Enum.sort(to_keep) + end end end diff --git a/test/realtime/tenants/janitor/maintenance_task_test.exs b/test/realtime/tenants/janitor/maintenance_task_test.exs index f4c51436e..4c42b7ab3 100644 --- a/test/realtime/tenants/janitor/maintenance_task_test.exs +++ b/test/realtime/tenants/janitor/maintenance_task_test.exs @@ -15,9 +15,15 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do end test "cleans messages older than 72 hours and creates partitions", %{tenant: tenant} do + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + utc_now = NaiveDateTime.utc_now() limit = NaiveDateTime.add(utc_now, -72, :hour) + date_start = Date.utc_today() |> Date.add(-10) + date_end = Date.utc_today() + create_messages_partitions(conn, date_start, date_end) + messages = for days <- -5..0 do inserted_at = NaiveDateTime.add(utc_now, days, :day) @@ -27,12 +33,11 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do to_keep = messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) |> MapSet.new() assert MaintenanceTask.run(tenant.external_id) == :ok - {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) {:ok, res} = Repo.all(conn, from(m in Message), Message) verify_partitions(conn) @@ -80,7 +85,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do defp verify_partitions(conn) do today = Date.utc_today() - yesterday = Date.add(today, -1) + yesterday = Date.add(today, -3) future = Date.add(today, 3) dates = Date.range(yesterday, future) diff --git a/test/realtime/tenants/janitor_test.exs b/test/realtime/tenants/janitor_test.exs index 4ac1a0eda..fb597a4c4 100644 --- a/test/realtime/tenants/janitor_test.exs +++ b/test/realtime/tenants/janitor_test.exs @@ -31,6 +31,14 @@ defmodule Realtime.Tenants.JanitorTest do end ) + date_start = Date.utc_today() |> Date.add(-10) + date_end = Date.utc_today() + + Enum.map(tenants, fn tenant -> + {:ok, conn} = Database.connect(tenant, "realtime_test", :stop) + create_messages_partitions(conn, date_start, date_end) + end) + start_supervised!( {Task.Supervisor, name: Realtime.Tenants.Janitor.TaskSupervisor, max_children: 5, max_seconds: 500, max_restarts: 1} @@ -62,7 +70,7 @@ defmodule Realtime.Tenants.JanitorTest do to_keep = messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) |> MapSet.new() start_supervised!(Janitor) @@ -105,7 +113,7 @@ defmodule Realtime.Tenants.JanitorTest do to_keep = messages - |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)) + |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)) |> MapSet.new() start_supervised!(Janitor) @@ -162,7 +170,7 @@ defmodule Realtime.Tenants.JanitorTest do defp verify_partitions(conn) do today = Date.utc_today() - yesterday = Date.add(today, -1) + yesterday = Date.add(today, -3) future = Date.add(today, 3) dates = Date.range(yesterday, future) diff --git a/test/realtime/tenants/replication_connection_test.exs b/test/realtime/tenants/replication_connection_test.exs index 2d367a846..b28a23988 100644 --- a/test/realtime/tenants/replication_connection_test.exs +++ b/test/realtime/tenants/replication_connection_test.exs @@ -98,6 +98,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do payload = %{ "event" => "INSERT", + "meta" => %{"id" => row.id}, "payload" => %{ "id" => row.id, "value" => value @@ -139,8 +140,9 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "event" => "broadcast", "payload" => %{ "event" => "INSERT", + "meta" => %{"id" => id}, "payload" => %{ - "id" => _, + "id" => id, "value" => ^value } }, @@ -222,21 +224,26 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "payload" => %{"value" => "something"} }) + fixture_id = fixture.id + assert_receive {:socket_push, :text, data}, 500 message = data |> IO.iodata_to_binary() |> Jason.decode!() assert %{ "event" => "broadcast", - "payload" => %{"event" => "INSERT", "payload" => payload, "type" => "broadcast"}, + "payload" => %{ + "event" => "INSERT", + "meta" => %{"id" => ^fixture_id}, + "payload" => payload, + "type" => "broadcast" + }, "ref" => nil, "topic" => ^topic } = message - id = fixture.id - assert payload == %{ "value" => "something", - "id" => id + "id" => fixture_id } end @@ -252,19 +259,25 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do payload = %{"value" => "something", "id" => "123456"} - message_fixture(tenant, %{ - "topic" => topic, - "private" => true, - "event" => "INSERT", - "payload" => payload - }) + %{id: fixture_id} = + message_fixture(tenant, %{ + "topic" => topic, + "private" => true, + "event" => "INSERT", + "payload" => payload + }) assert_receive {:socket_push, :text, data}, 500 message = data |> IO.iodata_to_binary() |> Jason.decode!() assert %{ "event" => "broadcast", - "payload" => %{"event" => "INSERT", "payload" => ^payload, "type" => "broadcast"}, + "payload" => %{ + "meta" => %{"id" => ^fixture_id}, + "event" => "INSERT", + "payload" => ^payload, + "type" => "broadcast" + }, "ref" => nil, "topic" => ^topic } = message diff --git a/test/realtime_web/channels/payloads/join_test.exs b/test/realtime_web/channels/payloads/join_test.exs index 32bf1b397..c1ea54a67 100644 --- a/test/realtime_web/channels/payloads/join_test.exs +++ b/test/realtime_web/channels/payloads/join_test.exs @@ -6,6 +6,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do alias RealtimeWeb.Channels.Payloads.Join alias RealtimeWeb.Channels.Payloads.Config alias RealtimeWeb.Channels.Payloads.Broadcast + alias RealtimeWeb.Channels.Payloads.Broadcast.Replay alias RealtimeWeb.Channels.Payloads.Presence alias RealtimeWeb.Channels.Payloads.PostgresChange @@ -17,7 +18,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do config = %{ "config" => %{ "private" => false, - "broadcast" => %{"ack" => false, "self" => false}, + "broadcast" => %{"ack" => false, "self" => false, "replay" => %{"since" => 1, "limit" => 10}}, "presence" => %{"enabled" => true, "key" => key}, "postgres_changes" => [ %{"event" => "INSERT", "schema" => "public", "table" => "users", "filter" => "id=eq.1"}, @@ -37,8 +38,9 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do postgres_changes: postgres_changes } = config - assert %Broadcast{ack: false, self: false} = broadcast + assert %Broadcast{ack: false, self: false, replay: replay} = broadcast assert %Presence{enabled: true, key: ^key} = presence + assert %Replay{since: 1, limit: 10} = replay assert [ %PostgresChange{event: "INSERT", schema: "public", table: "users", filter: "id=eq.1"}, @@ -56,6 +58,17 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do assert is_binary(key) end + test "invalid replay" do + config = %{"config" => %{"broadcast" => %{"replay" => 123}}} + + assert { + :error, + :invalid_join_payload, + %{config: %{broadcast: %{replay: ["unable to parse, expected a map"]}}} + } = + Join.validate(config) + end + test "missing enabled presence defaults to true" do config = %{"config" => %{"presence" => %{}}} diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs index 7a9e2eb25..91b16c089 100644 --- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs +++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs @@ -16,12 +16,12 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do describe "fastlane_metadata/5" do test "info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :info, "tenant_id") == - {:realtime_channel_fastlane, self(), Serializer, "realtime:topic", {:log, "tenant_id"}} + {:rc_fastlane, self(), Serializer, "realtime:topic", {:log, "tenant_id"}, MapSet.new()} end test "non-info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :warning, "tenant_id") == - {:realtime_channel_fastlane, self(), Serializer, "realtime:topic"} + {:rc_fastlane, self(), Serializer, "realtime:topic", MapSet.new()} end end @@ -50,12 +50,11 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do from_pid = :erlang.list_to_pid(~c'<0.2.1>') subscribers = [ - {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}}}, - {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic"}} + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", MapSet.new()}} ] msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}} - require Logger log = capture_log(fn -> @@ -75,6 +74,44 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do refute_receive _any end + test "does not dispatch messages to fastlane subscribers if they already replayed it" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + replaeyd_message_ids = MapSet.new(["123"]) + + subscribers = [ + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, replaeyd_message_ids}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", replaeyd_message_ids}} + ] + + msg = %Broadcast{ + topic: "some:other:topic", + event: "event", + payload: %{"data" => "test", "meta" => %{"id" => "123"}} + } + + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + + assert Agent.get(TestSerializer, & &1) == 0 + + refute_receive _any + end + test "dispatches messages to non fastlane subscribers" do from_pid = :erlang.list_to_pid(~c'<0.2.1>') diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 2dff83da3..4d90c3588 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -28,6 +28,168 @@ defmodule RealtimeWeb.RealtimeChannelTest do setup :rls_context + describe "broadcast" do + @describetag policies: [:authenticated_all_topic_read] + + test "wrong replay params", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => "not a number", "since" => :erlang.system_time(:millisecond) - 5 * 60000} + } + } + + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => 1, "since" => "not a number"} + } + } + + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{} + } + } + + assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + end + + test "failure to replay", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{ + "private" => true, + "broadcast" => %{ + "replay" => %{"limit" => 12, "since" => :erlang.system_time(:millisecond) - 5 * 60000} + } + } + + Authorization + |> expect(:get_read_authorizations, fn _, _, _ -> + {:ok, + %Authorization.Policies{ + broadcast: %Authorization.Policies.BroadcastPolicies{read: true, write: nil} + }} + end) + + # Broken database connection + conn = spawn(fn -> :ok end) + Connect.lookup_or_start_connection(tenant.external_id) + {:ok, _} = :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: conn} end) + + assert {:error, %{reason: "UnableToReplayMessages: Realtime was unable to replay messages"}} = + subscribe_and_join(socket, "realtime:test", %{"config" => config}) + end + + test "replay messages on public topic not allowed", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + config = %{ + "presence" => %{"enabled" => false}, + "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}} + } + + assert { + :error, + %{reason: "UnableToReplayMessages: Replay params are not valid"} + } = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + refute_receive _any + end + + @tag policies: [:authenticated_all_topic_read] + test "replay messages on private topic", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + # Old message + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day), + "event" => "old", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "old"} + }) + + %{id: message1_id} = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute), + "event" => "first", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "first"} + }) + + %{id: message2_id} = + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute), + "event" => "second", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "second"} + }) + + # This one should not be received because of the limit + message_fixture(tenant, %{ + "private" => true, + "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-3, :minute), + "event" => "third", + "extension" => "broadcast", + "topic" => "test", + "payload" => %{"value" => "third"} + }) + + config = %{ + "private" => true, + "presence" => %{"enabled" => false}, + "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}} + } + + assert {:ok, _, %Socket{}} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + assert_receive %Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: %{ + "event" => "first", + "meta" => %{"id" => ^message1_id, "replayed" => true}, + "payload" => %{"value" => "first"}, + "type" => "broadcast" + } + } + + assert_receive %Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: %{ + "event" => "second", + "meta" => %{"id" => ^message2_id, "replayed" => true}, + "payload" => %{"value" => "second"}, + "type" => "broadcast" + } + } + + refute_receive %Socket.Message{} + end + end + describe "presence" do test "events are counted", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) From c4ba2aa63901dd2a48affdf60b2d20b3398b8e55 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 16 Sep 2025 14:28:57 +1200 Subject: [PATCH 012/123] feat: gen_rpc pub sub adapter (#1529) Add a PubSub adapter that uses gen_rpc to send messages to other nodes. It uses :gen_rpc.abcast/3 instead of :erlang.send/2 The adapter works very similarly to the PG2 adapter. It consists of multiple workers that forward to the local node using PubSub.local_broadcast. The way to choose the worker to be used is based on the sending process just like PG2 adapter does The number of workers is controlled by `:pool_size` or `:broadcast_pool_size`. This distinction exists because Phoenix.PubSub uses `:pool_size` to define how many partitions the PubSub registry will use. It's possible to control them separately by using `:broadcast_pool_size` --- README.md | 2 + config/runtime.exs | 4 +- lib/realtime/application.ex | 4 +- lib/realtime/gen_rpc.ex | 16 ++++++ lib/realtime/gen_rpc/pub_sub.ex | 78 ++++++++++++++++++++++++++ lib/realtime_web/tenant_broadcaster.ex | 10 +--- mix.exs | 4 +- mix.lock | 2 +- test/realtime/gen_rpc_pub_sub_test.exs | 2 + test/realtime/gen_rpc_test.exs | 33 +++++++++++ 10 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 lib/realtime/gen_rpc/pub_sub.ex create mode 100644 test/realtime/gen_rpc_pub_sub_test.exs diff --git a/README.md b/README.md index 6a16a79ba..3cbe10ad1 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ If you're using the default tenant, the URL is `ws://realtime-dev.localhost:4000 | MAX_GEN_RPC_CLIENTS | number | Max amount of `gen_rpc` TCP connections per node-to-node channel | | REBALANCE_CHECK_INTERVAL_IN_MS | number | Time in ms to check if process is in the right region | | DISCONNECT_SOCKET_ON_NO_CHANNELS_INTERVAL_IN_MS | number | Time in ms to check if a socket has no channels open and if so, disconnect it | +| BROADCAST_POOL_SIZE | number | Number of processes to relay Phoenix.PubSub messages across the cluster | + The OpenTelemetry variables mentioned above are not an exhaustive list of all [supported environment variables](https://opentelemetry.io/docs/languages/sdk-configuration/). diff --git a/config/runtime.exs b/config/runtime.exs index ac0a2569b..f20f40ad7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -67,6 +67,7 @@ janitor_run_after_in_ms = Env.get_integer("JANITOR_RUN_AFTER_IN_MS", :timer.minu janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.seconds(5)) janitor_schedule_timer = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly +broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) no_channel_timeout_in_ms = if config_env() == :test, @@ -120,7 +121,8 @@ config :realtime, rpc_timeout: rpc_timeout, max_gen_rpc_clients: max_gen_rpc_clients, no_channel_timeout_in_ms: no_channel_timeout_in_ms, - platform: platform + platform: platform, + broadcast_pool_size: broadcast_pool_size if config_env() != :test && run_janitor? do config :realtime, diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 0f4c9ae50..cda853150 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -52,6 +52,7 @@ defmodule Realtime.Application do region = Application.get_env(:realtime, :region) :syn.join(RegionNodes, region, self(), node: node()) + broadcast_pool_size = Application.get_env(:realtime, :broadcast_pool_size, 10) migration_partition_slots = Application.get_env(:realtime, :migration_partition_slots) connect_partition_slots = Application.get_env(:realtime, :connect_partition_slots) no_channel_timeout_in_ms = Application.get_env(:realtime, :no_channel_timeout_in_ms) @@ -65,7 +66,8 @@ defmodule Realtime.Application do Realtime.Repo, RealtimeWeb.Telemetry, {Cluster.Supervisor, [topologies, [name: Realtime.ClusterSupervisor]]}, - {Phoenix.PubSub, name: Realtime.PubSub, pool_size: 10}, + {Phoenix.PubSub, + name: Realtime.PubSub, pool_size: 10, adapter: Realtime.GenRpcPubSub, broadcast_pool_size: broadcast_pool_size}, {Cachex, name: Realtime.RateCounter}, Realtime.Tenants.Cache, Realtime.RateCounter.DynamicSupervisor, diff --git a/lib/realtime/gen_rpc.ex b/lib/realtime/gen_rpc.ex index 3487cc933..a7b46a869 100644 --- a/lib/realtime/gen_rpc.ex +++ b/lib/realtime/gen_rpc.ex @@ -10,6 +10,22 @@ defmodule Realtime.GenRpc do @type result :: any | {:error, :rpc_error, reason :: any} + @doc """ + Broadcasts the message `msg` asynchronously to the registered process `name` on the specified `nodes`. + + Options: + + - `:key` - Optional key to consistently select the same gen_rpc clients to guarantee message order between nodes + """ + @spec abcast([node], atom, any, keyword()) :: :ok + def abcast(nodes, name, msg, opts) when is_list(nodes) and is_atom(name) and is_list(opts) do + key = Keyword.get(opts, :key, nil) + nodes = rpc_nodes(nodes, key) + + :gen_rpc.abcast(nodes, name, msg) + :ok + end + @doc """ Fire and forget apply(mod, func, args) on all nodes diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex new file mode 100644 index 000000000..b2a90b165 --- /dev/null +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -0,0 +1,78 @@ +defmodule Realtime.GenRpcPubSub do + @moduledoc """ + gen_rpc Phoenix.PubSub adapter + """ + + @behaviour Phoenix.PubSub.Adapter + alias Realtime.GenRpc + use Supervisor + + @impl true + def node_name(_), do: node() + + # Supervisor callbacks + + def start_link(opts) do + adapter_name = Keyword.fetch!(opts, :adapter_name) + name = Keyword.fetch!(opts, :name) + pool_size = Keyword.get(opts, :pool_size, 1) + broadcast_pool_size = Keyword.get(opts, :broadcast_pool_size, pool_size) + + Supervisor.start_link(__MODULE__, {adapter_name, name, broadcast_pool_size}, + name: :"#{name}#{adapter_name}_supervisor" + ) + end + + @impl true + def init({adapter_name, pubsub, pool_size}) do + workers = for number <- 1..pool_size, do: :"#{pubsub}#{adapter_name}_#{number}" + + :persistent_term.put(adapter_name, List.to_tuple(workers)) + + children = + for worker <- workers do + Supervisor.child_spec({Realtime.GenRpcPubSub.Worker, {pubsub, worker}}, id: worker) + end + + Supervisor.init(children, strategy: :one_for_one) + end + + defp worker_name(adapter_name, key) do + workers = :persistent_term.get(adapter_name) + elem(workers, :erlang.phash2(key, tuple_size(workers))) + end + + @impl true + def broadcast(adapter_name, topic, message, dispatcher) do + worker = worker_name(adapter_name, self()) + GenRpc.abcast(Node.list(), worker, forward_to_local(topic, message, dispatcher), key: worker) + end + + @impl true + def direct_broadcast(adapter_name, node_name, topic, message, dispatcher) do + worker = worker_name(adapter_name, self()) + GenRpc.abcast([node_name], worker, forward_to_local(topic, message, dispatcher), key: worker) + end + + defp forward_to_local(topic, message, dispatcher), do: {:ftl, topic, message, dispatcher} +end + +defmodule Realtime.GenRpcPubSub.Worker do + @moduledoc false + use GenServer + + @doc false + def start_link({pubsub, worker}), do: GenServer.start_link(__MODULE__, pubsub, name: worker) + + @impl true + def init(pubsub), do: {:ok, pubsub} + + @impl true + def handle_info({:ftl, topic, message, dispatcher}, pubsub) do + Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher) + {:noreply, pubsub} + end + + @impl true + def handle_info(_, pubsub), do: {:noreply, pubsub} +end diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex index ee8646614..9995f2f27 100644 --- a/lib/realtime_web/tenant_broadcaster.ex +++ b/lib/realtime_web/tenant_broadcaster.ex @@ -9,7 +9,7 @@ defmodule RealtimeWeb.TenantBroadcaster do def pubsub_broadcast(tenant_id, topic, message, dispatcher) do collect_payload_size(tenant_id, message) - Realtime.GenRpc.multicast(PubSub, :local_broadcast, [Realtime.PubSub, topic, message, dispatcher], key: topic) + PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher) :ok end @@ -25,13 +25,7 @@ defmodule RealtimeWeb.TenantBroadcaster do def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher) do collect_payload_size(tenant_id, message) - Realtime.GenRpc.multicast( - PubSub, - :local_broadcast_from, - [Realtime.PubSub, from, topic, message, dispatcher], - key: topic - ) - + PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher) :ok end diff --git a/mix.exs b/mix.exs index 1e17ec551..0866b0476 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.49.0", + version: "2.50.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -90,7 +90,7 @@ defmodule Realtime.MixProject do {:opentelemetry_phoenix, "~> 2.0"}, {:opentelemetry_cowboy, "~> 1.0"}, {:opentelemetry_ecto, "~> 1.2"}, - {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "d161cf263c661a534eaabf80aac7a34484dac772"}, + {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "5aea098b300a0a6ad13533e030230132cbe9ca2c"}, {:mimic, "~> 1.0", only: :test}, {:floki, ">= 0.30.0", only: :test}, {:mint_web_socket, "~> 1.0", only: :test}, diff --git a/mix.lock b/mix.lock index dd95486b6..df5f70f4d 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, - "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "d161cf263c661a534eaabf80aac7a34484dac772", [ref: "d161cf263c661a534eaabf80aac7a34484dac772"]}, + "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "5aea098b300a0a6ad13533e030230132cbe9ca2c", [ref: "5aea098b300a0a6ad13533e030230132cbe9ca2c"]}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs new file mode 100644 index 000000000..0013c2e7b --- /dev/null +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -0,0 +1,2 @@ +Application.put_env(:phoenix_pubsub, :test_adapter, {Realtime.GenRpcPubSub, []}) +Code.require_file("../../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__) diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs index e14d2d054..0c41d3ea1 100644 --- a/test/realtime/gen_rpc_test.exs +++ b/test/realtime/gen_rpc_test.exs @@ -186,6 +186,39 @@ defmodule Realtime.GenRpcTest do end end + describe "abcast/4" do + test "abcast to registered process", %{node: node} do + name = + System.unique_integer() + |> to_string() + |> String.to_atom() + + :erlang.register(name, self()) + + # Use erpc to make the other node abcast to this one + :erpc.call(node, GenRpc, :abcast, [[node()], name, "a message", []]) + + assert_receive "a message" + refute_receive _any + end + + @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}] + test "tcp error" do + Logger.put_process_level(self(), :debug) + + log = + capture_log(fn -> + assert GenRpc.abcast(Node.list(), :some_process_name, "a message", []) == :ok + # We have to wait for gen_rpc logs to show up + Process.sleep(100) + end) + + assert log =~ "[error] event=connect_to_remote_server" + + refute_receive _any + end + end + describe "multicast/4" do test "evals everywhere" do parent = self() From e8a343a9fd899e6e68dcaa3d393575420e910a8a Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 17 Sep 2025 14:27:57 +1200 Subject: [PATCH 013/123] fix: ensure message id doesn't raise on non-map payloads (#1534) --- .../realtime_channel/message_dispatcher.ex | 5 ++- mix.exs | 2 +- .../message_dispatcher_test.exs | 43 +++++++++++++++++++ .../channels/realtime_channel_test.exs | 40 +++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex index ef486c4e8..32e1528f3 100644 --- a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex +++ b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex @@ -25,7 +25,7 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do # This reduce caches the serialization and bypasses the channel process going straight to the # transport process - message_id = msg.payload["meta"]["id"] + message_id = message_id(msg.payload) # Credo doesn't like that we don't use the result aggregation _ = @@ -62,6 +62,9 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do :ok end + defp message_id(%{"meta" => %{"id" => id}}), do: id + defp message_id(_), do: nil + defp already_replayed?(nil, _replayed_message_ids), do: false defp already_replayed?(message_id, replayed_message_ids), do: MapSet.member?(replayed_message_ids, message_id) diff --git a/mix.exs b/mix.exs index 0866b0476..893c32f57 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.50.0", + version: "2.50.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs index 91b16c089..44ce83b99 100644 --- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs +++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs @@ -112,6 +112,49 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do refute_receive _any end + test "payload is not a map" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "event", payload: "not a map"} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}} + assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}} + + assert Agent.get(TestSerializer, & &1) == 1 + + assert_receive {:subscriber, :update_rate_counter} + assert_receive {:subscriber, :update_rate_counter} + + refute_receive _any + end + test "dispatches messages to non fastlane subscribers" do from_pid = :erlang.list_to_pid(~c'<0.2.1>') diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 4d90c3588..5269ff448 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -31,6 +31,46 @@ defmodule RealtimeWeb.RealtimeChannelTest do describe "broadcast" do @describetag policies: [:authenticated_all_topic_read] + test "broadcast map payload", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "presence" => %{"enabled" => false}, + "broadcast" => %{"self" => true} + } + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + push(socket, "broadcast", %{"event" => "my_event", "payload" => %{"hello" => "world"}}) + + assert_receive %Phoenix.Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: %{"event" => "my_event", "payload" => %{"hello" => "world"}} + } + end + + test "broadcast non-map payload", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + config = %{ + "presence" => %{"enabled" => false}, + "broadcast" => %{"self" => true} + } + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config}) + + push(socket, "broadcast", "not a map") + + assert_receive %Phoenix.Socket.Message{ + topic: "realtime:test", + event: "broadcast", + payload: "not a map" + } + end + test "wrong replay params", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) From 380b882fd963cb058717d8dfea62b3253ab40c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 18 Sep 2025 22:54:42 +0100 Subject: [PATCH 014/123] fix: match error on Connect (#1536) --------- Co-authored-by: Eduardo Gurgel Pinho --- lib/realtime/tenants/connect.ex | 7 +- mix.exs | 2 +- test/realtime/tenants/connect_test.exs | 100 +++++++++++++++++++------ 3 files changed, 83 insertions(+), 26 deletions(-) diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index 920205e95..3d8f39833 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -55,6 +55,7 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_unavailable} | {:error, :initializing} | {:error, :tenant_database_connection_initializing} + | {:error, :tenant_db_too_many_connections} | {:error, :rpc_error, term()} def lookup_or_start_connection(tenant_id, opts \\ []) when is_binary(tenant_id) do case get_status(tenant_id) do @@ -62,13 +63,16 @@ defmodule Realtime.Tenants.Connect do {:ok, conn} {:error, :tenant_database_unavailable} -> - call_external_node(tenant_id, opts) + {:error, :tenant_database_unavailable} {:error, :tenant_database_connection_initializing} -> call_external_node(tenant_id, opts) {:error, :initializing} -> {:error, :tenant_database_unavailable} + + {:error, :tenant_db_too_many_connections} -> + {:error, :tenant_db_too_many_connections} end end @@ -80,6 +84,7 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_unavailable} | {:error, :initializing} | {:error, :tenant_database_connection_initializing} + | {:error, :tenant_db_too_many_connections} def get_status(tenant_id) do case :syn.lookup(__MODULE__, tenant_id) do {pid, %{conn: nil}} -> diff --git a/mix.exs b/mix.exs index 893c32f57..5ea9c627f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.50.1", + version: "2.50.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index fdc3d6385..8ba462b27 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -100,6 +100,54 @@ defmodule Realtime.Tenants.ConnectTest do # This one will succeed {:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id) end + + test "too many db connections", %{tenant: tenant} do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => false, + "db_pool" => 100, + "subcriber_pool_size" => 100, + "subs_pool_size" => 100 + } + } + + {:ok, tenant} = update_extension(tenant, extension) + + parent = self() + + # Let's slow down Connect starting + expect(Database, :check_tenant_connection, fn t -> + :timer.sleep(1000) + call_original(Database, :check_tenant_connection, [t]) + end) + + connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end + + # Start an early connect + spawn(connect) + :timer.sleep(100) + + # Start others + spawn(connect) + spawn(connect) + + # This one should block and wait for the first Connect + {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) + + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + assert_receive {:error, :tenant_db_too_many_connections} + refute_receive _any + end end describe "region rebalancing" do @@ -263,6 +311,34 @@ defmodule Realtime.Tenants.ConnectTest do assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id) end + test "tenant not able to connect if database has not enough connections", %{ + tenant: tenant + } do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => false, + "db_pool" => 100, + "subcriber_pool_size" => 100, + "subs_pool_size" => 100 + } + } + + {:ok, tenant} = update_extension(tenant, extension) + + assert capture_log(fn -> + assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) + end) =~ ~r/Only \d+ available connections\. At least \d+ connections are required/ + end + test "handles tenant suspension and unsuspension in a reactive way", %{tenant: tenant} do assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) @@ -459,30 +535,6 @@ defmodule Realtime.Tenants.ConnectTest do test "if tenant does not exist, does nothing" do assert :ok = Connect.shutdown("none") end - - test "tenant not able to connect if database has not enough connections", %{tenant: tenant} do - extension = %{ - "type" => "postgres_cdc_rls", - "settings" => %{ - "db_host" => "127.0.0.1", - "db_name" => "postgres", - "db_user" => "supabase_admin", - "db_password" => "postgres", - "poll_interval" => 100, - "poll_max_changes" => 100, - "poll_max_record_bytes" => 1_048_576, - "region" => "us-east-1", - "ssl_enforced" => false, - "db_pool" => 100, - "subcriber_pool_size" => 100, - "subs_pool_size" => 100 - } - } - - {:ok, tenant} = update_extension(tenant, extension) - - assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id) - end end describe "registers into local registry" do From 4ba956fc5eee91b484e030ccb6066dad2e68b0c6 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 22 Sep 2025 12:58:18 +1200 Subject: [PATCH 015/123] feat: websocket max heap size configuration (#1538) * fix: set max process heap size to 500MB instead of 8GB * feat: set websocket transport max heap size WEBSOCKET_MAX_HEAP_SIZE can be used to configure it --- README.md | 1 + config/runtime.exs | 2 ++ lib/realtime_web/channels/user_socket.ex | 10 ++++++++++ mix.exs | 2 +- rel/vm.args.eex | 6 +++--- test/realtime_web/channels/realtime_channel_test.exs | 8 ++++++++ 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3cbe10ad1..4e13e44df 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ If you're using the default tenant, the URL is `ws://realtime-dev.localhost:4000 | CONNECT_PARTITION_SLOTS | number | Number of dynamic supervisor partitions used by the Connect, ReplicationConnect processes | | METRICS_CLEANER_SCHEDULE_TIMER_IN_MS | number | Time in ms to run the Metric Cleaner task | | METRICS_RPC_TIMEOUT_IN_MS | number | Time in ms to wait for RPC call to fetch Metric per node | +| WEBSOCKET_MAX_HEAP_SIZE | number | Max number of bytes to be allocated as heap for the WebSocket transport process. If the limit is reached the process is brutally killed. Defaults to 50MB. | | REQUEST_ID_BAGGAGE_KEY | string | OTEL Baggage key to be used as request id | | OTEL_SDK_DISABLED | boolean | Disable OpenTelemetry tracing completely when 'true' | | OTEL_TRACES_EXPORTER | string | Possible values: `otlp` or `none`. See [https://github.com/open-telemetry/opentelemetry-erlang/tree/v1.4.0/apps#os-environment] for more details on how to configure the traces exporter. | diff --git a/config/runtime.exs b/config/runtime.exs index f20f40ad7..39a69135a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -68,6 +68,7 @@ janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.se janitor_schedule_timer = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) +websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) no_channel_timeout_in_ms = if config_env() == :test, @@ -107,6 +108,7 @@ config :realtime, Realtime.Repo, ssl: ssl_opts config :realtime, + websocket_max_heap_size: websocket_max_heap_size, migration_partition_slots: migration_partition_slots, connect_partition_slots: connect_partition_slots, rebalance_check_interval_in_ms: rebalance_check_interval_in_ms, diff --git a/lib/realtime_web/channels/user_socket.ex b/lib/realtime_web/channels/user_socket.ex index 09dd15906..849aa052d 100644 --- a/lib/realtime_web/channels/user_socket.ex +++ b/lib/realtime_web/channels/user_socket.ex @@ -1,4 +1,12 @@ defmodule RealtimeWeb.UserSocket do + # This is defined up here before `use Phoenix.Socket` is called so that we can define `Phoenix.Socket.init/1` + # It has to be overridden because we need to set the `max_heap_size` flag from the transport process context + @impl true + def init(state) when is_tuple(state) do + Process.flag(:max_heap_size, max_heap_size()) + Phoenix.Socket.__init__(state) + end + use Phoenix.Socket use Realtime.Logs @@ -122,4 +130,6 @@ defmodule RealtimeWeb.UserSocket do _ -> @default_log_level end end + + defp max_heap_size(), do: Application.fetch_env!(:realtime, :websocket_max_heap_size) end diff --git a/mix.exs b/mix.exs index 5ea9c627f..170e161a7 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.50.2", + version: "2.51.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/rel/vm.args.eex b/rel/vm.args.eex index 278da5524..9de4e952f 100644 --- a/rel/vm.args.eex +++ b/rel/vm.args.eex @@ -10,8 +10,8 @@ ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 -## Limit process heap for all procs to 1000 MB -+hmax 1000000000 +## Limit process heap for all procs to 500 MB. The number here is the number of words ++hmax <%= div(500_000_000, :erlang.system_info(:wordsize)) %> ## Set distribution buffer busy limit (default is 1024) +zdbbl 100000 @@ -19,4 +19,4 @@ ## Disable Busy Wait +sbwt none +sbwtdio none -+sbwtdcpu none \ No newline at end of file ++sbwtdcpu none diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 5269ff448..0a0d8aca9 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -28,6 +28,14 @@ defmodule RealtimeWeb.RealtimeChannelTest do setup :rls_context + test "max heap size is set", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert Process.info(socket.transport_pid, :max_heap_size) == + {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}} + end + describe "broadcast" do @describetag policies: [:authenticated_all_topic_read] From 1df809e1aa9f4167bfe7fc7a5cfd38d44b4da8ff Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 22 Sep 2025 19:15:23 +1200 Subject: [PATCH 016/123] fix: update gen_rpc to fix gen_rpc_dispatcher issues (#1537) Issues: * Single gen_rpc_dispatcher that can be a bottleneck if the connecting takes some time * Many calls can land on the dispatcher but the node might be gone already. If we don't validate the node it might keep trying to connect until it times out instead of quickly giving up due to not being an actively connected node. --- mix.exs | 4 ++-- mix.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 170e161a7..b4f626b9f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.0", + version: "2.51.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -90,7 +90,7 @@ defmodule Realtime.MixProject do {:opentelemetry_phoenix, "~> 2.0"}, {:opentelemetry_cowboy, "~> 1.0"}, {:opentelemetry_ecto, "~> 1.2"}, - {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "5aea098b300a0a6ad13533e030230132cbe9ca2c"}, + {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "901aada9adb307ff89a8be197a5d384e69dd57d6"}, {:mimic, "~> 1.0", only: :test}, {:floki, ">= 0.30.0", only: :test}, {:mint_web_socket, "~> 1.0", only: :test}, diff --git a/mix.lock b/mix.lock index df5f70f4d..c5fce6022 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, - "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "5aea098b300a0a6ad13533e030230132cbe9ca2c", [ref: "5aea098b300a0a6ad13533e030230132cbe9ca2c"]}, + "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "901aada9adb307ff89a8be197a5d384e69dd57d6", [ref: "901aada9adb307ff89a8be197a5d384e69dd57d6"]}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, From 9a21897acd7aa789cab5372311765f337c7c29e2 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 22 Sep 2025 20:27:33 +1200 Subject: [PATCH 017/123] fix: improve ErlSysMon logging for processes (#1540) Include initial_call, ancestors, registered_name, message_queue_len and total_heap_size Also bump long_schedule and long_gc --- lib/realtime/monitoring/erl_sys_mon.ex | 34 +++++++++++++++++-- mix.exs | 2 +- test/realtime/monitoring/erl_sys_mon_test.exs | 27 ++++++++++----- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/lib/realtime/monitoring/erl_sys_mon.ex b/lib/realtime/monitoring/erl_sys_mon.ex index 32a4f857b..3278886d6 100644 --- a/lib/realtime/monitoring/erl_sys_mon.ex +++ b/lib/realtime/monitoring/erl_sys_mon.ex @@ -10,8 +10,8 @@ defmodule Realtime.ErlSysMon do @defaults [ :busy_dist_port, :busy_port, - {:long_gc, 250}, - {:long_schedule, 100}, + {:long_gc, 500}, + {:long_schedule, 500}, {:long_message_queue, {0, 1_000}} ] @@ -24,8 +24,36 @@ defmodule Realtime.ErlSysMon do {:ok, []} end + def handle_info({:monitor, pid, _type, _meta} = msg, state) when is_pid(pid) do + log_process_info(msg, pid) + {:noreply, state} + end + def handle_info(msg, state) do - Logger.error("#{__MODULE__} message: " <> inspect(msg)) + Logger.warning("#{__MODULE__} message: " <> inspect(msg)) {:noreply, state} end + + defp log_process_info(msg, pid) do + pid_info = + pid + |> Process.info(:dictionary) + |> case do + {:dictionary, dict} when is_list(dict) -> + {List.keyfind(dict, :"$initial_call", 0), List.keyfind(dict, :"$ancestors", 0)} + + other -> + other + end + + extra_info = Process.info(pid, [:registered_name, :message_queue_len, :total_heap_size]) + + Logger.warning( + "#{__MODULE__} message: " <> + inspect(msg) <> "|\n process info: #{inspect(pid_info)} #{inspect(extra_info)}" + ) + rescue + _ -> + Logger.warning("#{__MODULE__} message: " <> inspect(msg)) + end end diff --git a/mix.exs b/mix.exs index b4f626b9f..95e8393b3 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.1", + version: "2.51.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/monitoring/erl_sys_mon_test.exs b/test/realtime/monitoring/erl_sys_mon_test.exs index b1e122d58..e9c7b87b7 100644 --- a/test/realtime/monitoring/erl_sys_mon_test.exs +++ b/test/realtime/monitoring/erl_sys_mon_test.exs @@ -5,16 +5,25 @@ defmodule Realtime.Monitoring.ErlSysMonTest do describe "system monitoring" do test "logs system monitor events" do - start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 10}}]}) + start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 100}}]}) - assert capture_log(fn -> - Task.async(fn -> - Enum.map(1..1000, &send(self(), &1)) - # Wait for ErlSysMon to notice - Process.sleep(4000) - end) - |> Task.await() - end) =~ "Realtime.ErlSysMon message:" + log = + capture_log(fn -> + Task.async(fn -> + Process.register(self(), TestProcess) + Enum.map(1..1000, &send(self(), &1)) + # Wait for ErlSysMon to notice + Process.sleep(4000) + end) + |> Task.await() + end) + + assert log =~ "Realtime.ErlSysMon message:" + assert log =~ "$initial_call\", {Realtime.Monitoring.ErlSysMonTest" + assert log =~ "ancestors\", [#{inspect(self())}]" + assert log =~ "registered_name: TestProcess" + assert log =~ "message_queue_len: " + assert log =~ "total_heap_size: " end end end From 54cd3f763a817a703a2eee9c7f8c8ea5d29b684a Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 22 Sep 2025 20:48:12 +1200 Subject: [PATCH 018/123] fix: make pubsub adapter configurable (#1539) --- config/runtime.exs | 2 + lib/realtime/application.ex | 10 +- lib/realtime_web/tenant_broadcaster.ex | 22 ++- mix.exs | 2 +- test/realtime_web/tenant_broadcaster_test.exs | 140 ++++++++++-------- 5 files changed, 108 insertions(+), 68 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 39a69135a..47961f98a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -68,6 +68,7 @@ janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.se janitor_schedule_timer = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) +pubsub_adapter = System.get_env("PUBSUB_ADAPTER", "pg2") |> String.to_atom() websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) no_channel_timeout_in_ms = @@ -124,6 +125,7 @@ config :realtime, max_gen_rpc_clients: max_gen_rpc_clients, no_channel_timeout_in_ms: no_channel_timeout_in_ms, platform: platform, + pubsub_adapter: pubsub_adapter, broadcast_pool_size: broadcast_pool_size if config_env() != :test && run_janitor? do diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index cda853150..99096edfb 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -67,7 +67,7 @@ defmodule Realtime.Application do RealtimeWeb.Telemetry, {Cluster.Supervisor, [topologies, [name: Realtime.ClusterSupervisor]]}, {Phoenix.PubSub, - name: Realtime.PubSub, pool_size: 10, adapter: Realtime.GenRpcPubSub, broadcast_pool_size: broadcast_pool_size}, + name: Realtime.PubSub, pool_size: 10, adapter: pubsub_adapter(), broadcast_pool_size: broadcast_pool_size}, {Cachex, name: Realtime.RateCounter}, Realtime.Tenants.Cache, Realtime.RateCounter.DynamicSupervisor, @@ -154,4 +154,12 @@ defmodule Realtime.Application do OpentelemetryPhoenix.setup(adapter: :cowboy2) OpentelemetryEcto.setup([:realtime, :repo], db_statement: :enabled) end + + defp pubsub_adapter do + if Application.fetch_env!(:realtime, :pubsub_adapter) == :gen_rpc do + Realtime.GenRpcPubSub + else + Phoenix.PubSub.PG2 + end + end end diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex index 9995f2f27..da02df79e 100644 --- a/lib/realtime_web/tenant_broadcaster.ex +++ b/lib/realtime_web/tenant_broadcaster.ex @@ -9,7 +9,11 @@ defmodule RealtimeWeb.TenantBroadcaster do def pubsub_broadcast(tenant_id, topic, message, dispatcher) do collect_payload_size(tenant_id, message) - PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher) + if pubsub_adapter() == :gen_rpc do + PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher) + else + Realtime.GenRpc.multicast(PubSub, :local_broadcast, [Realtime.PubSub, topic, message, dispatcher], key: topic) + end :ok end @@ -25,7 +29,17 @@ defmodule RealtimeWeb.TenantBroadcaster do def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher) do collect_payload_size(tenant_id, message) - PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher) + if pubsub_adapter() == :gen_rpc do + PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher) + else + Realtime.GenRpc.multicast( + PubSub, + :local_broadcast_from, + [Realtime.PubSub, from, topic, message, dispatcher], + key: topic + ) + end + :ok end @@ -39,4 +53,8 @@ defmodule RealtimeWeb.TenantBroadcaster do defp collect_payload_size(tenant_id, payload) do :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{tenant: tenant_id}) end + + defp pubsub_adapter do + Application.fetch_env!(:realtime, :pubsub_adapter) + end end diff --git a/mix.exs b/mix.exs index 95e8393b3..9c66b3dde 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.2", + version: "2.51.3", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/tenant_broadcaster_test.exs b/test/realtime_web/tenant_broadcaster_test.exs index d9afbf641..ddda381a1 100644 --- a/test/realtime_web/tenant_broadcaster_test.exs +++ b/test/realtime_web/tenant_broadcaster_test.exs @@ -1,5 +1,5 @@ defmodule RealtimeWeb.TenantBroadcasterTest do - # Usage of Clustered + # Usage of Clustered and changing Application env use Realtime.DataCase, async: false alias Phoenix.Socket.Broadcast @@ -47,95 +47,107 @@ defmodule RealtimeWeb.TenantBroadcasterTest do pid: self() ) + original = Application.fetch_env!(:realtime, :pubsub_adapter) + on_exit(fn -> Application.put_env(:realtime, :pubsub_adapter, original) end) + Application.put_env(:realtime, :pubsub_adapter, context.pubsub_adapter) + :ok end - describe "pubsub_broadcast/4" do - test "pubsub_broadcast", %{node: node} do - message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + for pubsub_adapter <- [:gen_rpc, :pg2] do + describe "pubsub_broadcast/4 #{pubsub_adapter}" do + @describetag pubsub_adapter: pubsub_adapter - assert_receive ^message + test "pubsub_broadcast", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) - # Remote node received the broadcast - assert_receive {:relay, ^node, ^message} + assert_receive ^message - assert_receive { - :telemetry, - [:realtime, :tenants, :payload, :size], - %{size: 114}, - %{tenant: "realtime-dev"} - } - end + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} - test "pubsub_broadcast list payload", %{node: node} do - message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 114}, + %{tenant: "realtime-dev"} + } + end - assert_receive ^message + test "pubsub_broadcast list payload", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) - # Remote node received the broadcast - assert_receive {:relay, ^node, ^message} + assert_receive ^message - assert_receive { - :telemetry, - [:realtime, :tenants, :payload, :size], - %{size: 130}, - %{tenant: "realtime-dev"} - } - end + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} - test "pubsub_broadcast string payload", %{node: node} do - message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 130}, + %{tenant: "realtime-dev"} + } + end - assert_receive ^message + test "pubsub_broadcast string payload", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) - # Remote node received the broadcast - assert_receive {:relay, ^node, ^message} + assert_receive ^message - assert_receive { - :telemetry, - [:realtime, :tenants, :payload, :size], - %{size: 119}, - %{tenant: "realtime-dev"} - } + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 119}, + %{tenant: "realtime-dev"} + } + end end end - describe "pubsub_broadcast_from/5" do - test "pubsub_broadcast_from", %{node: node} do - parent = self() + for pubsub_adapter <- [:gen_rpc, :pg2] do + describe "pubsub_broadcast_from/5 #{pubsub_adapter}" do + @describetag pubsub_adapter: pubsub_adapter + + test "pubsub_broadcast_from", %{node: node} do + parent = self() - spawn_link(fn -> - Endpoint.subscribe(@topic) - send(parent, :ready) + spawn_link(fn -> + Endpoint.subscribe(@topic) + send(parent, :ready) - receive do - msg -> send(parent, {:other_process, msg}) - end - end) + receive do + msg -> send(parent, {:other_process, msg}) + end + end) - assert_receive :ready + assert_receive :ready - message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} + message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub) - assert_receive {:other_process, ^message} + assert_receive {:other_process, ^message} - # Remote node received the broadcast - assert_receive {:relay, ^node, ^message} + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} - assert_receive { - :telemetry, - [:realtime, :tenants, :payload, :size], - %{size: 114}, - %{tenant: "realtime-dev"} - } + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 114}, + %{tenant: "realtime-dev"} + } - # This process does not receive the message - refute_receive _any + # This process does not receive the message + refute_receive _any + end end end From e4ee7c83d619383ddc6291183cf609355afbe3b9 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 25 Sep 2025 12:38:26 +1200 Subject: [PATCH 019/123] fix: specify that only private channels are allowed when replaying (#1543) messages --- lib/realtime_web/channels/realtime_channel.ex | 5 ++++- mix.exs | 2 +- test/realtime_web/channels/realtime_channel_test.exs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 1d58d9da7..63be07f03 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -213,6 +213,9 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :invalid_replay_params} -> log_error(socket, "UnableToReplayMessages", "Replay params are not valid") + {:error, :invalid_replay_channel} -> + log_error(socket, "UnableToReplayMessages", "Replay is not allowed for public channels") + {:error, error} -> log_error(socket, "UnknownErrorOnChannel", error) {:error, %{reason: "Unknown Error on Channel"}} @@ -790,7 +793,7 @@ defmodule RealtimeWeb.RealtimeChannel do end defp maybe_replay_messages(%{"broadcast" => %{"replay" => _}}, _sub_topic, _db_conn, false = _private?) do - {:error, :invalid_replay_params} + {:error, :invalid_replay_channel} end defp maybe_replay_messages(%{"broadcast" => %{"replay" => replay_params}}, sub_topic, db_conn, true = _private?) diff --git a/mix.exs b/mix.exs index 9c66b3dde..139e862fc 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.3", + version: "2.51.4", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 0a0d8aca9..ae6c1734a 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -153,7 +153,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do assert { :error, - %{reason: "UnableToReplayMessages: Replay params are not valid"} + %{reason: "UnableToReplayMessages: Replay is not allowed for public channels"} } = subscribe_and_join(socket, "realtime:test", %{"config" => config}) refute_receive _any From d4565dfc53996cd94f5a11ba514f0b32808ce759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 26 Sep 2025 11:46:48 +0100 Subject: [PATCH 020/123] fix: rate limit connect module (#1541) On bad connection, we rate limit the Connect module so we prevent abuses and too much logging of errors --- README.md | 1 + lib/realtime/tenants.ex | 26 ++++++++++ lib/realtime/tenants/connect.ex | 32 +++++++------ lib/realtime_web/channels/realtime_channel.ex | 4 ++ mix.exs | 2 +- test/realtime/tenants/connect_test.exs | 47 +++++++++++++++++++ .../controllers/broadcast_controller_test.exs | 18 +++++-- 7 files changed, 111 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4e13e44df..7dd223bf3 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ This is the list of operational codes that can help you understand your deployme | ChannelRateLimitReached | The number of channels you can create has reached its limit | | ConnectionRateLimitReached | The number of connected clients as reached its limit | | ClientJoinRateLimitReached | The rate of joins per second from your clients has reached the channel limits | +| DatabaseConnectionRateLimitReached | The rate of attempts to connect to tenants database has reached the limit | | MessagePerSecondRateLimitReached | The rate of messages per second from your clients has reached the channel limits | | RealtimeDisabledForTenant | Realtime has been disabled for the tenant | | UnableToConnectToTenantDatabase | Realtime was not able to connect to the tenant's database | diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index 63965abea..db2a02cc4 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -328,6 +328,32 @@ defmodule Realtime.Tenants do %RateCounter.Args{id: {:channel, :authorization_errors, external_id}, opts: opts} end + @connect_per_second_default 10 + @doc "RateCounter arguments for counting connect per second." + @spec connect_per_second_rate(Tenant.t() | String.t()) :: RateCounter.Args.t() + def connect_per_second_rate(%Tenant{external_id: external_id}) do + connect_per_second_rate(external_id) + end + + def connect_per_second_rate(tenant_id) do + opts = [ + max_bucket_len: 10, + limit: [ + value: @connect_per_second_default, + measurement: :sum, + log_fn: fn -> + Logger.critical( + "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database", + external_id: tenant_id, + project: tenant_id + ) + end + ] + ] + + %RateCounter.Args{id: {:database, :connect, tenant_id}, opts: opts} + end + defp pool_size(%{extensions: [%{settings: settings} | _]}) do Database.pool_size_by_application_name("realtime_connect", settings) end diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index 3d8f39833..0ee43f161 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -11,8 +11,9 @@ defmodule Realtime.Tenants.Connect do use Realtime.Logs - alias Realtime.Tenants.Rebalancer alias Realtime.Api.Tenant + alias Realtime.GenCounter + alias Realtime.RateCounter alias Realtime.Rpc alias Realtime.Tenants alias Realtime.Tenants.Connect.CheckConnection @@ -20,6 +21,7 @@ defmodule Realtime.Tenants.Connect do alias Realtime.Tenants.Connect.Piper alias Realtime.Tenants.Connect.RegisterProcess alias Realtime.Tenants.Migrations + alias Realtime.Tenants.Rebalancer alias Realtime.Tenants.ReplicationConnection alias Realtime.UsersCounter @@ -39,11 +41,8 @@ defmodule Realtime.Tenants.Connect do @doc "Check if Connect has finished setting up connections" def ready?(tenant_id) do case whereis(tenant_id) do - pid when is_pid(pid) -> - GenServer.call(pid, :ready?) - - _ -> - false + pid when is_pid(pid) -> GenServer.call(pid, :ready?) + _ -> false end end @@ -55,24 +54,29 @@ defmodule Realtime.Tenants.Connect do | {:error, :tenant_database_unavailable} | {:error, :initializing} | {:error, :tenant_database_connection_initializing} - | {:error, :tenant_db_too_many_connections} + | {:error, :connect_rate_limit_reached} | {:error, :rpc_error, term()} def lookup_or_start_connection(tenant_id, opts \\ []) when is_binary(tenant_id) do - case get_status(tenant_id) do - {:ok, conn} -> - {:ok, conn} + rate_args = Tenants.connect_per_second_rate(tenant_id) + RateCounter.new(rate_args) - {:error, :tenant_database_unavailable} -> - {:error, :tenant_database_unavailable} + with {:ok, %{limit: %{triggered: false}}} <- RateCounter.get(rate_args), + {:ok, conn} <- get_status(tenant_id) do + {:ok, conn} + else + {:ok, %{limit: %{triggered: true}}} -> + {:error, :connect_rate_limit_reached} {:error, :tenant_database_connection_initializing} -> + GenCounter.add(rate_args.id) call_external_node(tenant_id, opts) {:error, :initializing} -> {:error, :tenant_database_unavailable} - {:error, :tenant_db_too_many_connections} -> - {:error, :tenant_db_too_many_connections} + {:error, reason} -> + GenCounter.add(rate_args.id) + {:error, reason} end end diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 63be07f03..91a417c21 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -167,6 +167,10 @@ defmodule RealtimeWeb.RealtimeChannel do msg = "Database can't accept more connections, Realtime won't connect" log_error(socket, "DatabaseLackOfConnections", msg) + {:error, :connect_rate_limit_reached} -> + msg = "Too many database connections attempts per second" + log_error(socket, "DatabaseConnectionRateLimitReached", msg) + {:error, :unable_to_set_policies, error} -> log_error(socket, "UnableToSetPolicies", error) {:error, %{reason: "Realtime was unable to connect to the project database"}} diff --git a/mix.exs b/mix.exs index 139e862fc..4b0b1f40c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.4", + version: "2.51.5", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index 8ba462b27..a52973d53 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -515,6 +515,53 @@ defmodule Realtime.Tenants.ConnectTest do assert capture_log(fn -> assert {:error, :rpc_error, _} = Connect.lookup_or_start_connection("tenant") end) =~ "project=tenant external_id=tenant [error] ErrorOnRpcCall" end + + test "rate limit connect when too many connections against bad database", %{tenant: tenant} do + extension = %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "postgres", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "us-east-1", + "ssl_enforced" => true + } + } + + {:ok, tenant} = update_extension(tenant, extension) + + log = + capture_log(fn -> + res = + for _ <- 1..50 do + Process.sleep(200) + Connect.lookup_or_start_connection(tenant.external_id) + end + + assert Enum.any?(res, fn {_, res} -> res == :connect_rate_limit_reached end) + end) + + assert log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database" + end + + test "rate limit connect will not trigger if connection is successful", %{tenant: tenant} do + log = + capture_log(fn -> + res = + for _ <- 1..20 do + Process.sleep(500) + Connect.lookup_or_start_connection(tenant.external_id) + end + + refute Enum.any?(res, fn {_, res} -> res == :tenant_db_too_many_connections end) + end) + + refute log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database" + end end describe "shutdown/1" do diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs index 9c38d58bd..7bd426353 100644 --- a/test/realtime_web/controllers/broadcast_controller_test.exs +++ b/test/realtime_web/controllers/broadcast_controller_test.exs @@ -272,6 +272,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) + connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) messages_to_send = @@ -290,7 +291,10 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages), fn ^broadcast_events_key -> :ok end) + |> expect(:add, length(messages), fn + ^broadcast_events_key -> :ok + ^connect_events_key -> :ok + end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -326,6 +330,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) + connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _ -> :ok end) channels = @@ -354,7 +359,10 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages), fn ^broadcast_events_key -> :ok end) + |> expect(:add, length(messages), fn + ^broadcast_events_key -> :ok + ^connect_events_key -> :ok + end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -408,6 +416,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) + connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) messages_to_send = @@ -428,7 +437,9 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages_to_send), fn ^broadcast_events_key -> :ok end) + # remove the one message that won't be broadcasted for this user + |> expect(:add, 1, fn ^connect_events_key -> :ok end) + |> expect(:add, length(messages) - 1, fn ^broadcast_events_key -> :ok end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -482,7 +493,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> 1 end) - |> reject(:add, 1) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) From d309c55bfb60c8377eb7cb4b240f2ecf7b2d6962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Sat, 27 Sep 2025 16:43:37 +0800 Subject: [PATCH 021/123] build: automatically cancel old tests/build on new push (#1545) Currently, whenever you push any commit to your branch, the old builds are still running and a new build is started. Once a new commit is added, the old test results no longer matter and it's just a waste of CI resources. Also reduces confusion with multiple builds running in parallel for the same branch/possibly blocking any merges. With this little change, we ensure that whenever a new commit is added, the previous build is immediately canceled/stopped and only the build (latest commit) runs. --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d3818814..c9c2a73fa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: tests: name: Tests From a72a8353cf6eb9bd7e3549422c5b5c3e70bcef3d Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 1 Oct 2025 11:53:59 +1300 Subject: [PATCH 022/123] fix: move message queue data to off-heap for gen_rpc pub sub workers (#1548) --- config/runtime.exs | 2 +- lib/realtime/gen_rpc/pub_sub.ex | 5 ++++- mix.exs | 2 +- test/realtime/gen_rpc_pub_sub_test.exs | 10 ++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 47961f98a..447934b65 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -68,7 +68,7 @@ janitor_children_timeout = Env.get_integer("JANITOR_CHILDREN_TIMEOUT", :timer.se janitor_schedule_timer = Env.get_integer("JANITOR_SCHEDULE_TIMER_IN_MS", :timer.hours(4)) platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws, else: :fly broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) -pubsub_adapter = System.get_env("PUBSUB_ADAPTER", "pg2") |> String.to_atom() +pubsub_adapter = System.get_env("PUBSUB_ADAPTER", "gen_rpc") |> String.to_atom() websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) no_channel_timeout_in_ms = diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex index b2a90b165..c8ddf5568 100644 --- a/lib/realtime/gen_rpc/pub_sub.ex +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -65,7 +65,10 @@ defmodule Realtime.GenRpcPubSub.Worker do def start_link({pubsub, worker}), do: GenServer.start_link(__MODULE__, pubsub, name: worker) @impl true - def init(pubsub), do: {:ok, pubsub} + def init(pubsub) do + Process.flag(:message_queue_data, :off_heap) + {:ok, pubsub} + end @impl true def handle_info({:ftl, topic, message, dispatcher}, pubsub) do diff --git a/mix.exs b/mix.exs index 4b0b1f40c..e093db4bf 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.5", + version: "2.51.6", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index 0013c2e7b..5e7a1f14b 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -1,2 +1,12 @@ Application.put_env(:phoenix_pubsub, :test_adapter, {Realtime.GenRpcPubSub, []}) Code.require_file("../../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__) + +defmodule Realtime.GenRpcPubSubTest do + use ExUnit.Case, async: true + + test "it sets off_heap message_queue_data flag on the workers" do + assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 + |> Process.whereis() + |> Process.info(:message_queue_data) == {:message_queue_data, :off_heap} + end +end From 353c14230ccc45278154e488ac990c93f2cdf33b Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 1 Oct 2025 13:47:00 +1300 Subject: [PATCH 023/123] fix: rate limit Connect.lookup_or_start_connection on error only (#1549) --- lib/realtime/tenants.ex | 12 ++++---- lib/realtime/tenants/connect.ex | 12 ++++++-- mix.exs | 2 +- test/realtime/tenants/connect_test.exs | 30 +++++++++++++++++++ .../controllers/broadcast_controller_test.exs | 14 ++------- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index db2a02cc4..019a87e99 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -328,18 +328,18 @@ defmodule Realtime.Tenants do %RateCounter.Args{id: {:channel, :authorization_errors, external_id}, opts: opts} end - @connect_per_second_default 10 + @connect_errors_per_second_default 10 @doc "RateCounter arguments for counting connect per second." - @spec connect_per_second_rate(Tenant.t() | String.t()) :: RateCounter.Args.t() - def connect_per_second_rate(%Tenant{external_id: external_id}) do - connect_per_second_rate(external_id) + @spec connect_errors_per_second_rate(Tenant.t() | String.t()) :: RateCounter.Args.t() + def connect_errors_per_second_rate(%Tenant{external_id: external_id}) do + connect_errors_per_second_rate(external_id) end - def connect_per_second_rate(tenant_id) do + def connect_errors_per_second_rate(tenant_id) do opts = [ max_bucket_len: 10, limit: [ - value: @connect_per_second_default, + value: @connect_errors_per_second_default, measurement: :sum, log_fn: fn -> Logger.critical( diff --git a/lib/realtime/tenants/connect.ex b/lib/realtime/tenants/connect.ex index 0ee43f161..caf49cc57 100644 --- a/lib/realtime/tenants/connect.ex +++ b/lib/realtime/tenants/connect.ex @@ -57,7 +57,7 @@ defmodule Realtime.Tenants.Connect do | {:error, :connect_rate_limit_reached} | {:error, :rpc_error, term()} def lookup_or_start_connection(tenant_id, opts \\ []) when is_binary(tenant_id) do - rate_args = Tenants.connect_per_second_rate(tenant_id) + rate_args = Tenants.connect_errors_per_second_rate(tenant_id) RateCounter.new(rate_args) with {:ok, %{limit: %{triggered: false}}} <- RateCounter.get(rate_args), @@ -68,8 +68,14 @@ defmodule Realtime.Tenants.Connect do {:error, :connect_rate_limit_reached} {:error, :tenant_database_connection_initializing} -> - GenCounter.add(rate_args.id) - call_external_node(tenant_id, opts) + case call_external_node(tenant_id, opts) do + {:ok, pid} -> + {:ok, pid} + + error -> + GenCounter.add(rate_args.id) + error + end {:error, :initializing} -> {:error, :tenant_database_unavailable} diff --git a/mix.exs b/mix.exs index e093db4bf..4e5bf5852 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.6", + version: "2.51.7", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index a52973d53..741f6ecf7 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -51,6 +51,36 @@ defmodule Realtime.Tenants.ConnectTest do end describe "handle cold start" do + test "multiple processes connecting calling Connect.connect", %{tenant: tenant} do + parent = self() + + # Let's slow down Connect.connect so that multiple RPC calls are executed + stub(Connect, :connect, fn x, y, z -> + :timer.sleep(1000) + call_original(Connect, :connect, [x, y, z]) + end) + + connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end + # Let's call enough times to potentially trigger the Connect RateCounter + + for _ <- 1..50, do: spawn(connect) + + assert_receive({:ok, pid}, 1100) + + for _ <- 1..49, do: assert_receive({:ok, ^pid}) + + # Does not trigger rate limit as connections eventually succeeded + + {:ok, rate_counter} = + tenant.external_id + |> Tenants.connect_errors_per_second_rate() + |> Realtime.RateCounter.get() + + assert rate_counter.sum == 0 + assert rate_counter.avg == 0.0 + assert rate_counter.limit.triggered == false + end + test "multiple proccesses succeed together", %{tenant: tenant} do parent = self() diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs index 7bd426353..d42466722 100644 --- a/test/realtime_web/controllers/broadcast_controller_test.exs +++ b/test/realtime_web/controllers/broadcast_controller_test.exs @@ -272,7 +272,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) messages_to_send = @@ -291,10 +290,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages), fn - ^broadcast_events_key -> :ok - ^connect_events_key -> :ok - end) + |> expect(:add, length(messages), fn ^broadcast_events_key -> :ok end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -330,7 +326,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _ -> :ok end) channels = @@ -359,10 +354,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) - |> expect(:add, length(messages), fn - ^broadcast_events_key -> :ok - ^connect_events_key -> :ok - end) + |> expect(:add, length(messages), fn ^broadcast_events_key -> :ok end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) @@ -416,7 +408,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - connect_events_key = Tenants.connect_per_second_rate(tenant).id expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) messages_to_send = @@ -438,7 +429,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do GenCounter |> expect(:add, fn ^request_events_key -> :ok end) # remove the one message that won't be broadcasted for this user - |> expect(:add, 1, fn ^connect_events_key -> :ok end) |> expect(:add, length(messages) - 1, fn ^broadcast_events_key -> :ok end) conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) From 748398ccf0e81b433afa61eb0200507251349aa4 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 1 Oct 2025 20:32:49 +1300 Subject: [PATCH 024/123] fix: increase connect error rate window to 30 seconds (#1550) --- lib/realtime/tenants.ex | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index 019a87e99..efd2397ac 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -337,7 +337,7 @@ defmodule Realtime.Tenants do def connect_errors_per_second_rate(tenant_id) do opts = [ - max_bucket_len: 10, + max_bucket_len: 30, limit: [ value: @connect_errors_per_second_default, measurement: :sum, diff --git a/mix.exs b/mix.exs index 4e5bf5852..8618d067f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.7", + version: "2.51.8", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 92e7b5999bc3a9617fc5cf891b326b92790b2ec0 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 2 Oct 2025 09:29:07 +1300 Subject: [PATCH 025/123] fix: set a lower fullsweep_after flag for GenRpcPubSub workers (#1551) --- lib/realtime/gen_rpc/pub_sub.ex | 1 + mix.exs | 2 +- test/realtime/gen_rpc_pub_sub_test.exs | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex index c8ddf5568..63fff145c 100644 --- a/lib/realtime/gen_rpc/pub_sub.ex +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -67,6 +67,7 @@ defmodule Realtime.GenRpcPubSub.Worker do @impl true def init(pubsub) do Process.flag(:message_queue_data, :off_heap) + Process.flag(:fullsweep_after, 1000) {:ok, pubsub} end diff --git a/mix.exs b/mix.exs index 8618d067f..e3123d41c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.8", + version: "2.51.9", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index 5e7a1f14b..f94bc5f89 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -9,4 +9,10 @@ defmodule Realtime.GenRpcPubSubTest do |> Process.whereis() |> Process.info(:message_queue_data) == {:message_queue_data, :off_heap} end + + test "it sets fullsweep_after flag on the workers" do + assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 + |> Process.whereis() + |> Process.info(:fullsweep_after) == {:fullsweep_after, 1000} + end end From 6248e2b19a1eb5116c308757da5c1ea33ab2b2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 2 Oct 2025 04:54:05 +0100 Subject: [PATCH 026/123] fix: hardcode presence limit (#1552) --- .../channels/realtime_channel/presence_handler.ex | 5 +++-- mix.exs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 9dc23d219..be3d8593d 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -138,13 +138,14 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do |> Phoenix.Presence.group() end + @presence_limit 1000 defp limit_presence_event(socket) do %{assigns: %{presence_rate_counter: presence_counter, tenant: tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) - tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) + # tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) - if rate_counter.avg > tenant.max_presence_events_per_second do + if rate_counter.avg > @presence_limit do {:error, :rate_limit_exceeded} else GenCounter.add(presence_counter.id) diff --git a/mix.exs b/mix.exs index e3123d41c..55bf9022d 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.9", + version: "2.51.10", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From e84ac08ca378be05ad9ef366b1903d5d3f8195ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 2 Oct 2025 05:33:08 +0100 Subject: [PATCH 027/123] fix: further decrease limit on presence events (#1553) --- .../channels/realtime_channel/presence_handler.ex | 4 ++-- mix.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index be3d8593d..e081fdffb 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -138,9 +138,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do |> Phoenix.Presence.group() end - @presence_limit 1000 + @presence_limit 500 defp limit_presence_event(socket) do - %{assigns: %{presence_rate_counter: presence_counter, tenant: tenant_id}} = socket + %{assigns: %{presence_rate_counter: presence_counter, tenant: _tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) # tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) diff --git a/mix.exs b/mix.exs index 55bf9022d..958bf7ed2 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.10", + version: "2.51.11", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 13052aa822139d764b3a3f9aa2063077fd325ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 2 Oct 2025 06:45:58 +0100 Subject: [PATCH 028/123] fix: bump up realtime (#1554) --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 958bf7ed2..fe2f8978c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.11", + version: "2.51.12", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 6e650f0a7306fd7022c698c5a474c65e5c2b8331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 2 Oct 2025 16:33:58 +0100 Subject: [PATCH 029/123] fix: lower rate limit to 100 events per second (#1556) --- lib/realtime_web/channels/realtime_channel/presence_handler.ex | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index e081fdffb..29ae9294e 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -138,7 +138,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do |> Phoenix.Presence.group() end - @presence_limit 500 + @presence_limit 100 defp limit_presence_event(socket) do %{assigns: %{presence_rate_counter: presence_counter, tenant: _tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) diff --git a/mix.exs b/mix.exs index fe2f8978c..2f31c5ada 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.12", + version: "2.51.13", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 05ac93eabdd764153995f4f999dcef2da9c686fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 2 Oct 2025 22:00:42 +0100 Subject: [PATCH 030/123] fix: move connect rate limit to socket (#1555) * fix: reduce max_frame_size to 5MB * fix: fullsweep_after=100 on gen rpc pub sub workers --------- Co-authored-by: Eduardo Gurgel Pinho --- lib/realtime/gen_rpc/pub_sub.ex | 2 +- .../realtime_channel/presence_handler.ex | 2 +- .../channels/tenant_rate_limiters.ex | 43 +++++++++++++++++++ lib/realtime_web/channels/user_socket.ex | 12 ++++++ lib/realtime_web/endpoint.ex | 2 +- mix.exs | 2 +- test/realtime/gen_rpc_pub_sub_test.exs | 2 +- .../presence_handler_test.exs | 1 + .../channels/tenant_rate_limiters_test.exs | 31 +++++++++++++ 9 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 lib/realtime_web/channels/tenant_rate_limiters.ex create mode 100644 test/realtime_web/channels/tenant_rate_limiters_test.exs diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex index 63fff145c..3ba9e053a 100644 --- a/lib/realtime/gen_rpc/pub_sub.ex +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -67,7 +67,7 @@ defmodule Realtime.GenRpcPubSub.Worker do @impl true def init(pubsub) do Process.flag(:message_queue_data, :off_heap) - Process.flag(:fullsweep_after, 1000) + Process.flag(:fullsweep_after, 100) {:ok, pubsub} end diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 29ae9294e..1af26c528 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -11,7 +11,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do alias Phoenix.Tracker.Shard alias Realtime.GenCounter alias Realtime.RateCounter - alias Realtime.Tenants + # alias Realtime.Tenants alias Realtime.Tenants.Authorization alias RealtimeWeb.Presence alias RealtimeWeb.RealtimeChannel.Logging diff --git a/lib/realtime_web/channels/tenant_rate_limiters.ex b/lib/realtime_web/channels/tenant_rate_limiters.ex new file mode 100644 index 000000000..2101ac945 --- /dev/null +++ b/lib/realtime_web/channels/tenant_rate_limiters.ex @@ -0,0 +1,43 @@ +defmodule RealtimeWeb.TenantRateLimiters do + @moduledoc """ + Rate limiters for tenants. + """ + require Logger + alias Realtime.UsersCounter + alias Realtime.Tenants + alias Realtime.RateCounter + alias Realtime.Api.Tenant + + @spec check_tenant(Realtime.Api.Tenant.t()) :: :ok | {:error, :too_many_connections | :too_many_joins} + def check_tenant(tenant) do + with :ok <- max_concurrent_users_check(tenant) do + max_joins_per_second_check(tenant) + end + end + + defp max_concurrent_users_check(%Tenant{max_concurrent_users: max_conn_users, external_id: external_id}) do + total_conn_users = UsersCounter.tenant_users(external_id) + + if total_conn_users < max_conn_users, + do: :ok, + else: {:error, :too_many_connections} + end + + defp max_joins_per_second_check(%Tenant{max_joins_per_second: max_joins_per_second} = tenant) do + rate_args = Tenants.joins_per_second_rate(tenant.external_id, max_joins_per_second) + + RateCounter.new(rate_args) + + case RateCounter.get(rate_args) do + {:ok, %{limit: %{triggered: false}}} -> + :ok + + {:ok, %{limit: %{triggered: true}}} -> + {:error, :too_many_joins} + + error -> + Logger.error("UnknownErrorOnCounter: #{inspect(error)}") + {:error, error} + end + end +end diff --git a/lib/realtime_web/channels/user_socket.ex b/lib/realtime_web/channels/user_socket.ex index 849aa052d..6d4bf9017 100644 --- a/lib/realtime_web/channels/user_socket.ex +++ b/lib/realtime_web/channels/user_socket.ex @@ -16,6 +16,7 @@ defmodule RealtimeWeb.UserSocket do alias Realtime.PostgresCdc alias Realtime.Tenants + alias RealtimeWeb.TenantRateLimiters alias RealtimeWeb.ChannelsAuthorization alias RealtimeWeb.RealtimeChannel alias RealtimeWeb.RealtimeChannel.Logging @@ -56,6 +57,7 @@ defmodule RealtimeWeb.UserSocket do token when is_binary(token) <- token, jwt_secret_dec <- Crypto.decrypt!(jwt_secret), {:ok, claims} <- ChannelsAuthorization.authorize_conn(token, jwt_secret_dec, jwt_jwks), + :ok <- TenantRateLimiters.check_tenant(tenant), {:ok, postgres_cdc_module} <- PostgresCdc.driver(postgres_cdc_default) do %Tenant{ extensions: extensions, @@ -111,6 +113,16 @@ defmodule RealtimeWeb.UserSocket do log_error("MalformedJWT", "The token provided is not a valid JWT") {:error, :token_malformed} + {:error, :too_many_connections} -> + msg = "Too many connected users" + Logging.log_error(socket, "ConnectionRateLimitReached", msg) + {:error, :too_many_connections} + + {:error, :too_many_joins} -> + msg = "Too many joins per second" + Logging.log_error(socket, "JoinsRateLimitReached", msg) + {:error, :too_many_joins} + error -> log_error("ErrorConnectingToWebsocket", error) error diff --git a/lib/realtime_web/endpoint.ex b/lib/realtime_web/endpoint.ex index 190e1a917..894911803 100644 --- a/lib/realtime_web/endpoint.ex +++ b/lib/realtime_web/endpoint.ex @@ -15,7 +15,7 @@ defmodule RealtimeWeb.Endpoint do websocket: [ connect_info: [:peer_data, :uri, :x_headers], fullsweep_after: 20, - max_frame_size: 8_000_000, + max_frame_size: 5_000_000, # https://github.com/ninenines/cowboy/blob/24d32de931a0c985ff7939077463fc8be939f0e9/doc/src/manual/cowboy_websocket.asciidoc#L228 # active_n: The number of packets Cowboy will request from the socket at once. # This can be used to tweak the performance of the server. Higher values reduce diff --git a/mix.exs b/mix.exs index 2f31c5ada..4cf76563f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.13", + version: "2.51.14", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index f94bc5f89..517c6c369 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -13,6 +13,6 @@ defmodule Realtime.GenRpcPubSubTest do test "it sets fullsweep_after flag on the workers" do assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 |> Process.whereis() - |> Process.info(:fullsweep_after) == {:fullsweep_after, 1000} + |> Process.info(:fullsweep_after) == {:fullsweep_after, 100} end end diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index 0cdf422e2..4891e4187 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -434,6 +434,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert log =~ "PresenceRateLimitReached" end + @tag :skip @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] test "respects rate limits on private channels", %{tenant: tenant, topic: topic, db_conn: db_conn} do key = random_string() diff --git a/test/realtime_web/channels/tenant_rate_limiters_test.exs b/test/realtime_web/channels/tenant_rate_limiters_test.exs new file mode 100644 index 000000000..05d56ec82 --- /dev/null +++ b/test/realtime_web/channels/tenant_rate_limiters_test.exs @@ -0,0 +1,31 @@ +defmodule RealtimeWeb.TenantRateLimitersTest do + use Realtime.DataCase, async: true + + use Mimic + alias RealtimeWeb.TenantRateLimiters + alias Realtime.Api.Tenant + + setup do + tenant = %Tenant{external_id: random_string(), max_concurrent_users: 1, max_joins_per_second: 1} + + %{tenant: tenant} + end + + describe "check_tenant/1" do + test "rate is not exceeded", %{tenant: tenant} do + assert TenantRateLimiters.check_tenant(tenant) == :ok + end + + test "max concurrent users is exceeded", %{tenant: tenant} do + Realtime.UsersCounter.add(self(), tenant.external_id) + + assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_connections} + end + + test "max joins is exceeded", %{tenant: tenant} do + expect(Realtime.RateCounter, :get, fn _ -> {:ok, %{limit: %{triggered: true}}} end) + + assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_joins} + end + end +end From e9eaf9f117dc97a42b66f28aa3afd0005e79a301 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Fri, 3 Oct 2025 11:17:13 +1300 Subject: [PATCH 031/123] fix: collect global metrics without tenant tagging (#1557) --- .../monitoring/prom_ex/plugins/tenant.ex | 18 ++++++++++ .../monitoring/prom_ex/plugins/tenants.ex | 9 +++++ mix.exs | 2 +- .../prom_ex/plugins/tenant_test.exs | 31 +++++++++++++++++ .../prom_ex/plugins/tenants_test.exs | 33 +++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex index 1bd324624..bf9d850ee 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex @@ -157,6 +157,12 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "Sum of messages sent on a Realtime Channel.", tags: [:tenant] ), + sum( + [:realtime, :channel, :global, :events], + event_name: [:realtime, :rate_counter, :channel, :events], + measurement: :sum, + description: "Global sum of messages sent on a Realtime Channel." + ), sum( [:realtime, :channel, :presence_events], event_name: [:realtime, :rate_counter, :channel, :presence_events], @@ -164,6 +170,12 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "Sum of presence messages sent on a Realtime Channel.", tags: [:tenant] ), + sum( + [:realtime, :channel, :global, :presence_events], + event_name: [:realtime, :rate_counter, :channel, :presence_events], + measurement: :sum, + description: "Global sum of presence messages sent on a Realtime Channel." + ), sum( [:realtime, :channel, :db_events], event_name: [:realtime, :rate_counter, :channel, :db_events], @@ -171,6 +183,12 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "Sum of db messages sent on a Realtime Channel.", tags: [:tenant] ), + sum( + [:realtime, :channel, :global, :db_events], + event_name: [:realtime, :rate_counter, :channel, :db_events], + measurement: :sum, + description: "Global sum of db messages sent on a Realtime Channel." + ), sum( [:realtime, :channel, :joins], event_name: [:realtime, :rate_counter, :channel, :joins], diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex index 0035e9594..e8106df58 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex @@ -21,6 +21,15 @@ defmodule Realtime.PromEx.Plugins.Tenants do unit: {:microsecond, :millisecond}, tags: [:success, :tenant, :mechanism], reporter_options: [buckets: [10, 250, 5000, 15_000]] + ), + distribution( + [:realtime, :global, :rpc], + event_name: [:realtime, :rpc], + description: "Global Latency of rpc calls", + measurement: :latency, + unit: {:microsecond, :millisecond}, + tags: [:success, :mechanism], + reporter_options: [buckets: [10, 250, 5000, 15_000]] ) ]) end diff --git a/mix.exs b/mix.exs index 4cf76563f..bc5fccc8b 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.14", + version: "2.51.15", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs index 164c8d2eb..cfa727cfa 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs @@ -129,6 +129,17 @@ defmodule Realtime.PromEx.Plugins.TenantTest do assert metric_value(pattern) == metric_value + 1 end + test "global event exists after counter added", %{tenant: %{external_id: external_id}} do + pattern = + ~r/realtime_channel_global_events\s(?\d+)/ + + metric_value = metric_value(pattern) + FakeUserCounter.fake_event(external_id) + + Process.sleep(200) + assert metric_value(pattern) == metric_value + 1 + end + test "db_event exists after counter added", %{tenant: %{external_id: external_id}} do pattern = ~r/realtime_channel_db_events{tenant="#{external_id}"}\s(?\d+)/ @@ -139,6 +150,16 @@ defmodule Realtime.PromEx.Plugins.TenantTest do assert metric_value(pattern) == metric_value + 1 end + test "global db_event exists after counter added", %{tenant: %{external_id: external_id}} do + pattern = + ~r/realtime_channel_global_db_events\s(?\d+)/ + + metric_value = metric_value(pattern) + FakeUserCounter.fake_db_event(external_id) + Process.sleep(200) + assert metric_value(pattern) == metric_value + 1 + end + test "presence_event exists after counter added", %{tenant: %{external_id: external_id}} do pattern = ~r/realtime_channel_presence_events{tenant="#{external_id}"}\s(?\d+)/ @@ -149,6 +170,16 @@ defmodule Realtime.PromEx.Plugins.TenantTest do assert metric_value(pattern) == metric_value + 1 end + test "global presence_event exists after counter added", %{tenant: %{external_id: external_id}} do + pattern = + ~r/realtime_channel_global_presence_events\s(?\d+)/ + + metric_value = metric_value(pattern) + FakeUserCounter.fake_presence_event(external_id) + Process.sleep(200) + assert metric_value(pattern) == metric_value + 1 + end + test "metric read_authorization_check exists after check", context do pattern = ~r/realtime_tenants_read_authorization_check_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/ diff --git a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs index 080fd3cfb..ded087c74 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs @@ -37,6 +37,16 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do assert metric_value(pattern) == previous_value + 1 end + test "global success" do + pattern = ~r/realtime_global_rpc_count{mechanism=\"erpc\",success="true"}\s(?\d+)/ + # Enough time for the poll rate to be triggered at least once + Process.sleep(200) + previous_value = metric_value(pattern) + assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: "123") + Process.sleep(200) + assert metric_value(pattern) == previous_value + 1 + end + test "failure" do pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/ # Enough time for the poll rate to be triggered at least once @@ -47,6 +57,16 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do assert metric_value(pattern) == previous_value + 1 end + test "global failure" do + pattern = ~r/realtime_global_rpc_count{mechanism=\"erpc\",success="false"}\s(?\d+)/ + # Enough time for the poll rate to be triggered at least once + Process.sleep(200) + previous_value = metric_value(pattern) + assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: "123") + Process.sleep(200) + assert metric_value(pattern) == previous_value + 1 + end + test "exception" do pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/ # Enough time for the poll rate to be triggered at least once @@ -59,6 +79,19 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do Process.sleep(200) assert metric_value(pattern) == previous_value + 1 end + + test "global exception" do + pattern = ~r/realtime_global_rpc_count{mechanism=\"erpc\",success="false"}\s(?\d+)/ + # Enough time for the poll rate to be triggered at least once + Process.sleep(200) + previous_value = metric_value(pattern) + + assert {:error, :rpc_error, %RuntimeError{message: "runtime error"}} = + Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: "123") + + Process.sleep(200) + assert metric_value(pattern) == previous_value + 1 + end end test "event_metrics rpc" do From 16bd44d17a9dda973eec1f0a5b0198e9d565ce15 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 6 Oct 2025 09:40:25 +1300 Subject: [PATCH 032/123] feat: presence payload size (#1559) * Also tweak buckets to account all the way to 3000KB * Start tagging the payload size metrics with message_type. message_type can be presence, broadcast or postgres_changes --- .../postgres_cdc_rls/replication_poller.ex | 2 +- .../monitoring/prom_ex/plugins/tenant.ex | 7 +-- lib/realtime/tenants/batch_broadcast.ex | 9 +++- .../realtime_channel/broadcast_handler.ex | 11 ++++- .../realtime_channel/presence_handler.ex | 1 + lib/realtime_web/tenant_broadcaster.ex | 32 +++++++----- mix.exs | 2 +- .../extensions/cdc_rls/cdc_rls_test.exs | 18 +++++++ .../prom_ex/plugins/tenant_test.exs | 12 ++--- .../presence_handler_test.exs | 31 +++++++++++- .../controllers/broadcast_controller_test.exs | 22 ++++----- test/realtime_web/tenant_broadcaster_test.exs | 49 ++++++++++++++++--- 12 files changed, 149 insertions(+), 47 deletions(-) diff --git a/lib/extensions/postgres_cdc_rls/replication_poller.ex b/lib/extensions/postgres_cdc_rls/replication_poller.ex index 65f4a33f1..85466ebe9 100644 --- a/lib/extensions/postgres_cdc_rls/replication_poller.ex +++ b/lib/extensions/postgres_cdc_rls/replication_poller.ex @@ -183,7 +183,7 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do change <- columns |> Enum.zip(row) |> generate_record() |> List.wrap() do topic = "realtime:postgres:" <> tenant_id - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher) + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher, :postgres_changes) end {:ok, rows_count} diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex index bf9d850ee..a3019a68a 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex @@ -36,10 +36,10 @@ defmodule Realtime.PromEx.Plugins.Tenant do event_name: [:realtime, :tenants, :payload, :size], measurement: :size, description: "Tenant payload size", - tags: [:tenant], + tags: [:tenant, :message_type], unit: :byte, reporter_options: [ - buckets: [100, 250, 500, 1000, 2000, 3000, 5000, 10_000, 25_000] + buckets: [250, 500, 1000, 3000, 5000, 10_000, 25_000, 100_000, 500_000, 1_000_000, 3_000_000] ] ), distribution( @@ -47,9 +47,10 @@ defmodule Realtime.PromEx.Plugins.Tenant do event_name: [:realtime, :tenants, :payload, :size], measurement: :size, description: "Payload size", + tags: [:message_type], unit: :byte, reporter_options: [ - buckets: [100, 250, 500, 1000, 2000, 3000, 5000, 10_000, 25_000] + buckets: [250, 500, 1000, 3000, 5000, 10_000, 25_000, 100_000, 500_000, 1_000_000, 3_000_000] ] ) ] diff --git a/lib/realtime/tenants/batch_broadcast.ex b/lib/realtime/tenants/batch_broadcast.ex index 98427621b..9e4ed4c3c 100644 --- a/lib/realtime/tenants/batch_broadcast.ex +++ b/lib/realtime/tenants/batch_broadcast.ex @@ -129,7 +129,14 @@ defmodule Realtime.Tenants.BatchBroadcast do broadcast = %Phoenix.Socket.Broadcast{topic: message.topic, event: @event_type, payload: payload} GenCounter.add(events_per_second_rate.id) - TenantBroadcaster.pubsub_broadcast(tenant.external_id, tenant_topic, broadcast, RealtimeChannel.MessageDispatcher) + + TenantBroadcaster.pubsub_broadcast( + tenant.external_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) end defp permissions_for_message(_, nil, _), do: nil diff --git a/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex b/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex index f8e736c2e..036ad9159 100644 --- a/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/broadcast_handler.ex @@ -76,14 +76,21 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandler do broadcast = %Phoenix.Socket.Broadcast{topic: tenant_topic, event: @event_type, payload: payload} if self_broadcast do - TenantBroadcaster.pubsub_broadcast(tenant_id, tenant_topic, broadcast, RealtimeChannel.MessageDispatcher) + TenantBroadcaster.pubsub_broadcast( + tenant_id, + tenant_topic, + broadcast, + RealtimeChannel.MessageDispatcher, + :broadcast + ) else TenantBroadcaster.pubsub_broadcast_from( tenant_id, self(), tenant_topic, broadcast, - RealtimeChannel.MessageDispatcher + RealtimeChannel.MessageDispatcher, + :broadcast ) end end diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 1af26c528..ec16c7b16 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -109,6 +109,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do %{assigns: %{presence_key: presence_key, tenant_topic: tenant_topic}} = socket payload = Map.get(payload, "payload", %{}) + RealtimeWeb.TenantBroadcaster.collect_payload_size(socket.assigns.tenant, payload, :presence) with :ok <- limit_presence_event(socket), {:ok, _} <- Presence.track(self(), tenant_topic, presence_key, payload) do diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex index da02df79e..f8b739a0b 100644 --- a/lib/realtime_web/tenant_broadcaster.ex +++ b/lib/realtime_web/tenant_broadcaster.ex @@ -5,9 +5,12 @@ defmodule RealtimeWeb.TenantBroadcaster do alias Phoenix.PubSub - @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher()) :: :ok - def pubsub_broadcast(tenant_id, topic, message, dispatcher) do - collect_payload_size(tenant_id, message) + @type message_type :: :broadcast | :presence | :postgres_changes + + @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher(), message_type) :: + :ok + def pubsub_broadcast(tenant_id, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) if pubsub_adapter() == :gen_rpc do PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher) @@ -23,11 +26,12 @@ defmodule RealtimeWeb.TenantBroadcaster do from :: pid, PubSub.topic(), PubSub.message(), - PubSub.dispatcher() + PubSub.dispatcher(), + message_type ) :: :ok - def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher) do - collect_payload_size(tenant_id, message) + def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) if pubsub_adapter() == :gen_rpc do PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher) @@ -45,16 +49,18 @@ defmodule RealtimeWeb.TenantBroadcaster do @payload_size_event [:realtime, :tenants, :payload, :size] - defp collect_payload_size(tenant_id, payload) when is_struct(payload) do + @spec collect_payload_size(tenant_id :: String.t(), payload :: term, message_type :: message_type) :: :ok + def collect_payload_size(tenant_id, payload, message_type) when is_struct(payload) do # Extracting from struct so the __struct__ bit is not calculated as part of the payload - collect_payload_size(tenant_id, Map.from_struct(payload)) + collect_payload_size(tenant_id, Map.from_struct(payload), message_type) end - defp collect_payload_size(tenant_id, payload) do - :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{tenant: tenant_id}) + def collect_payload_size(tenant_id, payload, message_type) do + :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{ + tenant: tenant_id, + message_type: message_type + }) end - defp pubsub_adapter do - Application.fetch_env!(:realtime, :pubsub_adapter) - end + defp pubsub_adapter, do: Application.fetch_env!(:realtime, :pubsub_adapter) end diff --git a/mix.exs b/mix.exs index bc5fccc8b..cb6633281 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.51.15", + version: "2.52.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index 5f341c134..d12c0ba73 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -236,6 +236,15 @@ defmodule Realtime.Extensions.CdcRlsTest do RateCounter.stop(tenant.external_id) + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :telemetry.attach( + __MODULE__, + [:realtime, :tenants, :payload, :size], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + %{tenant: tenant, conn: conn} end @@ -317,6 +326,13 @@ defmodule Realtime.Extensions.CdcRlsTest do assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate) assert 1 in bucket + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 341}, + %{tenant: "dev_tenant", message_type: :postgres_changes} + } end @aux_mod (quote do @@ -414,4 +430,6 @@ defmodule Realtime.Extensions.CdcRlsTest do :erpc.call(node, PostgresCdcRls, :handle_stop, [tenant.external_id, 10_000]) end end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs index cfa727cfa..77c1dc7cf 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs @@ -262,18 +262,18 @@ defmodule Realtime.PromEx.Plugins.TenantTest do external_id = context.tenant.external_id pattern = - ~r/realtime_tenants_payload_size_count{tenant="#{external_id}"}\s(?\d+)/ + ~r/realtime_tenants_payload_size_count{message_type=\"presence\",tenant="#{external_id}"}\s(?\d+)/ metric_value = metric_value(pattern) message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub) + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :presence) Process.sleep(200) assert metric_value(pattern) == metric_value + 1 bucket_pattern = - ~r/realtime_tenants_payload_size_bucket{tenant="#{external_id}",le="100"}\s(?\d+)/ + ~r/realtime_tenants_payload_size_bucket{message_type=\"presence\",tenant="#{external_id}",le="250"}\s(?\d+)/ assert metric_value(bucket_pattern) > 0 end @@ -281,17 +281,17 @@ defmodule Realtime.PromEx.Plugins.TenantTest do test "global metric payload size", context do external_id = context.tenant.external_id - pattern = ~r/realtime_payload_size_count\s(?\d+)/ + pattern = ~r/realtime_payload_size_count{message_type=\"broadcast\"}\s(?\d+)/ metric_value = metric_value(pattern) message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub) + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :broadcast) Process.sleep(200) assert metric_value(pattern) == metric_value + 1 - bucket_pattern = ~r/realtime_payload_size_bucket{le="100"}\s(?\d+)/ + bucket_pattern = ~r/realtime_payload_size_bucket{message_type=\"broadcast\",le="250"}\s(?\d+)/ assert metric_value(bucket_pattern) > 0 end diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index 4891e4187..219f13e55 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -100,25 +100,41 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do end describe "handle/3" do + setup do + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + :telemetry.attach( + __MODULE__, + [:realtime, :tenants, :payload, :size], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + end + test "with true policy and is private, user can track their presence and changes", %{ tenant: tenant, topic: topic, db_conn: db_conn } do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies) - PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) + PresenceHandler.handle(%{"event" => "track", "payload" => %{"A" => "b", "c" => "b"}}, db_conn, socket) topic = socket.assigns.tenant_topic assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 30}, + %{tenant: ^external_id, message_type: :presence}} end test "when tracking already existing user, metadata updated", %{tenant: tenant, topic: topic, db_conn: db_conn} do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: true, write: true}} socket = socket_fixture(tenant, topic, key, policies: policies) @@ -134,10 +150,18 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6}, + %{tenant: ^external_id, message_type: :presence}} + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 55}, + %{tenant: ^external_id, message_type: :presence}} + refute_receive :_ end test "with false policy and is public, user can track their presence and changes", %{tenant: tenant, topic: topic} do + external_id = tenant.external_id key = random_string() policies = %Policies{presence: %PresencePolicies{read: false, write: false}} socket = socket_fixture(tenant, topic, key, policies: policies, private?: false) @@ -147,6 +171,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do topic = socket.assigns.tenant_topic assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}} assert Map.has_key?(joins, key) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6}, + %{tenant: ^external_id, message_type: :presence}} end test "user can untrack when they want", %{tenant: tenant, topic: topic, db_conn: db_conn} do @@ -518,4 +545,6 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do } } end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs index d42466722..209c405de 100644 --- a/test/realtime_web/controllers/broadcast_controller_test.exs +++ b/test/realtime_web/controllers/broadcast_controller_test.exs @@ -272,7 +272,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end) messages_to_send = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -294,7 +294,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(messages_to_send, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -310,7 +310,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.any?(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) end) @@ -326,7 +326,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _, _ -> :ok end) channels = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -358,7 +358,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(channels, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -374,7 +374,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.count(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 end) @@ -393,7 +393,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do open_channel_topic = Tenants.tenant_topic(tenant, "open_channel", true) assert Enum.count(broadcast_calls, fn - [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 @@ -408,7 +408,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } do request_events_key = Tenants.requests_per_second_key(tenant) broadcast_events_key = Tenants.events_per_second_key(tenant) - expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end) + expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end) messages_to_send = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) @@ -433,7 +433,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages}) - broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4) + broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5) Enum.each(messages_to_send, fn %{topic: topic} -> broadcast_topic = Tenants.tenant_topic(tenant, topic, false) @@ -449,7 +449,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do } assert Enum.count(broadcast_calls, fn - [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true + [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true _ -> false end) == 1 end) @@ -462,7 +462,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do @tag role: "anon" test "user without permission won't broadcast", %{conn: conn, db_conn: db_conn, tenant: tenant} do request_events_key = Tenants.requests_per_second_key(tenant) - reject(&TenantBroadcaster.pubsub_broadcast/4) + reject(&TenantBroadcaster.pubsub_broadcast/5) messages = Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end) diff --git a/test/realtime_web/tenant_broadcaster_test.exs b/test/realtime_web/tenant_broadcaster_test.exs index ddda381a1..bc3b4f90a 100644 --- a/test/realtime_web/tenant_broadcaster_test.exs +++ b/test/realtime_web/tenant_broadcaster_test.exs @@ -60,7 +60,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do test "pubsub_broadcast", %{node: node} do message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -71,13 +71,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 114}, - %{tenant: "realtime-dev"} + %{tenant: "realtime-dev", message_type: :broadcast} } end test "pubsub_broadcast list payload", %{node: node} do message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -88,13 +88,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 130}, - %{tenant: "realtime-dev"} + %{tenant: "realtime-dev", message_type: :broadcast} } end test "pubsub_broadcast string payload", %{node: node} do message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} - TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) assert_receive ^message @@ -105,7 +105,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 119}, - %{tenant: "realtime-dev"} + %{tenant: "realtime-dev", message_type: :broadcast} } end end @@ -131,7 +131,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} - TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub) + TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub, :broadcast) assert_receive {:other_process, ^message} @@ -142,7 +142,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do :telemetry, [:realtime, :tenants, :payload, :size], %{size: 114}, - %{tenant: "realtime-dev"} + %{tenant: "realtime-dev", message_type: :broadcast} } # This process does not receive the message @@ -151,5 +151,38 @@ defmodule RealtimeWeb.TenantBroadcasterTest do end end + describe "collect_payload_size/3" do + @describetag pubsub_adapter: :gen_rpc + + test "emit telemetry for struct" do + TenantBroadcaster.collect_payload_size( + "realtime-dev", + %Phoenix.Socket.Broadcast{event: "broadcast", payload: %{"a" => "b"}}, + :broadcast + ) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 65}, + %{tenant: "realtime-dev", message_type: :broadcast}} + end + + test "emit telemetry for map" do + TenantBroadcaster.collect_payload_size( + "realtime-dev", + %{event: "broadcast", payload: %{"a" => "b"}}, + :postgres_changes + ) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 53}, + %{tenant: "realtime-dev", message_type: :postgres_changes}} + end + + test "emit telemetry for non-map" do + TenantBroadcaster.collect_payload_size("realtime-dev", "some blob", :presence) + + assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 15}, + %{tenant: "realtime-dev", message_type: :presence}} + end + end + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end From 07de6656527b69b11aa08dfed46ff55fdb635f51 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 6 Oct 2025 21:06:02 +1300 Subject: [PATCH 033/123] fix: use GenRpc for Realtime.Latency pings (#1560) --- Makefile | 4 ++-- lib/realtime/monitoring/latency.ex | 8 ++++---- mix.exs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index fd7f0f7fd..1259a1335 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,10 @@ PORT ?= 4000 # Common commands dev: ## Start a dev server - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=$(PORT) MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev REGION=fra DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5369 GEN_RPC_TCP_CLIENT_PORT=5469 iex --name $(NODE_NAME)@127.0.0.1 --cookie cookie -S mix phx.server + ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=$(PORT) MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev REGION=us-east-1 DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5369 GEN_RPC_TCP_CLIENT_PORT=5469 iex --name $(NODE_NAME)@127.0.0.1 --cookie cookie -S mix phx.server dev.orange: ## Start another dev server (orange) on port 4001 - ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=4001 MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5469 GEN_RPC_TCP_CLIENT_PORT=5369 iex --name orange@127.0.0.1 --cookie cookie -S mix phx.server + ELIXIR_ERL_OPTIONS="+hmax 1000000000" SLOT_NAME_SUFFIX=some_sha PORT=4001 MIX_ENV=dev SECURE_CHANNELS=true API_JWT_SECRET=dev METRICS_JWT_SECRET=dev REGION=eu-west-1 DB_ENC_KEY="1234567890123456" CLUSTER_STRATEGIES=$(CLUSTER_STRATEGIES) ERL_AFLAGS="-kernel shell_history enabled" GEN_RPC_TCP_SERVER_PORT=5469 GEN_RPC_TCP_CLIENT_PORT=5369 iex --name orange@127.0.0.1 --cookie cookie -S mix phx.server seed: ## Seed the database DB_ENC_KEY="1234567890123456" FLY_ALLOC_ID=123e4567-e89b-12d3-a456-426614174000 mix run priv/repo/dev_seeds.exs diff --git a/lib/realtime/monitoring/latency.ex b/lib/realtime/monitoring/latency.ex index 52c46adb4..d9ddd0d9a 100644 --- a/lib/realtime/monitoring/latency.ex +++ b/lib/realtime/monitoring/latency.ex @@ -7,7 +7,7 @@ defmodule Realtime.Latency do use Realtime.Logs alias Realtime.Nodes - alias Realtime.Rpc + alias Realtime.GenRpc defmodule Payload do @moduledoc false @@ -33,7 +33,7 @@ defmodule Realtime.Latency do } end - @every 5_000 + @every 15_000 def start_link(args) do GenServer.start_link(__MODULE__, args, name: __MODULE__) end @@ -76,7 +76,7 @@ defmodule Realtime.Latency do Task.Supervisor.async(Realtime.TaskSupervisor, fn -> {latency, response} = :timer.tc(fn -> - Rpc.call(n, __MODULE__, :pong, [pong_timeout], timeout: timer_timeout) + GenRpc.call(n, __MODULE__, :pong, [pong_timeout], timeout: timer_timeout) end) latency_ms = latency / 1_000 @@ -85,7 +85,7 @@ defmodule Realtime.Latency do from_node = Nodes.short_node_id_from_name(Node.self()) case response do - {:badrpc, reason} -> + {:error, :rpc_error, reason} -> log_error( "RealtimeNodeDisconnected", "Unable to connect to #{short_name} from #{region}: #{reason}" diff --git a/mix.exs b/mix.exs index cb6633281..72ae7f630 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.52.0", + version: "2.52.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From ecac071eded1a3ce7f3f4a2bf24dfff535c3f802 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 7 Oct 2025 12:21:22 +1300 Subject: [PATCH 034/123] Fastlane for phoenix presence_diff (#1558) It uses a fork of Phoenix for time being * fix: count presence_diff events on MessageDispatcher * fix: remove traces from console during development --- config/dev.exs | 4 +- lib/realtime_web/channels/presence.ex | 1 + lib/realtime_web/channels/realtime_channel.ex | 17 ----- .../realtime_channel/message_dispatcher.ex | 70 ++++++++++-------- mix.exs | 4 +- mix.lock | 2 +- .../message_dispatcher_test.exs | 71 ++++++++++++++++--- .../channels/realtime_channel_test.exs | 15 +--- 8 files changed, 113 insertions(+), 71 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index a438f8ea4..0eff300d8 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -97,6 +97,8 @@ config :phoenix, :plug_init_mode, :runtime # Disable caching to ensure the rendered spec is refreshed config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache -config :opentelemetry, traces_exporter: {:otel_exporter_stdout, []} +# Disabled but can print to stdout with: +# config :opentelemetry, traces_exporter: {:otel_exporter_stdout, []} +config :opentelemetry, traces_exporter: :none config :mix_test_watch, clear: true diff --git a/lib/realtime_web/channels/presence.ex b/lib/realtime_web/channels/presence.ex index f4d378b92..9e173febe 100644 --- a/lib/realtime_web/channels/presence.ex +++ b/lib/realtime_web/channels/presence.ex @@ -8,5 +8,6 @@ defmodule RealtimeWeb.Presence do use Phoenix.Presence, otp_app: :realtime, pubsub_server: Realtime.PubSub, + dispatcher: RealtimeWeb.RealtimeChannel.MessageDispatcher, pool_size: 10 end diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 91a417c21..104d9a077 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -18,7 +18,6 @@ defmodule RealtimeWeb.RealtimeChannel do alias Realtime.Tenants.Authorization alias Realtime.Tenants.Authorization.Policies alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies - alias Realtime.Tenants.Authorization.Policies.PresencePolicies alias Realtime.Tenants.Connect alias RealtimeWeb.Channels.Payloads.Join @@ -259,27 +258,11 @@ defmodule RealtimeWeb.RealtimeChannel do {:noreply, assign(socket, %{pg_sub_ref: pg_sub_ref})} end - def handle_info( - %{event: "presence_diff"}, - %{assigns: %{policies: %Policies{presence: %PresencePolicies{read: false}}}} = socket - ) do - Logger.warning("Presence message ignored") - {:noreply, socket} - end - def handle_info(_msg, %{assigns: %{policies: %Policies{broadcast: %BroadcastPolicies{read: false}}}} = socket) do Logger.warning("Broadcast message ignored") {:noreply, socket} end - def handle_info(%{event: "presence_diff", payload: payload} = msg, socket) do - %{presence_rate_counter: presence_rate_counter} = socket.assigns - GenCounter.add(presence_rate_counter.id) - maybe_log_info(socket, msg) - push(socket, "presence_diff", payload) - {:noreply, socket} - end - def handle_info(%{event: type, payload: payload} = msg, socket) do count(socket) maybe_log_info(socket, msg) diff --git a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex index 32e1528f3..6604eb2bd 100644 --- a/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex +++ b/lib/realtime_web/channels/realtime_channel/message_dispatcher.ex @@ -5,14 +5,8 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do require Logger - def fastlane_metadata(fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids \\ MapSet.new()) - - def fastlane_metadata(fastlane_pid, serializer, topic, :info, tenant_id, replayed_message_ids) do - {:rc_fastlane, fastlane_pid, serializer, topic, {:log, tenant_id}, replayed_message_ids} - end - - def fastlane_metadata(fastlane_pid, serializer, topic, _log_level, _tenant_id, replayed_message_ids) do - {:rc_fastlane, fastlane_pid, serializer, topic, replayed_message_ids} + def fastlane_metadata(fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids \\ MapSet.new()) do + {:rc_fastlane, fastlane_pid, serializer, topic, log_level, tenant_id, replayed_message_ids} end @doc """ @@ -20,48 +14,58 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do It also sends an :update_rate_counter to the subscriber and it can conditionally log """ @spec dispatch(list, pid, Phoenix.Socket.Broadcast.t()) :: :ok - def dispatch(subscribers, from, %Phoenix.Socket.Broadcast{} = msg) do + def dispatch(subscribers, from, %Phoenix.Socket.Broadcast{event: event} = msg) do # fastlane_pid is the actual socket transport pid # This reduce caches the serialization and bypasses the channel process going straight to the # transport process message_id = message_id(msg.payload) - # Credo doesn't like that we don't use the result aggregation - _ = - Enum.reduce(subscribers, %{}, fn - {pid, _}, cache when pid == from -> - cache + {_cache, count} = + Enum.reduce(subscribers, {%{}, 0}, fn + {pid, _}, {cache, count} when pid == from -> + {cache, count} - {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, replayed_message_ids}}, cache -> + {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, log_level, tenant_id, replayed_message_ids}}, + {cache, count} -> if already_replayed?(message_id, replayed_message_ids) do # skip already replayed message - cache + {cache, count} else - send(pid, :update_rate_counter) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) - end + if event != "presence_diff", do: send(pid, :update_rate_counter) - {pid, {:rc_fastlane, fastlane_pid, serializer, join_topic, {:log, tenant_id}, replayed_message_ids}}, cache -> - if already_replayed?(message_id, replayed_message_ids) do - # skip already replayed message - cache - else - send(pid, :update_rate_counter) - log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" - Logger.info(log, external_id: tenant_id, project: tenant_id) + maybe_log(log_level, join_topic, msg, tenant_id) - do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + cache = do_dispatch(msg, fastlane_pid, serializer, join_topic, cache) + {cache, count + 1} end - {pid, _}, cache -> + {pid, _}, {cache, count} -> send(pid, msg) - cache + {cache, count} end) + tenant_id = tenant_id(subscribers) + increment_presence_counter(tenant_id, event, count) + :ok end + defp increment_presence_counter(tenant_id, "presence_diff", count) when is_binary(tenant_id) do + tenant_id + |> Realtime.Tenants.presence_events_per_second_key() + |> Realtime.GenCounter.add(count) + end + + defp increment_presence_counter(_tenant_id, _event, _count), do: :ok + + defp maybe_log(:info, join_topic, msg, tenant_id) do + log = "Received message on #{join_topic} with payload: #{inspect(msg, pretty: true)}" + Logger.info(log, external_id: tenant_id, project: tenant_id) + end + + defp maybe_log(_level, _join_topic, _msg, _tenant_id), do: :ok + defp message_id(%{"meta" => %{"id" => id}}), do: id defp message_id(_), do: nil @@ -82,4 +86,10 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcher do Map.put(cache, serializer, encoded_msg) end end + + defp tenant_id([{_pid, {:rc_fastlane, _, _, _, _, tenant_id, _}} | _]) do + tenant_id + end + + defp tenant_id(_), do: nil end diff --git a/mix.exs b/mix.exs index 72ae7f630..d0e42bf11 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.52.1", + version: "2.53.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -53,7 +53,7 @@ defmodule Realtime.MixProject do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.0"}, + {:phoenix, override: true, github: "supabase/phoenix", branch: "feat/presence-custom-dispatcher-1.7.19"}, {:phoenix_ecto, "~> 4.4.0"}, {:ecto_sql, "~> 3.11"}, {:ecto_psql_extras, "~> 0.8"}, diff --git a/mix.lock b/mix.lock index c5fce6022..ba6f47328 100644 --- a/mix.lock +++ b/mix.lock @@ -66,7 +66,7 @@ "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, - "phoenix": {:hex, :phoenix, "1.7.19", "36617efe5afbd821099a8b994ff4618a340a5bfb25531a1802c4d4c634017a57", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ba4dc14458278773f905f8ae6c2ec743d52c3a35b6b353733f64f02dfe096cd6"}, + "phoenix": {:git, "https://github.com/supabase/phoenix.git", "7b884cc0cc1a49ad2bc272acda2e622b3e11c139", [branch: "feat/presence-custom-dispatcher-1.7.19"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs index 44ce83b99..53be2e51f 100644 --- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs +++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs @@ -16,12 +16,24 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do describe "fastlane_metadata/5" do test "info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :info, "tenant_id") == - {:rc_fastlane, self(), Serializer, "realtime:topic", {:log, "tenant_id"}, MapSet.new()} + {:rc_fastlane, self(), Serializer, "realtime:topic", :info, "tenant_id", MapSet.new()} end test "non-info level" do assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :warning, "tenant_id") == - {:rc_fastlane, self(), Serializer, "realtime:topic", MapSet.new()} + {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new()} + end + + test "replayed message ids" do + assert MessageDispatcher.fastlane_metadata( + self(), + Serializer, + "realtime:topic", + :warning, + "tenant_id", + MapSet.new([1]) + ) == + {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new([1])} end end @@ -50,8 +62,8 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do from_pid = :erlang.list_to_pid(~c'<0.2.1>') subscribers = [ - {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, MapSet.new()}}, - {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", MapSet.new()}} + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}} ] msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}} @@ -74,6 +86,48 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do refute_receive _any end + test "dispatches 'presence_diff' messages to fastlane subscribers" do + parent = self() + + subscriber_pid = + spawn(fn -> + loop = fn loop -> + receive do + msg -> + send(parent, {:subscriber, msg}) + loop.(loop) + end + end + + loop.(loop) + end) + + from_pid = :erlang.list_to_pid(~c'<0.2.1>') + + subscribers = [ + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant456", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant456", MapSet.new()}} + ] + + msg = %Broadcast{topic: "some:other:topic", event: "presence_diff", payload: %{data: "test"}} + + log = + capture_log(fn -> + assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok + end) + + assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}" + + assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}} + assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}} + + assert Agent.get(TestSerializer, & &1) == 1 + + assert Realtime.GenCounter.get(Realtime.Tenants.presence_events_per_second_key("tenant456")) == 2 + + refute_receive _any + end + test "does not dispatch messages to fastlane subscribers if they already replayed it" do parent = self() @@ -95,8 +149,9 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do subscribers = [ {subscriber_pid, - {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, replaeyd_message_ids}}, - {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", replaeyd_message_ids}} + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", replaeyd_message_ids}}, + {subscriber_pid, + {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", replaeyd_message_ids}} ] msg = %Broadcast{ @@ -131,8 +186,8 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do from_pid = :erlang.list_to_pid(~c'<0.2.1>') subscribers = [ - {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}, MapSet.new()}}, - {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", MapSet.new()}} + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}}, + {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}} ] msg = %Broadcast{topic: "some:other:topic", event: "event", payload: "not a map"} diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index ae6c1734a..8022d6ebd 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -239,23 +239,14 @@ defmodule RealtimeWeb.RealtimeChannelTest do end describe "presence" do - test "events are counted", %{tenant: tenant} do + test "presence state event is counted", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{}) - presence_diff = %Socket.Broadcast{event: "presence_diff", payload: %{joins: %{}, leaves: %{}}} - send(socket.channel_pid, presence_diff) - assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}} - assert_receive %Socket.Message{ - topic: "realtime:test", - event: "presence_diff", - payload: %{joins: %{}, leaves: %{}} - } - tenant_id = tenant.external_id # Wait for RateCounter to tick @@ -264,8 +255,8 @@ defmodule RealtimeWeb.RealtimeChannelTest do assert {:ok, %RateCounter{id: {:channel, :presence_events, ^tenant_id}, bucket: bucket}} = RateCounter.get(socket.assigns.presence_rate_counter) - # presence_state + presence_diff - assert 2 in bucket + # presence_state + assert Enum.sum(bucket) == 1 end end From 058be583242024d2cdf110a3b017ba10c67c67a5 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 8 Oct 2025 11:42:17 +1300 Subject: [PATCH 035/123] fix: limit db events (#1562) --- .github/workflows/tests.yml | 2 + .../postgres_cdc_rls/replication_poller.ex | 39 ++++++--- lib/realtime/tenants.ex | 25 ++++++ mix.exs | 2 +- test/integration/rt_channel_test.exs | 5 +- .../extensions/cdc_rls/cdc_rls_test.exs | 81 ++++++++++++++++++- 6 files changed, 139 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9c2a73fa..5d3f686dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,8 @@ jobs: restore-keys: | ${{ runner.os }}-mix- + - name: Pull postgres image quietly in background (used by test/support/containers.ex) + run: docker pull supabase/postgres:15.8.1.040 > /dev/null 2>&1 & - name: Install dependencies run: mix deps.get - name: Set up Postgres diff --git a/lib/extensions/postgres_cdc_rls/replication_poller.ex b/lib/extensions/postgres_cdc_rls/replication_poller.ex index 85466ebe9..34697572c 100644 --- a/lib/extensions/postgres_cdc_rls/replication_poller.ex +++ b/lib/extensions/postgres_cdc_rls/replication_poller.ex @@ -18,6 +18,8 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do alias Realtime.Adapters.Changes.NewRecord alias Realtime.Adapters.Changes.UpdatedRecord alias Realtime.Database + alias Realtime.RateCounter + alias Realtime.Tenants def start_link(opts), do: GenServer.start_link(__MODULE__, opts) @@ -26,6 +28,12 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do tenant_id = args["id"] Logger.metadata(external_id: tenant_id, project: tenant_id) + %Realtime.Api.Tenant{} = Tenants.Cache.get_tenant_by_external_id(tenant_id) + + rate_counter_args = Tenants.db_events_per_second_rate(tenant_id, 4000) + + RateCounter.new(rate_counter_args) + state = %{ backoff: Backoff.new(backoff_min: 100, backoff_max: 5_000, backoff_type: :rand_exp), db_host: args["db_host"], @@ -41,7 +49,8 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do retry_ref: nil, retry_count: 0, slot_name: args["slot_name"] <> slot_name_suffix(), - tenant_id: tenant_id + tenant_id: tenant_id, + rate_counter_args: rate_counter_args } {:ok, _} = Registry.register(__MODULE__.Registry, tenant_id, %{}) @@ -74,7 +83,8 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do max_record_bytes: max_record_bytes, max_changes: max_changes, conn: conn, - tenant_id: tenant_id + tenant_id: tenant_id, + rate_counter_args: rate_counter_args } = state ) do cancel_timer(poll_ref) @@ -84,7 +94,7 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do {time, list_changes} = :timer.tc(Replications, :list_changes, args) record_list_changes_telemetry(time, tenant_id) - case handle_list_changes_result(list_changes, tenant_id) do + case handle_list_changes_result(list_changes, tenant_id, rate_counter_args) do {:ok, row_count} -> Backoff.reset(backoff) @@ -177,20 +187,29 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do rows: [_ | _] = rows, num_rows: rows_count }}, - tenant_id + tenant_id, + rate_counter_args ) do - for row <- rows, - change <- columns |> Enum.zip(row) |> generate_record() |> List.wrap() do - topic = "realtime:postgres:" <> tenant_id + case RateCounter.get(rate_counter_args) do + {:ok, %{limit: %{triggered: true}}} -> + :ok - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher, :postgres_changes) + _ -> + Realtime.GenCounter.add(rate_counter_args.id, rows_count) + + for row <- rows, + change <- columns |> Enum.zip(row) |> generate_record() |> List.wrap() do + topic = "realtime:postgres:" <> tenant_id + + RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher, :postgres_changes) + end end {:ok, rows_count} end - defp handle_list_changes_result({:ok, _}, _), do: {:ok, 0} - defp handle_list_changes_result({:error, reason}, _), do: {:error, reason} + defp handle_list_changes_result({:ok, _}, _, _), do: {:ok, 0} + defp handle_list_changes_result({:error, reason}, _, _), do: {:error, reason} def generate_record([ {"wal", diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index efd2397ac..d792c1ca5 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -247,6 +247,31 @@ defmodule Realtime.Tenants do %RateCounter.Args{id: db_events_per_second_key(tenant_id), opts: opts} end + @doc "RateCounter arguments for counting database events per second with a limit." + @spec db_events_per_second_rate(String.t(), non_neg_integer) :: RateCounter.Args.t() + def db_events_per_second_rate(tenant_id, max_events_per_second) when is_binary(tenant_id) do + opts = [ + telemetry: %{ + event_name: [:channel, :db_events], + measurements: %{}, + metadata: %{tenant: tenant_id} + }, + limit: [ + value: max_events_per_second, + measurement: :avg, + log: true, + log_fn: fn -> + Logger.error("MessagePerSecondRateLimitReached: Too many postgres changes messages per second", + external_id: tenant_id, + project: tenant_id + ) + end + ] + ] + + %RateCounter.Args{id: db_events_per_second_key(tenant_id), opts: opts} + end + @doc """ The GenCounter key to use when counting events for RealtimeChannel events. iex> Realtime.Tenants.db_events_per_second_key("tenant_id") diff --git a/mix.exs b/mix.exs index d0e42bf11..65245c31d 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.53.0", + version: "2.53.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index 23b1a3a7f..f45b4aff5 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -1159,7 +1159,7 @@ defmodule Realtime.Integration.RtChannelTest do } end) - assert log =~ "ChannelShutdown: Token has expired 1000 seconds ago" + assert log =~ "ChannelShutdown: Token has expired" end test "ChannelShutdown include sub if available in jwt claims", %{tenant: tenant, topic: topic} do @@ -2240,7 +2240,8 @@ defmodule Realtime.Integration.RtChannelTest do # 0 events as no broadcast used assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id) assert 2 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id) - assert 10 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) + # 5 + 5 + 5 (5 for each websocket and 5 while publishing) + assert 15 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id) assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id) end diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index d12c0ba73..0c0df0e19 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -235,6 +235,7 @@ defmodule Realtime.Extensions.CdcRlsTest do end) RateCounter.stop(tenant.external_id) + on_exit(fn -> RateCounter.stop(tenant.external_id) end) on_exit(fn -> :telemetry.detach(__MODULE__) end) @@ -324,8 +325,11 @@ defmodule Realtime.Extensions.CdcRlsTest do rate = Realtime.Tenants.db_events_per_second_rate(tenant) - assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate) - assert 1 in bucket + assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = + RateCounter.get(rate) + + # 1 from ReplicationPoller and 1 from MessageDispatcher + assert Enum.sum(bucket) == 2 assert_receive { :telemetry, @@ -335,6 +339,79 @@ defmodule Realtime.Extensions.CdcRlsTest do } end + test "rate limit works", %{tenant: tenant, conn: conn} do + on_exit(fn -> PostgresCdcRls.handle_stop(tenant.external_id, 10_000) end) + + %Tenant{extensions: extensions, external_id: external_id} = tenant + postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions) + args = Map.put(postgres_extension, "id", external_id) + + pg_change_params = [ + %{ + id: UUID.uuid1(), + params: %{"event" => "*", "schema" => "public"}, + channel_pid: self(), + claims: %{ + "exp" => System.system_time(:second) + 100_000, + "iat" => 0, + "ref" => "127.0.0.1", + "role" => "anon" + } + } + ] + + ids = + Enum.map(pg_change_params, fn %{id: id, params: params} -> + {UUID.string_to_binary!(id), :erlang.phash2(params)} + end) + + topic = "realtime:test" + serializer = Phoenix.Socket.V1.JSONSerializer + + subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true} + metadata = [metadata: subscription_metadata] + :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata) + + # First time it will return nil + PostgresCdcRls.handle_connect(args) + # Wait for it to start + Process.sleep(3000) + {:ok, response} = PostgresCdcRls.handle_connect(args) + + # Now subscribe to the Postgres Changes + {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params) + assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + + log = + capture_log(fn -> + # increment artifically the counter to reach the limit + tenant.external_id + |> Realtime.Tenants.db_events_per_second_key() + |> Realtime.GenCounter.add(100_000_000) + + # Wait for RateCounter to update + Process.sleep(1500) + end) + + assert log =~ "MessagePerSecondRateLimitReached: Too many postgres changes messages per second" + + # Insert a record + %{rows: [[_id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", []) + + refute_receive {:socket_push, :text, _}, 5000 + + # Wait for RateCounter to update + Process.sleep(2000) + + rate = Realtime.Tenants.db_events_per_second_rate(tenant) + + assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket, limit: %{triggered: true}}} = + RateCounter.get(rate) + + # Nothing has changed + assert Enum.sum(bucket) == 100_000_000 + end + @aux_mod (quote do defmodule Subscriber do # Start CDC remotely From d5658ade5b6ffb347a1eabfd569ae92abf99d9cd Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 8 Oct 2025 21:35:16 +1300 Subject: [PATCH 036/123] chore: split tests and lint workflows (#1564) Also cache mix _build and deps --- .github/workflows/lint.yml | 78 +++++++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 40 ++++--------------- 2 files changed, 86 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..b27a4e9f3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,78 @@ +name: Lint +on: + pull_request: + paths: + - "lib/**" + - "test/**" + - "config/**" + - "priv/**" + - "assets/**" + - "rel/**" + - "mix.exs" + - "Dockerfile" + - "run.sh" + + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup elixir + id: beam + uses: erlef/setup-beam@v1 + with: + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.17.x # Define the elixir version [required] + - name: Cache Mix + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- + + - name: Install dependencies + run: mix deps.get + - name: Set up Postgres + run: docker compose -f docker-compose.dbs.yml up -d + - name: Run main database migrations + run: mix ecto.migrate --log-migrator-sql + - name: Run database tenant migrations + run: mix ecto.migrate --migrations-path lib/realtime/tenants/repo/migrations + - name: Run format check + run: mix format --check-formatted + - name: Credo checks + run: mix credo + - name: Run hex audit + run: mix hex.audit + - name: Run mix_audit + run: mix deps.audit + - name: Run sobelow + run: mix sobelow --config .sobelow-conf + - name: Retrieve PLT Cache + uses: actions/cache@v4 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' + run: | + mkdir -p priv/plts + mix dialyzer.build + - name: Run dialyzer + run: mix dialyzer + - name: Run dev seeds + run: DB_ENC_KEY="1234567890123456" mix ecto.setup diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d3f686dd..45d27634a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +env: + MIX_ENV: test + jobs: tests: name: Tests @@ -36,10 +39,12 @@ jobs: - name: Cache Mix uses: actions/cache@v4 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + path: | + deps + _build + key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }} restore-keys: | - ${{ runner.os }}-mix- + ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- - name: Pull postgres image quietly in background (used by test/support/containers.ex) run: docker pull supabase/postgres:15.8.1.040 > /dev/null 2>&1 & @@ -47,35 +52,6 @@ jobs: run: mix deps.get - name: Set up Postgres run: docker compose -f docker-compose.dbs.yml up -d - - name: Run main database migrations - run: mix ecto.migrate --log-migrator-sql - - name: Run database tenant migrations - run: mix ecto.migrate --migrations-path lib/realtime/tenants/repo/migrations - - name: Run format check - run: mix format --check-formatted - - name: Credo checks - run: mix credo - - name: Run hex audit - run: mix hex.audit - - name: Run mix_audit - run: mix deps.audit - - name: Run sobelow - run: mix sobelow --config .sobelow-conf - - name: Retrieve PLT Cache - uses: actions/cache@v4 - id: plt-cache - with: - path: priv/plts - key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} - - name: Create PLTs - if: steps.plt-cache.outputs.cache-hit != 'true' - run: | - mkdir -p priv/plts - mix dialyzer.build - - name: Run dialyzer - run: mix dialyzer - - name: Run dev seeds - run: DB_ENC_KEY="1234567890123456" mix ecto.setup - name: Start epmd run: epmd -daemon - name: Run tests From 8b621bd2749fc118b3183ce2bd81886a5d64bfe7 Mon Sep 17 00:00:00 2001 From: Chase Granberry Date: Wed, 8 Oct 2025 10:52:49 -0700 Subject: [PATCH 037/123] fix: use LiveView stream for status page (#1565) * fix: use LiveView stream for status page * fix: need full node name on localhost for tests * fix: cleanup * fix: add tests * fix: bump version * fix: cleanup syntax * fix: format --- lib/realtime/nodes.ex | 5 ++- lib/realtime_web/live/status_live/index.ex | 31 ++++++++++------- .../live/status_live/index.html.heex | 18 +++++----- mix.exs | 2 +- .../live/status_live/index_test.exs | 33 +++++++++++++++++++ 5 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 test/realtime_web/live/status_live/index_test.exs diff --git a/lib/realtime/nodes.ex b/lib/realtime/nodes.ex index ae237eb5f..34c9f3cfb 100644 --- a/lib/realtime/nodes.ex +++ b/lib/realtime/nodes.ex @@ -105,7 +105,7 @@ defmodule Realtime.Nodes do iex> node = :"pink@127.0.0.1" iex> Realtime.Helpers.short_node_id_from_name(node) - "127.0.0.1" + "pink@127.0.0.1" iex> node = :"pink@10.0.1.1" iex> Realtime.Helpers.short_node_id_from_name(node) @@ -124,6 +124,9 @@ defmodule Realtime.Nodes do [_, _, _, _, _, one, two, _] -> one <> two + ["127.0.0.1"] -> + Atom.to_string(name) + _other -> host end diff --git a/lib/realtime_web/live/status_live/index.ex b/lib/realtime_web/live/status_live/index.ex index 8a2d32054..f55eddfa5 100644 --- a/lib/realtime_web/live/status_live/index.ex +++ b/lib/realtime_web/live/status_live/index.ex @@ -3,11 +3,18 @@ defmodule RealtimeWeb.StatusLive.Index do alias Realtime.Latency.Payload alias Realtime.Nodes + alias RealtimeWeb.Endpoint @impl true def mount(_params, _session, socket) do - if connected?(socket), do: RealtimeWeb.Endpoint.subscribe("admin:cluster") - {:ok, assign(socket, pings: default_pings(), nodes: Enum.count(all_nodes()))} + if connected?(socket), do: Endpoint.subscribe("admin:cluster") + + socket = + socket + |> assign(nodes: Enum.count(all_nodes())) + |> stream(:pings, default_pings()) + + {:ok, socket} end @impl true @@ -17,17 +24,14 @@ defmodule RealtimeWeb.StatusLive.Index do @impl true def handle_info(%Phoenix.Socket.Broadcast{payload: %Payload{} = payload}, socket) do - pair = payload.from_node <> "_" <> payload.node - payload = %{pair => payload} - - pings = Map.merge(socket.assigns.pings, payload) + pair = pair_id(payload.from_node, payload.node) - {:noreply, assign(socket, pings: pings)} + {:noreply, stream(socket, :pings, [%{id: pair, payload: payload}])} end defp apply_action(socket, :index, _params) do socket - |> assign(:page_title, "Status - Supabase Realtime") + |> assign(:page_title, "Realtime Status") end defp all_nodes do @@ -35,9 +39,14 @@ defmodule RealtimeWeb.StatusLive.Index do end defp default_pings do - for n <- all_nodes(), f <- all_nodes(), into: %{} do - pair = n <> "_" <> f - {pair, %Payload{from_node: f, latency: "Loading...", node: n, timestamp: "Loading..."}} + for n <- all_nodes(), f <- all_nodes() do + pair = pair_id(f, n) + + %{id: pair, payload: %Payload{from_node: f, latency: "Loading...", node: n, timestamp: "Loading..."}} end end + + defp pair_id(from, to) do + from <> "_" <> to + end end diff --git a/lib/realtime_web/live/status_live/index.html.heex b/lib/realtime_web/live/status_live/index.html.heex index 645001714..63ea4fc0d 100644 --- a/lib/realtime_web/live/status_live/index.html.heex +++ b/lib/realtime_web/live/status_live/index.html.heex @@ -1,16 +1,16 @@ <.h1>Supabase Realtime: Multiplayer Edition + <.h2>Cluster Status +

Understand the latency between nodes across the Realtime cluster.

-
- <%= for {_pair, p} <- @pings do %> -
-
From: <%= p.from_region %> - <%= p.from_node %>
-
To: <%= p.region %> - <%= p.node %>
-
<%= p.latency %> ms
-
<%= p.timestamp %>
-
- <% end %> +
+
+
From: <%= p.payload.from_region %> - <%= p.payload.from_node %>
+
To: <%= p.payload.region %> - <%= p.payload.node %>
+
<%= p.payload.latency %> ms
+
<%= p.payload.timestamp %>
+
diff --git a/mix.exs b/mix.exs index 65245c31d..dcba5be5e 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.53.1", + version: "2.53.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/live/status_live/index_test.exs b/test/realtime_web/live/status_live/index_test.exs new file mode 100644 index 000000000..ae3af0ad0 --- /dev/null +++ b/test/realtime_web/live/status_live/index_test.exs @@ -0,0 +1,33 @@ +defmodule RealtimeWeb.StatusLive.IndexTest do + use RealtimeWeb.ConnCase + import Phoenix.LiveViewTest + + alias Realtime.Latency.Payload + alias Realtime.Nodes + alias RealtimeWeb.Endpoint + + describe "Status LiveView" do + test "renders status page", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/status") + + assert html =~ "Realtime Status" + end + + test "receives broadcast from PubSub", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/status") + + payload = %Payload{ + from_node: Nodes.short_node_id_from_name(:"pink@127.0.0.1"), + node: Nodes.short_node_id_from_name(:"orange@127.0.0.1"), + latency: "42ms", + timestamp: DateTime.utc_now() + } + + Endpoint.broadcast("admin:cluster", "ping", payload) + + html = render(view) + assert html =~ "42ms" + assert html =~ "pink@127.0.0.1_orange@127.0.0.1" + end + end +end From aeafab6e30bcc23c4dfef6104d3a1b6df461c8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 10 Oct 2025 15:31:34 +0100 Subject: [PATCH 038/123] fix: refine join payload checking (#1567) --- lib/realtime_web/channels/payloads/config.ex | 8 ++++++++ lib/realtime_web/channels/payloads/presence.ex | 2 +- mix.exs | 2 +- test/realtime_web/channels/payloads/join_test.exs | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/realtime_web/channels/payloads/config.ex b/lib/realtime_web/channels/payloads/config.ex index 923020174..029aa93b5 100644 --- a/lib/realtime_web/channels/payloads/config.ex +++ b/lib/realtime_web/channels/payloads/config.ex @@ -17,6 +17,14 @@ defmodule RealtimeWeb.Channels.Payloads.Config do end def changeset(config, attrs) do + attrs = + attrs + |> Enum.map(fn + {k, v} when is_list(v) -> {k, Enum.filter(v, fn v -> v != nil end)} + {k, v} -> {k, v} + end) + |> Map.new() + config |> cast(attrs, [:private], message: &Join.error_message/2) |> cast_embed(:broadcast, invalid_message: "unable to parse, expected a map") diff --git a/lib/realtime_web/channels/payloads/presence.ex b/lib/realtime_web/channels/payloads/presence.ex index 53e09047d..785df9222 100644 --- a/lib/realtime_web/channels/payloads/presence.ex +++ b/lib/realtime_web/channels/payloads/presence.ex @@ -8,7 +8,7 @@ defmodule RealtimeWeb.Channels.Payloads.Presence do embedded_schema do field :enabled, :boolean, default: true - field :key, :string, default: UUID.uuid1() + field :key, :any, default: UUID.uuid1(), virtual: true end def changeset(presence, attrs) do diff --git a/mix.exs b/mix.exs index dcba5be5e..06229331c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.53.2", + version: "2.53.3", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/payloads/join_test.exs b/test/realtime_web/channels/payloads/join_test.exs index c1ea54a67..f02c2a73d 100644 --- a/test/realtime_web/channels/payloads/join_test.exs +++ b/test/realtime_web/channels/payloads/join_test.exs @@ -58,6 +58,14 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do assert is_binary(key) end + test "presence key can be number" do + config = %{"config" => %{"presence" => %{"enabled" => true, "key" => 123}}} + + assert {:ok, %Join{config: %Config{presence: %Presence{key: key}}}} = Join.validate(config) + + assert key == 123 + end + test "invalid replay" do config = %{"config" => %{"broadcast" => %{"replay" => 123}}} @@ -105,5 +113,11 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do user_token: ["unable to parse, expected string"] } end + + test "handles postgres changes with nil value in array as empty array" do + config = %{"config" => %{"postgres_changes" => [nil]}} + + assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config) + end end end From cbcbbfd6ba882831d54ad083cb7b0e4684d3ccd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 10 Oct 2025 15:45:18 +0100 Subject: [PATCH 039/123] fix: shard user scopes in syn (#1566) --- config/runtime.exs | 4 +++- lib/realtime/application.ex | 3 +-- lib/realtime/tenants.ex | 3 ++- lib/realtime/user_counter.ex | 21 ++++++++++++++++++--- mix.exs | 2 +- test/integration/rt_channel_test.exs | 2 +- test/realtime/tenants/connect_test.exs | 4 ++-- 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/config/runtime.exs b/config/runtime.exs index 447934b65..f09d22846 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -70,6 +70,7 @@ platform = if System.get_env("AWS_EXECUTION_ENV") == "AWS_ECS_FARGATE", do: :aws broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) pubsub_adapter = System.get_env("PUBSUB_ADAPTER", "gen_rpc") |> String.to_atom() websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) +users_scope_shards = Env.get_integer("USERS_SCOPE_SHARDS", 5) no_channel_timeout_in_ms = if config_env() == :test, @@ -126,7 +127,8 @@ config :realtime, no_channel_timeout_in_ms: no_channel_timeout_in_ms, platform: platform, pubsub_adapter: pubsub_adapter, - broadcast_pool_size: broadcast_pool_size + broadcast_pool_size: broadcast_pool_size, + users_scope_shards: users_scope_shards if config_env() != :test && run_janitor? do config :realtime, diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 99096edfb..45cc0271e 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -46,8 +46,7 @@ defmodule Realtime.Application do Realtime.PromEx.set_metrics_tags() :ets.new(Realtime.Tenants.Connect, [:named_table, :set, :public]) :syn.set_event_handler(Realtime.SynHandler) - - :ok = :syn.add_node_to_scopes([:users, RegionNodes, Realtime.Tenants.Connect]) + :ok = :syn.add_node_to_scopes([RegionNodes, Realtime.Tenants.Connect | Realtime.UsersCounter.scopes()]) region = Application.get_env(:realtime, :region) :syn.join(RegionNodes, region, self(), node: node()) diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index d792c1ca5..9e53e18f1 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -21,7 +21,8 @@ defmodule Realtime.Tenants do """ @spec list_connected_tenants(atom()) :: [String.t()] def list_connected_tenants(node) do - :syn.group_names(:users, node) + UsersCounter.scopes() + |> Enum.flat_map(fn scope -> :syn.group_names(scope, node) end) end @doc """ diff --git a/lib/realtime/user_counter.ex b/lib/realtime/user_counter.ex index 6190030d9..9ea38c780 100644 --- a/lib/realtime/user_counter.ex +++ b/lib/realtime/user_counter.ex @@ -8,17 +8,32 @@ defmodule Realtime.UsersCounter do Adds a RealtimeChannel pid to the `:users` scope for a tenant so we can keep track of all connected clients for a tenant. """ @spec add(pid(), String.t()) :: :ok - def add(pid, tenant), do: :syn.join(:users, tenant, pid) + def add(pid, tenant_id), do: tenant_id |> scope() |> :syn.join(tenant_id, pid) @doc """ Returns the count of all connected clients for a tenant for the cluster. """ @spec tenant_users(String.t()) :: non_neg_integer() - def tenant_users(tenant), do: :syn.member_count(:users, tenant) + def tenant_users(tenant_id), do: tenant_id |> scope() |> :syn.member_count(tenant_id) @doc """ Returns the count of all connected clients for a tenant for a single node. """ @spec tenant_users(atom, String.t()) :: non_neg_integer() - def tenant_users(node_name, tenant), do: :syn.member_count(:users, tenant, node_name) + def tenant_users(node_name, tenant_id), do: tenant_id |> scope() |> :syn.member_count(tenant_id, node_name) + + @doc """ + Returns the scope for a given tenant id. + """ + @spec scope(String.t()) :: atom() + def scope(tenant_id) do + shards = Application.get_env(:realtime, :users_scope_shards) + shard = :erlang.phash2(tenant_id, shards) + :"users_#{shard}" + end + + def scopes() do + shards = Application.get_env(:realtime, :users_scope_shards) + Enum.map(0..(shards - 1), fn shard -> :"users_#{shard}" end) + end end diff --git a/mix.exs b/mix.exs index 06229331c..e98ac608f 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.53.3", + version: "2.53.4", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index f45b4aff5..8f4607b01 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -2588,7 +2588,7 @@ defmodule Realtime.Integration.RtChannelTest do Realtime.Tenants.Cache.invalidate_tenant_cache(external_id) end - defp assert_process_down(pid, timeout \\ 100) do + defp assert_process_down(pid, timeout \\ 300) do ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout end diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs index 741f6ecf7..804b3018f 100644 --- a/test/realtime/tenants/connect_test.exs +++ b/test/realtime/tenants/connect_test.exs @@ -328,9 +328,9 @@ defmodule Realtime.Tenants.ConnectTest do region = Tenants.region(tenant) assert {_pid, %{conn: ^db_conn, region: ^region}} = :syn.lookup(Connect, external_id) Process.sleep(1000) - :syn.leave(:users, external_id, self()) + external_id |> UsersCounter.scope() |> :syn.leave(external_id, self()) Process.sleep(1000) - assert :undefined = :syn.lookup(Connect, external_id) + assert :undefined = external_id |> UsersCounter.scope() |> :syn.lookup(external_id) refute Process.alive?(db_conn) Connect.shutdown(external_id) end From 05ec5893d19511895023d6dea99e6d068d2fe9d3 Mon Sep 17 00:00:00 2001 From: "blacksmith-sh[bot]" <157653362+blacksmith-sh[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:36:21 +1300 Subject: [PATCH 040/123] chore: migrate workflows to Blacksmith runners (#1569) --------- Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com> Co-authored-by: Eduardo Gurgel Pinho --- .github/workflows/lint.yml | 2 +- .github/workflows/manual_prod_build.yml | 24 +++++++++------------ .github/workflows/mirror.yml | 2 +- .github/workflows/prod_build.yml | 28 +++++++++++-------------- .github/workflows/prod_linter.yml | 2 +- .github/workflows/tests.yml | 2 +- .github/workflows/version_updated.yml | 2 +- test/realtime/nodes_test.exs | 5 +---- 8 files changed, 28 insertions(+), 39 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b27a4e9f3..555377898 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ concurrency: jobs: tests: name: Lint - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/manual_prod_build.yml b/.github/workflows/manual_prod_build.yml index f5014dd24..a57a46f55 100644 --- a/.github/workflows/manual_prod_build.yml +++ b/.github/workflows/manual_prod_build.yml @@ -10,7 +10,7 @@ on: required: true jobs: docker_x86_release: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 120 env: arch: amd64 @@ -25,7 +25,8 @@ jobs: tags: | type=raw,value=v${{ github.event.inputs.docker_tag }}_${{ env.arch }} - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - uses: docker/login-action@v2 with: @@ -33,13 +34,11 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@v2 with: push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - cache-from: type=gha - cache-to: type=gha,mode=max docker_arm_release: runs-on: arm-runner @@ -64,15 +63,11 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/setup-buildx-action@v2 - with: - driver: docker - driver-opts: | - image=moby/buildkit:master - network=host + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@v2 with: context: . push: true @@ -82,13 +77,14 @@ jobs: merge_manifest: needs: [docker_x86_release, docker_arm_release] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write id-token: write steps: - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - uses: docker/login-action@v2 with: diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 8fc83fe45..6149f28d7 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -10,7 +10,7 @@ on: jobs: mirror: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write diff --git a/.github/workflows/prod_build.yml b/.github/workflows/prod_build.yml index 9926c1c03..22c1b2899 100644 --- a/.github/workflows/prod_build.yml +++ b/.github/workflows/prod_build.yml @@ -15,7 +15,7 @@ on: jobs: release: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 outputs: published: ${{ steps.semantic.outputs.new_release_published }} version: ${{ steps.semantic.outputs.new_release_version }} @@ -30,7 +30,7 @@ jobs: docker_x86_release: needs: release - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 if: needs.release.outputs.published == 'true' timeout-minutes: 120 env: @@ -47,7 +47,8 @@ jobs: type=raw,value=v${{ needs.release.outputs.version }}_${{ env.arch }} type=raw,value=latest_${{ env.arch }} - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - uses: docker/login-action@v2 with: @@ -55,13 +56,11 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@v2 with: push: true tags: ${{ steps.meta.outputs.tags }} platforms: linux/${{ env.arch }} - cache-from: type=gha - cache-to: type=gha,mode=max docker_arm_release: needs: release @@ -89,15 +88,11 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/setup-buildx-action@v2 - with: - driver: docker - driver-opts: | - image=moby/buildkit:master - network=host + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - id: build - uses: docker/build-push-action@v3 + uses: useblacksmith/build-push-action@v2 with: context: . push: true @@ -107,13 +102,14 @@ jobs: merge_manifest: needs: [release, docker_x86_release, docker_arm_release] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: read packages: write id-token: write steps: - - uses: docker/setup-buildx-action@v2 + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@v1 - uses: docker/login-action@v2 with: @@ -160,7 +156,7 @@ jobs: update-branch-name: needs: [release, docker_x86_release, docker_arm_release, merge_manifest] - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout branch uses: actions/checkout@v2 diff --git a/.github/workflows/prod_linter.yml b/.github/workflows/prod_linter.yml index 6af6b5ed8..4aac1a2eb 100644 --- a/.github/workflows/prod_linter.yml +++ b/.github/workflows/prod_linter.yml @@ -7,7 +7,7 @@ on: jobs: format: name: Formatting Checks - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45d27634a..a010636fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ env: jobs: tests: name: Tests - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/version_updated.yml b/.github/workflows/version_updated.yml index 6125f1ff7..ba6d340ad 100644 --- a/.github/workflows/version_updated.yml +++ b/.github/workflows/version_updated.yml @@ -20,7 +20,7 @@ name: Default Checks jobs: versions_updated: name: Versions Updated - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/test/realtime/nodes_test.exs b/test/realtime/nodes_test.exs index ba3b6be0e..ef9d06fb9 100644 --- a/test/realtime/nodes_test.exs +++ b/test/realtime/nodes_test.exs @@ -16,10 +16,7 @@ defmodule Realtime.NodesTest do reject(&:syn.members/2) end - test "on existing tenant id, returns the node for the region using syn", %{ - tenant: tenant, - region: region - } do + test "on existing tenant id, returns the node for the region using syn", %{tenant: tenant, region: region} do expected_nodes = [:tenant@closest1, :tenant@closest2] expect(:syn, :members, fn RegionNodes, ^region -> From b8dd5f384f30303cfca7a4039737f8f3d796bff4 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 15 Oct 2025 11:37:10 +1300 Subject: [PATCH 041/123] feat: selective broadcast for postgres changes (#1573) Selective broadcast for Postgres Changes. We use the fact that we know where each subscription subscribed from and do a direct broadcast for each node instead of spamming the whole cluster. If the subscription node is not readily available (for whatever reason) we fallback to sending to the whole cluster. --- .../postgres_cdc_rls/replication_poller.ex | 49 ++- .../postgres_cdc_rls/subscription_manager.ex | 67 ++-- .../postgres_cdc_rls/subscriptions_checker.ex | 37 +- .../postgres_cdc_rls/worker_supervisor.ex | 11 +- lib/realtime/gen_rpc.ex | 30 ++ lib/realtime_web/tenant_broadcaster.ex | 31 ++ mix.exs | 2 +- test/realtime/api_test.exs | 1 + .../extensions/cdc_rls/cdc_rls_test.exs | 2 +- .../cdc_rls/replication_poller_test.exs | 349 +++++++++++++++++- .../cdc_rls/subscription_manager_test.exs | 158 ++++++++ .../cdc_rls/subscriptions_checker_test.exs | 66 +++- .../extensions/cdc_rls/subscriptions_test.exs | 3 +- test/realtime/gen_rpc_test.exs | 47 +++ test/realtime_web/tenant_broadcaster_test.exs | 67 +++- test/support/containers.ex | 22 ++ test/test_helper.exs | 3 +- 17 files changed, 863 insertions(+), 82 deletions(-) create mode 100644 test/realtime/extensions/cdc_rls/subscription_manager_test.exs diff --git a/lib/extensions/postgres_cdc_rls/replication_poller.ex b/lib/extensions/postgres_cdc_rls/replication_poller.ex index 34697572c..546bc702a 100644 --- a/lib/extensions/postgres_cdc_rls/replication_poller.ex +++ b/lib/extensions/postgres_cdc_rls/replication_poller.ex @@ -21,6 +21,8 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do alias Realtime.RateCounter alias Realtime.Tenants + alias RealtimeWeb.TenantBroadcaster + def start_link(opts), do: GenServer.start_link(__MODULE__, opts) @impl true @@ -28,8 +30,6 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do tenant_id = args["id"] Logger.metadata(external_id: tenant_id, project: tenant_id) - %Realtime.Api.Tenant{} = Tenants.Cache.get_tenant_by_external_id(tenant_id) - rate_counter_args = Tenants.db_events_per_second_rate(tenant_id, 4000) RateCounter.new(rate_counter_args) @@ -50,7 +50,8 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do retry_count: 0, slot_name: args["slot_name"] <> slot_name_suffix(), tenant_id: tenant_id, - rate_counter_args: rate_counter_args + rate_counter_args: rate_counter_args, + subscribers_nodes_table: args["subscribers_nodes_table"] } {:ok, _} = Registry.register(__MODULE__.Registry, tenant_id, %{}) @@ -84,6 +85,7 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do max_changes: max_changes, conn: conn, tenant_id: tenant_id, + subscribers_nodes_table: subscribers_nodes_table, rate_counter_args: rate_counter_args } = state ) do @@ -94,7 +96,7 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do {time, list_changes} = :timer.tc(Replications, :list_changes, args) record_list_changes_telemetry(time, tenant_id) - case handle_list_changes_result(list_changes, tenant_id, rate_counter_args) do + case handle_list_changes_result(list_changes, subscribers_nodes_table, tenant_id, rate_counter_args) do {:ok, row_count} -> Backoff.reset(backoff) @@ -187,6 +189,7 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do rows: [_ | _] = rows, num_rows: rows_count }}, + subscribers_nodes_table, tenant_id, rate_counter_args ) do @@ -201,15 +204,47 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do change <- columns |> Enum.zip(row) |> generate_record() |> List.wrap() do topic = "realtime:postgres:" <> tenant_id - RealtimeWeb.TenantBroadcaster.pubsub_broadcast(tenant_id, topic, change, MessageDispatcher, :postgres_changes) + case collect_subscription_nodes(subscribers_nodes_table, change.subscription_ids) do + {:ok, nodes} -> + for node <- nodes do + TenantBroadcaster.pubsub_direct_broadcast( + node, + tenant_id, + topic, + change, + MessageDispatcher, + :postgres_changes + ) + end + + {:error, :node_not_found} -> + TenantBroadcaster.pubsub_broadcast( + tenant_id, + topic, + change, + MessageDispatcher, + :postgres_changes + ) + end end end {:ok, rows_count} end - defp handle_list_changes_result({:ok, _}, _, _), do: {:ok, 0} - defp handle_list_changes_result({:error, reason}, _, _), do: {:error, reason} + defp handle_list_changes_result({:ok, _}, _, _, _), do: {:ok, 0} + defp handle_list_changes_result({:error, reason}, _, _, _), do: {:error, reason} + + defp collect_subscription_nodes(subscribers_nodes_table, subscription_ids) do + Enum.reduce_while(subscription_ids, {:ok, MapSet.new()}, fn subscription_id, {:ok, acc} -> + case :ets.lookup(subscribers_nodes_table, subscription_id) do + [{_, node}] -> {:cont, {:ok, MapSet.put(acc, node)}} + _ -> {:halt, {:error, :node_not_found}} + end + end) + rescue + _ -> {:error, :node_not_found} + end def generate_record([ {"wal", diff --git a/lib/extensions/postgres_cdc_rls/subscription_manager.ex b/lib/extensions/postgres_cdc_rls/subscription_manager.ex index 2dba9912e..175376e12 100644 --- a/lib/extensions/postgres_cdc_rls/subscription_manager.ex +++ b/lib/extensions/postgres_cdc_rls/subscription_manager.ex @@ -24,7 +24,8 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do defstruct [ :id, :publication, - :subscribers_tid, + :subscribers_pids_table, + :subscribers_nodes_table, :conn, :delete_queue, :no_users_ref, @@ -37,7 +38,8 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do @type t :: %__MODULE__{ id: String.t(), publication: String.t(), - subscribers_tid: :ets.tid(), + subscribers_pids_table: :ets.tid(), + subscribers_nodes_table: :ets.tid(), conn: Postgrex.conn(), oids: map(), check_oid_ref: reference() | nil, @@ -67,7 +69,12 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do @impl true def handle_continue({:connect, args}, _) do - %{"id" => id, "publication" => publication, "subscribers_tid" => subscribers_tid} = args + %{ + "id" => id, + "publication" => publication, + "subscribers_pids_table" => subscribers_pids_table, + "subscribers_nodes_table" => subscribers_nodes_table + } = args subscription_manager_settings = Database.from_settings(args, "realtime_subscription_manager") @@ -85,19 +92,21 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do check_region_interval = Map.get(args, :check_region_interval, rebalance_check_interval_in_ms()) send_region_check_message(check_region_interval) - state = %State{ - id: id, - conn: conn, - publication: publication, - subscribers_tid: subscribers_tid, - oids: oids, - delete_queue: %{ - ref: check_delete_queue(), - queue: :queue.new() - }, - no_users_ref: check_no_users(), - check_region_interval: check_region_interval - } + state = + %State{ + id: id, + conn: conn, + publication: publication, + subscribers_pids_table: subscribers_pids_table, + subscribers_nodes_table: subscribers_nodes_table, + oids: oids, + delete_queue: %{ + ref: check_delete_queue(), + queue: :queue.new() + }, + no_users_ref: check_no_users(), + check_region_interval: check_region_interval + } send(self(), :check_oids) {:noreply, state} @@ -105,11 +114,13 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do @impl true def handle_info({:subscribed, {pid, id}}, state) do - case :ets.match(state.subscribers_tid, {pid, id, :"$1", :_}) do - [] -> :ets.insert(state.subscribers_tid, {pid, id, Process.monitor(pid), node(pid)}) + case :ets.match(state.subscribers_pids_table, {pid, id, :"$1", :_}) do + [] -> :ets.insert(state.subscribers_pids_table, {pid, id, Process.monitor(pid), node(pid)}) _ -> :ok end + :ets.insert(state.subscribers_nodes_table, {UUID.string_to_binary!(id), node(pid)}) + {:noreply, %{state | no_users_ts: nil}} end @@ -132,7 +143,7 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do Process.demonitor(ref, [:flush]) send(pid, :postgres_subscribe) end - |> :ets.foldl([], state.subscribers_tid) + |> :ets.foldl([], state.subscribers_pids_table) new_oids end @@ -142,19 +153,25 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do def handle_info( {:DOWN, _ref, :process, pid, _reason}, - %State{subscribers_tid: tid, delete_queue: %{queue: q}} = state + %State{ + subscribers_pids_table: subscribers_pids_table, + subscribers_nodes_table: subscribers_nodes_table, + delete_queue: %{queue: q} + } = state ) do q1 = - case :ets.take(tid, pid) do + case :ets.take(subscribers_pids_table, pid) do [] -> q values -> for {_pid, id, _ref, _node} <- values, reduce: q do acc -> - id - |> UUID.string_to_binary!() - |> :queue.in(acc) + bin_id = UUID.string_to_binary!(id) + + :ets.delete(subscribers_nodes_table, bin_id) + + :queue.in(bin_id, acc) end end @@ -187,7 +204,7 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do {:noreply, %{state | delete_queue: %{ref: ref, queue: q1}}} end - def handle_info(:check_no_users, %{subscribers_tid: tid, no_users_ts: ts} = state) do + def handle_info(:check_no_users, %{subscribers_pids_table: tid, no_users_ts: ts} = state) do Helpers.cancel_timer(state.no_users_ref) ts_new = diff --git a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex b/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex index ed2b42eb5..50bf9eac1 100644 --- a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex +++ b/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex @@ -17,13 +17,14 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do defmodule State do @moduledoc false - defstruct [:id, :conn, :check_active_pids, :subscribers_tid, :delete_queue] + defstruct [:id, :conn, :check_active_pids, :subscribers_pids_table, :subscribers_nodes_table, :delete_queue] @type t :: %__MODULE__{ id: String.t(), conn: Postgrex.conn(), check_active_pids: reference(), - subscribers_tid: :ets.tid(), + subscribers_pids_table: :ets.tid(), + subscribers_nodes_table: :ets.tid(), delete_queue: %{ ref: reference(), queue: :queue.queue() @@ -47,7 +48,11 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do @impl true def handle_continue({:connect, args}, _) do - %{"id" => id, "subscribers_tid" => subscribers_tid} = args + %{ + "id" => id, + "subscribers_pids_table" => subscribers_pids_table, + "subscribers_nodes_table" => subscribers_nodes_table + } = args realtime_subscription_checker_settings = Database.from_settings(args, "realtime_subscription_checker") @@ -58,7 +63,8 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do id: id, conn: conn, check_active_pids: check_active_pids(), - subscribers_tid: subscribers_tid, + subscribers_pids_table: subscribers_pids_table, + subscribers_nodes_table: subscribers_nodes_table, delete_queue: %{ ref: nil, queue: :queue.new() @@ -69,18 +75,14 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do end @impl true - def handle_info( - :check_active_pids, - %State{check_active_pids: ref, subscribers_tid: tid, delete_queue: delete_queue, id: id} = - state - ) do + def handle_info(:check_active_pids, %State{check_active_pids: ref, delete_queue: delete_queue, id: id} = state) do Helpers.cancel_timer(ref) ids = - tid + state.subscribers_pids_table |> subscribers_by_node() |> not_alive_pids_dist() - |> pop_not_alive_pids(tid, id) + |> pop_not_alive_pids(state.subscribers_pids_table, state.subscribers_nodes_table, id) new_delete_queue = if length(ids) > 0 do @@ -128,10 +130,10 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do ## Internal functions - @spec pop_not_alive_pids([pid()], :ets.tid(), binary()) :: [Ecto.UUID.t()] - def pop_not_alive_pids(pids, tid, tenant_id) do + @spec pop_not_alive_pids([pid()], :ets.tid(), :ets.tid(), binary()) :: [Ecto.UUID.t()] + def pop_not_alive_pids(pids, subscribers_pids_table, subscribers_nodes_table, tenant_id) do Enum.reduce(pids, [], fn pid, acc -> - case :ets.lookup(tid, pid) do + case :ets.lookup(subscribers_pids_table, pid) do [] -> Telemetry.execute( [:realtime, :subscriptions_checker, :pid_not_found], @@ -149,8 +151,11 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do %{tenant_id: tenant_id} ) - :ets.delete(tid, pid) - UUID.string_to_binary!(postgres_id) + :ets.delete(subscribers_pids_table, pid) + bin_id = UUID.string_to_binary!(postgres_id) + + :ets.delete(subscribers_nodes_table, bin_id) + bin_id end ++ acc end end) diff --git a/lib/extensions/postgres_cdc_rls/worker_supervisor.ex b/lib/extensions/postgres_cdc_rls/worker_supervisor.ex index 37f88014e..9d5f6b949 100644 --- a/lib/extensions/postgres_cdc_rls/worker_supervisor.ex +++ b/lib/extensions/postgres_cdc_rls/worker_supervisor.ex @@ -19,12 +19,19 @@ defmodule Extensions.PostgresCdcRls.WorkerSupervisor do Logger.metadata(external_id: tenant, project: tenant) unless Api.get_tenant_by_external_id(tenant, :primary), do: raise(Exception) - tid_args = Map.merge(args, %{"subscribers_tid" => :ets.new(__MODULE__, [:public, :bag])}) + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + tid_args = + Map.merge(args, %{ + "subscribers_pids_table" => subscribers_pids_table, + "subscribers_nodes_table" => subscribers_nodes_table + }) children = [ %{ id: ReplicationPoller, - start: {ReplicationPoller, :start_link, [args]}, + start: {ReplicationPoller, :start_link, [tid_args]}, restart: :transient }, %{ diff --git a/lib/realtime/gen_rpc.ex b/lib/realtime/gen_rpc.ex index a7b46a869..c3af9b95b 100644 --- a/lib/realtime/gen_rpc.ex +++ b/lib/realtime/gen_rpc.ex @@ -26,6 +26,36 @@ defmodule Realtime.GenRpc do :ok end + @doc """ + Fire and forget apply(mod, func, args) on one node + + Options: + + - `:key` - Optional key to consistently select the same gen_rpc client to guarantee some message order between nodes + """ + @spec cast(node, module, atom, list(any), keyword()) :: :ok + def cast(node, mod, func, args, opts \\ []) + + # Local + def cast(node, mod, func, args, _opts) when node == node() do + :erpc.cast(node, mod, func, args) + :ok + end + + def cast(node, mod, func, args, opts) + when is_atom(node) and is_atom(mod) and is_atom(func) and is_list(args) and is_list(opts) do + key = Keyword.get(opts, :key, nil) + + # Ensure this node is part of the connected nodes + if node in Node.list() do + node_key = rpc_node(node, key) + + :gen_rpc.cast(node_key, mod, func, args) + end + + :ok + end + @doc """ Fire and forget apply(mod, func, args) on all nodes diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex index f8b739a0b..b1b878b5d 100644 --- a/lib/realtime_web/tenant_broadcaster.ex +++ b/lib/realtime_web/tenant_broadcaster.ex @@ -7,6 +7,37 @@ defmodule RealtimeWeb.TenantBroadcaster do @type message_type :: :broadcast | :presence | :postgres_changes + @spec pubsub_direct_broadcast( + node :: node(), + tenant_id :: String.t(), + PubSub.topic(), + PubSub.message(), + PubSub.dispatcher(), + message_type + ) :: + :ok + def pubsub_direct_broadcast(node, tenant_id, topic, message, dispatcher, message_type) do + collect_payload_size(tenant_id, message, message_type) + + do_direct_broadcast(node, topic, message, dispatcher) + + :ok + end + + # Remote + defp do_direct_broadcast(node, topic, message, dispatcher) when node != node() do + if pubsub_adapter() == :gen_rpc do + PubSub.direct_broadcast(node, Realtime.PubSub, topic, message, dispatcher) + else + Realtime.GenRpc.cast(node, PubSub, :local_broadcast, [Realtime.PubSub, topic, message, dispatcher], key: topic) + end + end + + # Local + defp do_direct_broadcast(_node, topic, message, dispatcher) do + PubSub.local_broadcast(Realtime.PubSub, topic, message, dispatcher) + end + @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher(), message_type) :: :ok def pubsub_broadcast(tenant_id, topic, message, dispatcher, message_type) do diff --git a/mix.exs b/mix.exs index e98ac608f..6d47591de 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.53.4", + version: "2.54.0", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/api_test.exs b/test/realtime/api_test.exs index 55dc609eb..72ef1c94f 100644 --- a/test/realtime/api_test.exs +++ b/test/realtime/api_test.exs @@ -252,6 +252,7 @@ defmodule Realtime.ApiTest do end describe "rename_settings_field/2" do + @tag skip: "** (Postgrex.Error) ERROR 0A000 (feature_not_supported) cached plan must not change result type" test "renames setting fields" do tenant = tenant_fixture() Api.rename_settings_field("poll_interval_ms", "poll_interval") diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index 0c0df0e19..ba7ebc072 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -1,7 +1,7 @@ defmodule Realtime.Extensions.CdcRlsTest do # async: false due to usage of dev_tenant # Also global mimic mock - use RealtimeWeb.ChannelCase, async: false + use Realtime.DataCase, async: false use Mimic import ExUnit.CaptureLog diff --git a/test/realtime/extensions/cdc_rls/replication_poller_test.exs b/test/realtime/extensions/cdc_rls/replication_poller_test.exs index 97d69af62..368f73613 100644 --- a/test/realtime/extensions/cdc_rls/replication_poller_test.exs +++ b/test/realtime/extensions/cdc_rls/replication_poller_test.exs @@ -1,8 +1,12 @@ -defmodule ReplicationPollerTest do - use ExUnit.Case, async: false +defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do + # Tweaking application env + use Realtime.DataCase, async: false + + alias Extensions.PostgresCdcRls.MessageDispatcher + use Mimic alias Extensions.PostgresCdcRls.ReplicationPoller, as: Poller - import Poller, only: [generate_record: 1] + alias Extensions.PostgresCdcRls.Replications alias Realtime.Adapters.Changes.{ DeletedRecord, @@ -10,6 +14,343 @@ defmodule ReplicationPollerTest do UpdatedRecord } + alias RealtimeWeb.TenantBroadcaster + + import Poller, only: [generate_record: 1] + + setup :set_mimic_global + + describe "poll" do + setup do + :telemetry.attach( + __MODULE__, + [:realtime, :replication, :poller, :query, :stop], + &__MODULE__.handle_telemetry/4, + pid: self() + ) + + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + tenant = Containers.checkout_tenant(run_migrations: true) + + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + args = + hd(tenant.extensions).settings + |> Map.put("id", tenant.external_id) + |> Map.put("subscribers_pids_table", subscribers_pids_table) + |> Map.put("subscribers_nodes_table", subscribers_nodes_table) + + # unless specified it will return empty results + empty_results = {:ok, %Postgrex.Result{rows: [], num_rows: 0}} + stub(Replications, :list_changes, fn _, _, _, _, _ -> empty_results end) + + %{args: args} + end + + test "handles no new changes", %{args: args} do + tenant_id = args["id"] + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + reject(&TenantBroadcaster.pubsub_broadcast/5) + {:ok, _pid} = start_supervised({Poller, args}) + + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + refute_receive _any + end + + test "handles new changes with missing ets table", %{args: args} do + tenant_id = args["id"] + + :ets.delete(args["subscribers_nodes_table"]) + + results = + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["wal", "is_rls_enabled", "subscription_ids", "errors"], + rows: [ + [ + %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"} + ], + "commit_timestamp" => "2025-10-13T07:50:28.066Z", + "record" => %{"details" => "test", "id" => 55}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + false, + [ + <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ], + [] + ] + ], + num_rows: 1, + connection_id: 123, + messages: [] + }} + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + _change, + MessageDispatcher, + :postgres_changes -> + :ok + end) + + {:ok, _pid} = start_supervised({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + end + + test "handles new changes with no subscription nodes", %{args: args} do + tenant_id = args["id"] + + results = + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["wal", "is_rls_enabled", "subscription_ids", "errors"], + rows: [ + [ + %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"} + ], + "commit_timestamp" => "2025-10-13T07:50:28.066Z", + "record" => %{"details" => "test", "id" => 55}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + false, + [ + <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ], + [] + ] + ], + num_rows: 1, + connection_id: 123, + messages: [] + }} + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + _change, + MessageDispatcher, + :postgres_changes -> + :ok + end) + + {:ok, _pid} = start_supervised({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + end + + test "handles new changes with missing subscription nodes", %{args: args} do + tenant_id = args["id"] + + results = + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["wal", "is_rls_enabled", "subscription_ids", "errors"], + rows: [ + [ + %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"} + ], + "commit_timestamp" => "2025-10-13T07:50:28.066Z", + "record" => %{"details" => "test", "id" => 55}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + false, + [ + sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ], + [] + ] + ], + num_rows: 1, + connection_id: 123, + messages: [] + }} + + # Only one subscription has node information + :ets.insert(args["subscribers_nodes_table"], {sub1, node()}) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_direct_broadcast/6) + + # Broadcast to the whole cluster due to missing node information + expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id, + "realtime:postgres:" <> ^tenant_id, + _change, + MessageDispatcher, + :postgres_changes -> + :ok + end) + + {:ok, _pid} = start_supervised({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + end + + test "handles new changes with subscription nodes information", %{args: args} do + tenant_id = args["id"] + + results = + {:ok, + %Postgrex.Result{ + command: :select, + columns: ["wal", "is_rls_enabled", "subscription_ids", "errors"], + rows: [ + [ + %{ + "columns" => [ + %{"name" => "id", "type" => "int4"}, + %{"name" => "details", "type" => "text"} + ], + "commit_timestamp" => "2025-10-13T07:50:28.066Z", + "record" => %{"details" => "test", "id" => 55}, + "schema" => "public", + "table" => "test", + "type" => "INSERT" + }, + false, + [ + sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, + sub2 = <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + ], + [] + ] + ], + num_rows: 1, + connection_id: 123, + messages: [] + }} + + # Both subscriptions have node information + :ets.insert(args["subscribers_nodes_table"], {sub1, node()}) + :ets.insert(args["subscribers_nodes_table"], {sub2, :"someothernode@127.0.0.1"}) + + expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) + reject(&TenantBroadcaster.pubsub_broadcast/5) + + topic = "realtime:postgres:" <> tenant_id + + # # Broadcast to the exact nodes only + expect(TenantBroadcaster, :pubsub_direct_broadcast, 2, fn + _node, ^tenant_id, ^topic, _change, MessageDispatcher, :postgres_changes -> + :ok + end) + + {:ok, _pid} = start_supervised({Poller, args}) + + # First poll with changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + # Second poll without changes + assert_receive { + :telemetry, + [:realtime, :replication, :poller, :query, :stop], + %{duration: _}, + %{tenant: ^tenant_id} + }, + 500 + + calls = calls(TenantBroadcaster, :pubsub_direct_broadcast, 6) + + assert Enum.count(calls) == 2 + + Enum.each(calls, fn [node, _, _, _, _, _] -> + assert node in [node(), :"someothernode@127.0.0.1"] + end) + end + end + @columns [ %{"name" => "id", "type" => "int8"}, %{"name" => "details", "type" => "text"}, @@ -305,4 +646,6 @@ defmodule ReplicationPollerTest do assert Poller.slot_name_suffix() == "" end end + + def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata}) end diff --git a/test/realtime/extensions/cdc_rls/subscription_manager_test.exs b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs new file mode 100644 index 000000000..7d150637c --- /dev/null +++ b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs @@ -0,0 +1,158 @@ +defmodule Realtime.Extensions.CdcRls.SubscriptionManagerTest do + use Realtime.DataCase, async: true + + alias Extensions.PostgresCdcRls + alias Extensions.PostgresCdcRls.SubscriptionManager + alias Extensions.PostgresCdcRls.Subscriptions + + setup do + tenant = Containers.checkout_tenant(run_migrations: true) + + subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag]) + subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set]) + + args = + hd(tenant.extensions).settings + |> Map.put("id", tenant.external_id) + |> Map.put("subscribers_pids_table", subscribers_pids_table) + |> Map.put("subscribers_nodes_table", subscribers_nodes_table) + + # register this process with syn as if this was the WorkersSupervisor + :syn.register(PostgresCdcRls, tenant.external_id, self(), %{region: "us-east-1", manager: nil, subs_pool: nil}) + + {:ok, pid} = SubscriptionManager.start_link(Map.put(args, "id", tenant.external_id)) + # This serves so that we know that handle_continue has finished + :sys.get_state(pid) + %{args: args, pid: pid} + end + + describe "subscription" do + test "subscription", %{pid: pid, args: args} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {uuid, bin_uuid, pg_change_params} = pg_change_params() + + subscriber = self() + + assert {:ok, [%Postgrex.Result{command: :insert, columns: ["id"], rows: [[1]], num_rows: 1}]} = + Subscriptions.create(conn, args["publication"], [pg_change_params], pid, subscriber) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + node = node() + + assert [{^subscriber, ^uuid, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"]) + + assert :ets.tab2list(args["subscribers_nodes_table"]) == [{bin_uuid, node}] + end + + test "subscriber died", %{pid: pid, args: args} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + self = self() + + subscriber = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + {uuid1, bin_uuid1, pg_change_params1} = pg_change_params() + {uuid2, bin_uuid2, pg_change_params2} = pg_change_params() + {uuid3, bin_uuid3, pg_change_params3} = pg_change_params() + + assert {:ok, _} = + Subscriptions.create(conn, args["publication"], [pg_change_params1, pg_change_params2], pid, subscriber) + + assert {:ok, _} = Subscriptions.create(conn, args["publication"], [pg_change_params3], pid, self()) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + node = node() + + assert :ets.info(args["subscribers_pids_table"], :size) == 3 + + assert [{^subscriber, ^uuid1, _, ^node}, {^subscriber, ^uuid2, _, ^node}] = + :ets.lookup(args["subscribers_pids_table"], subscriber) + + assert [{^self, ^uuid3, _ref, ^node}] = :ets.lookup(args["subscribers_pids_table"], self) + + assert :ets.info(args["subscribers_nodes_table"], :size) == 3 + assert [{^bin_uuid1, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid1) + assert [{^bin_uuid2, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid2) + assert [{^bin_uuid3, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid3) + + send(subscriber, :stop) + # Wait for subscription manager to receive the :DOWN message + Process.sleep(200) + + # Only the subscription we have not stopped should remain + + assert [{^self, ^uuid3, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"]) + assert [{^bin_uuid3, ^node}] = :ets.tab2list(args["subscribers_nodes_table"]) + end + end + + describe "subscription deletion" do + test "subscription is deleted when process goes away", %{pid: pid, args: args} do + {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"]) + {_uuid, _bin_uuid, pg_change_params} = pg_change_params() + + subscriber = + spawn(fn -> + receive do + :stop -> :ok + end + end) + + assert {:ok, [%Postgrex.Result{command: :insert, columns: ["id"], rows: [[1]], num_rows: 1}]} = + Subscriptions.create(conn, args["publication"], [pg_change_params], pid, subscriber) + + # Wait for subscription manager to process the :subscribed message + :sys.get_state(pid) + + assert :ets.info(args["subscribers_pids_table"], :size) == 1 + assert :ets.info(args["subscribers_nodes_table"], :size) == 1 + + assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", []) + + send(subscriber, :stop) + # Wait for subscription manager to receive the :DOWN message + Process.sleep(200) + + assert :ets.info(args["subscribers_pids_table"], :size) == 0 + assert :ets.info(args["subscribers_nodes_table"], :size) == 0 + + # Force check delete queue on manager + send(pid, :check_delete_queue) + Process.sleep(200) + end + end + + describe "check no users" do + test "exit is sent to manager", %{pid: pid} do + :sys.replace_state(pid, fn state -> %{state | no_users_ts: 0} end) + + send(pid, :check_no_users) + + assert_receive {:system, {^pid, _}, {:terminate, :shutdown}} + end + end + + defp pg_change_params do + uuid = UUID.uuid1() + + pg_change_params = %{ + id: uuid, + params: %{"event" => "*", "schema" => "public"}, + claims: %{ + "exp" => System.system_time(:second) + 100_000, + "iat" => 0, + "role" => "anon" + } + } + + {uuid, UUID.string_to_binary!(uuid), pg_change_params} + end +end diff --git a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs index bfbb4bd7a..db39678ac 100644 --- a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs +++ b/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs @@ -1,9 +1,10 @@ -defmodule SubscriptionsCheckerTest do +defmodule Realtime.Extensions.PostgresCdcRl.SubscriptionsCheckerTest do use ExUnit.Case, async: true alias Extensions.PostgresCdcRls.SubscriptionsChecker, as: Checker + import UUID, only: [uuid1: 0, string_to_binary!: 1] test "subscribers_by_node/1" do - tid = :ets.new(:table, [:public, :bag]) + subscribers_pids_table = :ets.new(:table, [:public, :bag]) test_data = [ {:pid1, "id1", :ref, :node1}, @@ -11,9 +12,9 @@ defmodule SubscriptionsCheckerTest do {:pid2, "id2", :ref, :node2} ] - :ets.insert(tid, test_data) + :ets.insert(subscribers_pids_table, test_data) - assert Checker.subscribers_by_node(tid) == %{ + assert Checker.subscribers_by_node(subscribers_pids_table) == %{ node1: MapSet.new([:pid1]), node2: MapSet.new([:pid2]) } @@ -40,41 +41,66 @@ defmodule SubscriptionsCheckerTest do end end - describe "pop_not_alive_pids/2" do + describe "pop_not_alive_pids/4" do test "one subscription per channel" do - tid = :ets.new(:table, [:public, :bag]) + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + subscribers_nodes_table = :ets.new(:table, [:public, :set]) - uuid1 = UUID.uuid1() - uuid2 = UUID.uuid1() + uuid1 = uuid1() + uuid2 = uuid1() + uuid3 = uuid1() - test_data = [ + pids_test_data = [ {:pid1, uuid1, :ref, :node1}, {:pid1, uuid2, :ref, :node1}, - {:pid2, "uuid", :ref, :node2} + {:pid2, uuid3, :ref, :node2} ] - :ets.insert(tid, test_data) + :ets.insert(subscribers_pids_table, pids_test_data) + + nodes_test_data = [ + {string_to_binary!(uuid1), :node1}, + {string_to_binary!(uuid2), :node1}, + {string_to_binary!(uuid3), :node2} + ] - not_alive = Enum.sort(Checker.pop_not_alive_pids([:pid1], tid, "id")) - expected = Enum.sort([UUID.string_to_binary!(uuid1), UUID.string_to_binary!(uuid2)]) + :ets.insert(subscribers_nodes_table, nodes_test_data) + + not_alive = Enum.sort(Checker.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id")) + expected = Enum.sort([string_to_binary!(uuid1), string_to_binary!(uuid2)]) assert not_alive == expected - assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}] + assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid3, :ref, :node2}] + assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid3), :node2}] end test "two subscriptions per channel" do - tid = :ets.new(:table, [:public, :bag]) + subscribers_pids_table = :ets.new(:table, [:public, :bag]) + subscribers_nodes_table = :ets.new(:table, [:public, :set]) - uuid1 = UUID.uuid1() + uuid1 = uuid1() + uuid2 = uuid1() test_data = [ {:pid1, uuid1, :ref, :node1}, - {:pid2, "uuid", :ref, :node2} + {:pid2, uuid2, :ref, :node2} ] - :ets.insert(tid, test_data) - assert Checker.pop_not_alive_pids([:pid1], tid, "id") == [UUID.string_to_binary!(uuid1)] - assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}] + :ets.insert(subscribers_pids_table, test_data) + + nodes_test_data = [ + {string_to_binary!(uuid1), :node1}, + {string_to_binary!(uuid2), :node2} + ] + + :ets.insert(subscribers_nodes_table, nodes_test_data) + + assert Checker.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id") == [ + string_to_binary!(uuid1) + ] + + assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid2, :ref, :node2}] + assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid2), :node2}] end end end diff --git a/test/realtime/extensions/cdc_rls/subscriptions_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_test.exs index cb53b72ed..7cab96abf 100644 --- a/test/realtime/extensions/cdc_rls/subscriptions_test.exs +++ b/test/realtime/extensions/cdc_rls/subscriptions_test.exs @@ -4,10 +4,9 @@ defmodule Realtime.Extensionsubscriptions.CdcRlsSubscriptionsTest do alias Extensions.PostgresCdcRls.Subscriptions alias Realtime.Database - alias Realtime.Tenants setup do - tenant = Tenants.get_tenant_by_external_id("dev_tenant") + tenant = Containers.checkout_tenant(run_migrations: true) {:ok, conn} = tenant diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs index 0c41d3ea1..5fff6b082 100644 --- a/test/realtime/gen_rpc_test.exs +++ b/test/realtime/gen_rpc_test.exs @@ -219,6 +219,53 @@ defmodule Realtime.GenRpcTest do end end + describe "cast/5" do + test "apply on a local node" do + parent = self() + + assert GenRpc.cast(node(), Kernel, :send, [parent, :sent]) == :ok + + assert_receive :sent + refute_receive _any + end + + test "apply on a remote node", %{node: node} do + parent = self() + + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + + assert_receive :sent + refute_receive _any + end + + test "bad node does nothing" do + node = :"unknown@1.1.1.1" + + parent = self() + + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + + refute_receive _any + end + + @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}] + test "tcp error", %{node: node} do + parent = self() + Logger.put_process_level(self(), :debug) + + log = + capture_log(fn -> + assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok + # We have to wait for gen_rpc logs to show up + Process.sleep(100) + end) + + assert log =~ "[error] event=connect_to_remote_server" + + refute_receive _any + end + end + describe "multicast/4" do test "evals everywhere" do parent = self() diff --git a/test/realtime_web/tenant_broadcaster_test.exs b/test/realtime_web/tenant_broadcaster_test.exs index bc3b4f90a..e2a46a2b2 100644 --- a/test/realtime_web/tenant_broadcaster_test.exs +++ b/test/realtime_web/tenant_broadcaster_test.exs @@ -55,7 +55,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do end for pubsub_adapter <- [:gen_rpc, :pg2] do - describe "pubsub_broadcast/4 #{pubsub_adapter}" do + describe "pubsub_broadcast/5 #{pubsub_adapter}" do @describetag pubsub_adapter: pubsub_adapter test "pubsub_broadcast", %{node: node} do @@ -109,10 +109,8 @@ defmodule RealtimeWeb.TenantBroadcasterTest do } end end - end - for pubsub_adapter <- [:gen_rpc, :pg2] do - describe "pubsub_broadcast_from/5 #{pubsub_adapter}" do + describe "pubsub_broadcast_from/6 #{pubsub_adapter}" do @describetag pubsub_adapter: pubsub_adapter test "pubsub_broadcast_from", %{node: node} do @@ -149,6 +147,67 @@ defmodule RealtimeWeb.TenantBroadcasterTest do refute_receive _any end end + + describe "pubsub_direct_broadcast/6 #{pubsub_adapter}" do + @describetag pubsub_adapter: pubsub_adapter + + test "pubsub_direct_broadcast", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}} + + TenantBroadcaster.pubsub_direct_broadcast(node(), "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 114}, + %{tenant: "realtime-dev", message_type: :broadcast} + } + end + + test "pubsub_direct_broadcast list payload", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + + TenantBroadcaster.pubsub_direct_broadcast(node(), "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 130}, + %{tenant: "realtime-dev", message_type: :broadcast} + } + end + + test "pubsub_direct_broadcast string payload", %{node: node} do + message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"} + + TenantBroadcaster.pubsub_direct_broadcast(node(), "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + TenantBroadcaster.pubsub_direct_broadcast(node, "realtime-dev", @topic, message, Phoenix.PubSub, :broadcast) + + assert_receive ^message + + # Remote node received the broadcast + assert_receive {:relay, ^node, ^message} + + assert_receive { + :telemetry, + [:realtime, :tenants, :payload, :size], + %{size: 119}, + %{tenant: "realtime-dev", message_type: :broadcast} + } + end + end end describe "collect_payload_size/3" do diff --git a/test/support/containers.ex b/test/support/containers.ex index bc49fa275..c17744f44 100644 --- a/test/support/containers.ex +++ b/test/support/containers.ex @@ -149,6 +149,28 @@ defmodule Containers do :poolboy.checkin(Containers.Pool, container) end) + publication = "supabase_realtime_test" + + Postgrex.transaction(conn, fn db_conn -> + queries = [ + "DROP TABLE IF EXISTS public.test", + "DROP PUBLICATION IF EXISTS #{publication}", + "create sequence if not exists test_id_seq;", + """ + create table "public"."test" ( + "id" int4 not null default nextval('test_id_seq'::regclass), + "details" text, + primary key ("id")); + """, + "grant all on table public.test to anon;", + "grant all on table public.test to postgres;", + "grant all on table public.test to authenticated;", + "create publication #{publication} for all tables" + ] + + Enum.each(queries, &Postgrex.query!(db_conn, &1, [])) + end) + tenant = if run_migrations? do case run_migrations(tenant) do diff --git a/test/test_helper.exs b/test/test_helper.exs index 435f00ef8..c97eaa0b2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -50,13 +50,14 @@ end_time = :os.system_time(:millisecond) IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms") Mimic.copy(:syn) +Mimic.copy(Extensions.PostgresCdcRls.Replications) +Mimic.copy(Realtime.Database) Mimic.copy(Realtime.GenCounter) Mimic.copy(Realtime.Nodes) Mimic.copy(Realtime.RateCounter) Mimic.copy(Realtime.Tenants.Authorization) Mimic.copy(Realtime.Tenants.Cache) Mimic.copy(Realtime.Tenants.Connect) -Mimic.copy(Realtime.Database) Mimic.copy(Realtime.Tenants.Migrations) Mimic.copy(Realtime.Tenants.Rebalancer) Mimic.copy(Realtime.Tenants.ReplicationConnection) From c11f0a41d17143f6269eec2ab22f9cf3708df8a1 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 15 Oct 2025 12:25:09 +1300 Subject: [PATCH 042/123] fix: set max_heap_size to RealtimeChannel the same as the websocket transport (#1563) Also set fullsweep_after for RealtimeChannel processes --- config/config.exs | 1 + lib/realtime/gen_rpc/pub_sub.ex | 2 +- lib/realtime_web/channels/realtime_channel.ex | 5 +++++ lib/realtime_web/endpoint.ex | 4 +++- mix.exs | 2 +- test/realtime/gen_rpc_pub_sub_test.exs | 2 +- .../channels/realtime_channel_test.exs | 17 ++++++++++++++++- 7 files changed, 28 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index cada8230f..7879c2d7a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,6 +8,7 @@ import Config config :realtime, + websocket_fullsweep_after: 20, ecto_repos: [Realtime.Repo], version: Mix.Project.config()[:version] diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex index 3ba9e053a..00bd127c1 100644 --- a/lib/realtime/gen_rpc/pub_sub.ex +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -67,7 +67,7 @@ defmodule Realtime.GenRpcPubSub.Worker do @impl true def init(pubsub) do Process.flag(:message_queue_data, :off_heap) - Process.flag(:fullsweep_after, 100) + Process.flag(:fullsweep_after, 20) {:ok, pubsub} end diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 104d9a077..255b6edfd 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -28,6 +28,7 @@ defmodule RealtimeWeb.RealtimeChannel do alias RealtimeWeb.RealtimeChannel.Tracker @confirm_token_ms_interval :timer.minutes(5) + @fullsweep_after Application.compile_env!(:realtime, :websocket_fullsweep_after) @impl true def join("realtime:", _params, socket) do @@ -42,6 +43,8 @@ defmodule RealtimeWeb.RealtimeChannel do transport_pid: transport_pid } = socket + Process.flag(:max_heap_size, max_heap_size()) + Process.flag(:fullsweep_after, @fullsweep_after) Tracker.track(socket.transport_pid) Logger.metadata(external_id: tenant_id, project: tenant_id) Logger.put_process_level(self(), log_level) @@ -799,4 +802,6 @@ defmodule RealtimeWeb.RealtimeChannel do end defp maybe_replay_messages(_, _, _, _), do: {:ok, MapSet.new()} + + defp max_heap_size(), do: Application.fetch_env!(:realtime, :websocket_max_heap_size) end diff --git a/lib/realtime_web/endpoint.ex b/lib/realtime_web/endpoint.ex index 894911803..dd91e7664 100644 --- a/lib/realtime_web/endpoint.ex +++ b/lib/realtime_web/endpoint.ex @@ -11,10 +11,12 @@ defmodule RealtimeWeb.Endpoint do signing_salt: "5OUq5X4H" ] + @fullsweep_after Application.compile_env!(:realtime, :websocket_fullsweep_after) + socket "/socket", RealtimeWeb.UserSocket, websocket: [ connect_info: [:peer_data, :uri, :x_headers], - fullsweep_after: 20, + fullsweep_after: @fullsweep_after, max_frame_size: 5_000_000, # https://github.com/ninenines/cowboy/blob/24d32de931a0c985ff7939077463fc8be939f0e9/doc/src/manual/cowboy_websocket.asciidoc#L228 # active_n: The number of packets Cowboy will request from the socket at once. diff --git a/mix.exs b/mix.exs index 6d47591de..301b2fcd1 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.54.0", + version: "2.54.1", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index 517c6c369..f86a98a73 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -13,6 +13,6 @@ defmodule Realtime.GenRpcPubSubTest do test "it sets fullsweep_after flag on the workers" do assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 |> Process.whereis() - |> Process.info(:fullsweep_after) == {:fullsweep_after, 100} + |> Process.info(:fullsweep_after) == {:fullsweep_after, 20} end end diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 8022d6ebd..055516e64 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -28,12 +28,27 @@ defmodule RealtimeWeb.RealtimeChannelTest do setup :rls_context - test "max heap size is set", %{tenant: tenant} do + test "max heap size is set for both transport and channel processes", %{tenant: tenant} do jwt = Generators.generate_jwt_token(tenant) {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) assert Process.info(socket.transport_pid, :max_heap_size) == {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}} + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{}) + + assert Process.info(socket.channel_pid, :max_heap_size) == + {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}} + end + + # We don't test the socket because on unit tests Phoenix is not setting the fullsweep_after config + test "fullsweep_after is set on channel process", %{tenant: tenant} do + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt)) + + assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{}) + + assert Process.info(socket.channel_pid, :fullsweep_after) == {:fullsweep_after, 20} end describe "broadcast" do From fc46833fd2d880f53211b12cf4da546bb70c683c Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 15 Oct 2025 16:05:03 +1300 Subject: [PATCH 043/123] fix: use gen_rpc on Postgres Changes (#1577) * Use GenRpc on SubscriptionsChecker * Use GenRpc on PostgresCdcRls --- lib/extensions/postgres_cdc_rls/cdc_rls.ex | 6 +- .../postgres_cdc_rls/subscriptions_checker.ex | 6 +- mix.exs | 2 +- ...subscriptions_checker_distributed_test.exs | 66 +++++++++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 test/realtime/extensions/cdc_rls/subscriptions_checker_distributed_test.exs diff --git a/lib/extensions/postgres_cdc_rls/cdc_rls.ex b/lib/extensions/postgres_cdc_rls/cdc_rls.ex index 57bf17352..3ca373674 100644 --- a/lib/extensions/postgres_cdc_rls/cdc_rls.ex +++ b/lib/extensions/postgres_cdc_rls/cdc_rls.ex @@ -9,7 +9,7 @@ defmodule Extensions.PostgresCdcRls do alias RealtimeWeb.Endpoint alias Extensions.PostgresCdcRls, as: Rls alias Rls.Subscriptions - alias Realtime.Rpc + alias Realtime.GenRpc @spec handle_connect(map()) :: {:ok, {pid(), pid()}} | nil def handle_connect(args) do @@ -32,7 +32,7 @@ defmodule Extensions.PostgresCdcRls do conn_node = node(conn) if conn_node !== node() do - Rpc.call(conn_node, Subscriptions, :create, opts, timeout: 15_000) + GenRpc.call(conn_node, Subscriptions, :create, opts, timeout: 15_000) else apply(Subscriptions, :create, opts) end @@ -70,7 +70,7 @@ defmodule Extensions.PostgresCdcRls do "Starting distributed postgres extension #{inspect(lauch_node: launch_node, region: region, platform_region: platform_region)}" ) - case Rpc.call(launch_node, __MODULE__, :start, [args], timeout: 30_000, tenant: tenant) do + case GenRpc.call(launch_node, __MODULE__, :start, [args], timeout: 30_000, tenant: tenant) do {:ok, _pid} = ok -> ok diff --git a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex b/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex index 50bf9eac1..cca3dc02a 100644 --- a/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex +++ b/lib/extensions/postgres_cdc_rls/subscriptions_checker.ex @@ -7,7 +7,7 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do alias Realtime.Database alias Realtime.Helpers - alias Realtime.Rpc + alias Realtime.GenRpc alias Realtime.Telemetry alias Rls.Subscriptions @@ -177,8 +177,8 @@ defmodule Extensions.PostgresCdcRls.SubscriptionsChecker do if node == node() do acc ++ not_alive_pids(pids) else - case Rpc.call(node, __MODULE__, :not_alive_pids, [pids], timeout: 15_000) do - {:badrpc, _} = error -> + case GenRpc.call(node, __MODULE__, :not_alive_pids, [pids], timeout: 15_000) do + {:error, :rpc_error, _} = error -> log_error("UnableToCheckProcessesOnRemoteNode", error) acc diff --git a/mix.exs b/mix.exs index 301b2fcd1..9e4ea1736 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.54.1", + version: "2.54.2", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/extensions/cdc_rls/subscriptions_checker_distributed_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_checker_distributed_test.exs new file mode 100644 index 000000000..3b459e6c1 --- /dev/null +++ b/test/realtime/extensions/cdc_rls/subscriptions_checker_distributed_test.exs @@ -0,0 +1,66 @@ +defmodule Realtime.Extensions.CdcRls.SubscriptionsCheckerDistributedTest do + # Usage of Clustered + use ExUnit.Case, async: false + import ExUnit.CaptureLog + + alias Extensions.PostgresCdcRls.SubscriptionsChecker, as: Checker + + setup do + {:ok, peer, remote_node} = Clustered.start_disconnected() + true = Node.connect(remote_node) + {:ok, peer: peer, remote_node: remote_node} + end + + describe "not_alive_pids_dist/1" do + test "returns empty list for all alive PIDs", %{remote_node: remote_node} do + assert Checker.not_alive_pids_dist(%{}) == [] + + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + + assert Checker.not_alive_pids_dist(%{node() => MapSet.new([pid1, pid2, pid3]), remote_node => MapSet.new([pid4])}) == + [] + end + + test "returns list of dead PIDs", %{remote_node: remote_node} do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + pid5 = Node.spawn(remote_node, Process, :sleep, [5000]) + + Process.exit(pid2, :kill) + Process.exit(pid5, :kill) + + assert Checker.not_alive_pids_dist(%{ + node() => MapSet.new([pid1, pid2, pid3]), + remote_node => MapSet.new([pid4, pid5]) + }) == [pid2, pid5] + end + + test "handles rpc error", %{remote_node: remote_node, peer: peer} do + pid1 = spawn(fn -> Process.sleep(5000) end) + pid2 = spawn(fn -> Process.sleep(5000) end) + pid3 = spawn(fn -> Process.sleep(5000) end) + pid4 = Node.spawn(remote_node, Process, :sleep, [5000]) + pid5 = Node.spawn(remote_node, Process, :sleep, [5000]) + + Process.exit(pid2, :kill) + + # Stop the other node + :peer.stop(peer) + + log = + capture_log(fn -> + assert Checker.not_alive_pids_dist(%{ + node() => MapSet.new([pid1, pid2, pid3]), + remote_node => MapSet.new([pid4, pid5]) + }) == [pid2] + end) + + assert log =~ "UnableToCheckProcessesOnRemoteNode" + end + end +end From 28d86096b0708be852bbd6c415ee714a20692b92 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 15 Oct 2025 21:17:42 +1300 Subject: [PATCH 044/123] chore: more async tests (#1578) Tweaked a few tests and increase max cases to 4 --- test/realtime/database_distributed_test.exs | 100 ++++++++++++++++++ test/realtime/database_test.exs | 83 +-------------- test/realtime/messages_test.exs | 3 +- .../channels/realtime_channel_test.exs | 9 +- test/test_helper.exs | 2 +- 5 files changed, 110 insertions(+), 87 deletions(-) create mode 100644 test/realtime/database_distributed_test.exs diff --git a/test/realtime/database_distributed_test.exs b/test/realtime/database_distributed_test.exs new file mode 100644 index 000000000..43b40743e --- /dev/null +++ b/test/realtime/database_distributed_test.exs @@ -0,0 +1,100 @@ +defmodule Realtime.DatabaseDistributedTest do + # async: false due to usage of Clustered + dev_tenant + use Realtime.DataCase, async: false + + import ExUnit.CaptureLog + + alias Realtime.Database + alias Realtime.Rpc + alias Realtime.Tenants.Connect + + doctest Realtime.Database + def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content}) + + setup do + tenant = Containers.checkout_tenant() + :telemetry.attach(__MODULE__, [:realtime, :database, :transaction], &__MODULE__.handle_telemetry/4, pid: self()) + + on_exit(fn -> :telemetry.detach(__MODULE__) end) + + %{tenant: tenant} + end + + @aux_mod (quote do + defmodule DatabaseAux do + def checker(transaction_conn) do + Postgrex.query!(transaction_conn, "SELECT 1", []) + end + + def error(transaction_conn) do + Postgrex.query!(transaction_conn, "SELECT 1/0", []) + end + + def exception(_) do + raise RuntimeError, "💣" + end + end + end) + + Code.eval_quoted(@aux_mod) + + describe "transaction/1 in clustered mode" do + setup do + Connect.shutdown("dev_tenant") + # Waiting for :syn to "unregister" if the Connect process was up + Process.sleep(100) + :ok + end + + test "success call returns output" do + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) + assert node(db_conn) == node + assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1) + end + + test "handles database errors" do + metadata = [external_id: "123", project: "123"] + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) + assert node(db_conn) == node + + assert capture_log(fn -> + assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata) + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + + test "handles exception" do + metadata = [external_id: "123", project: "123"] + {:ok, node} = Clustered.start(@aux_mod) + {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) + assert node(db_conn) == node + + assert capture_log(fn -> + assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata) + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + + test "db process is not alive anymore" do + metadata = [external_id: "123", project: "123", tenant_id: "123"] + {:ok, node} = Clustered.start(@aux_mod) + # Grab a remote pid that will not exist. :erpc uses a new process to perform the call. + # Once it has returned the process is not alive anymore + + pid = Rpc.call(node, :erlang, :self, []) + assert node(pid) == node + + assert capture_log(fn -> + assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} = + Database.transaction(pid, &DatabaseAux.checker/1, [], metadata) + + # We have to wait for logs to be relayed to this node + Process.sleep(100) + end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" + end + end +end diff --git a/test/realtime/database_test.exs b/test/realtime/database_test.exs index f48de14b6..df4e63456 100644 --- a/test/realtime/database_test.exs +++ b/test/realtime/database_test.exs @@ -1,12 +1,9 @@ defmodule Realtime.DatabaseTest do - # async: false due to usage of Clustered - use Realtime.DataCase, async: false + use Realtime.DataCase, async: true import ExUnit.CaptureLog alias Realtime.Database - alias Realtime.Rpc - alias Realtime.Tenants.Connect doctest Realtime.Database def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content}) @@ -215,84 +212,6 @@ defmodule Realtime.DatabaseTest do end end - @aux_mod (quote do - defmodule DatabaseAux do - def checker(transaction_conn) do - Postgrex.query!(transaction_conn, "SELECT 1", []) - end - - def error(transaction_conn) do - Postgrex.query!(transaction_conn, "SELECT 1/0", []) - end - - def exception(_) do - raise RuntimeError, "💣" - end - end - end) - - Code.eval_quoted(@aux_mod) - - describe "transaction/1 in clustered mode" do - setup do - Connect.shutdown("dev_tenant") - # Waiting for :syn to "unregister" if the Connect process was up - Process.sleep(100) - :ok - end - - test "success call returns output" do - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1) - end - - test "handles database errors" do - metadata = [external_id: "123", project: "123"] - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - - assert capture_log(fn -> - assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata) - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - - test "handles exception" do - metadata = [external_id: "123", project: "123"] - {:ok, node} = Clustered.start(@aux_mod) - {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"]) - assert node(db_conn) == node - - assert capture_log(fn -> - assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata) - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - - test "db process is not alive anymore" do - metadata = [external_id: "123", project: "123", tenant_id: "123"] - {:ok, node} = Clustered.start(@aux_mod) - # Grab a remote pid that will not exist. :erpc uses a new process to perform the call. - # Once it has returned the process is not alive anymore - - pid = Rpc.call(node, :erlang, :self, []) - assert node(pid) == node - - assert capture_log(fn -> - assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} = - Database.transaction(pid, &DatabaseAux.checker/1, [], metadata) - - # We have to wait for logs to be relayed to this node - Process.sleep(100) - end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:" - end - end - describe "pool_size_by_application_name/2" do test "returns the number of connections per application name" do assert Database.pool_size_by_application_name("realtime_connect", %{}) == 1 diff --git a/test/realtime/messages_test.exs b/test/realtime/messages_test.exs index cca0ce742..9b99a5580 100644 --- a/test/realtime/messages_test.exs +++ b/test/realtime/messages_test.exs @@ -1,5 +1,6 @@ defmodule Realtime.MessagesTest do - use Realtime.DataCase, async: true + # usage of Clustered + use Realtime.DataCase, async: false alias Realtime.Api.Message alias Realtime.Database diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 055516e64..96a46e913 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -1,6 +1,5 @@ defmodule RealtimeWeb.RealtimeChannelTest do - # Can't run async true because under the hood Cachex is used and it doesn't see Ecto Sandbox - use RealtimeWeb.ChannelCase, async: false + use RealtimeWeb.ChannelCase, async: true use Mimic import ExUnit.CaptureLog @@ -23,6 +22,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) + Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) {:ok, tenant: tenant} end @@ -978,7 +978,10 @@ defmodule RealtimeWeb.RealtimeChannelTest do put_in(extension, ["settings", "db_port"], db_port) ] - Realtime.Api.update_tenant(tenant, %{extensions: extensions}) + with {:ok, tenant} <- Realtime.Api.update_tenant(tenant, %{extensions: extensions}) do + Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + {:ok, tenant} + end end defp assert_process_down(pid) do diff --git a/test/test_helper.exs b/test/test_helper.exs index c97eaa0b2..002e01b13 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -2,7 +2,7 @@ start_time = :os.system_time(:millisecond) alias Realtime.Api alias Realtime.Database -ExUnit.start(exclude: [:failing], max_cases: 3, capture_log: true) +ExUnit.start(exclude: [:failing], max_cases: 4, capture_log: true) max_cases = ExUnit.configuration()[:max_cases] From c9ced7c55d0b5fc8a696eac1312aecc97607a092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Wed, 15 Oct 2025 16:19:04 +0100 Subject: [PATCH 045/123] fix: validate size of track message (#1574) Validates the size of the track message sent and fails if exceeds the value set by `tenant.max_payload_size_in_kb` --- lib/realtime_web/channels/realtime_channel.ex | 6 ++++ .../realtime_channel/presence_handler.ex | 33 ++++++++++++++----- mix.exs | 2 +- .../presence_handler_test.exs | 12 ++++++- .../channels/realtime_channel_test.exs | 29 ++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/lib/realtime_web/channels/realtime_channel.ex b/lib/realtime_web/channels/realtime_channel.ex index 255b6edfd..5d1455991 100644 --- a/lib/realtime_web/channels/realtime_channel.ex +++ b/lib/realtime_web/channels/realtime_channel.ex @@ -388,6 +388,9 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :rate_limit_exceeded} -> shutdown_response(socket, "Too many presence messages per second") + {:error, :payload_size_exceeded} -> + shutdown_response(socket, "Track message size exceeded") + {:error, error} -> log_error(socket, "UnableToHandlePresence", error) {:reply, :error, socket} @@ -401,6 +404,9 @@ defmodule RealtimeWeb.RealtimeChannel do {:error, :rate_limit_exceeded} -> shutdown_response(socket, "Too many presence messages per second") + {:error, :payload_size_exceeded} -> + shutdown_response(socket, "Track message size exceeded") + {:error, error} -> log_error(socket, "UnableToHandlePresence", error) {:reply, :error, socket} diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index ec16c7b16..7880605ca 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -11,7 +11,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do alias Phoenix.Tracker.Shard alias Realtime.GenCounter alias Realtime.RateCounter - # alias Realtime.Tenants + alias Realtime.Tenants alias Realtime.Tenants.Authorization alias RealtimeWeb.Presence alias RealtimeWeb.RealtimeChannel.Logging @@ -54,7 +54,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do @spec handle(map(), pid() | nil, Socket.t()) :: {:ok, Socket.t()} - | {:error, :rls_policy_error | :unable_to_set_policies | :rate_limit_exceeded | :unable_to_track_presence} + | {:error, + :rls_policy_error + | :unable_to_set_policies + | :rate_limit_exceeded + | :unable_to_track_presence + | :payload_size_exceeded} def handle(%{"event" => event} = payload, db_conn, socket) do event = String.downcase(event, :ascii) handle_presence_event(event, payload, db_conn, socket) @@ -105,14 +110,15 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do end defp track(socket, payload) do - socket = assign(socket, :presence_enabled?, true) - %{assigns: %{presence_key: presence_key, tenant_topic: tenant_topic}} = socket payload = Map.get(payload, "payload", %{}) - RealtimeWeb.TenantBroadcaster.collect_payload_size(socket.assigns.tenant, payload, :presence) - with :ok <- limit_presence_event(socket), + with tenant <- Tenants.Cache.get_tenant_by_external_id(socket.assigns.tenant), + :ok <- validate_payload_size(tenant, payload), + _ <- RealtimeWeb.TenantBroadcaster.collect_payload_size(socket.assigns.tenant, payload, :presence), + :ok <- limit_presence_event(socket), {:ok, _} <- Presence.track(self(), tenant_topic, presence_key, payload) do + socket = assign(socket, :presence_enabled?, true) {:ok, socket} else {:error, {:already_tracked, pid, _, _}} -> @@ -124,6 +130,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do {:error, :rate_limit_exceeded} -> {:error, :rate_limit_exceeded} + {:error, :payload_size_exceeded} -> + {:error, :payload_size_exceeded} + {:error, error} -> log_error("UnableToTrackPresence", error) {:error, :unable_to_track_presence} @@ -144,8 +153,6 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do %{assigns: %{presence_rate_counter: presence_counter, tenant: _tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) - # tenant = Tenants.Cache.get_tenant_by_external_id(tenant_id) - if rate_counter.avg > @presence_limit do {:error, :rate_limit_exceeded} else @@ -153,4 +160,14 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do :ok end end + + # Added due to the fact that JSON decoding adds some overhead and erlang term will be slighly larger + @payload_size_padding 500 + defp validate_payload_size(tenant, payload) do + if :erlang.external_size(payload) > tenant.max_payload_size_in_kb * 1000 + @payload_size_padding do + {:error, :payload_size_exceeded} + else + :ok + end + end end diff --git a/mix.exs b/mix.exs index 9e4ea1736..7c9402619 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.54.2", + version: "2.54.3", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index 219f13e55..69f89f36a 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -403,6 +403,17 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert log =~ "PresenceRateLimitReached" end + + test "fails on high payload size", %{tenant: tenant, topic: topic, db_conn: db_conn} do + key = random_string() + socket = socket_fixture(tenant, topic, key, private?: false) + payload_size = tenant.max_payload_size_in_kb * 1000 + + payload = %{content: random_string(payload_size)} + + assert {:error, :payload_size_exceeded} = + PresenceHandler.handle(%{"event" => "track", "payload" => payload}, db_conn, socket) + end end describe "sync/1" do @@ -461,7 +472,6 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do assert log =~ "PresenceRateLimitReached" end - @tag :skip @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence] test "respects rate limits on private channels", %{tenant: tenant, topic: topic, db_conn: db_conn} do key = random_string() diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index 96a46e913..16e337af8 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -273,6 +273,35 @@ defmodule RealtimeWeb.RealtimeChannelTest do # presence_state assert Enum.sum(bucket) == 1 end + + test "presence track closes on high payload size", %{tenant: tenant} do + topic = "realtime:test" + jwt = Generators.generate_jwt_token(tenant) + {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt)) + + assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, topic, %{}) + + assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500 + + payload = %{ + type: "presence", + event: "TRACK", + payload: %{name: "realtime_presence_96", t: 1814.7000000029802, content: String.duplicate("a", 3_500_000)} + } + + push(socket, "presence", payload) + + assert_receive %Phoenix.Socket.Message{ + event: "system", + payload: %{ + extension: "system", + message: "Track message size exceeded", + status: "error" + }, + topic: ^topic + }, + 500 + end end describe "unexpected errors" do From 617afae68ecb56547f8320008d26f55f5620d3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 16 Oct 2025 20:43:10 +0100 Subject: [PATCH 046/123] fix: set sane defaults for presence event limit (#1544) --- lib/realtime/api/tenant.ex | 2 +- .../channels/realtime_channel/presence_handler.ex | 4 ++-- mix.exs | 2 +- .../20250926223044_set_default_presence_value.exs | 10 ++++++++++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20250926223044_set_default_presence_value.exs diff --git a/lib/realtime/api/tenant.ex b/lib/realtime/api/tenant.ex index cf609cafc..65b19c40c 100644 --- a/lib/realtime/api/tenant.ex +++ b/lib/realtime/api/tenant.ex @@ -19,7 +19,7 @@ defmodule Realtime.Api.Tenant do field(:postgres_cdc_default, :string) field(:max_concurrent_users, :integer) field(:max_events_per_second, :integer) - field(:max_presence_events_per_second, :integer, default: 10_000) + field(:max_presence_events_per_second, :integer, default: 1000) field(:max_payload_size_in_kb, :integer, default: 3000) field(:max_bytes_per_second, :integer) field(:max_channels_per_client, :integer) diff --git a/lib/realtime_web/channels/realtime_channel/presence_handler.ex b/lib/realtime_web/channels/realtime_channel/presence_handler.ex index 7880605ca..d5a184caa 100644 --- a/lib/realtime_web/channels/realtime_channel/presence_handler.ex +++ b/lib/realtime_web/channels/realtime_channel/presence_handler.ex @@ -148,12 +148,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandler do |> Phoenix.Presence.group() end - @presence_limit 100 defp limit_presence_event(socket) do %{assigns: %{presence_rate_counter: presence_counter, tenant: _tenant_id}} = socket {:ok, rate_counter} = RateCounter.get(presence_counter) + tenant = Tenants.Cache.get_tenant_by_external_id(socket.assigns.tenant) - if rate_counter.avg > @presence_limit do + if rate_counter.avg > tenant.max_presence_events_per_second do {:error, :rate_limit_exceeded} else GenCounter.add(presence_counter.id) diff --git a/mix.exs b/mix.exs index 7c9402619..11583a9e4 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.54.3", + version: "2.54.4", elixir: "~> 1.17.3", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/priv/repo/migrations/20250926223044_set_default_presence_value.exs b/priv/repo/migrations/20250926223044_set_default_presence_value.exs new file mode 100644 index 000000000..5f1833a34 --- /dev/null +++ b/priv/repo/migrations/20250926223044_set_default_presence_value.exs @@ -0,0 +1,10 @@ +defmodule Realtime.Repo.Migrations.SetDefaultPresenceValue do + use Ecto.Migration + @disable_ddl_transaction true + @disable_migration_lock true + def change do + alter table(:tenants) do + modify :max_presence_events_per_second, :integer, default: 1000 + end + end +end From a125ba1cc401c290ded205e7d92a03b036923b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 20 Oct 2025 18:30:35 +0100 Subject: [PATCH 047/123] feat: bump up to 1.18 and otp28 (#1582) --- .github/workflows/lint.yml | 2 +- .github/workflows/prod_linter.yml | 4 +- .github/workflows/tests.yml | 2 +- .tool-versions | 4 +- Dockerfile | 6 +- .../channels/auth/channels_authorization.ex | 6 +- mix.exs | 4 +- mix.lock | 76 +++++++++---------- .../extensions/cdc_rls/cdc_rls_test.exs | 4 - 9 files changed, 52 insertions(+), 56 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 555377898..c6d8bf196 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,7 +32,7 @@ jobs: uses: erlef/setup-beam@v1 with: otp-version: 27.x # Define the OTP version [required] - elixir-version: 1.17.x # Define the elixir version [required] + elixir-version: 1.18.x # Define the elixir version [required] - name: Cache Mix uses: actions/cache@v4 with: diff --git a/.github/workflows/prod_linter.yml b/.github/workflows/prod_linter.yml index 4aac1a2eb..243493034 100644 --- a/.github/workflows/prod_linter.yml +++ b/.github/workflows/prod_linter.yml @@ -15,8 +15,8 @@ jobs: id: beam uses: erlef/setup-beam@v1 with: - otp-version: 26.x # Define the OTP version [required] - elixir-version: 1.16.x # Define the elixir version [required] + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.18.x # Define the elixir version [required] - name: Cache Mix uses: actions/cache@v4 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a010636fd..a0f982760 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,7 @@ jobs: uses: erlef/setup-beam@v1 with: otp-version: 27.x # Define the OTP version [required] - elixir-version: 1.17.x # Define the elixir version [required] + elixir-version: 1.18.x # Define the elixir version [required] - name: Cache Mix uses: actions/cache@v4 with: diff --git a/.tool-versions b/.tool-versions index 35b41200e..9ac56e91e 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.17.3 +elixir 1.18.4 nodejs 18.13.0 -erlang 27.1 +erlang 27 diff --git a/Dockerfile b/Dockerfile index 33da5983f..380072479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -ARG ELIXIR_VERSION=1.17.3 -ARG OTP_VERSION=27.1.2 -ARG DEBIAN_VERSION=bookworm-20241111-slim +ARG ELIXIR_VERSION=1.18 +ARG OTP_VERSION=27.3 +ARG DEBIAN_VERSION=bookworm-20250929-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" diff --git a/lib/realtime_web/channels/auth/channels_authorization.ex b/lib/realtime_web/channels/auth/channels_authorization.ex index 56c574f34..b5eeacc2f 100644 --- a/lib/realtime_web/channels/auth/channels_authorization.ex +++ b/lib/realtime_web/channels/auth/channels_authorization.ex @@ -20,10 +20,10 @@ defmodule RealtimeWeb.ChannelsAuthorization do def authorize_conn(token, jwt_secret, jwt_jwks) do case authorize(token, jwt_secret, jwt_jwks) do {:ok, claims} -> - required = MapSet.new(["role", "exp"]) - claims_keys = claims |> Map.keys() |> MapSet.new() + required = ["role", "exp"] + claims_keys = Map.keys(claims) - if MapSet.subset?(required, claims_keys), + if Enum.all?(required, &(&1 in claims_keys)), do: {:ok, claims}, else: {:error, :missing_claims} diff --git a/mix.exs b/mix.exs index 11583a9e4..17d1500c7 100644 --- a/mix.exs +++ b/mix.exs @@ -4,8 +4,8 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.54.4", - elixir: "~> 1.17.3", + version: "2.55.0", + elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), diff --git a/mix.lock b/mix.lock index ba6f47328..658c1c687 100644 --- a/mix.lock +++ b/mix.lock @@ -3,39 +3,39 @@ "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "bertex": {:hex, :bertex, "1.3.0", "0ad0df9159b5110d9d2b6654f72fbf42a54884ef43b6b651e6224c0af30ba3cb", [:mix], [], "hexpm", "0a5d5e478bb5764b7b7bae37cae1ca491200e58b089df121a2fe1c223d8ee57a"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "cachex": {:hex, :cachex, "4.0.3", "95e88c3ef4d37990948eaecccefe40b4ce4a778e0d7ade29081e6b7a89309ee2", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d5d632da7f162f8a190f1c39b712c0ebc9cf0007c4e2029d44eddc8041b52d55"}, - "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, + "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, + "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, - "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, - "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, - "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, - "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"}, "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, - "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, - "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"}, - "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, + "ex_json_schema": {:hex, :ex_json_schema, "0.11.1", "b593f92937a095f66054bb318681397dfe7304e7d2b6b1a7534ea3aa40024f8c", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "32d651a575a6ce2fd613f140b0fef8dd0acc7cf8e8bcd29a3a1be5c945700dd5"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "901aada9adb307ff89a8be197a5d384e69dd57d6", [ref: "901aada9adb307ff89a8be197a5d384e69dd57d6"]}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, - "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, + "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, @@ -45,22 +45,22 @@ "logflare_api_client": {:hex, :logflare_api_client, "0.3.5", "c427ebf65a8402d68b056d4a5ef3e1eb3b90c0ad1d0de97d1fe23807e0c1b113", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:finch, "~> 0.10", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "16d29abcb80c4f72745cdf943379da02a201504813c3aa12b4d4acb0302b7723"}, "logflare_etso": {:hex, :logflare_etso, "1.1.2", "040bd3e482aaf0ed20080743b7562242ec5079fd88a6f9c8ce5d8298818292e9", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "ab96be42900730a49b132891f43a9be1d52e4ad3ee9ed9cb92565c5f87345117"}, "logflare_logger_backend": {:hex, :logflare_logger_backend, "0.11.4", "3a5df94e764b7c8ee4bd7b875a480a34a27807128d8459aa59ea63b2b38bddc7", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:logflare_api_client, "~> 0.3.5", [hex: :logflare_api_client, repo: "hexpm", optional: false]}, {:logflare_etso, "~> 1.1.2", [hex: :logflare_etso, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "00998d81b3c481ad93d2bf25e66d1ddb1a01ad77d994e2c1a7638c6da94755c5"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimic": {:hex, :mimic, "1.12.0", "34c9d1fb8e756df09ca5f96861d273f2bb01063df1a6a51a4c101f9ad7f07a9c", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "eaa43d495d6f3bc8099b28886e05a1b09a2a6be083f6385c3abc17599e5e2c43"}, - "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"}, - "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "observer_cli": {:hex, :observer_cli, "1.8.1", "edfe0c0f983631961599326f239f6e99750aba7387515002b1284dcfe7fcd6d2", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "a3cd6300dd8290ade93d688fbd79c872e393b01256309dd7a653feb13c434fb4"}, + "observer_cli": {:hex, :observer_cli, "1.8.4", "09030c04d2480499037ba33d801c6e02adba4e7244a05e05b984b5a82843be71", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "0fcd71ac723bcd2d91266d99b3c3ccd9465c71c9f392d900cea8effdc1a1485c"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, - "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"}, - "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, - "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, + "open_api_spex": {:hex, :open_api_spex, "3.22.0", "fbf90dc82681dc042a4ee79853c8e989efbba73d9e87439085daf849bbf8bc20", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "dd751ddbdd709bb4a5313e9a24530da6e66594773c7242a0c2592cbd9f589063"}, + "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, "opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "1.0.0", "786c7cde66a2493323c79d2c94e679ff501d459a9b403d8b60b9bef116333117", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7575716eaccacd0eddc3e7e61403aecb5d0a6397183987d6049094aeb0b87a7c"}, "opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"}, - "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.9.0", "e344bf5e3dab2815fe381b0cac172c06cfc29ecf792c5d74cbbd2b3184af359c", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.6.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "2030a59e33afff6aaeba847d865c8db5dc3873db87a9257df2ca03cafd9e0478"}, "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"}, "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, @@ -69,41 +69,41 @@ "phoenix": {:git, "https://github.com/supabase/phoenix.git", "7b884cc0cc1a49ad2bc272acda2e622b3e11c139", [branch: "feat/presence-custom-dispatcher-1.7.19"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "69129221f0263aa13faa5fbb8af97c28aeb4f71c", []}, + "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "3b0700ee38a1dddaf7936c5793d6f35431fee2cd", []}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "snabbkaffe": {:git, "https://github.com/kafka4beam/snabbkaffe", "b59298334ed349556f63405d1353184c63c66534", [tag: "1.0.10"]}, - "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, + "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"}, "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, - "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, - "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"}, - "tls_certificate_check": {:hex, :tls_certificate_check, "1.28.0", "c39bf21f67c2d124ae905454fad00f27e625917e8ab1009146e916e1df6ab275", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3ab058c3f9457fffca916729587415f0ddc822048a0e5b5e2694918556d92df1"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index ba7ebc072..ef9218392 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -124,10 +124,6 @@ defmodule Realtime.Extensions.CdcRlsTest do {subscriber_manager_pid, conn} = Enum.reduce_while(1..25, nil, fn _, acc -> case PostgresCdcRls.get_manager_conn(tenant.external_id) do - nil -> - Process.sleep(200) - {:cont, acc} - {:error, :wait} -> Process.sleep(200) {:cont, acc} From 95416667faa799fe4e5f5c4e05c1795966bac03f Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 21 Oct 2025 09:32:54 +1300 Subject: [PATCH 048/123] fix: update gen_rpc with fullsweep_after=20 (#1581) https://github.com/supabase/gen_rpc/pull/5 --- mix.exs | 4 ++-- mix.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 17d1500c7..027c0d916 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.55.0", + version: "2.55.1", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -90,7 +90,7 @@ defmodule Realtime.MixProject do {:opentelemetry_phoenix, "~> 2.0"}, {:opentelemetry_cowboy, "~> 1.0"}, {:opentelemetry_ecto, "~> 1.2"}, - {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "901aada9adb307ff89a8be197a5d384e69dd57d6"}, + {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "5382a0f2689a4cb8838873a2173928281dbe5002"}, {:mimic, "~> 1.0", only: :test}, {:floki, ">= 0.30.0", only: :test}, {:mint_web_socket, "~> 1.0", only: :test}, diff --git a/mix.lock b/mix.lock index 658c1c687..5b9429da3 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, - "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "901aada9adb307ff89a8be197a5d384e69dd57d6", [ref: "901aada9adb307ff89a8be197a5d384e69dd57d6"]}, + "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "5382a0f2689a4cb8838873a2173928281dbe5002", [ref: "5382a0f2689a4cb8838873a2173928281dbe5002"]}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, From 0ffb34b218ce5603b9b4aa1b23cf914ed6a66d6a Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 21 Oct 2025 09:59:26 +1300 Subject: [PATCH 049/123] fix: on ReplicationPoller send only subscription IDs relevant to the node (#1583) --- .../postgres_cdc_rls/replication_poller.ex | 17 ++++++++++++----- mix.exs | 2 +- .../cdc_rls/replication_poller_test.exs | 13 ++++++++----- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/extensions/postgres_cdc_rls/replication_poller.ex b/lib/extensions/postgres_cdc_rls/replication_poller.ex index 546bc702a..271f7e5ea 100644 --- a/lib/extensions/postgres_cdc_rls/replication_poller.ex +++ b/lib/extensions/postgres_cdc_rls/replication_poller.ex @@ -206,12 +206,13 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do case collect_subscription_nodes(subscribers_nodes_table, change.subscription_ids) do {:ok, nodes} -> - for node <- nodes do + for {node, subscription_ids} <- nodes do TenantBroadcaster.pubsub_direct_broadcast( node, tenant_id, topic, - change, + # Send only the subscription IDs relevant to this node + %{change | subscription_ids: MapSet.new(subscription_ids)}, MessageDispatcher, :postgres_changes ) @@ -236,10 +237,16 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do defp handle_list_changes_result({:error, reason}, _, _, _), do: {:error, reason} defp collect_subscription_nodes(subscribers_nodes_table, subscription_ids) do - Enum.reduce_while(subscription_ids, {:ok, MapSet.new()}, fn subscription_id, {:ok, acc} -> + Enum.reduce_while(subscription_ids, {:ok, %{}}, fn subscription_id, {:ok, acc} -> case :ets.lookup(subscribers_nodes_table, subscription_id) do - [{_, node}] -> {:cont, {:ok, MapSet.put(acc, node)}} - _ -> {:halt, {:error, :node_not_found}} + [{_, node}] -> + updated_acc = + Map.update(acc, node, [subscription_id], fn existing_ids -> [subscription_id | existing_ids] end) + + {:cont, {:ok, updated_acc}} + + _ -> + {:halt, {:error, :node_not_found}} end end) rescue diff --git a/mix.exs b/mix.exs index 027c0d916..d3cb0272a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.55.1", + version: "2.55.2", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/extensions/cdc_rls/replication_poller_test.exs b/test/realtime/extensions/cdc_rls/replication_poller_test.exs index 368f73613..5824c880b 100644 --- a/test/realtime/extensions/cdc_rls/replication_poller_test.exs +++ b/test/realtime/extensions/cdc_rls/replication_poller_test.exs @@ -296,7 +296,8 @@ defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do false, [ sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>, - sub2 = <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>> + sub2 = <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>, + sub3 = <<49, 59, 209, 112, 173, 77, 17, 240, 191, 41, 118, 202, 193, 157, 232, 187>> ], [] ] @@ -306,9 +307,10 @@ defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do messages: [] }} - # Both subscriptions have node information + # All subscriptions have node information :ets.insert(args["subscribers_nodes_table"], {sub1, node()}) :ets.insert(args["subscribers_nodes_table"], {sub2, :"someothernode@127.0.0.1"}) + :ets.insert(args["subscribers_nodes_table"], {sub3, node()}) expect(Replications, :list_changes, fn _, _, _, _, _ -> results end) reject(&TenantBroadcaster.pubsub_broadcast/5) @@ -345,9 +347,10 @@ defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do assert Enum.count(calls) == 2 - Enum.each(calls, fn [node, _, _, _, _, _] -> - assert node in [node(), :"someothernode@127.0.0.1"] - end) + node_subs = Enum.map(calls, fn [node, _, _, change, _, _] -> {node, change.subscription_ids} end) + + assert {node(), MapSet.new([sub1, sub3])} in node_subs + assert {:"someothernode@127.0.0.1", MapSet.new([sub2])} in node_subs end end From f44287e8992fd64d7b1bcf88372a13e53bcc4354 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 21 Oct 2025 10:54:42 +1300 Subject: [PATCH 050/123] feat: regional broadcasting (#1580) When regional_broadcasting is true GenRpcPubSub uses a single node in each region to broadcast to the rest of the region. Nodes communicating inside the same region have a much lower latency. --- config/runtime.exs | 4 +- config/test.exs | 1 + lib/realtime/gen_rpc/pub_sub.ex | 67 ++++++++++-- lib/realtime/nodes.ex | 79 +++++--------- mix.exs | 2 +- test/realtime/gen_rpc_pub_sub/worker_test.exs | 71 ++++++++++++ test/realtime/gen_rpc_pub_sub_test.exs | 101 +++++++++++++++++- test/realtime/nodes_test.exs | 58 ++++++++++ .../rate_counter/rate_counter_test.exs | 2 +- .../message_dispatcher_test.exs | 7 +- test/support/clustered.ex | 2 +- test/test_helper.exs | 14 ++- 12 files changed, 336 insertions(+), 72 deletions(-) create mode 100644 test/realtime/gen_rpc_pub_sub/worker_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index f09d22846..f3319d636 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -71,6 +71,7 @@ broadcast_pool_size = Env.get_integer("BROADCAST_POOL_SIZE", 10) pubsub_adapter = System.get_env("PUBSUB_ADAPTER", "gen_rpc") |> String.to_atom() websocket_max_heap_size = div(Env.get_integer("WEBSOCKET_MAX_HEAP_SIZE", 50_000_000), :erlang.system_info(:wordsize)) users_scope_shards = Env.get_integer("USERS_SCOPE_SHARDS", 5) +regional_broadcasting = Env.get_boolean("REGIONAL_BROADCASTING", false) no_channel_timeout_in_ms = if config_env() == :test, @@ -128,7 +129,8 @@ config :realtime, platform: platform, pubsub_adapter: pubsub_adapter, broadcast_pool_size: broadcast_pool_size, - users_scope_shards: users_scope_shards + users_scope_shards: users_scope_shards, + regional_broadcasting: regional_broadcasting if config_env() != :test && run_janitor? do config :realtime, diff --git a/config/test.exs b/config/test.exs index a69c51701..f28b6b89e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -31,6 +31,7 @@ config :realtime, RealtimeWeb.Endpoint, server: true config :realtime, + regional_broadcasting: true, region: "us-east-1", db_enc_key: "1234567890123456", jwt_claim_validators: System.get_env("JWT_CLAIM_VALIDATORS", "{}"), diff --git a/lib/realtime/gen_rpc/pub_sub.ex b/lib/realtime/gen_rpc/pub_sub.ex index 00bd127c1..e1f21fd8d 100644 --- a/lib/realtime/gen_rpc/pub_sub.ex +++ b/lib/realtime/gen_rpc/pub_sub.ex @@ -5,6 +5,8 @@ defmodule Realtime.GenRpcPubSub do @behaviour Phoenix.PubSub.Adapter alias Realtime.GenRpc + alias Realtime.GenRpcPubSub.Worker + alias Realtime.Nodes use Supervisor @impl true @@ -45,36 +47,83 @@ defmodule Realtime.GenRpcPubSub do @impl true def broadcast(adapter_name, topic, message, dispatcher) do worker = worker_name(adapter_name, self()) - GenRpc.abcast(Node.list(), worker, forward_to_local(topic, message, dispatcher), key: worker) + + if Application.get_env(:realtime, :regional_broadcasting, false) do + my_region = Application.get_env(:realtime, :region) + # broadcast to all other nodes in the region + + other_nodes = for node <- Realtime.Nodes.region_nodes(my_region), node != node(), do: node + GenRpc.abcast(other_nodes, worker, Worker.forward_to_local(topic, message, dispatcher), key: worker) + + # send a message to a node in each region to forward to the rest of the region + other_region_nodes = nodes_from_other_regions(my_region, worker) + + GenRpc.abcast(other_region_nodes, worker, Worker.forward_to_region(topic, message, dispatcher), key: worker) + else + GenRpc.abcast(Node.list(), worker, Worker.forward_to_local(topic, message, dispatcher), key: worker) + end + + :ok + end + + defp nodes_from_other_regions(my_region, key) do + Enum.flat_map(Nodes.all_node_regions(), fn + ^my_region -> + [] + + region -> + case Nodes.node_from_region(region, key) do + {:ok, node} -> [node] + _ -> [] + end + end) end @impl true def direct_broadcast(adapter_name, node_name, topic, message, dispatcher) do worker = worker_name(adapter_name, self()) - GenRpc.abcast([node_name], worker, forward_to_local(topic, message, dispatcher), key: worker) + GenRpc.abcast([node_name], worker, Worker.forward_to_local(topic, message, dispatcher), key: worker) end - - defp forward_to_local(topic, message, dispatcher), do: {:ftl, topic, message, dispatcher} end defmodule Realtime.GenRpcPubSub.Worker do @moduledoc false use GenServer + def forward_to_local(topic, message, dispatcher), do: {:ftl, topic, message, dispatcher} + def forward_to_region(topic, message, dispatcher), do: {:ftr, topic, message, dispatcher} + @doc false - def start_link({pubsub, worker}), do: GenServer.start_link(__MODULE__, pubsub, name: worker) + def start_link({pubsub, worker}), do: GenServer.start_link(__MODULE__, {pubsub, worker}, name: worker) @impl true - def init(pubsub) do + def init({pubsub, worker}) do Process.flag(:message_queue_data, :off_heap) Process.flag(:fullsweep_after, 20) - {:ok, pubsub} + {:ok, {pubsub, worker}} end @impl true - def handle_info({:ftl, topic, message, dispatcher}, pubsub) do + # Forward to local + def handle_info({:ftl, topic, message, dispatcher}, {pubsub, worker}) do + Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher) + {:noreply, {pubsub, worker}} + end + + # Forward to the rest of the region + def handle_info({:ftr, topic, message, dispatcher}, {pubsub, worker}) do + # Forward to local first Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher) - {:noreply, pubsub} + + # Then broadcast to the rest of my region + my_region = Application.get_env(:realtime, :region) + other_nodes = for node <- Realtime.Nodes.region_nodes(my_region), node != node(), do: node + + if other_nodes != [] do + Realtime.GenRpc.abcast(other_nodes, worker, forward_to_local(topic, message, dispatcher), key: worker) + end + + {:noreply, {pubsub, worker}} end @impl true diff --git a/lib/realtime/nodes.ex b/lib/realtime/nodes.ex index 34c9f3cfb..e779360e3 100644 --- a/lib/realtime/nodes.ex +++ b/lib/realtime/nodes.ex @@ -64,6 +64,27 @@ defmodule Realtime.Nodes do def region_nodes(nil), do: [] + @doc """ + Picks a node from a region based on the provided key + """ + @spec node_from_region(String.t(), term()) :: {:ok, node} | {:error, :not_available} + def node_from_region(region, key) when is_binary(region) do + nodes = region_nodes(region) + + case nodes do + [] -> + {:error, :not_available} + + _ -> + member_count = Enum.count(nodes) + index = :erlang.phash2(key, member_count) + + {:ok, Enum.fetch!(nodes, index)} + end + end + + def node_from_region(_, _), do: {:error, :not_available} + @doc """ Picks the node to launch the Postgres connection on. @@ -132,59 +153,9 @@ defmodule Realtime.Nodes do end end - @mapping_realtime_region_to_tenant_region_aws %{ - "ap-southeast-1" => [ - "ap-east-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1" - ], - "ap-southeast-2" => ["ap-southeast-2"], - "eu-west-2" => [ - "eu-central-1", - "eu-central-2", - "eu-north-1", - "eu-west-1", - "eu-west-2", - "eu-west-3" - ], - "us-east-1" => [ - "ca-central-1", - "sa-east-1", - "us-east-1", - "us-east-2" - ], - "us-west-1" => ["us-west-1", "us-west-2"] - } - @mapping_realtime_region_to_tenant_region_fly %{ - "iad" => ["ca-central-1", "sa-east-1", "us-east-1"], - "lhr" => ["eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3"], - "sea" => ["us-west-1"], - "syd" => [ - "ap-east-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2" - ] - } + @all_regions ~w(eu-west-2 us-east-1 us-west-1 ap-southeast-1 ap-southeast-2) - @doc """ - Fetches the tenant regions for a given realtime reagion - """ - @spec region_to_tenant_regions(String.t()) :: list() | nil - def region_to_tenant_regions(region) do - platform = Application.get_env(:realtime, :platform) - - mappings = - case platform do - :aws -> @mapping_realtime_region_to_tenant_region_aws - :fly -> @mapping_realtime_region_to_tenant_region_fly - _ -> %{} - end - - Map.get(mappings, region) - end + @spec all_node_regions() :: [String.t()] + @doc "List all the regions where nodes can be launched" + def all_node_regions(), do: @all_regions end diff --git a/mix.exs b/mix.exs index d3cb0272a..93dc030bb 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.55.2", + version: "2.56.0", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/gen_rpc_pub_sub/worker_test.exs b/test/realtime/gen_rpc_pub_sub/worker_test.exs new file mode 100644 index 000000000..ded1e3ca5 --- /dev/null +++ b/test/realtime/gen_rpc_pub_sub/worker_test.exs @@ -0,0 +1,71 @@ +defmodule Realtime.GenRpcPubSub.WorkerTest do + use ExUnit.Case, async: true + alias Realtime.GenRpcPubSub.Worker + alias Realtime.GenRpc + alias Realtime.Nodes + + use Mimic + + @topic "test_topic" + + setup do + worker = start_link_supervised!({Worker, {Realtime.PubSub, __MODULE__}}) + %{worker: worker} + end + + describe "forward to local" do + test "local broadcast", %{worker: worker} do + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_local(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + refute_receive _any + end + end + + describe "forward to region" do + setup %{worker: worker} do + GenRpc + |> stub() + |> allow(self(), worker) + + Nodes + |> stub() + |> allow(self(), worker) + + :ok + end + + test "local broadcast + forward to other nodes", %{worker: worker} do + parent = self() + expect(Nodes, :region_nodes, fn "us-east-1" -> [node(), :node_us_2, :node_us_3] end) + + expect(GenRpc, :abcast, fn [:node_us_2, :node_us_3], + Realtime.GenRpcPubSub.WorkerTest, + {:ftl, "test_topic", "le message", Phoenix.PubSub}, + [key: Realtime.GenRpcPubSub.WorkerTest] -> + send(parent, :abcast_called) + :ok + end) + + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + assert_receive :abcast_called + refute_receive _any + end + + test "local broadcast and no other nodes", %{worker: worker} do + expect(Nodes, :region_nodes, fn "us-east-1" -> [node()] end) + + reject(GenRpc, :abcast, 4) + + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic) + send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub)) + + assert_receive "le message" + refute_receive _any + end + end +end diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index f86a98a73..65976c6a7 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -2,7 +2,8 @@ Application.put_env(:phoenix_pubsub, :test_adapter, {Realtime.GenRpcPubSub, []}) Code.require_file("../../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__) defmodule Realtime.GenRpcPubSubTest do - use ExUnit.Case, async: true + # Application env being changed + use ExUnit.Case, async: false test "it sets off_heap message_queue_data flag on the workers" do assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1 @@ -15,4 +16,102 @@ defmodule Realtime.GenRpcPubSubTest do |> Process.whereis() |> Process.info(:fullsweep_after) == {:fullsweep_after, 20} end + + @aux_mod (quote do + defmodule Subscriber do + # Relay messages to testing node + def subscribe(subscriber, topic) do + spawn(fn -> + RealtimeWeb.Endpoint.subscribe(topic) + send(subscriber, :ready) + + loop = fn f -> + receive do + msg -> send(subscriber, {:relay, node(), msg}) + end + + f.(f) + end + + loop.(loop) + end) + end + end + end) + + Code.eval_quoted(@aux_mod) + + @topic "gen-rpc-pub-sub-test-topic" + + for regional_broadcasting <- [true, false] do + describe "regional balancing = #{regional_broadcasting}" do + setup do + value = Application.get_env(:realtime, :regional_broadcasting) + Application.put_env(:realtime, :regional_broadcasting, unquote(regional_broadcasting)) + on_exit(fn -> Application.put_env(:realtime, :regional_broadcasting, value) end) + + :ok + end + + @describetag regional_broadcasting: regional_broadcasting + + test "all messages are received" do + # start 1 node in us-east-1 to test my region broadcasting + # start 2 nodes in ap-southeast-2 to test other region broadcasting + + us_node = :us_node + ap2_nodeX = :ap2_nodeX + ap2_nodeY = :ap2_nodeY + + # Avoid port collision + client_config_per_node = %{ + :"main@127.0.0.1" => 5369, + :"#{us_node}@127.0.0.1" => 16970, + :"#{ap2_nodeX}@127.0.0.1" => 16971, + :"#{ap2_nodeY}@127.0.0.1" => 16972 + } + + extra_config = [{:gen_rpc, :client_config_per_node, {:internal, client_config_per_node}}] + + on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end) + Application.put_env(:gen_rpc, :client_config_per_node, {:internal, client_config_per_node}) + + us_extra_config = + [{:realtime, :region, "us-east-1"}, {:gen_rpc, :tcp_server_port, 16970}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: us_node, extra_config: us_extra_config, phoenix_port: 4014) + + ap2_nodeX_extra_config = + [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16971}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeX, extra_config: ap2_nodeX_extra_config, phoenix_port: 4015) + + ap2_nodeY_extra_config = + [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16972}] ++ extra_config + + {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeY, extra_config: ap2_nodeY_extra_config, phoenix_port: 4016) + + RealtimeWeb.Endpoint.subscribe(@topic) + :erpc.multicall(Node.list(), Subscriber, :subscribe, [self(), @topic]) + + # Ensuring that syn had enough time to propagate to all nodes the group information + Process.sleep(500) + + assert_receive :ready + assert_receive :ready + assert_receive :ready + + message = %Phoenix.Socket.Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]} + Phoenix.PubSub.broadcast(Realtime.PubSub, @topic, message) + + assert_receive ^message + + # Remote nodes received the broadcast + assert_receive {:relay, :"us_node@127.0.0.1", ^message}, 1000 + assert_receive {:relay, :"ap2_nodeX@127.0.0.1", ^message}, 1000 + assert_receive {:relay, :"ap2_nodeY@127.0.0.1", ^message}, 1000 + refute_receive _any + end + end + end end diff --git a/test/realtime/nodes_test.exs b/test/realtime/nodes_test.exs index ef9d06fb9..8adfdce84 100644 --- a/test/realtime/nodes_test.exs +++ b/test/realtime/nodes_test.exs @@ -4,6 +4,64 @@ defmodule Realtime.NodesTest do alias Realtime.Nodes alias Realtime.Tenants + defp spawn_fake_node(region, node) do + parent = self() + + fun = fn -> + :syn.join(RegionNodes, region, self(), node: node) + send(parent, :joined) + + receive do + :ok -> :ok + end + end + + {:ok, _pid} = start_supervised({Task, fun}, id: {region, node}) + assert_receive :joined + end + + describe "region_nodes/1" do + test "nil region returns empty list" do + assert Nodes.region_nodes(nil) == [] + end + + test "returns nodes from region" do + region = "ap-southeast-2" + spawn_fake_node(region, :node_1) + spawn_fake_node(region, :node_2) + + spawn_fake_node("eu-west-2", :node_3) + + assert Nodes.region_nodes(region) == [:node_1, :node_2] + assert Nodes.region_nodes("eu-west-2") == [:node_3] + end + + test "on non-existing region, returns empty list" do + assert Nodes.region_nodes("non-existing-region") == [] + end + end + + describe "node_from_region/2" do + test "nil region returns error" do + assert {:error, :not_available} = Nodes.node_from_region(nil, :any_key) + end + + test "empty region returns error" do + assert {:error, :not_available} = Nodes.node_from_region("empty-region", :any_key) + end + + test "returns the same node given the same key" do + region = "ap-southeast-3" + spawn_fake_node(region, :node_1) + spawn_fake_node(region, :node_2) + + spawn_fake_node("eu-west-3", :node_3) + + assert {:ok, :node_2} = Nodes.node_from_region(region, :key1) + assert {:ok, :node_2} = Nodes.node_from_region(region, :key1) + end + end + describe "get_node_for_tenant/1" do setup do tenant = Containers.checkout_tenant() diff --git a/test/realtime/rate_counter/rate_counter_test.exs b/test/realtime/rate_counter/rate_counter_test.exs index 6d3f57401..8606f1e28 100644 --- a/test/realtime/rate_counter/rate_counter_test.exs +++ b/test/realtime/rate_counter/rate_counter_test.exs @@ -263,7 +263,7 @@ defmodule Realtime.RateCounterTest do {:ok, pid} = RateCounter.new(args, idle_shutdown: 5) Process.monitor(pid) - assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 25 + assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 100 # Cache has not expired yet assert {:ok, %RateCounter{}} = Cachex.get(RateCounter, args.id) Process.sleep(2000) diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs index 53be2e51f..1bc74520c 100644 --- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs +++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs @@ -39,7 +39,12 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do describe "dispatch/3" do setup do - {:ok, _pid} = Agent.start_link(fn -> 0 end, name: TestSerializer) + {:ok, _pid} = + start_supervised(%{ + id: TestSerializer, + start: {Agent, :start_link, [fn -> 0 end, [name: TestSerializer]]} + }) + :ok end diff --git a/test/support/clustered.ex b/test/support/clustered.ex index c7028b79b..f0caa6df0 100644 --- a/test/support/clustered.ex +++ b/test/support/clustered.ex @@ -39,6 +39,7 @@ defmodule Clustered do def start_disconnected(aux_mod \\ nil, opts \\ []) do extra_config = Keyword.get(opts, :extra_config, []) phoenix_port = Keyword.get(opts, :phoenix_port, 4012) + name = Keyword.get(opts, :name, :peer.random_name()) :ok = case :net_kernel.start([:"main@127.0.0.1"]) do @@ -53,7 +54,6 @@ defmodule Clustered do end true = :erlang.set_cookie(:cookie) - name = :peer.random_name() {:ok, pid, node} = ExUnit.Callbacks.start_supervised(%{ diff --git a/test/test_helper.exs b/test/test_helper.exs index 002e01b13..98ae3ac04 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -46,13 +46,11 @@ end) Ecto.Adapters.SQL.Sandbox.mode(Realtime.Repo, :manual) -end_time = :os.system_time(:millisecond) -IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms") - Mimic.copy(:syn) Mimic.copy(Extensions.PostgresCdcRls.Replications) Mimic.copy(Realtime.Database) Mimic.copy(Realtime.GenCounter) +Mimic.copy(Realtime.GenRpc) Mimic.copy(Realtime.Nodes) Mimic.copy(Realtime.RateCounter) Mimic.copy(Realtime.Tenants.Authorization) @@ -65,3 +63,13 @@ Mimic.copy(RealtimeWeb.ChannelsAuthorization) Mimic.copy(RealtimeWeb.Endpoint) Mimic.copy(RealtimeWeb.JwtVerification) Mimic.copy(RealtimeWeb.TenantBroadcaster) + +# Set the node as the name we use on Clustered.start +# Also update syn metadata to reflect the new name +:net_kernel.start([:"main@127.0.0.1"]) +region = Application.get_env(:realtime, :region) +[{pid, _}] = :syn.members(RegionNodes, region) +:syn.update_member(RegionNodes, region, pid, fn _ -> [node: node()] end) + +end_time = :os.system_time(:millisecond) +IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms") From 9adde301384d571d9bc712cf42ca46a5abb722cc Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 21 Oct 2025 15:43:33 +1300 Subject: [PATCH 051/123] chore: remove demo (old multiplayer.dev) (#1586) --- demo/.env.example | 4 - demo/.eslintrc.json | 3 - demo/.gitignore | 38 - demo/.prettierignore | 3 - demo/.prettierrc.json | 7 - demo/README.md | 34 - demo/client.ts | 15 - demo/components/Chatbox.tsx | 47 - demo/components/Cursor.tsx | 140 - demo/components/DarkModeToggle.tsx | 71 - demo/components/Loader.tsx | 12 - demo/components/Users.tsx | 38 - demo/components/WaitlistPopover.tsx | 180 - demo/lib/RandomColor.ts | 56 - demo/lib/ThemeProvider.tsx | 44 - demo/lib/sendLog.ts | 9 - demo/next-env.d.ts | 5 - demo/next.config.js | 14 - demo/package-lock.json | 10978 ---------------- demo/package.json | 34 - demo/pages/[...slug].tsx | 578 - demo/pages/_app.tsx | 34 - demo/pages/_document.tsx | 24 - demo/pages/api/log.ts | 31 - demo/postcss.config.js | 6 - demo/public/css/fonts.css | 72 - demo/public/favicon.ico | Bin 25931 -> 0 bytes .../fonts/custom-font/CustomFont-Black.woff | Bin 42288 -> 0 bytes .../fonts/custom-font/CustomFont-Black.woff2 | Bin 28432 -> 0 bytes .../custom-font/CustomFont-BlackItalic.woff | Bin 45820 -> 0 bytes .../custom-font/CustomFont-BlackItalic.woff2 | Bin 31208 -> 0 bytes .../fonts/custom-font/CustomFont-Bold.woff | Bin 42408 -> 0 bytes .../fonts/custom-font/CustomFont-Bold.woff2 | Bin 28564 -> 0 bytes .../custom-font/CustomFont-BoldItalic.woff | Bin 45688 -> 0 bytes .../custom-font/CustomFont-BoldItalic.woff2 | Bin 31308 -> 0 bytes .../fonts/custom-font/CustomFont-Book.woff | Bin 37616 -> 0 bytes .../fonts/custom-font/CustomFont-Book.woff2 | Bin 24964 -> 0 bytes .../custom-font/CustomFont-BookItalic.woff | Bin 39992 -> 0 bytes .../custom-font/CustomFont-BookItalic.woff2 | Bin 27016 -> 0 bytes .../fonts/custom-font/CustomFont-Medium.woff | Bin 41824 -> 0 bytes .../fonts/custom-font/CustomFont-Medium.woff2 | Bin 28204 -> 0 bytes .../source-code-pro/SourceCodePro-Regular.eot | Bin 190540 -> 0 bytes .../source-code-pro/SourceCodePro-Regular.svg | 4016 ------ .../source-code-pro/SourceCodePro-Regular.ttf | Bin 190248 -> 0 bytes .../SourceCodePro-Regular.woff | Bin 89312 -> 0 bytes .../SourceCodePro-Regular.woff2 | Bin 62304 -> 0 bytes demo/public/img/multiplayer-og.png | Bin 89239 -> 0 bytes demo/public/img/supabase-dark.svg | 23 - demo/public/img/supabase-light.svg | 23 - demo/public/vercel.svg | 4 - demo/styles/globals.css | 95 - demo/tailwind.config.js | 147 - demo/tsconfig.json | 20 - demo/types.ts | 23 - demo/utils.ts | 5 - 55 files changed, 16833 deletions(-) delete mode 100644 demo/.env.example delete mode 100644 demo/.eslintrc.json delete mode 100644 demo/.gitignore delete mode 100644 demo/.prettierignore delete mode 100644 demo/.prettierrc.json delete mode 100644 demo/README.md delete mode 100644 demo/client.ts delete mode 100644 demo/components/Chatbox.tsx delete mode 100644 demo/components/Cursor.tsx delete mode 100644 demo/components/DarkModeToggle.tsx delete mode 100644 demo/components/Loader.tsx delete mode 100644 demo/components/Users.tsx delete mode 100644 demo/components/WaitlistPopover.tsx delete mode 100644 demo/lib/RandomColor.ts delete mode 100644 demo/lib/ThemeProvider.tsx delete mode 100644 demo/lib/sendLog.ts delete mode 100644 demo/next-env.d.ts delete mode 100644 demo/next.config.js delete mode 100644 demo/package-lock.json delete mode 100644 demo/package.json delete mode 100644 demo/pages/[...slug].tsx delete mode 100644 demo/pages/_app.tsx delete mode 100644 demo/pages/_document.tsx delete mode 100644 demo/pages/api/log.ts delete mode 100644 demo/postcss.config.js delete mode 100644 demo/public/css/fonts.css delete mode 100644 demo/public/favicon.ico delete mode 100644 demo/public/fonts/custom-font/CustomFont-Black.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-Black.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-BlackItalic.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-BlackItalic.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-Bold.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-Bold.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-BoldItalic.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-Book.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-Book.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-BookItalic.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-BookItalic.woff2 delete mode 100644 demo/public/fonts/custom-font/CustomFont-Medium.woff delete mode 100644 demo/public/fonts/custom-font/CustomFont-Medium.woff2 delete mode 100644 demo/public/fonts/source-code-pro/SourceCodePro-Regular.eot delete mode 100644 demo/public/fonts/source-code-pro/SourceCodePro-Regular.svg delete mode 100644 demo/public/fonts/source-code-pro/SourceCodePro-Regular.ttf delete mode 100644 demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff delete mode 100644 demo/public/fonts/source-code-pro/SourceCodePro-Regular.woff2 delete mode 100644 demo/public/img/multiplayer-og.png delete mode 100644 demo/public/img/supabase-dark.svg delete mode 100644 demo/public/img/supabase-light.svg delete mode 100644 demo/public/vercel.svg delete mode 100644 demo/styles/globals.css delete mode 100644 demo/tailwind.config.js delete mode 100644 demo/tsconfig.json delete mode 100644 demo/types.ts delete mode 100644 demo/utils.ts diff --git a/demo/.env.example b/demo/.env.example deleted file mode 100644 index 25edd5cc0..000000000 --- a/demo/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -LOGFLARE_API_KEY= -LOGFLARE_SOURCE_ID= diff --git a/demo/.eslintrc.json b/demo/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/demo/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/demo/.gitignore b/demo/.gitignore deleted file mode 100644 index 7d093c39f..000000000 --- a/demo/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env.local -.env.development.local -.env.test.local -.env.production.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo diff --git a/demo/.prettierignore b/demo/.prettierignore deleted file mode 100644 index ba898f1ef..000000000 --- a/demo/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -.next -node_modules -package-lock.json diff --git a/demo/.prettierrc.json b/demo/.prettierrc.json deleted file mode 100644 index 8df6df775..000000000 --- a/demo/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "trailingComma": "es5", - "tabWidth": 2, - "semi": false, - "singleQuote": true, - "printWidth": 100 -} diff --git a/demo/README.md b/demo/README.md deleted file mode 100644 index c87e0421d..000000000 --- a/demo/README.md +++ /dev/null @@ -1,34 +0,0 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/demo/client.ts b/demo/client.ts deleted file mode 100644 index c39f3982d..000000000 --- a/demo/client.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createClient } from '@supabase/supabase-js' - -const supabaseClient = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - realtime: { - params: { - eventsPerSecond: 1000, - }, - }, - } -) - -export default supabaseClient diff --git a/demo/components/Chatbox.tsx b/demo/components/Chatbox.tsx deleted file mode 100644 index 0f9695537..000000000 --- a/demo/components/Chatbox.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { IconLoader } from '@supabase/ui' -import { FC, RefObject } from 'react' -import { Message } from '../types' - -interface Props { - messages: Message[] - chatboxRef: RefObject - messagesInTransit: string[] - areMessagesFetched: boolean -} - -const Chatbox: FC = ({ messages, chatboxRef, messagesInTransit, areMessagesFetched }) => { - return ( -
-
- {!areMessagesFetched ? ( -
- -

Loading messages

-
- ) : messages.length === 0 && messagesInTransit.length === 0 ? ( -
- Type anything to start chatting 🥳 -
- ) : ( -
- )} - {messages.map((message) => ( -

- {message.message} -

- ))} - {messagesInTransit.map((message, idx: number) => ( -

- {message} -

- ))} -
-
-
- ) -} - -export default Chatbox diff --git a/demo/components/Cursor.tsx b/demo/components/Cursor.tsx deleted file mode 100644 index f6ad32ea6..000000000 --- a/demo/components/Cursor.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { FC, FormEvent, useEffect, useRef, useState } from 'react' - -interface Props { - x?: number - y?: number - color: string - hue: string - message: string - isTyping: boolean - isCancelled?: boolean - isLocalClient?: boolean - onUpdateMessage?: (message: string) => void -} - -const MAX_MESSAGE_LENGTH = 70 -const MAX_DURATION = 4000 -const MAX_BUBBLE_WIDTH_THRESHOLD = 280 + 50 -const MAX_BUBBLE_HEIGHT_THRESHOLD = 40 + 50 - -const Cursor: FC = ({ - x, - y, - color, - hue, - message, - isTyping, - isCancelled, - isLocalClient, - onUpdateMessage = () => {}, -}) => { - // Don't show cursor for the local client - const _isLocalClient = !x || !y || isLocalClient - const inputRef = useRef() as any - const timeoutRef = useRef() as any - const chatBubbleRef = useRef() as any - - const [flipX, setFlipX] = useState(false) - const [flipY, setFlipY] = useState(false) - const [hideInput, setHideInput] = useState(false) - const [showMessageBubble, setShowMessageBubble] = useState(false) - - useEffect(() => { - if (isTyping) { - setShowMessageBubble(true) - if (timeoutRef.current) clearTimeout(timeoutRef.current) - - if (isLocalClient) { - if (inputRef.current) inputRef.current.focus() - setHideInput(false) - } - } else { - if (!message || isCancelled) { - setShowMessageBubble(false) - } else { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - if (isLocalClient) setHideInput(true) - const timeoutId = setTimeout(() => { - setShowMessageBubble(false) - }, MAX_DURATION) - timeoutRef.current = timeoutId - } - } - }, [isLocalClient, isTyping, isCancelled, message, inputRef]) - - useEffect(() => { - // [Joshen] Experimental: dynamic flipping to ensure that chat - // bubble always stays within the viewport, comment this block - // out if the effect seems weird. - setFlipX((x || 0) + MAX_BUBBLE_WIDTH_THRESHOLD >= window.innerWidth) - setFlipY((y || 0) + MAX_BUBBLE_HEIGHT_THRESHOLD >= window.innerHeight) - }, [x, y, isTyping, chatBubbleRef]) - - return ( - <> - {!_isLocalClient && ( - - - - )} -
- {_isLocalClient && !hideInput ? ( - <> - ) => { - const text = e.currentTarget.value - if (text.length <= MAX_MESSAGE_LENGTH) onUpdateMessage(e.currentTarget.value) - }} - /> -

- {message.length}/{MAX_MESSAGE_LENGTH} -

- - ) : message.length ? ( -
{message}
- ) : ( -
-
-
-
-
-
-
-
- )} -
- - ) -} - -export default Cursor diff --git a/demo/components/DarkModeToggle.tsx b/demo/components/DarkModeToggle.tsx deleted file mode 100644 index c84626a90..000000000 --- a/demo/components/DarkModeToggle.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { IconSun, IconMoon } from '@supabase/ui' -import { useEffect } from 'react' -import { useTheme } from '../lib/ThemeProvider' - -function DarkModeToggle() { - const { isDarkMode, toggleTheme } = useTheme() - - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - } - - useEffect(() => { - const key = localStorage.getItem('supabaseDarkMode') - if (key && key == 'false') { - document.documentElement.className = '' - } - }, []) - - return ( -
- -
- ) -} - -export default DarkModeToggle diff --git a/demo/components/Loader.tsx b/demo/components/Loader.tsx deleted file mode 100644 index ee7675512..000000000 --- a/demo/components/Loader.tsx +++ /dev/null @@ -1,12 +0,0 @@ -const Loader = () => { - return ( -
- - - - -
- ) -} - -export default Loader diff --git a/demo/components/Users.tsx b/demo/components/Users.tsx deleted file mode 100644 index 67051321b..000000000 --- a/demo/components/Users.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FC } from 'react' -import { User } from '../types' - -interface Props { - users: Record -} - -const Users: FC = ({ users }) => { - return ( -
- {Object.entries(users).map(([userId, userData], idx) => { - return ( -
-
-
-
-
- ) - })} -
- ) -} - -export default Users diff --git a/demo/components/WaitlistPopover.tsx b/demo/components/WaitlistPopover.tsx deleted file mode 100644 index 7958af6cf..000000000 --- a/demo/components/WaitlistPopover.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { FC, useState, memo } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { - Button, - Form, - Input, - IconMinimize2, - IconMaximize2, - IconGitHub, - IconTwitter, -} from '@supabase/ui' -import supabaseClient from '../client' -import { useTheme } from '../lib/ThemeProvider' - -interface Props {} - -const WaitlistPopover: FC = ({}) => { - const { isDarkMode } = useTheme() - const [isExpanded, setIsExpanded] = useState(true) - const [isSuccess, setIsSuccess] = useState(false) - const [error, setError] = useState() - - const initialValues = { email: '' } - - const getGeneratedTweet = () => { - return `Join me to experience Realtime by Supabase!%0A%0A${window.location.href}` - } - - const onValidate = (values: any) => { - const errors = {} as any - const emailValidateRegex = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ - if (!emailValidateRegex.test(values.email)) errors.email = 'Please enter a valid email' - return errors - } - - const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => { - setIsSuccess(false) - setError(undefined) - setSubmitting(true) - const { error } = await supabaseClient.from('waitlist').insert([{ email: values.email }]) - if (!error) { - resetForm() - setIsSuccess(true) - } else { - setError(error) - } - setSubmitting(false) - } - - return ( -
-
-
- supabase -
-

- / -

-

- Realtime -

-
-
- {isExpanded ? ( - setIsExpanded(false)} - /> - ) : ( - setIsExpanded(true)} - /> - )} -
- -
-
-
-

Realtime

-
-

- Realtime collaborative app to display broadcast, presence, and database listening over - WebSockets -

-
-
- - Realtime Multiplayer by Supabase - Easily build real-time apps that enables user collaboration | Product Hunt - -
-
- - - - - - -
-
- -
- {({ isSubmitting }: any) => { - return ( - <> - - Get early access - , - ]} - /> - {isSuccess && ( -

- Thank you for submitting your interest! -

- )} - {error?.message.includes('duplicate key') && ( -

- Email has already been registered for waitlist -

- )} - {error && !error?.message.includes('duplicate key') && ( -

Unable to register email for waitlist

- )} - - ) - }} -
-
- ) -} - -export default memo(WaitlistPopover) diff --git a/demo/lib/RandomColor.ts b/demo/lib/RandomColor.ts deleted file mode 100644 index beea3b369..000000000 --- a/demo/lib/RandomColor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import sampleSize from 'lodash.samplesize' - -const colors = { - tomato: { - bg: 'var(--colors-tomato9)', - hue: 'var(--colors-tomato7)', - }, - crimson: { - bg: 'var(--colors-crimson9)', - hue: 'var(--colors-crimson7)', - }, - pink: { - bg: 'var(--colors-pink9)', - hue: 'var(--colors-pink7)', - }, - plum: { - bg: 'var(--colors-plum9)', - hue: 'var(--colors-plum7)', - }, - indigo: { - bg: 'var(--colors-indigo9)', - hue: 'var(--colors-indigo7)', - }, - blue: { - bg: 'var(--colors-blue9)', - hue: 'var(--colors-blue7)', - }, - cyan: { - bg: 'var(--colors-cyan9)', - hue: 'var(--colors-cyan7)', - }, - green: { - bg: 'var(--colors-green9)', - hue: 'var(--colors-green7)', - }, - orange: { - bg: 'var(--colors-orange9)', - hue: 'var(--colors-orange7)', - }, -} - -export const getRandomUniqueColor = (currentColors: string[]) => { - const colorNames = Object.values(colors).map((col) => col.bg) - const uniqueColors = colorNames.filter((color: string) => !currentColors.includes(color)) - const uniqueColor = uniqueColors[Math.floor(Math.random() * uniqueColors.length)] - const uniqueColorSet = Object.values(colors).find((color) => color.bg === uniqueColor) - return uniqueColorSet || getRandomColor() -} - -export const getRandomColors = (qty: number) => { - return sampleSize(Object.values(colors), qty) -} - -export const getRandomColor = () => { - return Object.values(colors)[Math.floor(Math.random() * Object.values(colors).length)] -} diff --git a/demo/lib/ThemeProvider.tsx b/demo/lib/ThemeProvider.tsx deleted file mode 100644 index ab023562c..000000000 --- a/demo/lib/ThemeProvider.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react' - -interface UseThemeProps { - isDarkMode?: boolean - toggleTheme: () => void -} - -interface ThemeProviderProps { - children?: any -} - -export const ThemeContext = createContext({ - isDarkMode: true, - toggleTheme: () => {}, -}) - -export const useTheme = () => useContext(ThemeContext) - -export const ThemeProvider = ({ children }: ThemeProviderProps) => { - const [isDarkMode, setIsDarkMode] = useState(false) - - useEffect(() => { - const key = localStorage.getItem('supabaseDarkMode') - // Default to dark mode if no preference config - setIsDarkMode(!key || key === 'true') - }, []) - - const toggleTheme = () => { - setIsDarkMode(!isDarkMode) - } - - return ( - <> - - {children} - - - ) -} diff --git a/demo/lib/sendLog.ts b/demo/lib/sendLog.ts deleted file mode 100644 index c3c7e1ab5..000000000 --- a/demo/lib/sendLog.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function sendLog(message: string) { - return fetch('/api/log', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message }), - }) -} diff --git a/demo/next-env.d.ts b/demo/next-env.d.ts deleted file mode 100644 index 4f11a03dc..000000000 --- a/demo/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/demo/next.config.js b/demo/next.config.js deleted file mode 100644 index b556a415f..000000000 --- a/demo/next.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - async rewrites() { - return [ - { - source: '/', - destination: '/room', - }, - ] - }, - reactStrictMode: true, -} - -module.exports = nextConfig diff --git a/demo/package-lock.json b/demo/package-lock.json deleted file mode 100644 index bcf6da697..000000000 --- a/demo/package-lock.json +++ /dev/null @@ -1,10978 +0,0 @@ -{ - "name": "demo", - "version": "0.1.2", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "demo", - "version": "0.1.2", - "dependencies": { - "@supabase/supabase-js": "^2.1.0", - "@supabase/ui": "0.37.0-alpha.81", - "lodash.clonedeep": "^4.5.0", - "lodash.samplesize": "^4.2.0", - "lodash.throttle": "^4.1.1", - "next": "^15.2.4", - "react": "17.0.2", - "react-dom": "17.0.2" - }, - "devDependencies": { - "@types/lodash.clonedeep": "^4.5.6", - "@types/lodash.samplesize": "^4.2.6", - "@types/lodash.throttle": "^4.1.6", - "@types/node": "17.0.21", - "@types/react": "17.0.41", - "autoprefixer": "^10.4.4", - "eslint": "8.11.0", - "eslint-config-next": "^12.3.4", - "postcss": "^8.4.31", - "tailwindcss": "^3.0.23", - "typescript": "4.6.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dependencies": { - "@babel/highlight": "^7.16.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.1.tgz", - "integrity": "sha512-CGulbEDcg/ND1Im7fUNRZdGXmX2MTWVVZacQi/6DiKE5HNwZ3aVTm5PV4lO8HHz0B2h8WQyvKKjbX5XgTtydsg==", - "dev": true, - "dependencies": { - "core-js-pure": "^3.25.1", - "regenerator-runtime": "^0.13.10" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@headlessui/react": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.4.tgz", - "integrity": "sha512-D8n5yGCF3WIkPsjEYeM8knn9jQ70bigGGb5aUvN6y4BGxcT3OcOQOKcM3zRGllRCZCFxCZyQvYJF6ZE7bQUOyQ==", - "dependencies": { - "client-only": "^0.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18", - "react-dom": "^16 || ^17 || ^18" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@mertasan/tailwindcss-variables": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@mertasan/tailwindcss-variables/-/tailwindcss-variables-2.5.1.tgz", - "integrity": "sha512-I1Jvpu5fcinGT/yEDL53dRXznFWV4LoTCUVcTvQqA1YH1iAfs72OO/VZdBKPqcxe/lS2nBr/Ikloe+pLsxemmA==", - "dependencies": { - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "autoprefixer": "^10.0.2", - "postcss": "^8.0.9" - } - }, - "node_modules/@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-12.3.4.tgz", - "integrity": "sha512-BFwj8ykJY+zc1/jWANsDprDIu2MgwPOIKxNVnrKvPs+f5TPegrVnem8uScND+1veT4B7F6VeqgaNLFW1Hzl9Og==", - "dev": true, - "dependencies": { - "glob": "7.1.7" - } - }, - "node_modules/@next/eslint-plugin-next/node_modules/glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@radix-ui/colors": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-0.1.8.tgz", - "integrity": "sha512-jwRMXYwC0hUo0mv6wGpuw254Pd9p/R6Td5xsRpOmaWkUHlooNWqVcadgyzlRumMq3xfOTXwJReU0Jv+EIy4Jbw==" - }, - "node_modules/@radix-ui/popper": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz", - "integrity": "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "csstype": "^3.0.4" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-0.1.0.tgz", - "integrity": "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.2.tgz", - "integrity": "sha512-3BRlFZraooIUfRlyN+b/Xs5hq1lanOOo/+3h6Pwu2GMFjkGKKa4Rd51fcqGqnVlbr3jYg+WLuGyAV4KlgqwrQw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@radix-ui/rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-0.1.1.tgz", - "integrity": "sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", - "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", - "dev": true - }, - "node_modules/@supabase/functions-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.0.0.tgz", - "integrity": "sha512-ozb7bds2yvf5k7NM2ZzUkxvsx4S4i2eRKFSJetdTADV91T65g4gCzEs9L3LUXSrghcGIkUaon03VPzOrFredqg==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/gotrue-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.3.1.tgz", - "integrity": "sha512-txYVDrKAFXxT4nyVGnW3M9Oid4u3Xe/Na+wTEzwU+IBuPUEz72ZBHNKo6HBKlZNpnlGtgCSciYhH8qFkZYGV3g==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.1.0.tgz", - "integrity": "sha512-qkY8TqIu5sJuae8gjeDPjEqPrefzcTraW9PNSVJQHq4TEv98ZmwaXGwBGz0bVL63bqrGA5hqREbQHkANUTXrvA==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.1.0.tgz", - "integrity": "sha512-iplLCofTeYjnx9FIOsIwHLhMp0+7UVyiA4/sCeq40VdOgN9eTIhjEno9Tgh4dJARi4aaXoKfRX1DTxgZaOpPAw==", - "dependencies": { - "@types/phoenix": "^1.5.4", - "websocket": "^1.0.34" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.0.0.tgz", - "integrity": "sha512-7kXThdRt/xqnOOvZZxBqNkeX1CFNUWc0hYBJtNN/Uvt8ok9hD14foYmroWrHn046wEYFqUrB9U35JYsfTrvltA==", - "dependencies": { - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.1.0.tgz", - "integrity": "sha512-hODrAUDSC6RV6EhwuSMyhaQCF32gij0EBTceuDR+8suJsg7XcyUG0fYgeYecWIvt0nz61xAMY6E+Ywb0tJaAng==", - "dependencies": { - "@supabase/functions-js": "^2.0.0", - "@supabase/gotrue-js": "^2.3.0", - "@supabase/postgrest-js": "^1.1.0", - "@supabase/realtime-js": "^2.1.0", - "@supabase/storage-js": "^2.0.0", - "cross-fetch": "^3.1.5" - } - }, - "node_modules/@supabase/ui": { - "version": "0.37.0-alpha.81", - "resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.81.tgz", - "integrity": "sha512-CxqdikE6wGw6pGQ6b3vRA8qnvCK20VyeMyy8Z4hJ/Dg2qRfgQqbrv7qS+6A1S8pg657EzCCo0DIH75SijaU8eA==", - "dependencies": { - "@headlessui/react": "^1.0.0", - "@mertasan/tailwindcss-variables": "^2.0.1", - "@radix-ui/colors": "^0.1.8", - "@radix-ui/react-accordion": "^0.1.5", - "@radix-ui/react-collapsible": "^0.1.5", - "@radix-ui/react-context-menu": "^0.1.0", - "@radix-ui/react-dialog": "^0.1.5", - "@radix-ui/react-dropdown-menu": "^0.1.4", - "@radix-ui/react-popover": "^0.1.0", - "@radix-ui/react-portal": "^0.1.3", - "@radix-ui/react-tabs": "^0.1.0", - "@tailwindcss/forms": "^0.4.0", - "@tailwindcss/typography": "^0.5.0", - "autoprefixer": "^10.4.2", - "deepmerge": "^4.2.2", - "formik": "^2.2.9", - "lodash": "^4.17.20", - "postcss": "^8.4.5", - "prop-types": "^15.7.2", - "tailwindcss": "^3.0.15", - "tailwindcss-radix": "^1.6.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.1", - "react-dom": "^16.13.1 || ^17.0.1" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-0.1.6.tgz", - "integrity": "sha512-LOXlqPU6y6EMBopdRIKCWFvMPY1wPTQ4uJiX7ZVxldrMJcM7imBzI3wlRTkPCHZ3FLHmpuw+cQi3du23pzJp1g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collapsible": "0.1.6", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-0.1.6.tgz", - "integrity": "sha512-Gkf8VuqMc6HTLzA2AxVYnyK6aMczVLpatCjdD9Lj4wlYLXCz9KtiqZYslLMeqnQFLwLyZS0WKX/pQ8j5fioIBw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-0.1.6.tgz", - "integrity": "sha512-0qa6ABaeqD+WYI+8iT0jH0QLLcV8Kv0xI+mZL4FFnG4ec9H0v+yngb5cfBBfs9e/KM8mDzFFpaeegqsQlLNqyQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz", - "integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-0.1.6.tgz", - "integrity": "sha512-RZhtzjWwJ4ZBN7D8ek4Zn+ilHzYuYta9yIxFnbC0pfqMnSi67IQNONo1tuuNqtFh9SRHacPKc65zo+kBBlxtdg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-0.1.6.tgz", - "integrity": "sha512-zQzgUqW4RQDb0ItAL1xNW4K4olUrkfV3jeEPs9rG+nsDQurO+W9TT+YZ9H1mmgAJqlthyv1sBRZGdBm4YjtD6Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-body-pointer-events/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.4.tgz", - "integrity": "sha512-MO0wRy2eYRTZ/CyOri9NANCAtAtq89DEtg90gicaTlkCfdqCLEBsLb+/q66BZQTr3xX/Vq01nnVfc/TkCqoqvw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0", - "react-dom": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-0.1.5.tgz", - "integrity": "sha512-ieVQS1TFr0dX1XA8B+CsSFKOE7kcgEaNWWEfItxj9D1GZjn1o3WqPkW+FhQWDAWZLSKCH2PezYF3MNyO41lgJg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@supabase/ui/node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state/node_modules/@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/forms": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.4.1.tgz", - "integrity": "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A==", - "dependencies": { - "mini-svg-data-uri": "^1.2.3" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz", - "integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==", - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "node_modules/@types/lodash": { - "version": "4.14.180", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz", - "integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==", - "dev": true - }, - "node_modules/@types/lodash.clonedeep": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", - "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.samplesize": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@types/lodash.samplesize/-/lodash.samplesize-4.2.6.tgz", - "integrity": "sha512-yBgEuIxVIM+corHdvB+NHgzni1Oc0aEd7acuO/jET0vO2Y2f6sl7vfQlaZKgzcN+ZqWLB6B2VQTKc1T5zQra+Q==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/lodash.throttle": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.6.tgz", - "integrity": "sha512-/UIH96i/sIRYGC60NoY72jGkCJtFN5KVPhEMMMTjol65effe1gPn0tycJqV5tlSwMTzX8FqzB5yAj0rfGHTPNg==", - "dev": true, - "dependencies": { - "@types/lodash": "*" - } - }, - "node_modules/@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true - }, - "node_modules/@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "node_modules/@types/phoenix": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", - "integrity": "sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "devOptional": true - }, - "node_modules/@types/react": { - "version": "17.0.41", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.41.tgz", - "integrity": "sha512-chYZ9ogWUodyC7VUTRBfblysKLjnohhFY9bGLwvnUFFy48+vB9DikmB3lW0qTFmBcKSzmdglcvkHK71IioOlDA==", - "devOptional": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true - }, - "node_modules/@typescript-eslint/parser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.44.0.tgz", - "integrity": "sha512-H7LCqbZnKqkkgQHaKLGC6KUjt3pjJDx8ETDqmwncyb6PuoigYajyAwBGz08VU/l86dZWZgI4zm5k2VaKqayYyA==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.44.0.tgz", - "integrity": "sha512-2pKml57KusI0LAhgLKae9kwWeITZ7IsZs77YxyNyIVOwQ1kToyXRaJLl+uDEXzMN5hnobKUOo2gKntK9H1YL8g==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.44.0.tgz", - "integrity": "sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.44.0.tgz", - "integrity": "sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.44.0.tgz", - "integrity": "sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "5.44.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/acorn": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", - "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-node/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-hidden": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", - "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.9.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz", - "integrity": "sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - } - ], - "dependencies": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001317", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.20.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.2.tgz", - "integrity": "sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001317", - "electron-to-chromium": "^1.4.84", - "escalade": "^3.1.1", - "node-releases": "^2.0.2", - "picocolors": "^1.0.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001689", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", - "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "node_modules/core-js-pure": { - "version": "3.26.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", - "integrity": "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ==", - "dev": true, - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "dependencies": { - "node-fetch": "2.6.7" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "dev": true, - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "node_modules/detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "dependencies": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.4.93", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.93.tgz", - "integrity": "sha512-ywq9Pc5Gwwpv7NG767CtoU8xF3aAUQJjH9//Wy3MBCg4w5JSLbJUq2L8IsCdzPMjvSgxuue9WcVaTOyyxCL0aQ==" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-abstract": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", - "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.5", - "string.prototype.trimstart": "^1.0.5", - "unbox-primitive": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "dependencies": { - "has": "^1.0.3" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz", - "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==", - "dev": true, - "dependencies": { - "@eslint/eslintrc": "^1.2.1", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-12.3.4.tgz", - "integrity": "sha512-WuT3gvgi7Bwz00AOmKGhOeqnyA5P29Cdyr0iVjLyfDbk+FANQKcOjFUTZIdyYfe5Tq1x4TGcmoe4CwctGvFjHQ==", - "dev": true, - "dependencies": { - "@next/eslint-plugin-next": "12.3.4", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.21.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^2.7.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "resolve": "^1.20.0" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", - "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "glob": "^7.2.0", - "is-glob": "^4.0.3", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", - "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", - "dev": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", - "minimatch": "^3.1.2", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.31.11", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", - "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "dependencies": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/eslint/node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esniff/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", - "dev": true, - "dependencies": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "node_modules/fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true - }, - "node_modules/formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "dependencies": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/formik/node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/formik/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/fraction.js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", - "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://www.patreon.com/infusion" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true - }, - "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", - "dev": true, - "dependencies": { - "language-subtag-registry": "~0.3.2" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==", - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" - }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "node_modules/lodash.samplesize": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz", - "integrity": "sha1-Rgdi+7KzQikFF0mekNUVhttGX/k=" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "bin": { - "mini-svg-data-uri": "cli.js" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "node_modules/next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", - "license": "MIT", - "dependencies": { - "@next/env": "15.2.4", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.15", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "sharp": "^0.33.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", - "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "dependencies": { - "postcss-selector-parser": "^6.0.6" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" - } - }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, - "node_modules/regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "dependencies": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.23.tgz", - "integrity": "sha512-+OZOV9ubyQ6oI2BXEhzw4HrqvgcARY38xv3zKcjnWtMIZstEsXdI9xftd1iB7+RbOnj2HOEzkA0OyB5BaSxPQA==", - "dependencies": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss": "^8.4.6", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "autoprefixer": "^10.0.2", - "postcss": "^8.0.9" - } - }, - "node_modules/tailwindcss-radix": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tailwindcss-radix/-/tailwindcss-radix-1.6.0.tgz", - "integrity": "sha512-5oBgGCVGsITMiUVlc6Euj4kt03l8htLJxVT9AXbkFxcJiXLtQxJriFq/8R+3s63OKit/ynCVdkqvlnW6H7iG1g==" - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.3.tgz", - "integrity": "sha512-5EYgaM9auHGtO//ljHH+v/aC/TQ5LHXtL7bQajNAUBKUVKiYE8rYpFms7+V26D9FncaGe2zwCoPQsFKb5zF/Hw==", - "dependencies": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "ts-node": { - "optional": true - } - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", - "dev": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.1", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "node_modules/v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "engines": { - "node": ">=0.10.32" - } - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - } - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==" - }, - "@babel/highlight": { - "version": "7.16.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz", - "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==", - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, - "@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "requires": { - "regenerator-runtime": "^0.14.0" - }, - "dependencies": { - "regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - } - } - }, - "@babel/runtime-corejs3": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.20.1.tgz", - "integrity": "sha512-CGulbEDcg/ND1Im7fUNRZdGXmX2MTWVVZacQi/6DiKE5HNwZ3aVTm5PV4lO8HHz0B2h8WQyvKKjbX5XgTtydsg==", - "dev": true, - "requires": { - "core-js-pure": "^3.25.1", - "regenerator-runtime": "^0.13.10" - } - }, - "@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "optional": true, - "requires": { - "tslib": "^2.4.0" - } - }, - "@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.3.1", - "globals": "^13.9.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.0.4", - "strip-json-comments": "^3.1.1" - } - }, - "@headlessui/react": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.4.tgz", - "integrity": "sha512-D8n5yGCF3WIkPsjEYeM8knn9jQ70bigGGb5aUvN6y4BGxcT3OcOQOKcM3zRGllRCZCFxCZyQvYJF6ZE7bQUOyQ==", - "requires": { - "client-only": "^0.0.1" - } - }, - "@humanwhocodes/config-array": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz", - "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.4" - } - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "optional": true, - "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "optional": true - }, - "@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "optional": true - }, - "@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "optional": true - }, - "@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "optional": true - }, - "@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "optional": true - }, - "@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "optional": true - }, - "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "optional": true - }, - "@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "optional": true, - "requires": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "optional": true, - "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "optional": true, - "requires": { - "@emnapi/runtime": "^1.2.0" - } - }, - "@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "optional": true - }, - "@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "optional": true - }, - "@mertasan/tailwindcss-variables": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@mertasan/tailwindcss-variables/-/tailwindcss-variables-2.5.1.tgz", - "integrity": "sha512-I1Jvpu5fcinGT/yEDL53dRXznFWV4LoTCUVcTvQqA1YH1iAfs72OO/VZdBKPqcxe/lS2nBr/Ikloe+pLsxemmA==", - "requires": { - "lodash": "^4.17.21" - } - }, - "@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==" - }, - "@next/eslint-plugin-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-12.3.4.tgz", - "integrity": "sha512-BFwj8ykJY+zc1/jWANsDprDIu2MgwPOIKxNVnrKvPs+f5TPegrVnem8uScND+1veT4B7F6VeqgaNLFW1Hzl9Og==", - "dev": true, - "requires": { - "glob": "7.1.7" - }, - "dependencies": { - "glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - } - } - }, - "@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", - "optional": true - }, - "@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", - "optional": true - }, - "@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", - "optional": true - }, - "@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", - "optional": true - }, - "@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", - "optional": true - }, - "@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", - "optional": true - }, - "@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", - "optional": true - }, - "@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", - "optional": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "@radix-ui/colors": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-0.1.8.tgz", - "integrity": "sha512-jwRMXYwC0hUo0mv6wGpuw254Pd9p/R6Td5xsRpOmaWkUHlooNWqVcadgyzlRumMq3xfOTXwJReU0Jv+EIy4Jbw==" - }, - "@radix-ui/popper": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/popper/-/popper-0.1.0.tgz", - "integrity": "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "csstype": "^3.0.4" - } - }, - "@radix-ui/primitive": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-0.1.0.tgz", - "integrity": "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-presence": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.2.tgz", - "integrity": "sha512-3BRlFZraooIUfRlyN+b/Xs5hq1lanOOo/+3h6Pwu2GMFjkGKKa4Rd51fcqGqnVlbr3jYg+WLuGyAV4KlgqwrQw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-0.1.1.tgz", - "integrity": "sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@rushstack/eslint-patch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", - "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", - "dev": true - }, - "@supabase/functions-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.0.0.tgz", - "integrity": "sha512-ozb7bds2yvf5k7NM2ZzUkxvsx4S4i2eRKFSJetdTADV91T65g4gCzEs9L3LUXSrghcGIkUaon03VPzOrFredqg==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/gotrue-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.3.1.tgz", - "integrity": "sha512-txYVDrKAFXxT4nyVGnW3M9Oid4u3Xe/Na+wTEzwU+IBuPUEz72ZBHNKo6HBKlZNpnlGtgCSciYhH8qFkZYGV3g==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/postgrest-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.1.0.tgz", - "integrity": "sha512-qkY8TqIu5sJuae8gjeDPjEqPrefzcTraW9PNSVJQHq4TEv98ZmwaXGwBGz0bVL63bqrGA5hqREbQHkANUTXrvA==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/realtime-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.1.0.tgz", - "integrity": "sha512-iplLCofTeYjnx9FIOsIwHLhMp0+7UVyiA4/sCeq40VdOgN9eTIhjEno9Tgh4dJARi4aaXoKfRX1DTxgZaOpPAw==", - "requires": { - "@types/phoenix": "^1.5.4", - "websocket": "^1.0.34" - } - }, - "@supabase/storage-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.0.0.tgz", - "integrity": "sha512-7kXThdRt/xqnOOvZZxBqNkeX1CFNUWc0hYBJtNN/Uvt8ok9hD14foYmroWrHn046wEYFqUrB9U35JYsfTrvltA==", - "requires": { - "cross-fetch": "^3.1.5" - } - }, - "@supabase/supabase-js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.1.0.tgz", - "integrity": "sha512-hODrAUDSC6RV6EhwuSMyhaQCF32gij0EBTceuDR+8suJsg7XcyUG0fYgeYecWIvt0nz61xAMY6E+Ywb0tJaAng==", - "requires": { - "@supabase/functions-js": "^2.0.0", - "@supabase/gotrue-js": "^2.3.0", - "@supabase/postgrest-js": "^1.1.0", - "@supabase/realtime-js": "^2.1.0", - "@supabase/storage-js": "^2.0.0", - "cross-fetch": "^3.1.5" - } - }, - "@supabase/ui": { - "version": "0.37.0-alpha.81", - "resolved": "https://registry.npmjs.org/@supabase/ui/-/ui-0.37.0-alpha.81.tgz", - "integrity": "sha512-CxqdikE6wGw6pGQ6b3vRA8qnvCK20VyeMyy8Z4hJ/Dg2qRfgQqbrv7qS+6A1S8pg657EzCCo0DIH75SijaU8eA==", - "requires": { - "@headlessui/react": "^1.0.0", - "@mertasan/tailwindcss-variables": "^2.0.1", - "@radix-ui/colors": "^0.1.8", - "@radix-ui/react-accordion": "^0.1.5", - "@radix-ui/react-collapsible": "^0.1.5", - "@radix-ui/react-context-menu": "^0.1.0", - "@radix-ui/react-dialog": "^0.1.5", - "@radix-ui/react-dropdown-menu": "^0.1.4", - "@radix-ui/react-popover": "^0.1.0", - "@radix-ui/react-portal": "^0.1.3", - "@radix-ui/react-tabs": "^0.1.0", - "@tailwindcss/forms": "^0.4.0", - "@tailwindcss/typography": "^0.5.0", - "autoprefixer": "^10.4.2", - "deepmerge": "^4.2.2", - "formik": "^2.2.9", - "fsevents": "^2.3.2", - "lodash": "^4.17.20", - "postcss": "^8.4.5", - "prop-types": "^15.7.2", - "tailwindcss": "^3.0.15", - "tailwindcss-radix": "^1.6.0" - }, - "dependencies": { - "@radix-ui/react-accordion": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-0.1.6.tgz", - "integrity": "sha512-LOXlqPU6y6EMBopdRIKCWFvMPY1wPTQ4uJiX7ZVxldrMJcM7imBzI3wlRTkPCHZ3FLHmpuw+cQi3du23pzJp1g==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collapsible": "0.1.6", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-collapsible": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-0.1.6.tgz", - "integrity": "sha512-Gkf8VuqMc6HTLzA2AxVYnyK6aMczVLpatCjdD9Lj4wlYLXCz9KtiqZYslLMeqnQFLwLyZS0WKX/pQ8j5fioIBw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-context-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-0.1.6.tgz", - "integrity": "sha512-0qa6ABaeqD+WYI+8iT0jH0QLLcV8Kv0xI+mZL4FFnG4ec9H0v+yngb5cfBBfs9e/KM8mDzFFpaeegqsQlLNqyQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-dialog": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz", - "integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - } - }, - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-dropdown-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-0.1.6.tgz", - "integrity": "sha512-RZhtzjWwJ4ZBN7D8ek4Zn+ilHzYuYta9yIxFnbC0pfqMnSi67IQNONo1tuuNqtFh9SRHacPKc65zo+kBBlxtdg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-menu": "0.1.6", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-menu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-0.1.6.tgz", - "integrity": "sha512-ho3+bhpr3oAFkOBJ8VkUb1BcGoiZBB3OmcWPqa6i5RTUKrzNX/d6rauochu2xDlWjiRtpVuiAcsTVOeIC4FbYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-direction": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-direction": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-direction/-/react-use-direction-0.1.0.tgz", - "integrity": "sha512-NajpY/An9TCPSfOVkgWIdXJV+VuWl67PxB6kOKYmtNAFHvObzIoh8o0n9sAuwSAyFCZVq211FEf9gvVDRhOyiA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-popover": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-0.1.6.tgz", - "integrity": "sha512-zQzgUqW4RQDb0ItAL1xNW4K4olUrkfV3jeEPs9rG+nsDQurO+W9TT+YZ9H1mmgAJqlthyv1sBRZGdBm4YjtD6Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-dismissable-layer": "0.1.5", - "@radix-ui/react-focus-guards": "0.1.0", - "@radix-ui/react-focus-scope": "0.1.4", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-popper": "0.1.4", - "@radix-ui/react-portal": "0.1.4", - "@radix-ui/react-presence": "0.1.2", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-controllable-state": "0.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "^2.4.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz", - "integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-body-pointer-events": "0.1.1", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-escape-keydown": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-body-pointer-events": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz", - "integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", - "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - } - } - } - }, - "@radix-ui/react-focus-guards": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", - "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.4.tgz", - "integrity": "sha512-fbA4ES3H4Wkxp+OeLhvN6SwL7mXNn/aBtUf7DRYxY9+Akrf7dRxl2ck4lgcpPsSg3zSDsEwLcY+h5cmj5yvlug==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-popper": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.4.tgz", - "integrity": "sha512-18gDYof97t8UQa7zwklG1Dr8jIdj3u+rVOQLzPi9f5i1YQak/pVGkaqw8aY+iDUknKKuZniTk/7jbAJUYlKyOw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/popper": "0.1.0", - "@radix-ui/react-arrow": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-rect": "0.1.1", - "@radix-ui/react-use-size": "0.1.1", - "@radix-ui/rect": "0.1.1" - }, - "dependencies": { - "@radix-ui/react-arrow": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-0.1.4.tgz", - "integrity": "sha512-BB6XzAb7Ml7+wwpFdYVtZpK1BlMgqyafSQNGzhIpSZ4uXvXOHPlR5GP8M449JkeQzgQjv9Mp1AsJxFC0KuOtuA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4" - } - }, - "@radix-ui/react-use-rect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz", - "integrity": "sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "0.1.1" - } - }, - "@radix-ui/react-use-size": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz", - "integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-portal": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.4.tgz", - "integrity": "sha512-MO0wRy2eYRTZ/CyOri9NANCAtAtq89DEtg90gicaTlkCfdqCLEBsLb+/q66BZQTr3xX/Vq01nnVfc/TkCqoqvw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-tabs": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-0.1.5.tgz", - "integrity": "sha512-ieVQS1TFr0dX1XA8B+CsSFKOE7kcgEaNWWEfItxj9D1GZjn1o3WqPkW+FhQWDAWZLSKCH2PezYF3MNyO41lgJg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-roving-focus": "0.1.5", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-context": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz", - "integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz", - "integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-layout-effect": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", - "integrity": "sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-primitive": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz", - "integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - }, - "@radix-ui/react-roving-focus": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-0.1.5.tgz", - "integrity": "sha512-ClwKPS5JZE+PaHCoW7eu1onvE61pDv4kO8W4t5Ra3qMFQiTJLZMdpBQUhksN//DaVygoLirz4Samdr5Y1x1FSA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "0.1.0", - "@radix-ui/react-collection": "0.1.4", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-id": "0.1.5", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-use-callback-ref": "0.1.0", - "@radix-ui/react-use-controllable-state": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-collection": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-0.1.4.tgz", - "integrity": "sha512-3muGI15IdgaDFjOcO7xX8a35HQRBRF6LH9pS6UCeZeRmbslkVeHyJRQr2rzICBUoX7zgIA0kXyMDbpQnJGyJTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0", - "@radix-ui/react-context": "0.1.1", - "@radix-ui/react-primitive": "0.1.4", - "@radix-ui/react-slot": "0.1.2" - }, - "dependencies": { - "@radix-ui/react-slot": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz", - "integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "0.1.0" - } - } - } - }, - "@radix-ui/react-compose-refs": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz", - "integrity": "sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-0.1.0.tgz", - "integrity": "sha512-zv7CX/PgsRl46a52Tl45TwqwVJdmqnlQEQhaYMz/yBOD2sx2gCkCFSoF/z9mpnYWmS6DTLNTg5lIps3fV6EnXg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "0.1.0" - }, - "dependencies": { - "@radix-ui/react-use-callback-ref": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", - "integrity": "sha512-Va041McOFFl+aV+sejvl0BS2aeHx86ND9X/rVFmEFQKTXCp6xgUK0NGUAGcgBlIjnJSbMYPGEk1xKSSlVcN2Aw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - } - } - } - } - }, - "@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "requires": { - "tslib": "^2.8.0" - } - }, - "@tailwindcss/forms": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.4.1.tgz", - "integrity": "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A==", - "requires": { - "mini-svg-data-uri": "^1.2.3" - } - }, - "@tailwindcss/typography": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.8.tgz", - "integrity": "sha512-xGQEp8KXN8Sd8m6R4xYmwxghmswrd0cPnNI2Lc6fmrC3OojysTBJJGSIVwPV56q4t6THFUK3HJ0EaWwpglSxWw==", - "requires": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - } - }, - "@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "@types/lodash": { - "version": "4.14.180", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.180.tgz", - "integrity": "sha512-XOKXa1KIxtNXgASAnwj7cnttJxS4fksBRywK/9LzRV5YxrF80BXZIGeQSuoESQ/VkUj30Ae0+YcuHc15wJCB2g==", - "dev": true - }, - "@types/lodash.clonedeep": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz", - "integrity": "sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/lodash.samplesize": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@types/lodash.samplesize/-/lodash.samplesize-4.2.6.tgz", - "integrity": "sha512-yBgEuIxVIM+corHdvB+NHgzni1Oc0aEd7acuO/jET0vO2Y2f6sl7vfQlaZKgzcN+ZqWLB6B2VQTKc1T5zQra+Q==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/lodash.throttle": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.6.tgz", - "integrity": "sha512-/UIH96i/sIRYGC60NoY72jGkCJtFN5KVPhEMMMTjol65effe1gPn0tycJqV5tlSwMTzX8FqzB5yAj0rfGHTPNg==", - "dev": true, - "requires": { - "@types/lodash": "*" - } - }, - "@types/node": { - "version": "17.0.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.21.tgz", - "integrity": "sha512-DBZCJbhII3r90XbQxI8Y9IjjiiOGlZ0Hr32omXIZvwwZ7p4DMMXGrKXVyPfuoBOri9XNtL0UK69jYIBIsRX3QQ==", - "dev": true - }, - "@types/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" - }, - "@types/phoenix": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.5.4.tgz", - "integrity": "sha512-L5eZmzw89eXBKkiqVBcJfU1QGx9y+wurRIEgt0cuLH0hwNtVUxtx+6cu0R2STwWj468sjXyBYPYDtGclUd1kjQ==" - }, - "@types/prop-types": { - "version": "15.7.4", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", - "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", - "devOptional": true - }, - "@types/react": { - "version": "17.0.41", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.41.tgz", - "integrity": "sha512-chYZ9ogWUodyC7VUTRBfblysKLjnohhFY9bGLwvnUFFy48+vB9DikmB3lW0qTFmBcKSzmdglcvkHK71IioOlDA==", - "devOptional": true, - "requires": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "@types/scheduler": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "devOptional": true - }, - "@typescript-eslint/parser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.44.0.tgz", - "integrity": "sha512-H7LCqbZnKqkkgQHaKLGC6KUjt3pjJDx8ETDqmwncyb6PuoigYajyAwBGz08VU/l86dZWZgI4zm5k2VaKqayYyA==", - "dev": true, - "requires": { - "@typescript-eslint/scope-manager": "5.44.0", - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/typescript-estree": "5.44.0", - "debug": "^4.3.4" - } - }, - "@typescript-eslint/scope-manager": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.44.0.tgz", - "integrity": "sha512-2pKml57KusI0LAhgLKae9kwWeITZ7IsZs77YxyNyIVOwQ1kToyXRaJLl+uDEXzMN5hnobKUOo2gKntK9H1YL8g==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0" - } - }, - "@typescript-eslint/types": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.44.0.tgz", - "integrity": "sha512-Tp+zDnHmGk4qKR1l+Y1rBvpjpm5tGXX339eAlRBDg+kgZkz9Bw+pqi4dyseOZMsGuSH69fYfPJCBKBrbPCxYFQ==", - "dev": true - }, - "@typescript-eslint/typescript-estree": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.44.0.tgz", - "integrity": "sha512-M6Jr+RM7M5zeRj2maSfsZK2660HKAJawv4Ud0xT+yauyvgrsHu276VtXlKDFnEmhG+nVEd0fYZNXGoAgxwDWJw==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "@typescript-eslint/visitor-keys": "5.44.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - } - }, - "@typescript-eslint/visitor-keys": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.44.0.tgz", - "integrity": "sha512-a48tLG8/4m62gPFbJ27FxwCOqPKxsb8KC3HkmYoq2As/4YyjQl1jDbRr1s63+g4FS/iIehjmN3L5UjmKva1HzQ==", - "dev": true, - "requires": { - "@typescript-eslint/types": "5.44.0", - "eslint-visitor-keys": "^3.3.0" - } - }, - "acorn": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", - "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "requires": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" - } - } - }, - "acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==" - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "requires": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - } - }, - "arg": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.1.tgz", - "integrity": "sha512-e0hDa9H2Z9AwFkk2qDlwhoMYE4eToKarchkQHovNdLTCYMHZHeRjI71crOh+dio4K6u1IcwubQqo79Ga4CyAQA==" - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "aria-hidden": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz", - "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - } - }, - "array-includes": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", - "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "is-string": "^1.0.7" - } - }, - "array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true - }, - "array.prototype.flat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", - "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.flatmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", - "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0" - } - }, - "array.prototype.tosorted": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", - "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.1.3" - } - }, - "ast-types-flow": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", - "dev": true - }, - "autoprefixer": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz", - "integrity": "sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==", - "requires": { - "browserslist": "^4.20.2", - "caniuse-lite": "^1.0.30001317", - "fraction.js": "^4.2.0", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - } - }, - "axe-core": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.5.2.tgz", - "integrity": "sha512-u2MVsXfew5HBvjsczCv+xlwdNnB1oQR9HlAcsejZttNjKKSkeDNVwB1vMThIUIFI9GoT57Vtk8iQLwqOfAkboA==", - "dev": true - }, - "axobject-query": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", - "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "requires": { - "fill-range": "^7.1.1" - } - }, - "browserslist": { - "version": "4.20.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.2.tgz", - "integrity": "sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==", - "requires": { - "caniuse-lite": "^1.0.30001317", - "electron-to-chromium": "^1.4.84", - "escalade": "^3.1.1", - "node-releases": "^2.0.2", - "picocolors": "^1.0.0" - } - }, - "bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, - "camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" - }, - "caniuse-lite": { - "version": "1.0.30001689", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz", - "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==" - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "requires": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "fsevents": "~2.3.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, - "requires": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true - }, - "core-js-pure": { - "version": "3.26.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", - "integrity": "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ==", - "dev": true - }, - "cosmiconfig": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz", - "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==", - "requires": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - } - }, - "cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", - "requires": { - "node-fetch": "2.6.7" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" - }, - "csstype": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", - "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==" - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" - }, - "define-properties": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", - "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", - "dev": true, - "requires": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - } - }, - "defined": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", - "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=" - }, - "detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "optional": true - }, - "detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "detective": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz", - "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==", - "requires": { - "acorn-node": "^1.6.1", - "defined": "^1.0.0", - "minimist": "^1.1.1" - } - }, - "didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "requires": { - "path-type": "^4.0.0" - } - }, - "dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "electron-to-chromium": { - "version": "1.4.93", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.93.tgz", - "integrity": "sha512-ywq9Pc5Gwwpv7NG767CtoU8xF3aAUQJjH9//Wy3MBCg4w5JSLbJUq2L8IsCdzPMjvSgxuue9WcVaTOyyxCL0aQ==" - }, - "emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "requires": { - "is-arrayish": "^0.2.1" - } - }, - "es-abstract": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.4.tgz", - "integrity": "sha512-0UtvRN79eMe2L+UNEF1BwRe364sj/DXhQ/k5FmivgoSdpM90b8Jc0mDzKMGo7QS0BVbOP/bTwBKNnDc9rNzaPA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.1.3", - "get-symbol-description": "^1.0.0", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.2", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.4.3", - "safe-regex-test": "^1.0.0", - "string.prototype.trimend": "^1.0.5", - "string.prototype.trimstart": "^1.0.5", - "unbox-primitive": "^1.0.2" - } - }, - "es-shim-unscopables": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", - "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", - "dev": true, - "requires": { - "has": "^1.0.3" - } - }, - "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "requires": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - } - }, - "es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==" - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz", - "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==", - "dev": true, - "requires": { - "@eslint/eslintrc": "^1.2.1", - "@humanwhocodes/config-array": "^0.9.2", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "functional-red-black-tree": "^1.0.1", - "glob-parent": "^6.0.1", - "globals": "^13.6.0", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.0.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0", - "v8-compile-cache": "^2.0.3" - }, - "dependencies": { - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - } - } - }, - "eslint-config-next": { - "version": "12.3.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-12.3.4.tgz", - "integrity": "sha512-WuT3gvgi7Bwz00AOmKGhOeqnyA5P29Cdyr0iVjLyfDbk+FANQKcOjFUTZIdyYfe5Tq1x4TGcmoe4CwctGvFjHQ==", - "dev": true, - "requires": { - "@next/eslint-plugin-next": "12.3.4", - "@rushstack/eslint-patch": "^1.1.3", - "@typescript-eslint/parser": "^5.21.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^2.7.1", - "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.31.7", - "eslint-plugin-react-hooks": "^4.5.0" - } - }, - "eslint-import-resolver-node": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", - "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", - "dev": true, - "requires": { - "debug": "^3.2.7", - "resolve": "^1.20.0" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-import-resolver-typescript": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-2.7.1.tgz", - "integrity": "sha512-00UbgGwV8bSgUv34igBDbTOtKhqoRMy9bFjNehT40bXg6585PNIct8HhXZ0SybqB9rWtXj9crcku8ndDn/gIqQ==", - "dev": true, - "requires": { - "debug": "^4.3.4", - "glob": "^7.2.0", - "is-glob": "^4.0.3", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - } - }, - "eslint-module-utils": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", - "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", - "dev": true, - "requires": { - "debug": "^3.2.7" - }, - "dependencies": { - "debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - } - } - }, - "eslint-plugin-import": { - "version": "2.26.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", - "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", - "dev": true, - "requires": { - "array-includes": "^3.1.4", - "array.prototype.flat": "^1.2.5", - "debug": "^2.6.9", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-module-utils": "^2.7.3", - "has": "^1.0.3", - "is-core-module": "^2.8.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.values": "^1.1.5", - "resolve": "^1.22.0", - "tsconfig-paths": "^3.14.1" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - } - } - }, - "eslint-plugin-jsx-a11y": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", - "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", - "dev": true, - "requires": { - "@babel/runtime": "^7.18.9", - "aria-query": "^4.2.2", - "array-includes": "^3.1.5", - "ast-types-flow": "^0.0.7", - "axe-core": "^4.4.3", - "axobject-query": "^2.2.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "has": "^1.0.3", - "jsx-ast-utils": "^3.3.2", - "language-tags": "^1.0.5", - "minimatch": "^3.1.2", - "semver": "^6.3.0" - }, - "dependencies": { - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "eslint-plugin-react": { - "version": "7.31.11", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.11.tgz", - "integrity": "sha512-TTvq5JsT5v56wPa9OYHzsrOlHzKZKjV+aLgS+55NJP/cuzdiQPC7PfYoUjMoxlffKtvijpk7vA/jmuqRb9nohw==", - "dev": true, - "requires": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", - "doctrine": "^2.1.0", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.3", - "semver": "^6.3.0", - "string.prototype.matchall": "^4.0.8" - }, - "dependencies": { - "resolve": { - "version": "2.0.0-next.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", - "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", - "dev": true, - "requires": { - "is-core-module": "^2.9.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - } - } - }, - "eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "requires": {} - }, - "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, - "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", - "dev": true - }, - "esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "espree": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz", - "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==", - "dev": true, - "requires": { - "acorn": "^8.7.0", - "acorn-jsx": "^5.3.1", - "eslint-visitor-keys": "^3.3.0" - } - }, - "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "requires": { - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-glob": { - "version": "3.2.11", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz", - "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==", - "requires": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "dependencies": { - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "requires": { - "is-glob": "^4.0.1" - } - } - } - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", - "dev": true - }, - "fastq": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", - "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "requires": { - "to-regex-range": "^5.0.1" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz", - "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==", - "dev": true - }, - "formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", - "requires": { - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" - }, - "dependencies": { - "deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==" - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "fraction.js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", - "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==" - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true - }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "optional": true - }, - "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - } - }, - "functional-red-black-tree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", - "dev": true - }, - "functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true - }, - "get-intrinsic": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", - "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, - "requires": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.3" - } - }, - "get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" - }, - "get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - } - }, - "glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz", - "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "requires": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - } - }, - "has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "requires": { - "function-bind": "^1.1.1" - } - }, - "has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.1" - } - }, - "has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true - }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "requires": { - "react-is": "^16.7.0" - } - }, - "ignore": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", - "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", - "dev": true, - "requires": { - "get-intrinsic": "^1.1.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - } - }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" - }, - "is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "requires": { - "has-bigints": "^1.0.1" - } - }, - "is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "requires": { - "binary-extensions": "^2.0.0" - } - }, - "is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true - }, - "is-core-module": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", - "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "requires": { - "has": "^1.0.3" - } - }, - "is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "dev": true - }, - "is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "requires": { - "has-symbols": "^1.0.2" - } - }, - "is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", - "dev": true - }, - "json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "requires": { - "minimist": "^1.2.0" - } - }, - "jsx-ast-utils": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", - "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", - "dev": true, - "requires": { - "array-includes": "^3.1.5", - "object.assign": "^4.1.3" - } - }, - "language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true - }, - "language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", - "dev": true, - "requires": { - "language-subtag-registry": "~0.3.2" - } - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "lilconfig": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz", - "integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==" - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==" - }, - "lodash.clonedeep": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" - }, - "lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" - }, - "lodash.samplesize": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz", - "integrity": "sha1-Rgdi+7KzQikFF0mekNUVhttGX/k=" - }, - "lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" - }, - "loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "requires": { - "js-tokens": "^3.0.0 || ^4.0.0" - } - }, - "merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "requires": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - } - }, - "mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==" - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==" - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==" - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", - "dev": true - }, - "next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", - "requires": { - "@next/env": "15.2.4", - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.15", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "sharp": "^0.33.5", - "styled-jsx": "5.1.6" - }, - "dependencies": { - "postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "requires": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - } - } - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==" - }, - "node-releases": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", - "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" - }, - "normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" - }, - "normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" - }, - "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true - }, - "object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true - }, - "object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - } - }, - "object.entries": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", - "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.fromentries": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", - "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.hasown": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", - "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", - "dev": true, - "requires": { - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "object.values": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", - "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "requires": { - "callsites": "^3.0.0" - } - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" - }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" - }, - "picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" - }, - "postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", - "requires": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - } - }, - "postcss-js": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", - "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", - "requires": { - "camelcase-css": "^2.0.1" - } - }, - "postcss-nested": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", - "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", - "requires": { - "postcss-selector-parser": "^6.0.6" - } - }, - "postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "requires": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - } - }, - "postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "requires": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, - "quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" - }, - "react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" - } - }, - "react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "requires": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - }, - "react-remove-scroll-bar": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", - "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", - "requires": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - } - }, - "react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "requires": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - } - }, - "readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "requires": { - "picomatch": "^2.2.1" - } - }, - "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, - "regexp.prototype.flags": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", - "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "functions-have-names": "^1.2.2" - } - }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true - }, - "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", - "requires": { - "is-core-module": "^2.8.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - } - }, - "scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - }, - "semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true - }, - "sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "optional": true, - "requires": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5", - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - } - }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, - "requires": { - "is-arrayish": "^0.3.1" - }, - "dependencies": { - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true - } - } - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true - }, - "source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, - "string.prototype.matchall": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", - "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.4.3", - "side-channel": "^1.0.4" - } - }, - "string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - } - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "requires": { - "client-only": "0.0.1" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } - }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" - }, - "tailwindcss": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.0.23.tgz", - "integrity": "sha512-+OZOV9ubyQ6oI2BXEhzw4HrqvgcARY38xv3zKcjnWtMIZstEsXdI9xftd1iB7+RbOnj2HOEzkA0OyB5BaSxPQA==", - "requires": { - "arg": "^5.0.1", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "cosmiconfig": "^7.0.1", - "detective": "^5.2.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "normalize-path": "^3.0.0", - "object-hash": "^2.2.0", - "postcss": "^8.4.6", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.0", - "postcss-nested": "5.0.6", - "postcss-selector-parser": "^6.0.9", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.0" - }, - "dependencies": { - "postcss-load-config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.3.tgz", - "integrity": "sha512-5EYgaM9auHGtO//ljHH+v/aC/TQ5LHXtL7bQajNAUBKUVKiYE8rYpFms7+V26D9FncaGe2zwCoPQsFKb5zF/Hw==", - "requires": { - "lilconfig": "^2.0.4", - "yaml": "^1.10.2" - } - } - } - }, - "tailwindcss-radix": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tailwindcss-radix/-/tailwindcss-radix-1.6.0.tgz", - "integrity": "sha512-5oBgGCVGsITMiUVlc6Euj4kt03l8htLJxVT9AXbkFxcJiXLtQxJriFq/8R+3s63OKit/ynCVdkqvlnW6H7iG1g==" - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", - "dev": true - }, - "tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "requires": { - "is-number": "^7.0.0" - } - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", - "dev": true, - "requires": { - "@types/json5": "^0.0.29", - "json5": "^1.0.1", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "requires": { - "tslib": "^1.8.1" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - } - } - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "requires": { - "is-typedarray": "^1.0.0" - } - }, - "typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "dev": true - }, - "unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "requires": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "use-callback-ref": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", - "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", - "requires": { - "tslib": "^2.0.0" - } - }, - "use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "requires": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - } - }, - "utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "requires": { - "node-gyp-build": "^4.3.0" - } - }, - "util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "v8-compile-cache": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", - "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", - "dev": true - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "websocket": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", - "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", - "requires": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.50", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - } - } - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "requires": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - } - }, - "word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true - }, - "xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" - }, - "yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==" - }, - "yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" - } - } -} diff --git a/demo/package.json b/demo/package.json deleted file mode 100644 index 00dcfcf0b..000000000 --- a/demo/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "demo", - "version": "0.1.2", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@supabase/supabase-js": "^2.1.0", - "@supabase/ui": "0.37.0-alpha.81", - "lodash.clonedeep": "^4.5.0", - "lodash.samplesize": "^4.2.0", - "lodash.throttle": "^4.1.1", - "next": "^15.2.4", - "react": "17.0.2", - "react-dom": "17.0.2" - }, - "devDependencies": { - "@types/lodash.clonedeep": "^4.5.6", - "@types/lodash.samplesize": "^4.2.6", - "@types/lodash.throttle": "^4.1.6", - "@types/node": "17.0.21", - "@types/react": "17.0.41", - "autoprefixer": "^10.4.4", - "eslint": "8.11.0", - "eslint-config-next": "^12.3.4", - "postcss": "^8.4.31", - "tailwindcss": "^3.0.23", - "typescript": "4.6.2" - } -} diff --git a/demo/pages/[...slug].tsx b/demo/pages/[...slug].tsx deleted file mode 100644 index 07c5be211..000000000 --- a/demo/pages/[...slug].tsx +++ /dev/null @@ -1,578 +0,0 @@ -import { useEffect, useState, useRef, ReactElement } from 'react' -import type { NextPage } from 'next' -import { useRouter } from 'next/router' -import { nanoid } from 'nanoid' -import cloneDeep from 'lodash.clonedeep' -import throttle from 'lodash.throttle' -import { Badge } from '@supabase/ui' -import { - PostgrestResponse, - REALTIME_LISTEN_TYPES, - REALTIME_POSTGRES_CHANGES_LISTEN_EVENT, - REALTIME_PRESENCE_LISTEN_EVENTS, - REALTIME_SUBSCRIBE_STATES, - RealtimeChannel, - RealtimeChannelSendResponse, - RealtimePostgresInsertPayload, -} from '@supabase/supabase-js' - -import supabaseClient from '../client' -import { Coordinates, Message, Payload, User } from '../types' -import { removeFirst } from '../utils' -import { getRandomColor, getRandomColors, getRandomUniqueColor } from '../lib/RandomColor' -import { sendLog } from '../lib/sendLog' - -import Chatbox from '../components/Chatbox' -import Cursor from '../components/Cursor' -import Loader from '../components/Loader' -import Users from '../components/Users' -import WaitlistPopover from '../components/WaitlistPopover' -import DarkModeToggle from '../components/DarkModeToggle' - -const LATENCY_THRESHOLD = 400 -const MAX_ROOM_USERS = 50 -const MAX_DISPLAY_MESSAGES = 50 -const MAX_EVENTS_PER_SECOND = 10 -const X_THRESHOLD = 25 -const Y_THRESHOLD = 35 - -// Generate a random user id -const userId = nanoid() - -const Room: NextPage = () => { - const router = useRouter() - - const localColorBackup = getRandomColor() - - const chatboxRef = useRef() - // [Joshen] Super hacky fix for a really weird bug for onKeyDown - // input field. For some reason the first keydown event appends the character twice - const chatInputFix = useRef(true) - - // These states will be managed via ref as they're mutated within event listeners - const usersRef = useRef<{ [key: string]: User }>({}) - const isTypingRef = useRef(false) - const isCancelledRef = useRef(false) - const messageRef = useRef() - const messagesInTransitRef = useRef() - const mousePositionRef = useRef() - - const joinTimestampRef = useRef() - const insertMsgTimestampRef = useRef() - - // We manage the refs with a state so that the UI can re-render - const [isTyping, _setIsTyping] = useState(false) - const [isCancelled, _setIsCancelled] = useState(false) - const [message, _setMessage] = useState('') - const [messagesInTransit, _setMessagesInTransit] = useState([]) - const [mousePosition, _setMousePosition] = useState() - - const [areMessagesFetched, setAreMessagesFetched] = useState(false) - const [isInitialStateSynced, setIsInitialStateSynced] = useState(false) - const [latency, setLatency] = useState(0) - const [messages, setMessages] = useState([]) - const [roomId, setRoomId] = useState(undefined) - const [users, setUsers] = useState<{ [key: string]: User }>({}) - - const setIsTyping = (value: boolean) => { - isTypingRef.current = value - _setIsTyping(value) - } - - const setIsCancelled = (value: boolean) => { - isCancelledRef.current = value - _setIsCancelled(value) - } - - const setMessage = (value: string) => { - messageRef.current = value - _setMessage(value) - } - - const setMousePosition = (coordinates: Coordinates) => { - mousePositionRef.current = coordinates - _setMousePosition(coordinates) - } - - const setMessagesInTransit = (messages: string[]) => { - messagesInTransitRef.current = messages - _setMessagesInTransit(messages) - } - - const mapInitialUsers = (userChannel: RealtimeChannel, roomId: string) => { - const state = userChannel.presenceState() - const _users = state[roomId] - - if (!_users) return - - // Deconflict duplicate colours at the beginning of the browser session - const colors = Object.keys(usersRef.current).length === 0 ? getRandomColors(_users.length) : [] - - if (_users) { - setUsers((existingUsers) => { - const updatedUsers = _users.reduce( - (acc: { [key: string]: User }, { user_id: userId }: any, index: number) => { - const userColors = Object.values(usersRef.current).map((user: any) => user.color) - // Deconflict duplicate colors for incoming clients during the browser session - const color = colors.length > 0 ? colors[index] : getRandomUniqueColor(userColors) - - acc[userId] = existingUsers[userId] || { - x: 0, - y: 0, - color: color.bg, - hue: color.hue, - } - return acc - }, - {} - ) - usersRef.current = updatedUsers - return updatedUsers - }) - } - } - - useEffect(() => { - let roomChannel: RealtimeChannel - - const { slug } = router.query - const slugRoomId = Array.isArray(slug) ? slug[0] : undefined - - if (!roomId) { - // roomId is undefined when user first attempts to join a room - - joinTimestampRef.current = performance.now() - - /* - Client is joining 'rooms' channel to examine existing rooms and their users - and then the channel is removed once a room is selected - */ - roomChannel = supabaseClient.channel('rooms') - - roomChannel - .on(REALTIME_LISTEN_TYPES.PRESENCE, { event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC }, () => { - let newRoomId - const state = roomChannel.presenceState() - - // User attempting to navigate directly to an existing room with users - if (slugRoomId && slugRoomId in state && state[slugRoomId].length < MAX_ROOM_USERS) { - newRoomId = slugRoomId - } - - // User will be assigned an existing room with the fewest users - if (!newRoomId) { - const [mostVacantRoomId, users] = - Object.entries(state).sort(([, a], [, b]) => a.length - b.length)[0] ?? [] - - if (users && users.length < MAX_ROOM_USERS) { - newRoomId = mostVacantRoomId - } - } - - // Generate an id if no existing rooms are available - setRoomId(newRoomId ?? nanoid()) - }) - .subscribe() - } else { - // When user has been placed in a room - - joinTimestampRef.current && - sendLog( - `User ${userId} joined Room ${roomId} in ${( - performance.now() - joinTimestampRef.current - ).toFixed(1)} ms` - ) - - /* - Client is re-joining 'rooms' channel and the user's id will be tracked with Presence. - - Note: Realtime enforces unique channel names per client so the previous 'rooms' channel - has already been removed in the cleanup function. - */ - roomChannel = supabaseClient.channel('rooms', { config: { presence: { key: roomId } } }) - roomChannel.on( - REALTIME_LISTEN_TYPES.PRESENCE, - { event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC }, - () => { - setIsInitialStateSynced(true) - mapInitialUsers(roomChannel, roomId) - } - ) - roomChannel.subscribe(async (status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - const resp: RealtimeChannelSendResponse = await roomChannel.track({ user_id: userId }) - - if (resp === 'ok') { - router.push(`/${roomId}`) - } else { - router.push(`/`) - } - } - }) - - // Get the room's existing messages that were saved to database - supabaseClient - .from('messages') - .select('id, user_id, message') - .filter('room_id', 'eq', roomId) - .order('created_at', { ascending: false }) - .limit(MAX_DISPLAY_MESSAGES) - .then((resp: PostgrestResponse) => { - resp.data && setMessages(resp.data.reverse()) - setAreMessagesFetched(true) - if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - }) - } - - // Must properly remove subscribed channel - return () => { - roomChannel && supabaseClient.removeChannel(roomChannel) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId]) - - useEffect(() => { - if (!roomId || !isInitialStateSynced) return - - let pingIntervalId: ReturnType | undefined - let messageChannel: RealtimeChannel, pingChannel: RealtimeChannel - let setMouseEvent: (e: MouseEvent) => void = () => {}, - onKeyDown: (e: KeyboardEvent) => void = () => {} - - // Ping channel is used to calculate roundtrip time from client to server to client - pingChannel = supabaseClient.channel(`ping:${userId}`, { - config: { broadcast: { ack: true } }, - }) - pingChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - pingIntervalId = setInterval(async () => { - const start = performance.now() - const resp = await pingChannel.send({ - type: 'broadcast', - event: 'PING', - payload: {}, - }) - - if (resp !== 'ok') { - console.log('pingChannel broadcast error') - setLatency(-1) - } else { - const end = performance.now() - const newLatency = end - start - - if (newLatency >= LATENCY_THRESHOLD) { - sendLog( - `Roundtrip Latency for User ${userId} surpassed ${LATENCY_THRESHOLD} ms at ${newLatency.toFixed( - 1 - )} ms` - ) - } - - setLatency(newLatency) - } - }, 1000) - } - }) - - messageChannel = supabaseClient.channel(`chat_messages:${roomId}`) - - // Listen for messages inserted into the database - messageChannel.on( - REALTIME_LISTEN_TYPES.POSTGRES_CHANGES, - { - event: REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.INSERT, - schema: 'public', - table: 'messages', - filter: `room_id=eq.${roomId}`, - }, - ( - payload: RealtimePostgresInsertPayload<{ - id: number - created_at: string - message: string - user_id: string - room_id: string - }> - ) => { - if (payload.new.user_id === userId && insertMsgTimestampRef.current) { - sendLog( - `Message Latency for User ${userId} from insert to receive was ${( - performance.now() - insertMsgTimestampRef.current - ).toFixed(1)} ms` - ) - insertMsgTimestampRef.current = undefined - } - - setMessages((prevMsgs: Message[]) => { - const messages = prevMsgs.slice(-MAX_DISPLAY_MESSAGES + 1) - const msg = (({ id, message, room_id, user_id }) => ({ - id, - message, - room_id, - user_id, - }))(payload.new) - messages.push(msg) - - if (msg.user_id === userId) { - const updatedMessagesInTransit = removeFirst( - messagesInTransitRef?.current ?? [], - msg.message - ) - setMessagesInTransit(updatedMessagesInTransit) - } - - return messages - }) - - if (chatboxRef.current) { - chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - } - } - ) - - // Listen for cursor positions from other users in the room - messageChannel.on( - REALTIME_LISTEN_TYPES.BROADCAST, - { event: 'POS' }, - (payload: Payload<{ user_id: string } & Coordinates>) => { - setUsers((users) => { - const userId = payload!.payload!.user_id - const existingUser = users[userId] - - if (existingUser) { - const x = - (payload?.payload?.x ?? 0) - X_THRESHOLD > window.innerWidth - ? window.innerWidth - X_THRESHOLD - : payload?.payload?.x - const y = - (payload?.payload?.y ?? 0 - Y_THRESHOLD) > window.innerHeight - ? window.innerHeight - Y_THRESHOLD - : payload?.payload?.y - - users[userId] = { ...existingUser, ...{ x, y } } - users = cloneDeep(users) - } - - return users - }) - } - ) - - // Listen for messages sent by other users directly via Broadcast - messageChannel.on( - REALTIME_LISTEN_TYPES.BROADCAST, - { event: 'MESSAGE' }, - (payload: Payload<{ user_id: string; isTyping: boolean; message: string }>) => { - setUsers((users) => { - const userId = payload!.payload!.user_id - const existingUser = users[userId] - - if (existingUser) { - users[userId] = { - ...existingUser, - ...{ isTyping: payload?.payload?.isTyping, message: payload?.payload?.message }, - } - users = cloneDeep(users) - } - - return users - }) - } - ) - messageChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - // Lodash throttle will be removed once realtime-js client throttles on the channel level - const sendMouseBroadcast = throttle(({ x, y }) => { - messageChannel - .send({ - type: 'broadcast', - event: 'POS', - payload: { user_id: userId, x, y }, - }) - .catch(() => {}) - }, 1000 / MAX_EVENTS_PER_SECOND) - - setMouseEvent = (e: MouseEvent) => { - const [x, y] = [e.clientX, e.clientY] - sendMouseBroadcast({ x, y }) - setMousePosition({ x, y }) - } - - onKeyDown = async (e: KeyboardEvent) => { - if (document.activeElement?.id === 'email') return - - // Start typing session - if (e.code === 'Enter' || (e.key.length === 1 && !e.metaKey)) { - if (!isTypingRef.current) { - setIsTyping(true) - setIsCancelled(false) - - if (chatInputFix.current) { - setMessage('') - chatInputFix.current = false - } else { - setMessage(e.key.length === 1 ? e.key : '') - } - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: true, message: '' }, - }) - .catch(() => {}) - } else if (e.code === 'Enter') { - // End typing session and send message - setIsTyping(false) - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: false, message: messageRef.current }, - }) - .catch(() => {}) - if (messageRef.current) { - const updatedMessagesInTransit = (messagesInTransitRef?.current ?? []).concat([ - messageRef.current, - ]) - setMessagesInTransit(updatedMessagesInTransit) - if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' }) - insertMsgTimestampRef.current = performance.now() - await supabaseClient.from('messages').insert([ - { - user_id: userId, - room_id: roomId, - message: messageRef.current, - }, - ]) - } - } - } - - // End typing session without sending - if (e.code === 'Escape' && isTypingRef.current) { - setIsTyping(false) - setIsCancelled(true) - chatInputFix.current = true - - messageChannel - .send({ - type: 'broadcast', - event: 'MESSAGE', - payload: { user_id: userId, isTyping: false, message: '' }, - }) - .catch(() => {}) - } - } - - window.addEventListener('mousemove', setMouseEvent) - window.addEventListener('keydown', onKeyDown) - } - }) - - return () => { - pingIntervalId && clearInterval(pingIntervalId) - - window.removeEventListener('mousemove', setMouseEvent) - window.removeEventListener('keydown', onKeyDown) - - pingChannel && supabaseClient.removeChannel(pingChannel) - messageChannel && supabaseClient.removeChannel(messageChannel) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId, isInitialStateSynced]) - - if (!roomId) { - return - } - - return ( -
-
-
-
- - -
-
-
- - Latency: {latency.toFixed(1)}ms -
-
- -
-
-
- -
-
-

Chat

- - ↩ - -
-
-

Escape

- - ESC - -
-
- - {Object.entries(users).reduce((acc, [userId, data]) => { - const { x, y, color, message, isTyping, hue } = data - if (x && y) { - acc.push( - - ) - } - return acc - }, [] as ReactElement[])} - - {/* Cursor for local client: Shouldn't show the cursor itself, only the text bubble */} - {Number.isInteger(mousePosition?.x) && Number.isInteger(mousePosition?.y) && ( - - )} -
- ) -} - -export default Room diff --git a/demo/pages/_app.tsx b/demo/pages/_app.tsx deleted file mode 100644 index c55f6089f..000000000 --- a/demo/pages/_app.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import '../styles/globals.css' -import type { AppProps } from 'next/app' -import Head from 'next/head' -import { ThemeProvider } from '../lib/ThemeProvider' - -function MyApp({ Component, pageProps }: AppProps) { - return ( - <> - - Realtime | Supabase - - - - - - - - - - - - - ) -} - -export default MyApp diff --git a/demo/pages/_document.tsx b/demo/pages/_document.tsx deleted file mode 100644 index 9e20a3fd2..000000000 --- a/demo/pages/_document.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document' - -class MyDocument extends Document { - static async getInitialProps(ctx: DocumentContext) { - const initialProps = await Document.getInitialProps(ctx) - return initialProps - } - - render() { - return ( - - - - - -
- - - - ) - } -} - -export default MyDocument diff --git a/demo/pages/api/log.ts b/demo/pages/api/log.ts deleted file mode 100644 index a8a26317c..000000000 --- a/demo/pages/api/log.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' - -const LOGFLARE_API_KEY = process.env.LOGFLARE_API_KEY || '' -const LOGFLARE_SOURCE_ID = process.env.LOGFLARE_SOURCE_ID || '' - -const recordLogs = async (req: NextApiRequest, res: NextApiResponse) => { - if (!LOGFLARE_API_KEY || !LOGFLARE_SOURCE_ID) { - return res.status(400).json('Logs are not being recorded') - } - if (req.method !== 'POST') { - return res.status(400).json('Only POST methods are supported') - } - - const body = await req.body - - try { - await fetch(`https://api.logflare.app/api/logs?source=${LOGFLARE_SOURCE_ID}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-KEY': `${LOGFLARE_API_KEY}`, - }, - body: JSON.stringify(body), - }) - res.json('ok') - } catch (e) { - console.error(JSON.stringify(e)) - } -} - -export default recordLogs diff --git a/demo/postcss.config.js b/demo/postcss.config.js deleted file mode 100644 index 33ad091d2..000000000 --- a/demo/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/demo/public/css/fonts.css b/demo/public/css/fonts.css deleted file mode 100644 index 47a1664ed..000000000 --- a/demo/public/css/fonts.css +++ /dev/null @@ -1,72 +0,0 @@ -/* header and body font */ - -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Book.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Book.woff) format('woff'); - font-weight: 400; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BookItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BookItalic.woff) format('woff'); - font-weight: 400; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Medium.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Medium.woff) format('woff'); - font-weight: 500; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-MediumItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-MediumItalic.woff) format('woff'); - font-weight: 500; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Bold.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Bold.woff) format('woff'); - font-weight: 700; - font-style: 600; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BoldItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BoldItalic.woff) format('woff'); - font-style: 600; - font-style: italic; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-Black.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-Black.woff) format('woff'); - font-weight: 800; - font-style: normal; -} -@font-face { - font-family: 'circular'; - src: url(/fonts/custom-font/CustomFont-BlackItalic.woff2) format('woff2'), - url(/fonts/custom-font/CustomFont-BlackItalic.woff) format('woff'); - font-weight: 800; - font-style: italic; -} - -/* mono font */ - -@font-face { - font-family: 'source code pro'; - src: url('/fonts/source-code-pro/SourceCodePro-Regular.eot'); - src: url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'), - url('/fonts/source-code-pro/SourceCodePro-Regular.woff') format('woff'), - url('/fonts/source-code-pro/SourceCodePro-Regular.ttf') format('truetype'), - url('/fonts/source-code-pro/SourceCodePro-Regular.svg#SourceCodePro-Regular') format('svg'); - font-weight: normal; - font-style: normal; - font-display: swap; -} diff --git a/demo/public/favicon.ico b/demo/public/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/demo/public/fonts/custom-font/CustomFont-Black.woff b/demo/public/fonts/custom-font/CustomFont-Black.woff deleted file mode 100644 index 091f927ea14f94ef0f2887cfd303408c1f9ac28b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42288 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 diff --git a/demo/public/fonts/custom-font/CustomFont-Black.woff2 b/demo/public/fonts/custom-font/CustomFont-Black.woff2 deleted file mode 100644 index e3c834e57f291b97c981c6adfdb2630d6dc7427f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28432 zcmV(=K-s@{Pew8T0RR910B;Zg4*&oF0Z}Lb0B*1V0RR9100000000000000000000 z0000#Mn+Uk92zzof}AO(~i2bf1& zzA147H+N99R*>BQsMYURSB`3wZC{D#wt;8_TXQGOj0zhE0D=0`nEn6%=OvXfG!2^4 z3W#pHxgTQg&T8aV!$!2;g%+cmK!lNPS*zJ+#t|nJ+kUJiMK_g9MN8xK^~Ouq>2*^H zlblgWR5!!adQ|40FWqo3JyZ+qOa+a0yvYvHx*Ld2Afh^udrxxpf zcSS>fFi&h3W*`vFv|(zy_a{vva1B$@X8gkVhKZwR&w{qz%RW~2pJ)MzP8IaB5nqX_;V0i!_>6a`UGu@JEeE2pBpxfyfLZEn-0i*{AIs9#k6FZ%2MbXxf%u>S~> ze2*X+gq2KA@ay%wPXsn8GihA zhxz}1?)m*(``mgZ7E&Y7%o6 z(BK~a|9@5WUQkNgdjZpHj_mOU$Y+P5APKS2lx@`>orFK2{YNH$%8)$P(pZVIh6YHu zQs+GXJ-O6n8|0!0YI5qOKV#>QC8AX=Bw)2Htc`WX?=OseWIfK6>vrU?|#w! z5&#Y;yNBc~$6j`vWFfd0+hI$NNwl#oSc5cgW5qKs)>!jwN@kKgF)-k*^)C5ZA%JTh zO^62`1+q~Fea7k_fSWazXdp3U7Fa(-Ugxs*o%lDvzkk3-Yc)%XXO^0|q+soMG17}c zZbLD=KafgfqU(PEe~8e(YUX^(T=10 zg(c+_H{?0F7W ziNn}_V-=z{>2ieY8I8u$?5s=yd8}-JHi|V@}Cc~XChYv_}n_N&f-c6SZl?q*Z|g;N&Aub#65 zX+ZQ&ym?kc9xA8-p=!6>le<W6=&W;s z+;mGwFbE8UwWmD3{I9*-JHMtFYswQC;Fa*p6HfcgL88Z24 zI%$S9t1(YAzhr*h!peeWkz!G2alle(nP{oAEVZh#Y8d_=tI^kO(lXI%jr9>YA07&i zwCaHm*=)BtVcTkZ&i1zLTRVas*Dl|##WK-uzTJ>L-+t2my8WN_|2SYA{2e3?YKIhu ze1~?2`3^%4YaDhu>~py4aNAO6sqALQTQwkl!Zbv*Y}LK2Z1oVZSrZTl5YYNrNE9Xu4}f+)2;hjK%aojJ||=lEw^8s|di4j96D)cKh6 zIp^!h?Vf>F z8QYWm31ClP?_+=C__#vc8r)snW88OFdsmrjv1^0ta@TdPn_WMy`sV+Nf22jJ;9LPny_67Py zgls4&R0x$LmBvgrq86%Gi%lUj8E+!6Qwb6|jTNQI){Y&gG0c1tfwvmkSo}cn?i#%2 z_;CS;EEE>S*0{#HXZu0B4%u+T4M*d`$Lu)nhEs57aiCc4-KLOAeWrWCm(KXwobP?0 z*1$~QpZfl9px%|2c`@hD^kf;j;N`fegtO()33LkU@QhL;1-3Kryl?w6ShG zLX(kd>46TiqS?@#*xbZC#Jp%enPHhRl^_8#WI+Uprd%2?ZU7q8CyvRNQ4B2&ZH(I# zH(6$&ovd&+BEl4Z<9#?J&@# z$4kSUU~XofC~e9rf*Q=4;YR$LCY)xVxmF9I7*7LRnbcr6Zy*ooM=Jz!n{VG@;D2z~raF=2*mJOeHb4)>X8P{ga{VI))x;CQ~1 zMi`Fb$Vd8<2E5XoY);HnO7BQ7}caX52o6uK#7@hvXHYDX6sxIG-r062%}G0iPlJLl)ZV+ zZVx*~JK66V5%{%<7UxV-g!HIzFb_2Iaov=XPr`!G!l@SWSp5u^P*_@86@>$*5MMf% zE^mhLA5t3lBw`U9jy|qY9^w-MuaNqL91)a-lB60Omysp{9^?Z-B+8bJNy4!ygUDx4 z<273VsH*kNki%uh^T>hJ1|T}MmBZ2lrk3PsNLRC&b8tvX5bY@Flng~#<)|?zn6|Zj zR6?~aGrPx+jlz6(gcu0Tbv}Xrd@0V9M-pTnP)xH3T20vuZGm=>b|WRBgGMT8rqk4* z22GQu6}cL^23!YjNRBuz`&YFbQU*Rfp4Z0q>5xN<<6}la{W5r#o?pbx;iU*$ZP|hRoc&y}! zd2L?eI7u^4 zgiy62K!t9Mn5(%Y2z}*(d5K0zetdC1k)Qdco{9W}2i7}4X&FxnGEMa`ovG+QRNs@G+<3E48sTZJBph~GaGqzI11EL8c?&;7enqq59mD{)rDbHA&kg~{RZ0+Ii%)@Lr3l0$8;RG zO)?l`f9%N5*8M{Fdli3l>YteZ3;VFyw_-nZ?}c@k3>LvoL!HxK)j+l(U^q*J%i>}} z4$3P?_zGh4xaMal(kxQ7Jj05*SD?!zWxgEKu14)9m7Ft4IH>8Urc;W_^_v1&Ht$q5 z6lbqhZ(f6;iEE|R2+Gt40*2sCa%9PIhL1Tzo*5oR!L?r2yA@f#-A(=5CWje&qfdEfU+t0!-vt|Rr{W&48>@D#Ybw&oYnKU4Y8X(p?sc}PI1%5!rgiTNt5<(eUa;UP|J0U)p z=Ht*OnaV{fJ#V^K7%#%N1_NC)0SlP*SVE+>H6#$J#Ag8FBDqMOAqf-$^P}=D7gj_Q z>a{yti6!?QYEqIjGkC~oCX#2)aLX1-xq?B2!1wM5bs95S*oyE% zK;Cky^b>CEC+K$+W9wG8!U};$Ks21q5?5}1C*|ps+)<3H4z}8Egq}CSnwen$4W4l= z`1ygm&^?a(0uL;B$nl8n3DEXE&}N9Sv=>_4G53&E^Q<^qlS|TyLS%Q#S+As`i9T+I zrXFfSeGVBFXzI!&Hi+}qHtSJPt7=r7ni&5jqM;J8SG?J5ABW~AEoioKmh-V4%?^>y zF!hL&#~7Y+@(d#=AeRMOE<}0A6+l!7!99Y>rHBGTc_h4X1rcn?BY^+`7IYkiHy(KJ z>){38pa^=1h%TFw6FDNEm=7EM33ZGwVgfN1FF3G4#E8JQ))FEHkxxhhFTC)=3vwrU zCPXJ9u$dC6el$am$y1)0vZOabgnJ^V@06d!J?SHc2l-YT$+%>zEJO|Hm?MUuVansk zRH#SF9}?`@9V+oSh`cquA`}1sfCvBpz<-KL>CHmg&(AWH5A&EfOCLK6wICn`#-cE2 zg8Ow!o6sg2eL|$jvpljov^lUPu)S`(QU;kU4>8ZawOCcC-dzn$6LUQP3;;M~O2Pzz ztbAAVp%yL@Y?~w(ZygtUZ->xhD)iDnLR+h`Lyau1_lW&y- z5HjK01XUyJ?S?=qv3kl%0UOU_to1Spp9JeC(v^n57Kt)vku9lKtleh2?Ikr=q2}tO zL5tD)$&OAciI&W)(#};}O{mk5W|-N?y0MMiL~JM>=s*YET((*HN+iD;z-eT|Tq%Uw zS`+`qZzcZVW-R$5=fPdaq6K}k)fiyFBW6-a+$B4u-q2602b~asSi7!Wnpd^sR@vTH zhbQoGjFsecxbbYah2mza{9K5Gj*Y-d()orW9an#U`dS(^wMR zLqKgmOEJ9i1Y-vnWN)Wfc8+8X1&6By{GUEO2!-<#B}#x8F#^Sk6+}%fSh8dxQl$!| zl_^Y?0wSeK$yBHmtxi3;CQV|sX`|F*wK$t>Qe=yritVvSg}wHxG;2!~Q_0g=lfG>G%B{e`iYs=s(#jpHvPQEt*X&};TINz~TkDUe zn|8U)ZFZ&YZFjXj?QyNc9djLY(IFFH&VPBp)PxNLJUr&ra)=cj5>gu+;TDn1LQDR z0U+eTEdbndL98cIm2?cMF#xzj?t>yz3R+4b!vet5004E61=j#@!|yHE$}-$g(IG7> z+p=ugQ<-fVW)NUcFPM$WmUTe@Fr6(Ohk|5>fm>{8Sth_zfNonLHTWFN`}D?rh7STnSfE4(JqwH>rN00-Tnr0#T+jpG2qeSXcM_r# z!I*&ESFGC{PH@{(kr=dgehyhhkg}j>jO4=2h+od2rDVoqLK0%5l`F5`lHaM+N*Jbw z!H0nlCxnDjnIh7_>aXz~zFDqQ5=Mjo2*f4~bW|p_wNnHwPO4@smA~*+hgQs+EfPN< z@<1LC)NCSiz0p{zPA1xihf0@wEDN#)ol<9XOfLD2MsxswDQ{39eUu#hg4B8-QEPS4 zz}I6_0XZ}@i=$L$65(ckDl@)eU+Ls#=XGm&Dh3C#HvpswQNa7cv&E*c zL0baC+4FL?^^{nKL2tZ?7GRzOtl% ztJSvZ5ORTf?w+>FGoMMY9Yx^g-Br2AQ)-N7Is7h+MClzFt{#c+tGG1&^1W9D1v%PS;|im)>b#ex%89T=|)6 zx0)v+S)i9I^1|zll)$WCxZcNFHN0-6{x)d(pE;gsRC&fQz{IrXCr;ZT^4qJGe&?`6 znINODG06Fst1L@eI!V`&>73u1MY1eqIp>flaA!U(zVKHy0Zm1&8hVt-dHf?_+t4)w zBjGae5sL_tsqUDQj**y~ilBdO@mG~`xUmEvY`1+f^DBE!%7Kcn%ReLUuiH|$>gwno zie2x1WYz1?=-L8)$FY5X-~Sr7taY^2_NS62@au&bPvrxdbelAbBq+Brnp^>iwG4|j z2m+6lptgG$!^L)(kw(1F32AAO@7991hLA|MhQ-aJ&R{{6c0*JSm;(1UQtQu zds1Fuk<2I@`kuz%7XP*h#CHkPN~YoH(hM9Vt&CG4sIsZaYOrf@Xmi4HhFnIIG&U79 z<24twKw3U(T5g`KT-AleCbc1>Hm z?PKS_w0F=ENJpU@Lv|d>2}Ea)EJIofO*Rnf>N|%y`3>F&^94?QqM?mT~V9=0Zqh@*8D)q&Px4PY(?sl*HJ?LSNdfb!RI|nom zjl~g&B(kHDq>xIjq5WtxEXNCeu_(zpx_bHs{`~XbS?64K#SJ}r^~EkflboZN8(sS! z%naiF^$?hL1juina^tuPLx|w0#V>++X5XfW-y3`Fxy)Lam$ zAn?VjCvTQ{BMz{1j}UNz%T}>vMv{COr+7a9Fzp`5J~C9CWtHrlBV-_h&M4{i6{p}8 z;kyRN|6q3%YJ+KPe_yE*nuI500Gre`6%8sfwH~tidtLm?|C+X2T9=GRtKaF`ukDb!=I!A%QV-)0 zh1#Ks-EV>+VcfvFl&|~im@WUTM|M%0Tz8}KE^j(7Bt5P zucXSa(}8YqpL2LbXGswf__Lq~0G*dAZWYJdmBr90soQ0Q0rIzhH_5z%)kxG;Z%f3oOtA8Vu=(G{WnbM7?4kKac^2YMoSHp`$qd(o(#zh<7^d3?w>$mCA z!e2QNa9FxeQB>y#QY;G*H7RS>teCJ`+rh!H3#QRPnK1#)LhmqP!xo$kTX0s)Sb%~C zRXPvTY#0kjrY{g+&XOtfS4&U}!GLvQf?}#>Mq|NXiD9J%N3w;pi(?14z}7ap*0!)MY^!h3&>AbK_8S>wg*3mhMN6Bk{7~EEDy{SBW{{Ps z6{M`D(aIXjIcUN*ux;2rtEl<)42`Xg$HvgOAWPmIbbE5mYxUD#r$><7zk~N|Z5Z&hjJk$YW1D^~`gxz4b1yeDlk{pn?l2 zw6FvT3s2PgHZ-yxE?f7&ts?(t@!uPsno={voBFV(=OfR!qTl8NANlwW zK0>1)Y$CUKawv=o!zYZ-95HXy%{yfGe|&@q=DMB8y}1VL)r zM)Rm%bz!zao-?(F8)(n@7CDzkB(fG&d5q1PiydS}n%dD;S;Gc_tK0IiCS+*bj`&du z?y*NGlEE+B4`ZVwb9opn4DlJ_W*@ox&kzNRQb#?Hfurqy$1ml{QgUS9hKh9#15hda zGfl7jp|V`y+F?{ElT4e9s3!=ob^3e=mLiEM>YJ5alN}yCsntO6Gmdo`yZFeDWs7K$ zq?S<`>d!Mw&w4VRmdwN0oK{S{`zX6n4Mv6U`AF^Jlztf{5W=t8c|l}gl2?D_!%hb~zGI^%hIm!$Al$n)JKR2X7a_#3En7GMIpMEhesiiGlf z$1XPu#i=&v3`3^ss3dh$A8vS~XWyZ8LlL+3!*^CFRQ)JPloJRSu}{S$ydC!)Sw?PP zg{B#{jEw_yt;T_HSS60vj4*Ep0*a#-|asS$OG0gB$zO z5xrX=Q>r9iyp`D!{(S#42P~spLIu%hBt^oab9y%2cf`ikg~7TP+_PiX5{l)+`>%$} zVbM73knWot=bx1dLvCqI2f@8!cj4zY5q*{SpNW&c;%zt7hcvXXmIAwRi?)kyLc(hg z<0aa~_L{wB^Mj2>O|z+`A84h8VGAE~`>p1o$9V2O;m}gOKnM>{ge^ye@cy_Um%YiF z=iwZcET~{~?i`v?)}!a$$+ChPweaJXRUjIaNs`$F6|oU|MXI`84f)fh3`5h7QfcS( z4sMedoMKqa=am&|T<4=@_B0(UndRbC2(1n!S7%L`5o^N&tXOx}Y!0oLXgFnok1wNP?!OqO_eUm$mhFn+;wx zw^V4o_O;&9TT8F@d%N2&Zq!`MjE?|oC>{7i?8;`(S-)9fuU$j?;%gPxe3B8Wmiepc zbs2j?^Uk~8EYsF@8inMQ?OXTjEH~r6jv4|o!j7lFQ{T|k*m!}wQCP$8Ywosfl3NG(|^cJUgJzWP7a+I;#g9dV(rCfW#$?ik!PE3pyhvA zcvBI1FyRHMJ>4=?4t7?>h*?c-V`**H47Wn;LY8GrJh@6D3Yau4TcHvkZAU=hnMPHt z97luZ;%?>sqFZ<+v+A#2_did0C+y#tS@Q49@LIwG<@LEUDr)+eG)dMqTX!S>K;P&3 zr{}EMvFtrplBcpv+manMSwGK6|FuLT__?SMjufEMK3UeHeFe3bfL2`P@rTBJ-bqkb zLh%goRRf&7wA3F#4Gy)wUtq*y^K$dWeLB$Y%+-xGHfW1#dP>96`za&+Q+NKhdvh13e1i47+ z5%CQI=3t!6GkPkr9WA)#ye=~E0Eq6=~ zDF8rM+gsTUJo2(1Oc17wYKVONc+$fy%0aOQIck#Qn!BL>72{_9%%T-J`r!f@!7QW={+cLNLlsa~hHXU@2ZfP7+iIo*B$5cs zEeN5RNaJ{4Qh4-q@D|Od{XyQuSleHyg1nZ$&*a9<*jx`GU;3AN@_0XlJKvg7v)RL|_Q^BG(7_dX?_|&Rv$*W=%Nk2%&H_R3#k#u84 z3UCjB#iWrB{!;NB_^|kw)@($$5$*Nd*<^TFKS5Wh5k-^BkuSu}3E)o3Q1s zO-mszs6Mz3oG$hn05ylOMQc=P!5NpWExj149JZQ8ht_wZc1(x(2`lQ{N!Bo;13E1` zt~;->Q`QyhHZ&Z}J1Mb4XACtJ<4q*3q6oL>2WG=c4q??+R)(ZRB~sj`vsX)dR|mz- z`LRyBFE#<_NvssPHWX3Se{gV#g6N*zLN6zuSC=ie|0PIV8CO;vQ9DW9u0u)H8|YlO zy)SuzkqhqTy8o^dx z2chx3kXCefoJ-pH6_aFF333L)*?|WA`W-OmVpdKr3hs z;oQbpGHpASS7K+|6Dxs(2sjdsq%a~OMR6j75)CVcGdULuXt5^-7WPvGM-9#rxJg8i z#GM9F3X(Kr=|3r_jThwA0#E}6nHmBM!?;ZQkXf)~bK1$ho`VfCBW)axrUjBS9PGe3 zfLuF}5s~4LIS+Gl2!lEW=`?F+5S(S_9DC;vO42wL8Rw#cNfzg#FPL86F@Pozk;j8D zi62vEjR0shV&SmhrCYgf{^mFK76T#_94<>nz&QXo@+`2VkGKAUJoz9BKou&YWv!H% zP{1`^>PMpzXYA6aUl%}~sE6O&J?3d8(I#2DUT|qjnFB@wfaClCH_=o9ZYu-8T7dxO z@C3|2R|9}0jBs!rvVl#`0M2bhmLva=3jt#kr|Oe^By|QGAvI{tx9ie=ZAz6QTjuIP z~vfA@_K0-2ndA}5sMI86L|cuR4qZGBpLu~%w;z}L$c9yKy6XfDGm%s5WXw}Bc*F4Z2W26oB1MsjHoyAw$W^e_$wsFP z)qsZ-Dt-^STCHp)ypCDHGTR;*uMC)u27-ahgbzCchtonJh_6uqbeWFv?>YonzsxI% zOxh>P0VnCqIj=Bj(aPJ?VKpFt-dY>`cQQ(aL+9L3FeXf|2r#Ui!za@FPsmk&r2hq+ zezPV)>*+Jm^9e?RfwQ&V!pQ%+(+k4*#flnX-_^!>k6dlB471nl190yC>z*&)YgfU| z`?#UGuSoY48D1mgPgM35Rs3GE8t{L^WWkVGB3v3?b(QT90_e~ad2<&BEUN66@GwB2 z0b-#!L_rKRg*fO6-5?$kpgV*?6KFQv8JSFc-GB5HtJ0*yd?VJ_Y_~&ZU2xrf&%O86 zKbb(_5M;Q>vEmn;xR}(Xm0d9iAYp(3QgzG#)yTFwCV({iKmchmwyPv+26E z#k+~Wy!*b#EuC}=h2zoC!gl>xy8APrkEMrJw@Xhg?ezu_T^R0F!6bJ>{TfW~S9|*c zfh3ZG7CpJSytFl-HdEDHOY=H%Rm4xD*4kQMyALgBpoJ}JuswOTp^X<%_>X-0;)(a} zP{WP1xY4$>q^%umtZi*?X?xq({tk4oLyfnr<*hga!zZTvhgP;~zM$1jHq{yss;VL* z8w(lk6J_8DB@jWvZO;`$PQmtcbd#k>&`A z3+}q-frs9B>zxmQ3p?m#9sjU_IKl=26<0VVtzr#&j9O!-eW1y#iD~2|`75LE%%e!7 zu?{~9>L|0smaM8yKMQ6SWhtub@~aSLi7VB|y8SMcS>j9csUCk9#w@DReXiF(37AEl z)|dMHn~+%&%J8**{}s+GiRpZ6zW*D5M&Ar*Dmx6y1Plx?1P+9ln6(56;9wvyAdn!) zAb`mrAW}g=d?VofP1JyL!vNs|icfIkkQiK#Bl8coBC|n?{&9kYL&m}vOe}^v5f~hJ z!ogs{BLss1j{poBJYis5z!M6_89X6iP~Zs$;{=`{Fi3C^5Y`~fJQLM_`Nuo6vd`2uJ}}`ODJ*5B7%;M7%2|caiWrlwZM}SRS_>bk)PUu_oo90 zdr&BZk$I3U;#Dnh4ul&GQZxf5%lr^EKFiL5!1?-w10OgTb5L&J4f3=iFVXY%LV+P* z;7`GJNf#!_O~IGo7CgXb7ltV^4u9cdrtrbO5^>B>c?l$@pqYvvczQSHXt;t&C|hli zTWO<60*jJ-J`5EpUYcBnTlk0$k!3kQ8)Ve9@>OUWW>WMVnH3ct3@6elRHa4FO`V7X zf#Zee4q(b?Lwy+6AIQOUAgo>ymTHioYlx*2-Roopse+ zPrdck-+ZaT#6qX###$;1%n312I68DNz%pRz4jeD=ir{2HVPik7RHdaYoWE(@`shMY z*^3~)UhZh}MQ&-UT+vVHN(g)T!R>_x5f0@1Z)3AIEC{|9Abi_ZFa@8+6VW;aCQ58` zuR5kPphtNc)R=9eKtP_sEd2oE;^C}dxFM>yl!Lo(piqzmkzhgyB@6*V!V!sAF+i5U zlJ7>Af$i#p%rhgyjIV9@3j+2{5GTKck3B4Hf}|Xa+s6Qk%r2SDk1wU1gs^K9%5sU+ z=JCC{0=c9#C6`iaX{DzvBi&VKjF21<^!!##Dt+r<8e8t02C(Np*qoq@0aPC=6DR}% zfk0b<1wZio0f3$Wd%xeGpq0)4Ic|RH%=RKU0SG{Tt~tO&)_?}k0LFp<)IKl}z*r{b z4|uPY87n|PE&fj{JXM+1)zZ*Tue0mHHgX&9(47vB4HUo&Vju$w00J7QpaJ@biNioW zy=>O81WR=3!7_(k>PB~aiWor$48jPE!wf7y0Zyk|&gRG`%Bo$Rr+lat6J_Ge>qGzQ zzpVf#(DF9-iceQv%?<4oyRj|}kZ&;xQXmIFs=ihgt((2E*hP+}wu5jg4YN0C+u6Ad zULHvQM~LK}?rpTIbZ2L!q2#as?mf=`s`Nsc0M=+s2|Xf3zCL21v^@nA&Y}p;qAn2$*-H9l^FTw#z{JAF;mM0PE*?G~zWn$L5GY8n5TW*4IDJ+j zVWJl<{%;+4ty`~t!^TXQGHH!<)>?0)O{Q(O#Wq`Qx5F-b>^5Vcy$(6(up?fmkS#}n zSL&>0P!tr@D2%1YcyyJqbNNf`QD?@7l0xdsJ?*EBuwFw(6&Oz zmQOeap>_fg2T3Cd1tuyW12O@_p5#uJp8f3Q-HwB-Mj;b0u^@{8ds~1ptl82(RSP=l zxgsdBabV0|Y>gF5SwI$&rU#+IG>ag`3=%g28JV+8Mh$=|Lx(yVoW=BC?}pse@wZt<9QfjIWWNsBEyBHy0fwmT zad>*p)_Y8|xpyvr-2CsFYO{xF@D6m53)Rf)ss7{SXAc$z9s^mk z(dN@KOR1GlJSXt}y6X&lr#Ag^S|%Z(Gv`X-9=H!IEk`4BHczk!U->JP69XWwExZ^{ zO_Tm0luH8Dz^`EtWh2xi5U{y|EHtR1;f+hfXbcv=FQNkkOt1n6$YQ=we#i)bL1a`V zij##I_82A-089wGA$O4}ZcrL@v&>L)sA=I*#gk3A%V+t-FlknPh@d#NodBeJSq0y9 zgT{s85Cg;MB-rO%uN0`LcsfQ-7#HZ!M!RiU@8hmoZW1YIe~L6@(1TE0UQ()t!H>{Y z2Kl4Gqjq&9?U1AOX2UcM5QVU%nG13b`3hGOA0cR_!3_?#rx`X96u)!1HfD9be4M#{ z)~~q5i)*u|)w*^n^R$OXsGlm;y)6@n;DN7DM`sL+rzuKU-~|GVQHQ_2@A-~=XAXpd z50I_-CyXQz5{HZU`2=%@_FaL=_z`1_04-yFQe_m4fcRcyJ0)Z4K7Q7(GuLoV;w(m_ zqH_bhC3}A<5P8>ff&!P)`wGLQK`>Ap$`pDz-C&|1rMpoES+m#{02Pq7ybEOsE0u2S zXt@);=qFXBf{ryZl*{u(h3EoL$RR3>gbBO9lRdGKD8!{QtemL)Vi+MP;H0_`BJQQh zdz#-Xh^114khP^fYT`G(i%5sNmaXWV<|Ep$-*R(E%P2ZnmZIVx6e^AnRg^9VpaW8J z2&7iV5)C5*pgJU77%qI%(ZClA@&53fW-;X1=nOMQIe3GD(Qw_(Y`E?9w^;jN+1Z#Q z%Lhe7D2Eb+alC+ydLK`bXq)PQ?bT@AdA)i~_5q``jSh%1k%((x5Dbs>9ih=@3zq?i zLcaw=E$>`>?!PzLIADuj?(j|GD;OAw%kdLYDqj~4zJMq$4g_$aEBY-G6pz&3J7tQE z{(Ua@2i>V+>juDJd6EBI2eEW*{nC!|oc@v%>kCM&CD8wv!xg%$W(y3Hh7uPW6In4x z{y6<18+uTag+?14%dE$T8uV(>qgJ$jNrVWOs!6Jqe*?E(c!YkExw*1udS6vyznnIQ zMKfsmfEZMS2$Y}swd}$+pbFPs;~oCwX)9IK{sV$D&Ik9#PYH5%v(VrF+AZq@zO!eu z!{4E^MC-L4)S-BRJQ4w`OAKkBUF~%E4o1l%05)v>s0-lRx_(^Pl4sBv0zQg-uB%VljIVOW_4{2)59_52C#m7Qef0?*4a=pt zP$||F;&;mjqjxGsa|ERb%4S+=^ML~ffC{EZD-p3pX?Yu84R8ioi7G{^feS*DI5sq_ z^9_(vV?8I!+^X2DDs;&We$+%&R85%tQxR1i;X)i^Ji~9$0nTB)y_9z8b)03J+!QH6 zCe2c>=xG;Si;`o|w=oHIPL_VeKvkSQ!22a3R~x1=F$`;MbTaB2WK`sR1RXV9Q`Kon z*DIVxt=Mirm%hgmpMZ-jWh?_BdgSsq%nvc43S`+kOucSw4W_j{G7xN3=n_OdtK$t-|VClubjyGO=`!zB5Jx2*dUt zN*F=CE>?momP8NQO;A>IReF9io{b#8e-+gzQlwti4q8)*GSEP&SH@^6ihY1C*avHU zR9$zJUr^p3g;WwEYZ&A!#Bz23$Lx=OgnpVgi3s+OJfVG3D`?o!R@fvTiSNcs``Py~j`MoGCX_c~0^ z1M2YJ_H!8fu(};{p4oPG;~$eZ;3Sw78ReZ8JGb#@zLs23LhMzO>$He{@0*_ zEn?m5snNpl7JVLliMTLT9k)f@`}jZvo!H}dqCHZYMRp&VBec=+I2)s7N6LvxDFRAZ zmA-GGR1ud8mq)rB;fvWrcgNG=g_xuLrczvBh^Lu zIgeD9soC2S4c&*DA^gLFH{b!a*O^5wP1C4H8qF<0Jl`hC6ZNB~%a#}-sCRsS?!^+a z$F4J%ODmQkbUs1)i<2}*k8Y@hY=~*aO7W~4D3<$b?RE2l^jr5$E0dwd zv-DKDnf3|(E-S^dK9_>@y;e+BZ)fc+_d0gy;0Y*^t&J`&A23j!h1OfaACnRL*9ZX9 z68qpqCcwgf8L$KeN*~FQUK;(A2oGjs$ZZ;#;In41`0eQ{xx%CFUGBd?ZMe?dBO?6c z{vSKwiSIL=_$Omnc)04DfoO;2+(_+EoD#9qBc^*&RJc^U5cHLD z0E#y8d?AGwkuJb-z;=5P0HSXI3lH zVSpyoFO7WHyI3T^nBuzPVGYJj%RgQ>b(sgC9tdP%UKEc#7o#z z+WVejSIL@G^q$o}^kq*aH&;5!41?P2$mu==I^dUH(DG&ak9qcG^%@V3vF>RaZkm$# z<(V$dg2n6s9tZeDi3&3D#WqM1J>EP<3DPjKq6M1u1aW^xhZW%rNgD;~Wk7?dISTId z*emyI8)bjE#2}Mt)G(`ti%X1@q?Sdrb@dhU@NiNn+my>>($>QT@c|sKE0G(*4gB_Fuyu$MfIM31ITpcg!n3cE=33~p9NngN6FP4iW8>y#rHD|277yCGsre(-Zt8M7yreg; zx5IQh<7pI<3kOE4~C}Ga^V*(&+%iynM4#s>N$+)}0;xZ3oZ@@V4%NSlQa2xXJdPDi! zwBvO}nHb zS1ZEoVSl65WZ*m(V-AR-Tpa_oUhDZvIcWwmNQOE)u%Bc4GAIvrPjFJk843bLUf8Sk zAPHmgDFE=@nXkbym_*M_b2V3G98oVCMBrGePWd@oH|Top*$L^(I{ijC{I|gI?(JU4 zF;$eXLDYM}Yz^8?TxG+f>F;2$^x_#zTI{AdQBS6(^5Zf^p@hEz!o(Mn<0_&!(5FLn z&)hzcr0RyQmt@rHUci>Zft-yF%wHhZ4>ZwUxN;!F-Xp;iBTKVEo=?-k*D6`*{`i*% zWwnyYxd0bPx>c8bc_Vtf_$+h;C5c#QXMfJttl;xf*SMUb9k3H>bNokD<^`?uSIuVI|M^QwbU%UlUo~hMyxCBv{03d`%es0n850R@2tIf9c}W#rgSk+tGFO|Py_A9X za;A(8I>)KjVP<>P#(Vi!cE4zsMkQ`T%$Ii~)0{|xZLxQf)0P;FB#a|}6aCIfC;{p& z715LObr(hQoywtj&_QbH$Lj**RcRB?2i~sa$1mns1W{-HlVe=2LX-(g@I_`iH{NP9 z|C0rDBrGLEsc5BwsxxI=iIiwFi!lJba|O+!#nF!7w(91(t-2k4((l;*cIYA~pAGZh zF@nQimxc;DSH}{~-|0#jK--SKCvWAq9{N||0~Qs>P=;Ht(Kqu+c|^DmHaEkQm8J;D z&_4;@f2fO%Zh_KlW%X^JWCmh2XN=mw8E7GaPfyev)Mkg0u2`cR2UT1%L+}d3(S3?POb}s0qk5&P=}-8B;ler1WT3w7NK@AXo#YXFwVrX(5qfF;n9;v zFW%+czkK}karCK5N3ktB5lPF|tlO|-zK=@7 zF~*M=`DQp=Xh0l__OmdU->5XoDZRgoHz9$A+fwM`G%cC0hRXx1i&4smVKS?RCk&-b z!9;ACS=owAX~u6Zv1`;%SKQqt;)<3gp>*=LjP$j)&xm`+Z;@Y+l!lqkT7+ULoh(=sG~%20|Kt!x?^kOuQZ)Ca%k)$t#jcO*E>4Jg|n>#Ua&8^-*fuuOpfwz0P(RPP~r8(h* z&<6hyb^&xJbAw`=Uksjl#&X6b;@4v``b*slq!q=EI)MzmGH;`~m7Zo)LF{IVmHEtF zo;HI|L_f4fm$ zBCL*#h$NCp#7G2i6OjHoAsFmKym$Mi?swge0SVm&iwH|90JMNgxyFJ)Y+FO&AYoZ0 z0WF}ysIG7!wygrR+y+5eRb^48T~UK+M6X+@Ff@ev$(8v)gH$_aWJ%%*%i?T`E6bwo z6SN2V$ae9Gv>6O@OCllznUcRKvrJ_RhW>PO#A1-HI7dFI2pf^5>m!fC2&rBxm1Jh{ z?V`USK8SCf>uCedVPa)`L3T}*A|x>J{&SckG>VxX@4EjYi`15Ms4 zMK?s6tYdY2(23`(yb3~c{~@@aXRux{oWNzYC~sxEUB~r?m5nhb`K9qGeXUg`{dMMv zOR(5pHcLcdHc{PxTh}U^%tI~t9r@i8-5ob4ZvgbZL09J<4!wfOS4iK8?^E%ingUUwf8b|Tf*VJWX4;ag9MpyN2K7VCg_~YBWp}aA#xA;_#b19NAt3 zcLz_7gFC@%eBQ&pf&^YrqT^$3JR`oN;A&?SiX^QaHsgYTOUsPTU_{3W71xS`8*eb|fdy|(-x)N;}4 zXn#r|lW?EOaG{`?{U%)J)Rv~g%6XM>MY_n;xqAz?Pse`^fMmW2^+_I6DM-{s>ndB6 zxS$QSdA!ds3;0Jk#a|PVH!#o8w0g3=X~oL!tSBBgGHxC{aL3cj8#ljrxNiCRg9}Pq z26jFmyui`9LT^hRUsqeM zj!%xeI?5Ph#m2~6yujO$w4~C!oZQm9q_j*=Fb7$E;;7`r5ny*>?)YL-w?)524;j2) zId^uHIcmPh4BT(El#b+hOUEu3()h3&22_YqXA=1A8=g8`*q zcH|-lf$QbD2@V$+mfhTUqjldOmq+I=op`wK;T^DNJ?YBT6?4Zdw{DvNr_Wt(C|Ofq za=E^AjoYoYH!tvST#USNfq(M?c-NK2HqaS5PFhiL$^R24>*B6?Svg(JfRuTou%<6I zGf|tDl$a6E&KVD7r{I?xO5({udFA;@=0C4h!u2#&@bTji^B zNVd-{i`6e~%Ma5Nm)d|F_C3x7=k5{}xMTNqVxVnqVqcg*SS1jK?VFfu1Nti)K5u}| zPQhlkg1`Q)I5l}}c53v-k{j!e&2Ffg8=9L0t7o5Oz0e)&f6@2iz;pfc%p?6T`d@(m zJ-#j9zsPLJZ2QmUvO+fN-HVT{h53bC5LUXbs~d6_ukSWtojx4w2oog=oOM*2oST z@^e8;2&}^Xp$bFyP(kizs|PD<1PGw~l?>B@J-pKn5N{Um(yA5`LR_m!DIQ%|Uo|wC zQ`ON`N2R5>2z}XdkZve0A1jY-jb$cf=O=Shn)ql;358k={_dnyEnqaxbC&9|jJ^5U z?E~3<5tP$DgS)CaM>}%6^3VLE-Ng02uX*py1$Rm0vFl^73-RgN(j@;eH;(}t=>oVn z_GxTQg^Wk0h-4xP8H78Ujyc6>(xQ1bEfFp}_;k8}%FD&~fJO$WJ|Jd?#m!>L$qBbwDNbS;=e{rhy(bOTG>?2MEl~^yfh_^2*s-}k|LEXYtkvX@mwPZ{F`Q)hnhDRZVn?N{ov1yt?zi7lOsQi z3unS+fXOw#mVuTdaM^+nATE+6^;0Lcl_X|X<(MQ4IMGLmI8QVwESnqdE{hFQN75yJ ziAk-+Ni|(AOf6DIyAoW8D9;m-qU0N?IX>0S6o}ne7m5O$k_3kY7X;@8+nM({fu_5k zSFihXa^2_2^?&}}&%J$zdq>})Z*y0iCI7`_{7<=QbM>y*Q=`NxL-B^vk~nEf)U4k1 z1@P8s7T;7_8XVe9W{XjymJP6PxPN& z(08Wf_`*jpFbW{Gwx6tS{=wNLkGX_p#OBqiG_U=SWpRmyA*ocol>rQvwt}+eb-RB&-Hpo69%Fii=j=#rbeWM{n1J+!ZG-GXURZCS| zu~wS82|Gu2Cy^b-q98eS9s8j=Zs{V z!19NzX{6JBXViWtq~ngn&zrOH$uYl{NPL4-iYom+F^Ko|=W_7x$)yP0*x_rlZi~s? zmi?AJls4m0CNPue|n6EJY|lmfuTm5DpGMvEf!Bx(g5O?YB@ve{f$C~$a>Gt+{qki%LV)Q2-b|+pWx}PVy zAG}0(^ue;68%F^pz*k$QC9uJXFz)zV>t9s12#`qgj?#QKQ}ejf(zyOhvu84?9x4eN z(w=XNPe{Uyv@^vUiKJ^SlIFTA&%Bmd{)FVz5gc0Vt(y2CCpx<@$Cz59LdmID>28b{bl z46?=^|9Gi^rj>~~Zw`?$Ygvq*klQ2AY6t4`i(5;Sx+I@prL6VbF+0zdyWi<4!z9o5 zc%q z5{2rt!OL3|ri)MriDH77Z524MyP4uBs!L9(kSIh7Luz)CpyZ-j2eE-fyUT8GpdvS? z+&{uUsiIB7kV$6>_#DMXdyn+xY0R|k@kH8KUOVRi;jwvA&MEj;ua#MM&Xi|kN&wZ_ zwhZJ7KNBz5cslnJL+K5=pK0|#_9}3dc`P&5hXzyEv1pm@`cZ_Cj67|Dt<%U{0Vm=l zAgP^`R|H~0lHec#e}2;m#+gn$;evv*WJZ2=a>$n9vg>!WbZ?ngHriiUJY27LJO1#; zAANlHq$z}7|8Px1bVWCK|HQ7SYVQ3)k2CBBT1Dm>x1T*WJE-^ zB=ER%)BQb|nEE7n9B=2;i^fB`d}}2wUXe@eB|zdpBzdp>ZU4qp^RH$={eQ8;Ms5+PWds3>LBQp&DfC9I zoH${#Dym_XY#kT;8v7kjX(UD&V`vq%VO(WYLyrSa2Fqk^<2Ns?p@>6`k+R?udmpE8tSYSlADMMX!I9Y--R7d6fg5U4XqcY|>-;t>G#`0aV3 zN+OMrmY0nVc1_6gQY>~|Ra58v2|*=U@LHOp`L2*k2&+BCktv(Mg{ob}s`Qo``#8JU z@^~@#uXbm5mj~4~r7b-%Eq2QCI5Gb};}5FK1JFf4Nx%t_MFvwmOjRT$Lxeh%m)HxH z2@yKa8epxGxl<|L6tXvk3jXb<0Izn7p7=)uc_-zvqwW4Yjzf+**p!yDR?qW%v;|r3bh*5&ofu82J-SQRHz=WTZDfhMurmB=Eke_2pJa z`FpRp9|G&{vdtxG8B9;w^@0$%KL~4p&C8CflG$Ezf6fIsPPS0$D(d=M`s*v|f%kuP zr@f*DEeojA2EStJ0FRT@(6Cv%lM;gVfe(l#^C)CKiNYsScx=j6V>~jIB;&CtByr?T zm=A`m0>$x$aITIR>;i#AubclyJT%O;apn7xU2!B|o^(bq^wxgZgAK8L**Zzg>(_G0 z!AD55o7atbc~c=#9R$Ecj*N>9^eJ6ES)juc()EO(_}IV@m4a3j7@tYNr)L(iAhRiY zIw2khT3aI(SRp-zxpe}VG@Mbf&vs9GJxob`=(V4$|91WbIOFp0uQuS!WjVlpqMSMr z9ImTlDw=utRm$33!|dRQQ2&7Vn5eKsF~~cFNP0_7ah6zn2Sf(>2Ww=J(zG;rR&!+r zYv^CZ654u%;|AIo$em5A&9U2BN5H1B?WYg!N(a}t1d$C~L>0kpM5cq>8hhJLuTEQg zuj1&0ATD8>`D<4kTM;OYln1hLuCIR{cIB%=fKX$f)JMreNUeE3iV)x6#Aul`RU4C2 zSD8-l`k$82mLMG0g@DN`o%4RI1t~IKnyW;Zv6tRafybGG7x*&8=e!?XIrvQnOrC23 z!;jjOtHkAs(8n7~$(gCS5=(@=r6ebnoVls8I9tl{t6eV3_LhI;WIwaCM_68BipGuejKN>+##6>_+y=Xh*!KN80l{dz z?W=ABwy(js!oVCj=6h=FSfLg2>)IbBWzRIWTgL0-&j2q(ZwP7y<4xluR-I)mz9C>U zFfb4H@LlLMzG|-qP8#KFXWXi^=A-K4{40z#aVmYe4uIR(jE}(BVe~p~#KvZgTaDGm zE@PEZn*|FaFlU+r?M292G&^uO{m9@+g;BY$Jya8>5wb(njo@`pctnBms4;(G&6jI* zM^$K+V08m1q|t@OGIhOn8taX#*MUQS|1;(%l#~q|JiK`1(6PSKFtP8_A4|E^$X|@8 z(gJ--agjnE8+!ya_blGBjj?s>VwJI)tlz=ZM-KG*16kP$KB#IpMwd8@c)V+{OJOVp zG9kPzU{(xQtXPNww*I&;xNo~Z89xCBN5W_1juplgdw}RTJK+1w2jhns@UZIZ7? za^rF!KB;SK|NJR6_4B8;cHraDdLN{X`WgHNy4JQ&pTi610krHi_}dx#1^aC#kN<*7 zyXtenc(tUbS*P!AE-e8;Z?RuhgS$7m8z*RBozZ>!?#IwBHVBw?8rrrS_YL=D7Yls8 zliRg>d&d^AL733UIllW7;u~T|D3IzV()_M(M#245C4Y?fel>mt|A}IxM4yHXUIj~O zpW+>^VD==hTzp};c&xozDl01D29>~b%X4G1{Dh$`t|DKtE2B&ciY+YC3JTK# zJAItDV*hds>hwh&0UsYK1XCJ6K|sr|Vk{3RX|Dc0!NG49V0;P76vC-wKcB$#U(20& z_%s9sU;nf>E6~1x+UppY(V^uu4O5)5`f61W7#NGyOlagW$x3)}4wze?0u#m%e1kif-^W?hPn%=oePf%xxpGe_0+cM zszcmHB6FJuvcx_C&3rVbh^<=;HW>D%0bXk4(AZC7@GNt)A4DgIoSd0wORq02pGn&c zaK7i&*vLlu3$2|d!08?nDE z&E=6QG%ThtJ0UJFR~nzLRh!#cK|W(nx^aO!Ga8fvzjQxbh(so!+)TaZfS5wzqJgio z4x3c$s!p<6Bb21+q9keA66)`TB}$hgI{{SWE&x0rU{^4b?tjLSN#B^e+xX^H1S9?ds_o z>^;{VTl+b|@VCJ(+W?L-PxQI{=|!}eWvaM9DEGw5wt%rTcUrAq-E{c@(^ z#Kj(`@?FEis+NkrtREir=sLsGV&dw2(xC4rI5t|cebehoRt|vQ8`wYf6-?`A?F ztTW~T0=#=lp4pS%aYqg5#q4#j(T>1(VY)43DcS?)Xr5GnO7xBq1xAR3)D*i&hFvTi zIdUedk>(vG7DY)V;NpiIQKqPwM6vypnosik+h;H&(*e5;%>q~^!(}`?RH_JgP&V80 z2hT(FDFdFs2BSU3G{Znzo#LwWDlqG*5uElrT~d@*8g)PNzM(j+((e?&GHbTfZ#4#{ z1g13APB-po3r!D72N%~>D|QdDn&Njy?XCgoOb`9&@*yogb;aarVB14jqAD!F&WkT9 zu*zj}K6zzPUKofg%(L2sz%N+_amgHA4-{8c7U?W>wc;LCh#I$-3YnoVELG~&+WoyC zuFIZqa(<*#=kEsGIp>KqJ4(SlidwP2w$_=I4|WBl3B$GF;b|gK8aOC&L#Z!kD9|p7jLT}2 zo2L{Z{3G%aLMWX$>&ufvO8g}YBn7@gcL-T)Wu zVomS_;7VF%9&AbNNbOz$R`kT{8095y0ltYgkLMR#sj^nN%4xnCHMOJ@3yh_ei&yq+ zxo~A;GHsUDNuyV)tLU^2;33{fyt{x2S!0&&yG>jvOjczlbpWzOEH+8hgcZx&nd!}2 z($`vAJ~%b9dDn@xX%?qw@rrn*8D0hU(KY{T1Yw>xtmW^`0Q=JE>sb6CnQs6&<&@A3 zCQB3qTXi8BWNvbz#3D8i+dV8;TB%k!DuEPC<8Bo)^`laUSJX zY65~V{T)VZ)syj85pQeVP1_Ns)EKIJ_cG6*4#MmSrj3?CHmz?SZeCHiBFu;qx_(Yq zyyMMH_5Rcqt`A!e{)5;j^?s{5L9Od<0Txc|aRZBT2%0+2YG=C6Xu+#5oRHY7EDn+z zQ%%2`)D1+BCz2edIRZvO?KV*9Iq#L(q1LEAS|7@33&us}%Wed|k4(Z`?y2{nT$PH) zKj8Dj!>796n`phPtiwITIF2)xIU@6o01Crx;GsDxa3>-%hteAAgB^T`&3*#6v9-C1 z$r<(*>wH5=^8#gMa8Q&gHxv%=Vr&OqJd73i7aAb`Ex+cYx%P2_UAAnjbd9!wVT1B(~c2Vj>yll3%8k*r6|o==u3b+I$wWA`dE?yRX9`Okoxemwtq zsy3<1h~2B3QR>38XMqM|r?oo~)xWm>DsMd72>z@7UDb59sr)x!k(+dy#!UA93 zbNG<=Jdx*ctX|)!0jgC^87)9>aqI)b`J(p_RHx9A%7pYcUpnv2&U=ad7^lItWp-^# z9H8dtjaT+YZh zww;OpPVh){vz}+-Ddqdn-v#>$0w{pgK7935>NZlt(X|>!aUjX`rM74)+7_Igm0ilhT>?(~UH6e_P9C&yY4p~h{EbmU)G$=5^>0?4nIxM`FKLaZajWG^uS3H zP!@oizx!r_`yqG20m@I$;idJy`~VoYUz!g4|An;k3retkI zI_+oeb3y`?(FJTb0KBp>&8YP>3QCMRfe~DoRou>ZyA7;+N3|hJ%=!!W(Pm1%O1_wU zFAM}39^P=#@q4@syWnpY*09(A6E5Da{~!Nf%^0v>0Cc1Q6o8J74gf%T@!wYfbk+Z} zRt5}10|-|louwiIO8>dv!5kLfD)_xdnHv4_dF+;OPvA>JDGA6o$R*(<6i^p-QX%~X z)IF3|BhCYUBo?KE9~)+oD1!pi3PD~JeBnY83F#NoGWhokei--*Lf%7GRX~0wfV`XJ zlakM${E*1|L|(wshuqnqd^75>svs2o#^wy5D#vBQte)eJk?shq)(%+L-m|UMtf~XK zX!1S**cH=q1}_MJhRm4t1z|ThE@xmFRMnyx@fE`oLSx=*M^-ld2LKf4&^(LHNr4=%tNod+}_O%C=2IzK!toK_#@(1(j@s zIgHx`)I%i$O&7{p@F1bw=hy&;#yRdd&~yiE{4Yr_!m5Dm0H7WVdAOuL!GpGwU5@3t ziCfojX9f5-Lj2_ONCeZMLQxU3fqX)0FVAK~wV0iH1vcP)(0T6~JNV=-LQWufRL)gSesnC+;WglftR@6JLpleVF3K zW9&J?6^_S4*gr^T`G0=NMCU^AY!R^v1gs+JBl&`4SK@HG3}l*sor6CGdKFA7173># zN5ZH5w-EJfu2ZI4pA=}e1N9c>$OykEmej2RuP=0;A-ap$>0wXK3w3PQ?qpp?0Ug@r zBKV^Q0N0*{%%UOugS>&7&eCtnF7oSNEk21`Ldk3VdQTal+c=W$Qf80sBw9nY?1sm_;IWbJb0dYl7~wZH7~e`7Ih~SmEKFfnDsz6&ao$y~G%9XAy2BE~bC@FtfF9ge00N*VvswxQpclJ31_GcDr~Ly0pfA#;fB@(h@qq<^ z0O-&2wFLygK=2+vKmZJ4+S48cz%Yo9U=RSqnfJ^918FS*@CaDv5M+CEC|e(M7(o`f z31UULDQ>HsSuXx7Hy1uiZUJ8Xxh1!)%B?YWIfs)RBDblYYsqbCU-I0Z%j-ezz^bQ7 z?u7h-`st(C_KYRkRpwl(4H1%QxBrd6g$VEr=6u>Btabzc6WUTB5~()q(>q%DCY0LE5GtkhS7^Kd@Y8 zaszRy=1)lsMedAAaAaM?QM=D$QVC{4L4@r_s3b$Q%{O ziuW9L?dB~bE;CBR`qja5-6|W>)G&f*UC}XFX?_g*H4UdtW`-bx#1wF7s8dJky{rE- zRIdsqs)Fd2bZD1E0l?{(8Sx(-VEEin`bcrjtGtncGAA+==^vz``^2>`nVs>qM_r7< zBh8~zfJU+9G6RV@pMcDXR3R=sH8@XTMTa{>)CO|qdr)x|@3KcUb z0q>CByW%x?1Q&j5y>@F|E`yP}&ME!D(*Y#Vk5&I$1Orn#bUJL*Yh4EPTVbO$nSefb z84dgFiv^bGaneJdOqlk~SKm$A;;hrQ(#x_)w)1kFan2tuxag8Ma$R%T72D+bXwXg9 z-H`8{4|)|UP^3h$Qmd3HXHcP1wJJ4gy;Y|{y++NN?6cZDEn2mC?@tHZ^3*fi?eN?S z2fg&lI=4N^6x?&)1CQPDC^LvT!~!hK5@H3h20QICW3O4e?J=j_3Eeh1<%pwOBfG8~ zW1X;wsF=8fq?ELbtem`pBG7y*n+GD0C^QC(!~Y)5_X?FpXF$r5*6-9cq^TGK z)L;Ebx3z{Wx86b{7E57&^V~&WJ?ZMx++DhL=U#Gwu6!kamq*~uvC(4^G2BXOg)iKd z^I%2yZ{hql4}4-%QAydBZ98`D*>|8qqpLbpQ`gYcVlY{3j)#u4b#%ErzTj(1c6#G? z_RPzY>eRN`I3w5g!0f;-X$)x1P}opvfe<7GXtA`Q7*3EBpqF^jA?eZ@{}%0-y6TK9Si)SICZmqzceyNd)|1UnKXzUAB@!|LiSz;h004lncd;R1 z%iK0BU!L+}j#qJXad|mKt$KWc(TQqIdPkj=)ZLqzCN0U0=_x#u#+IwPW2K3@XY|(H zF%|3XG)}uw@$vOX0bT?c#vr-|w+#kEw8y|*gTXL*z(}ZvY)M<=SbR&;%6K`GloF{@ ztwvfuYRkyV$t&nrtf-`{j=JipUkzp_eZP~cIm(t)<`qf2dG1>O-D@_I)f+Z}X%hxYxqE3i zjS3W6-vaGLriA{*gb6*!-4He`FNUF1eHRJ&p;pTg>i<*YJK z&j^UFrrxUj#%Vqf(+x#nxP%eVSm5Wp;vHh2pJtp-YnzQ!+!-!JDDLO;<@&(o2<1Du zg|_yza+zuRp<%jyTPzu6o07@llIC@7zEdDU+Vj8`UMFT~uW!1&Tdwof=F56K5U?B6Tv(*j}44hcDBO&71=>l!eYst-)&QI#PXCV`l!X8&flKWg~pN;I(sikn!^NGRu~mjFQme48J{P zQD*LPli`%PIMyBna!-iW#fyxgR DxOWet diff --git a/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff b/demo/public/fonts/custom-font/CustomFont-BlackItalic.woff deleted file mode 100644 index b5f6a877dcea2eb12181138dbaaa74ab833aad37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45820 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

rs!BaAk05z@@R2xH9KgrVjW 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 diff --git a/demo/public/fonts/custom-font/CustomFont-Bold.woff2 b/demo/public/fonts/custom-font/CustomFont-Bold.woff2 deleted file mode 100644 index 5e7af459481a05034c6a5714e9ff402fe42b6767..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28564 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 L00000&5UgUMEEOz diff --git a/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 b/demo/public/fonts/custom-font/CustomFont-BoldItalic.woff2 deleted file mode 100644 index ac0edd55fd3f387d930cce4bd021827a110c94f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31308 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 TIJPo}*2k5>Rhc6lm8}#2BQQY~ diff --git a/demo/public/fonts/custom-font/CustomFont-Book.woff b/demo/public/fonts/custom-font/CustomFont-Book.woff deleted file mode 100644 index 7d53d032fc4279ce039da645623df89bbd69757f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37616 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 diff --git a/demo/public/fonts/custom-font/CustomFont-Book.woff2 b/demo/public/fonts/custom-font/CustomFont-Book.woff2 deleted file mode 100644 index abd31f7ec272f3fd46a87d7431bb2c243ee604c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24964 zcmV)0K+eB+Pew8T0RR910AYjx4*&oF0Yk6=0AV8l0RR9100000000000000000000 z0000#Mn+Uk92zzof`(KaoCXG90Ek`@2nvFfSb@)93x_fQ0X7081CC$>AO(~Q2bf1& zd>nBD-L_q|pqwYb?#*gFXIsLyX+aOV4FG+ohFX0W1>6{?-3~&L;`_wx|NlQPL79wo z3Bz7mOF|3wNifok+yblpbSrdkUvRFUisrJY{K9noiZ)@|L1Mkd_s?Bqe*s#SuI zQqgEah5Wo;DOdJ%%gm9YJ@ZJ~*~5(PSNhKT`kcS|Ibi6TpV2R!>M5j?6jJ9b`7HH% zzw7;dH(8r1{;7%G586-2|KX$VA`k~AnfHZV6^Mu>Fzt*$iU57d4iRcQLhU0vgA?Qo zvC2wjYFK)%fVD0IW0ygRXk*3sn)v6({`a%?xu{BmD5jBQnlyAGL=s*tKBAWL_2>C* z{<)9z(E=ifNXd{1vjd~1n}rxtXT~a&l}@+*F50!O>u|G(E}?~JtjVMek(P--fApe}j6q*;5CJ(BQZ1RR|D`iUYj}~RjPh-sH7Kxw%1Lto(E4;p6@{Q^sJt#j;Gz#8 zL1p)Rl}koSMp~W>FMmE^@4)C5gqIq>G`_7xhc6nS!jV~nJkC} zRol2=d9X(bgkp&18`@qfCddEHSxPN`C5y;OB>l{KILWwgT?V^lyL&CeWDx>`7=xDR zaBSKC-sWfNJ9U45Fh3LiuBk3t<{xARic&j>r2ATuudaGzjj{DU)GwKO+N6<5W({i0 z5D{07vdpl%28y;EFF_Jv<{;bMAetaZmg8*yPc_peM-Ua9=W=+xi%uIzr&z^URKlMb z`~?OBXt}pKiCAiNA+9evB@SjV3z8tIlrH7gA;q@vQ|srl;ySLcy64Kzosl6o4mO-X~9RfQp zIPsBUMe%XKDIm>CTp)&xhOOJ&som~6EFH#yx6<=+A~y&0Qz`fWs7WV#I9W2l(Ndh_ zz$Un6U;#=rY1?|fKO@+s4k00rh!Bxj_RY61OrhOt&N6h4EsyXBt^^Sg5ecEp=iZ6m zlS%Jd=&|*zRjXF5s$7j25i!Py5fQ!nZ#pX^J0k-tkwbU$MfaCYZG;_HoYsjWx=I5? zO?iBBce^HYhd=i|lrBfd)GE^i2y-Qu3H>ahb1*YIXHSJZp&tqXeE<7f0DS%yd&l2- zYGr|dzQBB-kf8WM@h=<}eK(5i&@UqJrs>VCt#zI6Mek}`RLpOMEbCe|zng;-LRWg|M!Jh0 zrmy&O2k*kFpFZ8ZVK5m|hLJH~kJ&C+!S<1hsMJ1}Vp5syr5&YXZ?DzGyv}@rHIzQF z#4HudMaVDJv*4>Yi&e*ppkX&|Jb#0m?^e5$tc$EytUvv@Ef~{R-$G|=*dc5=+st;d zgX|V|IBX^R2>Uks1^XTQ$2_T@V=F^Ba*mo4$?4z>axQM^18+ZWwKdT?$hyXQqxB~1 zM_gO(P;MW0Hg`GqH1`Gf6II9k!^3z=p2-wO;m!MW^1eZJkZ{;=q|l2Ze-*ZY@CkSe@2&-!qfuNUaE8wDquyS`GCFdNz^|!YF z#E#9*o$b2V3prq)=AcT4(qY^Y+|jGsse2iti%;hYJW--VC~@vTiR%Dk$2fz6nrLlw zrhBjzE7kfm6=we>R zsz4?^(}jpx3NKF6yd)6Gs4ip>jCLVRFt@Uo&ItUa8nsELoS$@EdL~Zx#>1>d@4Na1 ze+O7jr#49N&52!cW>`ALk%e!b=*GoM7yXnLjuOGd~CxD5APX7dIJ!)wV8889xG70!p{6Ij=LdhgAeWgc#VR2*R24 z!EA?x9YL~*r^qwJEd17dmmelS_wZkv-_@Vu?-C-De>x}2uGQc=T}bPbaYKBr$srfB8__oavR_xf zi9h@QTm0Mn9}Y*kzm}49t;a3|Ow==8)S>IWi5Vm?n>iSfZ20 zl3494%1P;Ygb(3o@B_c(x8V=|4gB}_@pP*YkUHV$za8g=PW{d8_Pk~mHG9Im&O_5c zaxa(*hMP|o{E#g+8U}P=bFQ=zIkUu*!$25Jc+O?zx3Es&M0|n-DU*D9j|XTS(R29( z7(WH$lsFyU3tY!M#XZA)8@?NU=+Df8!PD>;_?P?-zMA%^MLx!+aKJJh+G9aZPr*ND z{*AzR>+~vLwi>o!J9c0vc40Sb#`8ym=Bqge&3@&KFL4k+k-!%^HN{zOP0`-vccLb| z@)Of5w60E3Yy&qI&=3xXP!PnkqsmC`oJ3cI4^D@~I3Ym6P-Vt&QmofvlQi|qxQ+4; z5)iN#h$65a4uYF927DMau@sqLgw{j(lA#OB*jf&CevRpR^1}heIEqJ6Ez)ZS4;`_1 z!oX;H9;6A5y9!7wLIxaP{NTp&z=fXkF6MFA`+ynU@IUYB36!{<$qD7^o z%do*=ADANy4#0+h9fT>YvL+P^fWFDv8wgd~vEXqW>@r||KnkB@b`Cl)jmL7s^JsaJ zU>h(+Br=67bEuNFc4=nZnIVmJXw7|uJOu!BI23|5XakH^{X{6CYfyQXucm^EQ@5-Z zsGfG>N`y9!YLMU(7_Kn3x?&f2GRJK(1}j#gu37a z0v=Caiij2pQ4mu(9xA8YSsFc`-&``18ef0r1kdFOCLW>{H=-JwIa>=QrcP9+8xj15 zKs-aD1>i0|2od&C1S;2!;;@L`w;cypA(vn-gn=}46HCnHMQkWQ8mdVN(j#~VRW3eB zMHb}b%pKRlz#pZ5uy6Omu!%jTBXXaY!p8tX9q#Ga9I|7nYwRan2mumgs771F#v5d# zw8)zkL@7$009P1o*u)YUk3#9>(|{5>sv+c!BKL-;jBMt(Y!aKwY|A`P^%=4=Sl_G#8Lk5lRf7ppxe%V0t|8YS=7U^UHXaGbOJ7q#FaaP5 ze;WeSD+lv3BLCZi{-0Nfg%a^yioKw%omQ3nzZemvFkT2)z#!HbcZXLNa7uIrpK(<8dj%VE$hX?94WdZaGVnPB`FrG zlB$cTY;!|(-0rZ2)}!6-)F%9WNEJ^Xti$?R9A5n&t^}Y}RfOmW>-lvVACzJJSy!JFwR;`Z4!4@1#i2Oruf8wrIT=UEWcp6o;z}uZzUb}fb#oRVtbOy`bHlM= zA1orlL?8NjyY1c#l8-9K#<_>1<^}4g)`h$73=M11yhCc$^6Mjth$B)}8m`Nel!Fa8 zSsG71>EEnMto=el%|>1Sn&;W|?S%3}S{XKC^tZLI@fghM=XN9e|GEcThi5^6K1FOh zLwId9y90E0t7$8ihO_LvNZ~!YFt~8xAgS&WwW=)6PczvH0ea)ouVOA)0U&4}HP9Fri zEv06A^X)_H9|SDlQTa~Q)M(8e)ox6(rp(sj7c{jU&91-Snl??O7RYJ9xr zOWN4Z++fe*2i%wXp5FfTH;5T6DEKU^TP6<3HkuN3lrxd}IBb3sG(QFyCS|+VC zj9Sa3TzQmXIF&1)whC#g63RD%W*SKi#?UZhX_WCa*aRA)k;YjG=Q*|Up62*KhxtfzeWH0j(|lj(a9?SGZ?w>NTI2^U_LG+QMN9prW&Y4| ze`$q&TIru+s{$awKt?5ar@}rpgR5Xr1EIwkzqELy!?}E@84kMw1QjBz2#1PMR)SNd zXe&ckIWAS;S|x5(;Z-%h)eulEp|ucGD+xtNs*SXwWYtbh9n==1zD`EeH6xpcGvNA; zf{0EHuZ1{@$9EmzIZjX~^hY901riy2%di%kJq{KwK4JOnNerZfIC7>MfdQO04UK`E ztEEMt<9ggcz@0M>0-l_CapKLE4(WPKWMVE#n-2evGjJUG!WK+szr<_J5E7k1P(3n^WlNpdEnC1%1G>^0S zY%Hj=g%%T7TD4_X5?Y05^>xVG^Z z(R33yjo}O@XK|cEbe_nCDm~E0o|%FL+e31XC_KjYgxm`(uUL7l)Kij61WjJ~tX}?w zhexGA%|;keve`&nr;)g=MtHVIP_?sN39}nUc=i~n+WY_b2-?>XB%5Ze()TKwSK7%GqT56dUR$6DuW?P(b zUDi|2JonxQAAR!K7hiqz-C|vCkJslnQF&6iLZ#Md{W2WM6-vTiBa)&S)<2FHL}L?E zGjj|7t+zp^Ep|BOxD#Rhenjf8rlBM%0Mj56{zqqaKfgeO9D4M5-b6QmYzLi402&nr ztq3;$BTT}W=CHk-wv_1-ha_%N#AwkI>=H<{>zzBOS+yPaR^ipRSPiMz9BLpgLpN6* za<-G*1LZFs+3WR;Q4>|Km;Eu3U@Lf39)+=54V4sSZPjo>Nr@q^oYTCzM`hkk4+Te} z*ISo5x=})Qb4D6f4s&;kR@V|H4yRpOmejFXziy4En>M)$5{qfYHZ6D*&oq8q+NMxn zGDK4NEZJMzwHdH6Oso~dJH=6vKCQk0Hm|eyel79qt^zW6WkYyf;4eT4X`}7EZ<6rn>dZ3`7d=H)r!MDOG_dF!hnk~ zfjlBJ+0o^RmxM7TF9kd)_bz#&BkaV>D~D^}^6t7^R4*ADB9JFNZ|>hzr~Ej8rv%d< zl;S@5R^%&n-2j)7U_${50Rj&lvC9iW`Ez_yTZx^vgYND5r!LCNBs4TQBK(p?OpFzw zY=9vn!lG($L<~%H3`}%5GAaxfroJDfY?>RObld>}3M(X(cPp65fPmp3j~sQB=)=Yw zE38{|aKqKrxX3ZYd~{n3`vMIU34sqh9wkvAv2hW9Vi$e>6Ic<`L5F9lvC)sbYTrNJOzoO6I_6$6EU5+iW4tZoOrQC z8#}2@o3m5OlvIWca&}e>2exFSNs7+gOpomCjufS*m|}`c8ZJdz9|WINh$|A@=y-8( ziL-TrkQBNO#xsR^=C;VR3-JUW_>*M$xalR`=t;Bh2q^zmyI zY6LG9RRY{10A|)jkHX=olMt?EKXIZKZ^h2@GE^7%mi*kabKr};|8!PHiW1^jS!*N| zQ}!c}4lN=O>>|=Wo?A`GA@<_Z(w<>!A7g~*HV(jQwl&l6(_t^a&83z*_Sut;*@y0T?c2K!Dey7{!3EXNQ@ zkc;0)OzNq|t+%X;vXHr-?j1tf75(KDg0toD!(c>Kwsp~9KY>|!VtIktfwr6{Q$|Se zo?$v_f@5fy=K+Y+OCx)6fSSd#0T^XJR^D1zL8{8NUE#XC0m_ zQz{g$AOc7TS*PI@Q7)<8;}m+tQg+bIVRY!=l%3L;ZuC}#eQMGmRDn_q(n*q@J%?1H z{0a@r@*b-TX|c9vf}P;>i(r^x?kY1U#uf?=4;YBl2?>lK)K8v+na@k$z@GpZ)1!!Ll6bJ~a#&Nf%{|D;A6B(b&agPEQO#dKe3nSr46YEvH1oySnjogK{qnHRR zfwuxRlQ^~{sT59y_^}jV=Tw!@o&8hKk~E=MbrKRiBTAge(1~@{xQqU_u6+#%X}|efU6pAlR^J5EVhe!~ zwv}c!(5u2zC_Io1@Rm$imbV}kErAAwb!VO`$`AWlwfk1s zT2dpd=6!R`m{O)@|5S)vw5&Eer{8R;C_nVu8r=|5&$keG^{sPrpaSIZ0;mg0hS=I= zGe+qs>w)*gv8j~NFQON`15uK1!zTjF!Kb=cIgX9F$AQ`Tyf8;t-t$7v))VKMT2!M9C5Gc2D(`aIu z)w&JHVfL76zkf?U4zsnt_F5Oyn-o@z~DP6@?xMn6U&b%r_cUg~0FH?S4IT3F(G zsgh0UG$HSpqY?cE#v1PIJk)0xIs8t1T_+kRkWhI)_pxl51UOz<+VPm zO$bJJ6jX2?6p%UUxXXR>QhWjMCC% z<{W@^;zxAnP-+|Zx3u)%0GfQ*xLy^u(~QIHXQ^_X%*YLY?D_Gc(xXEwmr-t%7kjG) zUwKhgAMG9qc}d(%q^Fe*bz5;lh*;8hRs|J20k$MI3Ii2eHCpML?e32;0MbT zU|9k!o8EFnSg}aU6=g-DEl-T)i?uf5teto(kYH^MwnB+2Y8r-Jkt8MoNk~evRL)4N zkY$y!2OYagfrib3nQb1D`LI+Al++4LRI(z*OqO;Vaclzb;+t`G*oxN>Oac;<9f`@F z&wv9`{?YTrG>&yW{^uP3B z>H%o#IU0;Y&7_>kATU@Sy^&a|2pUx5HOnKc;xuW<6r+t1XsiU|OhuD!A~J9g0QQIi&XK7E&MO0830Hur1^^Su zO$T6(@sLbF-UmA4w+cjQ@)aI=v1T?(CTfGp4yq7Yt1~7Wr_s1HOp++i4>yIB$1{~4 zIGjIoq)|pZUx_0NlL@#O)~8v{n-5=p`~`@XAybT4apE(hkzy-tkdO7xy?Lq5{Xj5r z?XZI1zgS^m_Zy`3r~j`EWJg6aA1D9@Ua<{gRaN57Mx!n!>=x(bGW_y2D>j-JhJMAHSa8ZvB|GH zdhkD)F!^Jiu}{5Z#5<}VDSxYoB&*swP_Je`#O}Ka9oZ3uY)C{T5|cei$bqCJBLWeL z{3|A)6l*A>0u@G>V5(LNt+T}*M|8R9ru&|F+ zATRZ)JC|-j5Ez0`;e0($bc-ycK;bXysJ;wpFcO`?aR_*0v6koKB zdmiZX(krjM4bZeyw=?|53ps{CO2ti>BsnUK)1=KhyBvTeuN6p1$mD%fd_&3L%*MCQ z_xwAKoWv|fwBC;bkdu_v$Ts*{AaaIe^Pw4j6@;9j*?p|Z?}Cw&oWrM@{V4=FDLH*^ zroV+EC$(B%YSCX9a?*17TC0BvVae}#Sn9h5LqtFTh7d#a^k`D0q9Z^=5FSJc0h1v} zCJe$Q1}odz9hcBRY+*cvAZbRZ2g=uxZdX?E6+ou5yz}#4$(e@$dht>cA`qh|41pbb zLJ^426M{g9o?ryF=m|m~Ku;h78}tMq;G@SM0S_I5&>&=wyVeWwMFjWW_&G!NzdL02 zkGdGm+Y(ZzNt-S>eMp9ko7I}MdGtyls6(bcIp-7H5~^xR6TQJd6AsOHHV8J14e3fZ zQ1@Wd_(BOF5+7u$SpwJhZL0pXxBwzmjDb@Vlq(F-7Jpt-Q<~berZ=Oenwwcm zty%-sg0IVqTcQi1m1>}S>^LC+=ztS*aPMj=i**4CyRXajmPmMZ=}Y0Rj5$Vyt%7u; z-ih@~Yirx|O0}!jBW(SlY^C9pgX`JdYNAF~1Rn8{D^Eh7M$4%?jUYy1)2}SPS7Ar` z3QRGT$3W22lG9=l%{LSVCn6!IqM_r)i=QAU{!jv-1VY&&7$FEn7{b*h8sI5(uCH(9 znRo9%$vz#%N1fm+*f)wCm1ydl_L3EK5|5IkMjne2D|zClOH$4wESXqkypl}u_(Od# zW$Br-WX+a6N6u<<-F{U~NQDIL#StsAmyvw$GnWC__AhJ>NvZ%;A2Ja`2t!x{Sn%t~ z3!o=p`_KCmG+_KUgo>fgZcn32K>+!&x`BykKm!^u76Q}`1PB<5SU$ib4ajhypGp2j zE-ZcSI_gOwqv{NH4tqdYs5k2)`h=7!rAc{GkyIj;OI1=Ash>1VDw67?%cL=BzjTX? zE0fDqvQk;IY>RA(Y_n{S?6e${ljSVAwVW>($tCiE@@TnB?w3z1yB*g!?smN3ctxcf zz#YIJ&}6}W6Z`)S`jJ|VTu~^3kqdR8ZZxV+W9P759H8u#E47u1rP2xC?IO}ug}Jhj zY*^Mdu>+h5A^CP+?YPVF{E`L>{PuI&|6hCZyr%U7byb<8^ppSo_#5_@@K^j-Xt`mz zW;t&;V>xZ;DH)au2_&y6br7uuM;uVk#6u9@}>>9PV5o z+9t3=Q=&XW$Zxr=Jud91{28w@m??pZi}d3F`4Q~7Q^ z5AIoz-))Np7F%hpcI#}g-bR~s>afLD+ikPMPP^^3$36$_cf?^w9rIM36nvUe&yBH0 zjq;v>QOd$;vNR|4<9AhTbOk9>xRXYO33_aDNTcDZDtEDg$yJ)@g?uK{EYj^X|NBQC z-`z0kV4nVW&f8!b9UbJEu^xKjrNQyHdx}|Rnq#hcX8TVY@Z54MEOXK&H`Ti8n(M~7 z<%Y{Z{tpI77ofcYZ6TQT53KkCU~MvJEk~Vzga8d85L0xrz)nLzt?``8Rs6vU#?+`yhk$%w z4b3tdh?Ih#hbfW>8#8VCme7hNC9WI@3Dpt1A~k5d1_D#_?dZWb3Y=cV+vc4RI)KV` z%+kwvtGh&a6BnI%9u?kf%q#VE0|1tC!mVT)4Hu6>*3p58(Ea3TpS^dRmLNL{AlLkL zrdoAV4Z8_+k&CfWAF4{9*hmRAq64J7N$lR(=A(?$**j>_z)fe?dTZ6oI!UQ$*SH?Q zEx(>@27V%fzFait3@0nl0{aPb%UP8|=7kW!^8PeoT*bsm)Hf{Pb=a<*35)L zD81^G^;vxNuuM{FT7&c;>?d_LB+3tUwaGxDwdMDd%|DR?+aS23fd=>jBWz|X;rhCk za)$L>6G6K2F(L$*V2dq6j+!Ap3MmC$`}AUOi*06k-)MkHFnk5j`Juq%-{h2v3@Vo! z^G?>x*kx9pB}=5H;BkhpqBP3FKIj6}g@mFpjegLxx6|-@e>1&JYE%dsaORfGuPiJ9 zKAbo(!_lf+0{d@PnS#F8bF(4qq=`7-=Ij#a}kChUeM4btB-&VDq zpOQ1c_%B&5=_P)FiamZ*9g#epNizp=Tw;(-SG}rt3Y=T*@u31b_I}(kgcNa|!*8bfDPbZl$+~fUMoyo!>NY0r?K_PZ zt!6Ncvx1F7-y7nrytu29N7UHCz$GA$5r3|9`j?{vI8yC}y&r!5k zQcwnMt@hmABXP8jv{6*5tY+@~f)dBjB~lg+_FbOD9&nG_oxUdJ#BOb*b5u)qYE%p6 zX!yhoSnHC>choz99k-MfTl_9o?9B`Ut)j}LD7CUCX~`i(aH$^Gqnof(8kliOc6jCR zqN?Kleju7)W@%Qa0g^4Ldw%8}t3j0(POM6|@BeM{7v%QlaxAi2lNft}s0UeTU6v!WInu=p~I-%$Bo>Wj$CMEo5UiX)?@(A*uIXP03yEq8k$DuX0G}QQ-+@chNN(*cevyv(hyCFc zy#Q%gL2Ix#{nakqz~6+wQsx8lMRVG^1EW0uw}QRmXdF$usa*eNu$>(U(o zD-1m~b=i?`%xwueF@on=5ny#|#hdhTyTwh+wd`0rr+%Ww)Z|?a4iysqtu*=5Awng7 zJxV>6Zu2UT!x!!JNk~sn!m!2DYEewsi6S2rbpag6!18@nczc(Y;uQ#bJK#x=bpa} zqs@S;xc4GfjsMnrfeG*Wey?$iEXksaYKO`=h#zip1>or!HoVYyh*Eu}6KH1LI58dt zvBpHZZ_-KJ2i62{r6nzd)9UI#KkOdK`NXvfc8}S6rNx)kYbC&cQT>k2AkEQ;Tqs)P zBYNJ@Rnm?dqGL`N|E%iRq}~+McFxvE-^JUl2W*W<^-50WapmK0sLYI=v+~ZQ`rt}r z;Wb!EOV~kT_M^CFu4t{W=_U4VTy<@GIe)zmsQ+!I?mm=Cvd@iJRtBSI(rJHe%h_lgjD$LBK#`ex+zRtIq4@Hij0)8|MG(oLavjk9|#ne0@3m6HZ69`=A_ z!TVNAIfzY1_4^kE8IdQ=dk|rvYS3oj$$6)4{yy1vCauw0Ey>@yo*Bkq>HGw*4g z4JP9ensywhj820!FvVPv<<)of#in%~#ja9HH(X_3GS?C5jeW4=r$Cq75G=@SF7yZ} zCd;kxw2l*i?ZIMA?4f9~mvwj6#V{M69(fUi31C0j^@aI%P^T>nz z30cIlQoF|NWk4-3vKUPh6My>pAdN3D9g^s$;xz~{z=VWV^zJu8MOd$lL2py%ATbuz z_{CY8RkZra+9HGvjb1VRVf;PcX1 ze6i}(0I-G;fEBHxz;}RCrAYz?!;f@Edlbz|AcYonIf5BIdnJToph8P;N-0-YJ@I^hy!6*wOii3A)uciA2g=NjWvSmKygS^k#fQYxrgG+8Y9ypD0629%xV zqH52_Wo;y)01Np$inU+@Kb&>)BWX{0H;~kFZmZP&GD=T6xS$7@t+-u?}MT<3y) z?sRc}>V|FSou0J*D77s-@+KxebT(4gC_{P><~tDrMFUdRBuq?YmG3WmL;7@jbIvtn zz}-`0kAz0z$nze@naqRcVQ(HHP&7|(`z$drhZRHq;>y@{IcYFBD`DY_N$#EAo0QoI zkpA*9*@5j4dWwMSxugMOJZKPHJy`zY+Yih?q5~2kH4khj*eZ0D9o@@jSXwD-q2nWK z-x+L$>CE9vc!V*^DjCylcZM~;JrUY3V;nv&98$=#SWEtN8YMndN(eRcfN{=_J2_Uw za*2S!>o~0PjN5X?{eZ6}mUpCEB9OS%$@EVw7)?)>)mnzt!l-&gH$d(j)tVw+A`%vq z=7UP{oSpZygbrI?vXqm;`lI_^xWf*ph~1k4onjxE?xkuunOU${+a|l5sAK~(5D5%r3eYcD%sX_H znuJoz*y~czV5c1h)dkcVWYepTUV{@<<8r}TUmZys+K?z$r2rtNGORvwp+X{18#wpd zw4;~AgAZt})qx&{N6zG7EqyoZa{G#tII9#;FWtWUr9r~G_*BR|vxm@{Z9XOz3`*nS zuaLwCxid<17=`}498@46ozuIq!zEig^*zUlOGkr#c5ic0Qd7T6a*dTUI5$Nd^xyVA zmemAK`C9EUqH}!jhf9)ruF4chPv0z5?|wmVDxE)46%#K#QI(A9{WGQXNHSHF1eYdb zF$>IBMmdu00%l6QcmJ$r47Fb7Ot{4sej|R~VrU8<&D9yw*tmt3%%3JC(+-SciJwT2 zk!gtR5~sx8lJFcWC=VhqRz^PRaJCc~73Jti8{NGZTH>>~*VqQ`fjJ>E$d;T=2v6z1 zmL)!>fY*UJ#YctSDoGe87BsBmOK&f4HWmLJ<*gx;3uShs@zN}JU@vV@jk5ms1E=ga zh(5=TOkHkTYWv%n0WFuRgQX7lOEd?!&qoD6@o`_VJ;J?+t>i9FDmD7Q=R!^2tI6f- zY;H<#B_Xto5>w%YX=x;%%(x6Z%MCzA(>lqtTJn1BepWn<9Aa`3KD!JqV(l)X0TD~O zbN;C$Ag2MTm?S^t+g!~W1D9(&(`5GLt7f@IkYmV4V*}uIG{&X~JsEBsfc7Em+XnN+-)U$2m8QgZl95*R( z>lBQ5H~l@uzMCcAFKPc znR9h1rC&2~LV~Onycm0Eh2y`kaem{5J%8N3WV&qsd%i!b(43V8FCXwiEH4 zK)q?{uv~pexP|JBf&8g|2b9A_M-f1SpR{_FI|}yoDR&heM*t04Qjc7RCD!^yQh%d$k@LlZ zr{7P0`*Z;e*^;jxiv#^!(N)1!(G@|Ditn_oskQM1wJi}+`1tprFVmms?GJjbz{BEj z)(2~w8X+Fr@ZX|4&HM8O`MPke46b*o^Ug0Z-wllABN>69L zKQP_&R6;H;C@WMqMJ&kkx;EK(W_)OL^J~Ye@%oFfmqJ$VD>S4 znP6vE+LR6A!RQ_5mRL`0)2EnSbr^<06nGYhHv7}$8?bS`wnxkd8DU3^pmn8t@dA!1 z)>-FoYeBkV%9T11cCX5IYmQFRrLAlfo9Sv2m(S#IA=X0p6gNnF{T$8uqWQ&>nvQ_O z*d1$XTorv5sYMaAN;Xd>s${7+G&GQpEJv;D9c#8VkJgUbI9~*S{G213rG+>wjg-aH zh}qEHnLpF?e4`abeCeFU-P!7OuI*{?t!oDK7PMwo87u>pi z+xaW6&9_ir4NR8PSz4%utA(Ok2t4R@uKh>tXDz3mUEuP^DT{`QjWj2fcjoo;O;#Xi zB?%cs9G?|J0Y}VZ7wLLr93n|pqYhsUiTKOt`xApYh6vH!XAPxlx_nph0@PHp^!K$Q z149RwSZ;0r@EJPCGj4( z9(^$5?P1kBtIt)t*A0u)eQ@)n9~or&0{RMbW%_sC)gH^Me~sp4!r860bkj zM-rU>-M36=J3|T-)eW1Qw3&blcADl1rhPn%-HGr%&FSUsT5}i=NDk< z`o`N*Z>(l$o}3jqIY=zz{=+@k$rRR5D#a{zG_#K_<5X7hYq$fTl{s?x%*mF(sXef@ z<8Vh-z?!#Iv)pJ3ZW&#$WkJx?YEM%d^K$AAuK9^@Wh9-|3 zJ9_gFusYYh-KKh@OoJXO)#y7TfqVD2;Owgodx1kL#~Khuot%|2^4xethz7{iS%!BhM6&m(~^GH`h8AQ$3Q z(;)$OaG!jCp=bITcdXrNi?sn~Pn3@LdFx=ifN5~*DRs7(XRr6y1=|)hh5{X}iv(yYG1z15p#-kpz;a@)k{%fW9p|N&eTS{4dr#U*oww$SeS4m__Ey$TTu0kkJ8%6!1&&xRhwr0;bstaKP zqrP=>xVe2e7$U;i&T*xc&(+v=Yn)sj5boq4Cangs)Ubcp02i^s7Gr(?NO)QQV9*@0 zBp^j6K7ihjN_nk-t8gn7kZqO9kukfVSV^i1tQZd0uN?KUAi@Lh>@Kp@rU0$7`Fx?M zhAx#QpVelk3YH@}|G>s*xOd3e6zgkE$!MI1K-)4dPElg1t`%$Qyj>gI;Sz<=agCG#SBYI^d`p+_^E;e4WTOJ%)F|~5 zsT>AdMcK|7CzdGo=*{&A*;x=M>e%S}_Gptq!Akd*g7pxqtXwp2BSo zgDpoL`)mrao=6WyHaCSkh5}VOEI<1kq;&FmYA2*nJ6EZkp#PfVnPN4T>hBp1HFORK zm}>IeSX=bacBPBQRlByU>^wd&KJ^Jw*KK{`{LGGEZN4NRUxA<{qvX=d*`$w#BzJM8 zl{G$#gdol8suIGv+1nM%vWuogx<*yfKO#AeVYlfmUNgAVu`_50{#E!$$$zTtD6f%9 z`3vlZtP)v)8npTrsfzKiJA@FO1vIL78ACuXboLP;YZZ9?>5Wf^z5HoIUrT=r$g#Lr z)YsR0$3D$l*|eg`yXVv5#(IEX{Brt-y0+u3$D2?7@Mz1O8_T=S$IgQ@uLj!xxqB+K zO);eKgANls0aGEY-kMAZe}>0&%2Hy|xIQrC zoj@X2PRLbo4Vxf^Cd9Hnkt8CLoD@q*H9Vh8aZ;uzfw@dM6qZ3uh71bJAppBKjk(88 z$WCZaNZU@vw~Rl#mALF+e7p3x_BbFN;=n_2ubiIX@y{4{(LenJRR!GcH#mL1Ifyx# zQ7xpAM)35rfhs|vRL;RtMYFrkvoN|+MvOAO1}Ew<)}L1u>D6z43@2cdJZ+osbOZ{cU;73S-4 zqC~Zl%Ywv`>Jw#LDL3a=i2B21t0U2w@U@yd0seWi*IlP7$C~Tn>-Q1B@2$wqF8CgR zY>s;^{U#e873}Q*grXMKy;Pg!cgfw30 zgZnP3E^dG~T)6a{<~h*nEhi+Ar9w7KDj*5OSSjuRf`9ulowovp)M28E1 zUU8~V@e{dT&BJ#|?piUAqhRe8WGN5kK0w&Czo}L_gpl-s$m=GyGH&xO*IHuCJ*{Tx!1OfIC@H?6_V*;>di< zjXD28>X$q|Po|RbH-$HXEq<;8A(S?Xjx~gr0dW!9RQ5`Jv?%GyXsx)O&oK5|(s(Wb zyxTp;-fE7n_D6kJ5l7hI*2?nV*-iNCXi48jy|dxp<=fom3qFiOF}|ekD#JRK^LOxA z6q$rvDHVg$=ca5X-t2j2>N~g?9Qa?Kxy$#kPi8Ef=w1UuXE3xm(iFQV%RU(ME#mbK zIRq8I8>=0;*Ee@9w`Sz$CYZ4%aZ&Zv)K4|-{2h;kqoRvUlzfOAjweQ1O?t?Q#K0vn z;U^ox&wPOY;$l^4KMQPr(!F!;pL0ljYb22JO{|2K1n8Vx2&p&y&N#S}S#53gsZPo2 zPEdu76ZN}5yyee3+GaYa|HwZ$cKm}rxa;Q88z|CqfOf2g4vjS@91?$h+Z z#zx*)%wlcy{rR>avC4$aD|xpv0gaVSy#m;LEWS|^OG%AmF&gC@@d6Le=MtnVo^X=6 z{PL@))YIjsQc>W|VO@4Azs;bk30BSV3G`{~dUgg!cB8c+w`7D$o{lml#pyeZ^7cxN zNaN#S&gIn1h6Fv#uKQ*#(;$+ikE6s3c{#5LA5u^v5r;07fj|CvPwb1LgTC;_;eZIW z|2_c*%Q#}KVe;5V;G$Hs{sZTki&u1j2`N$cg#r9tVt_38#`v0?YZ=MUuZ%bkB#8x$*TizypJh&d$eVJ zE8px`w|jYGe_ya+;~Kl`LM|+3t>N8u5WWvd$x+x+b=4u-N27ZTdPr`7dyM)~t}GRl zq0cH8*2Fh<TGyaigwk$}RV~$?_`4S$Sz|FsNO1Vbsw%JCl;L)qolSZt)z0HQViV742s*U{-~27gmE-^ywpZFk%zrE9N1ha zsI4&Z{;NC%<08|z_^}2Y$w@MonzA`GIfc#&lbpiGxz6uA=kPb~R(M%k)>&rvW|#vm z)z`YNq1DR@J^kWXb>xHy*UHdv8*#XCrP%$CtCBVu;oLN-0&=nYg8sQSEf?Y1Bhd~i zLP?g-B-Z|RyV<|F8BklHVQQORm5;5%?ovv&?u2bgH2XDQE^yLfTzM?4UzxTnUTTxE zFn{W!=)$67iJH~=QQdAYhTnw^d1^3L+J}S zQiQTW0a`^dp01=VMnCRRN*N>LYa8}ckrYi5&_Kqzi5h!shh;=S8TrlIoL=k^0H~?b zw5fRH+FvWd$F5g*u50!8^ufvN$mZAext;q$yYB5Tdr^S26y zmm`m7FG&4ZB1+zoA?FYb&x7?SGA-pM@V){VfEV)HOYlozCzdJ5$s4QC!RA!rZ@UtG z@mWb=z)+?Soqr93AT}{+UI(_bdD#>&!EPms60_Bl*~Bb^N269}DlOg#M$nPy6KPG1 zv35Hz55Sz2x|Q4DZA}$${%Z1{4*DJrBBaKS*lC`@Xo$s5?lbV%Z={K62+crCDLLtr z8)!*uT4C-C1V zSpWI&f0~c34`=6U(7Br2l9RuaCT@rAPBUyG(Ss~{ZFJTzA7^o^(X)yjem~k2F6`vX@QID645^2z(cjBLny z9FfrghJ-N(Z(+T?!k*r(PfVg+wW{ZfXSGjhr-1L{i1KrDvhob2n?J1v+LMk?X}wv+ zJ+&L_Hr5A(miEBuUjK-C!#qRfx~okT8*ZlCp|p%lY3##OWoYf~`yZA*d!8E}k~G=$ z|9&sNVwoldD}!B+aw+1xAu0I{4PP2qzA-N+5P01WoxG!S$>LodQ~V7xdS^WANlPC$ zrmlAM_^cEN>Ai;Cz*vT?PTh*^65|ExV7X4{%JYx8gx=^9vwFrpW70n_Ew+AGpOQGij&qWi;^ zNZTCl28mrKQR&6N?+?}uyq4=le+)-`IQIzPdIH0q>YtL9LlNFSn8V;(xANaS2*Na! zE0YmQji#dCa&q(krg=AaEej6z1R}wu|L(PyvTDCxQ-_6_VbAm| zz#Eei*bCF1Seur_@fVGT4GoqC>kM;!)vi@|;{c{mU4UUqjQbvcufB*WP#0n{zI%60ISwkM zGC87`ml$&kO=XZ=hd@|kUO@~zI*nt%W)kPS54qHknu#EU!FLr3a`865aKPFwg1j!) zGH8a7)-P3Pna7n$uxbRbIIbE0$1pa6Py*3{d=bWU1`N|BQmphpe8Fx>fjb8c z7fRq8`eEhhKBh_@;&4#aeQg@BvbRSY6ogCySbJmj!-x->FUV8BvUv)9c>_;25%Yg7 zNe}GWd(904=T|&>da1qHAU)J6KYTfI{4qSreM8`~+MB6!rZ*Son|4RbrbG1!kMGvzbpqjtSCJi6J-97$mPgAbt<*nFXyUx*94g>E}Dnf+?HrB139J_JM9S@e-w^=MD~FCS}JOsUTUtKB|<%2cbHw}?Ray}(y4 z0wS?CMVZ18awzowsB8oOQZnjW$sjEVlS`7we-(S{%MZ#$Cc8{xbc&UuIEW^P-4?z{ za$tK!Isso=UQvoCrh)d~uP9~}Z{&osYvinV;$GLt3!1=o54m1$^keS)j?G04H1S&$ z?IbxK{bkV3#keJupd&tK#=zjv&FsM7RgZjDPV3tY+C3Dr3b5wwxhKVaH$G1}pj42> zeC~%a4+;c@1p|d|#6EyT1p`H({l}C-4-w`^u+mJau-ObfIl5-M{9TCr>Z&pb_U<eOm@H{gbQ3@O4fJ=6)5emSn`-+CG+jL*NOvVjm59Wp)i#)EEqXsJ*?=5 z_4Yrle}Z|v3Hm4EC#n#vH2i`5p>Be;(kGyp+I1>+)T&yhTxM!Nq6|H<=-P^*P$lTt ztb;F%V2$37YCqC|RF9>C3wO5(mhdj#-v?gx79Dl#TJpBJb*)9m&5T8Gv!H8OGnPc0 zFlL2CW;p(P-nPgI1d+u`pJQPtJYQl%Q&@`N>k&mFVjTzsv6DYk9-yKC^h3)W~qscz0fZfAyPg zU#IT*fv`{KyLeZ5w|Q^yu!NfnTyQ_$_=Wbb$&}B!)Vc!4=u#3teQQGQ1`xBoH}l_| zzv81eP%SHKaOD7vXddowWji^jcH=BZ5b{2n>4zn&R) zo?tJ&4(XH{8GnQHaYy$ztbjnVRQ{W~$?(wMXpKk{zUm|Wna z(`ENB+Y+XgWtr@pR5pLn47vwaVat@007u(3s2`yl(r?CF?3DxI`a0ca{SY2$>FSRS zgsuJCxE6m#JSm9?=uAtU7Bh zO52JU8@CQG**3qSw!^njk!$w1BYE)T1eozzDR*!4?PtsQXXepxaOKF1dPg5lsS=ua z@flN~TG$4#aBW}*8|R(S_r=$r828!e zobq_2ZB=L;ZP~--2V>v1Kedb=YC}f`+%IS)`Vzp}rqpEgw=GZWKo6_*p)W3YG6*Y+ zbg2S4B~@1p&=y;&m};ORRKQxBG=LtKJbdaz>e7?tCzhs80RF?;-D9y&!?jTfI|@o0 zOmc^|cZFYr(AR*iDGlGcS?iZH-d8l|$DJz(L7i09CbEBBu$b`Tzsf(9|K1$>bA@m{ z835=Bi&3ln`TqjYz54~GT7r_?yRPLX3E;2!?KjG-U@S_#N8Fao?Ob{9ph)Ma)A3FR z@|t4ayiV4$6FY!PS5|BzyA4H3`o+C z$+K{+JvKFlrS(=zYKo%a`1ZZT^&7kivGN*TG{AG6x;)y*V9bqFeiVLz3=^~5-dX4* zoUJCj|DMK?S{K!M(zZod?~vR}w3X6++H{KQ3Fj@e^#WTiAZv>GOIizsHDM`fj>De_ z?;X6OgZI(I3~kqsr}?U&-NArl14UF}`!Ytk18^3tZnCyurG<6mg>C3x7`l@fS1n2N zRh7vr{DLYhQ5V??c!s~-xd+c3tgQFw71#Hn##he zU)ZwlhZ8hX>_|6d@4+_094WjcYb=MV>(gzZjdl7ezbegKq8kyjuuw7`a^(C!@^^seENciEih6 z?EUsFQB|xMq)>C~CfETyr_U4a3xBN{WIBq+2fcf_Z2{i0V2qpG;(G_^dL50|zs(Iq zy7y;>?h5abgm;AHlm}bhi!PidqY9Co#l`$)*a-&UC^H5hW%;5T@leFO1H-qnTPC|! zaIF2;xkUI#z(9o>*6_e_oP`{v@Ps6BY=H_gXkmv7IFH!GwIDe0f{YlqNw*T zS*)ZT5!wUVwi3EP;UtAEgj-;9)%FjNiXbn-IILU5zVt6^+X7xp{W3dmOUOsEtD4Sb z!atA@NS82VfC2&m8v$Ty`yc?;8V3Oc$2|z3Q2d1e8iyhvfDU#uAb@U!U4#I-V~+|5 zpa())K zj!npNcPFx@qX4WRfFg#Jf&iHdXN7mKdru!vR-B`{AqA@^8^(98zn8QEaG3Ol`5<$v zhOFs$WIG6C8BuvRlRhXrs^nc1DnMn)3KaxD$R`G(~ z>QFC$&zA#Rf{GVOrBWoxPG|w(Gs8${17mpl_!-M68n(1Fl1U$C!K9fhaYDy3q% zmHYoC$uID90SWf6`wxre!dg>Jb5xrbrkkzB3Y)Eq2s7O^#R8vvHp?PSPU-W}N*%uX z;+u9`opZ)E`G%XTzy*cQI`4{$F1hTbA~#%h&345;nCG^eZYlBFTg}RpDp#pOmDQ@% zs!^xjNF$6g+ACv>GuC($O>n>(lTFf~(Hrj^(&LfGcG&5Orw)7Oxea743xE)epcqb&6wR<4-++C| zgd(v-Dw8XeMKJr^=?zAc+2Y~*52uKKn%e2O-FA5t@^!?hamNhVYMYK<^V~%Ti|mKb za&rP~izOZe}P9`y}be9SI+FpsEWvaNxj!0|&12oQn<8jYpIu z!sTgCErDiO@Z$2)F?D31;ZIO1s2NQ`pD;0~fw?nrSVwg*Uc_=yjI*5O#gsu0%>srb zG~1u0h34$x=PvNtpAR3#Pnl*>}fq@uIyPnCx%^gb)EgMh<`Y=#5VA$KijXse4=B75`C2AQJ>{ZvG;3DpTQV;zR z%pHyF?$|k79MddkvrW%lzO0uz%tjPas{Wgnpr(wHR#ofJ5n887_R< zXSa(E4n%kE)#oko<8*>DG`m0xGq02N7XTXPwqN;o{zn$R#d==;8p2;T?TVIq0!ata z#h7hn%_JYoP5OCtg;}TvS<};x<@UM1$E1NpmO9FNV~d+0lb_3~mOgO>Wl1%7#6003Z! z006MXfMA~zvWm0}zm-V*_0#(gvZAslhSvIazZ@+90N~$xf#_hJZ!Vh|>l^*@=>E(9 zKm2r2@tgfBe|2WRX`){cL;8b7n^`;k);Fc{*UuIJ09#vlxzT0npfRVo(u>Sx! zCkJ4oZ*Bamd;I17`a=cqqfLF<**ZG?wpZ_$gYpafA1wg6|LGY(00I1`MMqtDt|tI! z;L~3lz*MB?-~TJEaelmi(7+&oyMNOV06_m~0RY@G4fTxl^nBiLpn-ueW`3G}99P-U z70`t+VgU|>0ouhNFn?LU+0Xq75FlQ_egM#484jfOa|1M5Vmn$Bkaj`kQ@B^!gZGsS|XC$DLl3~;AtX}Q9W^~}ez+chG+O1GCqL!nwwS|;~9*Y&3o#ukYN1J_h zWJre5mz`0nu5_Y6;z0A<+7cy93m+hK^-c>-sh)P4Lj@ORZ}MPspuY%UoUQoL#<}4(?uTHZ%Ysnd;ppZHjO?F!t~(m z=Vlfi?$eIBaeaz|uVJrmK@dE@&}z#kLpA5p%AGqv=yb1{5Y=)oX{&jQ>OS;R$;8MR z(;n=YkivCju{47Lub9Z;wvun27p8I9*t)(pj9Xkc%HO!U{t`{xhRFuw2c)XqLemAC zH(Y+>L;Z_oPXKy?v6vLwubx&`*Eo`o9;)s{TO2tZggBy6-J?i@5aEnlp8a0mOu>ut z(L605H<)xGSHpe2%eW_-al`7mH7pDjTNLMLT(giSMQS}z(zIaUg#?eo;op>R?Gb(x z&PfKp*0Jt+VpiCkQ!W9|-<-Z>uNKE*^xWedf|;YuSWzd zsf&Z8XUm=ep3>!hcp+A&8#I<`pzKK4Wdy^ya|1jXsJ?Q;cm9B%SHrd?9y;De6XMc| zU%{rsA|_ONig-etcMKy-CQdfFb?d}1*|;y39q2YKJKFk)-R(`*3c3?dwrG{ZZg_0E zfs{)=!g_-AO}^jI?@~)kfa74FLM6M_(&BWBpEgA`9_&rhNkc%pYB~PNb>NcPldNvy z1&;QvdB6Qu*t98hUHQ_5Cw$@bA)>t2qrl+{*sPx8&|Ho{@;GNChb6B%?plaNPTxyc zBc$&hCr%~|6(&S(YLLGL;pl5a3btM&#x@+0kvudz z?og;Gj{?jZr}D3!o=`r&QMhOxlU5b$CJBXMqH*9dF&K}WGubCdaq<1CLbKHu?kk{W zYo%Irz$IbNThwQ_43YXsAEpuwoTWU!ed*trrO}jlc zF&IrE_V&}qffh=_A}t)A+#p{BRH3*?|4fP=Z$6EKd5|gxrO(vb;@MB#+>B>A92VPz zyo|BSeumtuH1m7q_?U(lzIE$o^K+pgE98dPw5H+46#UlCm6TvITv?A|PD@n1YqC4j zptF&wL{vSLvOSE7ULX?5aZsRcnnQm{<0;!gHt3}FgltVF{ose?g&%b!(4MVE}K`}|WUgcTv%oFuNmqUj+@ z)nH^ls9+{rx3Z2EX{LH-7(7)>fZMaORKU?ndbhq6Ir{v zO?Ac^`qnAz4wPvSW54(wh_}khS;Bi@_1sds1m4`o6Rvh%%LV+S7q=!_X@*3FN0a82 z0!g{rl$@|dqeV@;Qs#Y`+LFXj;HI45sZuEm)7wnGp1GZT?cZ0K+M}XglAj?%muA<{ zB+nLC_|aPpX>)VJEluOC6=~djx6wk{bW|=Y2J``<;)g1;DZ{~eFg*Lg2R7N+WDrDi zQ-u%Wu}0RjTKiKzC?BB0`8b)vI2qIS*gi$4BCIXqNY0iEt+&2D|XkFrC2@Yn1C?0vS8*ME3z{hpDgKnbnG*e(oN zg!=RuWBH7dYlIy1icWtpqz=9HBar9*MG!`jPo4njIwT5rPASdBDH3Y4bxr_%?@>#r z^^263KjW?dZW;k{dH$tm)~f zO|vvNBvJSrVKcG`;`A`aJY*iVaP}PlUH#6w|IPa*dapmAp^6wl=(PX<+NHo1|D#Xr z1lK|yyJzERnCNXi_o#=fCPHUj7@}+sj>0lj#`^)sm~OkkQX@kS;OU3D--a z4As8KWjKZEGiYQc2$OY@OI8_t%H7pv0i3)3;Gq>(Pt9(1+ZJSb)` zpU{s}JS>E#Jfu=Q_^+tbUJ&cq6|>5Q7L73(s8d~}(1x?mrXEd$846PGB3T*J!u0l2 z&nCG{=o|diXRJ(I=s!}4CdrSv5@t3HuIY~^4UbVC;{K%hHXK_yH=}B%SdTXAZPaP6 zW?SjH{N)VK8U(A=ugsP{KF^B9-0#exl@sqYL{RG!u%~N!5wOm9JK>RGCX6)~f&7j_<(k9#{nN~Kb zfUBA=*(77qCEYj`hlSJFW&5&RJ#G!S0nH?55+)NvH2{eXXC=Mi zAhVTV^REpe+UStOLaX~G6iw2`0L}r8{Y$IpCYfWAyR@U_L-R4*@$@n7@trZi5Auh!t-50w?w9L)VI7h>MPA8|xxS?5a zSL@F7%r3>l@-`eUu^BQ~N5ztyiVJg#P7KUh(ov9I5x=o(b2Sa&QtH%&)WD^=cvxs? z`hUl6TPdT|MeChy7q!ruo|4U*&FRHPR%RTjsH(CvGYgKi^y%W_h_X6H=I7$!vBgJv zW^ZX}-kH(S+EqQ>9=6oko& zBIU?r+54aJ&FJOu@%Hd^_swPR#p(0VZ_mlo6A)z2{nNiU!dZ7^V1ACv=J~gM3MKFb zBnVt^04!MG{YAtbz&-GXdYYARn30W%O2a0=yCDBC+PARAw9&S3^93?Oq#O{jE?Oq* z6X?(Q2ql#bAGj9%#_1@Z>`$tld2K?0wcRT6j-0Qqt&>n+H}Lv~bA1xV%_hH!Qp$64 zv4}}!gn3p+>T=uv606F5E-cL2Qc;D?-DcIKSxyXF;-_EOM6l>>J>6gL(sxlX zf}HSSFwjp)8>eau-cEdlHD_VEYEp$|NTsaYVNj8bP2CvZi>X|R#}!2LsahC}%S`jBo{Yxt^;%0>YNB)tOIk{#)e2=xQ@oy% zYk2OLRJ-5DRDItt@pXZZE{xtViFE^xEJM9ulWO$unYFq_Mpwk%FiCU^jw~a+mL^uz z-Z05@4~{O7kvb=h8}ZKjC(x9S@xobo^!wak^vO0^=Qq&G^l8~hp|Eb~8s!%F^ittn zjJ||F#MdL96P!X+)g9-2>bGm-8d0yv&R}Zlhx5Or-uumgjfHM%cWdR`*@$cFY1isO z$kXVWQBbJ?GuXoWIX98M=Mz###dhRw+%AVd(ofwaH^lN0k7=)PHt|se0zrbu;{yjl zM8Si>CE0lXz4PIv9qiVQ@`useIV|##sO}&v-($e_o zz{)|&uxJ~P_nm?KSKI#x6^G(?1tqc;nEvT4A(=4f=IJpa zBxxA`5&_wl6p>)VB^{s>*G)QDYleii8k1NFzvZG=8^aWcyS&55KuWyINcxUNdLR9j zDu=fzzGHlPgLRG-*1T(ogKn{vr|qd}S>tx$qn|e?$kkOTDm3y661=9y85A@!(JqMJ zLw8}P)XAi8oEU1yv#gM6W*v_7v*8%5zxs9w1Oto`8BcG>1_Mr=3Q!DHU{KDmM8~AW z$egO1njT3~&_^e)SDCs#StEsW%nE~XLH~q7U0_(528PbVVt~mgomwo#Sj-+APo1VB zWmybsIM6s$ThG1lug;i*AuDwn-C$imb}i{_*vZs`jyp{U-HwzlIudCi!uaPj*I?}%&x4

~N-997i9o8{GwesQ_ei%XZ!I1@ z$t&7N+E>6=0;q}+>?K-qy~s1=2ik`%Xn`IIRFHyxuy{aQzd#>l`Cp~*@^Fh(fl?G^ z2U>QXCo- z8sr+3>wm3~t?2>HYff`(m7SEF6r5C?a z2bGNKdww8xnKxpI#%Td6Hg|RnyA3 zoAt!Hv?b@_b2*Nxr})+4dWpr)Tg6*VfN$38MWSxXr^n;UiZkZones@*^d<-A1@e!| zRZHzo^b(E&YwmSsySekjIZs7NscMf-jmO1#R>ezkcd<{)d)!mZa!)Q!Nz_X9vWfXb zBbYYt7UzY?vSDS48X8mSmY#!PMld*5G82R4Vtbry!JvpG=9T4R?p^7yc+?9kja5HW zoh9O?k@!NeG&pmVMSJ(zRPjyvlQrrxRo+^)$x23y*>~*_Ep?^_NK5JpVb#z#Y8$mp zliPN^yyQr7AGK>s=jOe4MR^rwBu+9e~x8n-!*C7FfV3T(A?gQ|Yb*m3x{c#1KD*4nbsdULt1%5sC< zT5*Hftfg%0;@RqB`RZ%K*5ao$pm-n@u7l&#m~)(E=vibW8|TE8>fG$K@ak}NtYTqP zri0_i*<)j^`Jr|CfdlVcYd+{=zGEVerltLJLdMe6aAn4(af_*A<~iyzWSzEY$HaI3 z+hRqxqxq7fu|jrbO&i|1V!6t)1#4^D)ppg}=_Q35>`CnWuKQ##+tP#mX?>@n@a1%S zt?R+V*S_>J#EE%4zaFIg}H1{S*< zuieV*oUTDXD&z_Cn4xp~mS$ip*b_d7+iU1HX+X6N#}{v_$zxzp?HN!CvHH)evX(%_pXl>$7Su}R^JxQc z?!v)(ImJp^Cb@qx%D_pOhzpOi2e?k1;9CZ39|q8X7IA#*aV&&|XljVQ>jykzfw;4` zo-MEhFJ|2RS3%LU66Y&JBU|nGe5~ugdip*5+!Vi5#IE2##gxW*LD;U>`bWjeqjs`G z3>Y7b2fhc;^*M@u?)c|8;0m%B-t*Ba@sca}2gr#S0gw`UtuT!n)}F4!Zmk|1q-v{8k6r#Yx@L7)_-2&zHZlLP91 z@F&c^)o4f{4##H8NkyGxI1PIACw1aI5k_}(sX9pUB5rSR=@WP$s@GzN#|VhM6$Fkx zb7tJc$R%j}A^RA0$tUA^(r=_^`DGJ+F{{dErRiIOW`*;GS-~+*4=o!!Fp+d>&{6VZ zHjddaOIQ|LEm{veSFn#h1>y*J8q9&o_DSb|uAXBW_-_h705Brn^()j(Ae>5y~fuKZBY-o4hweR}y&t>_SfZQ|tmz`yG+# z?4PogTMG?9M4gaWQDR`VCzI>Pt%~B`W`HYUy{F@yjvO^h*b0?`<~fqfdd?>+^_jvS z^ul5SSS0r{>0FVPKjIVm#Pw;PNTQ z|AlKce%Xohy(I_&aUm)?mb|YnC-O!q;l*<)99Q)pAWYXSr}_{x6HY)ZOqM^ z07d`7Fwi4ZgAF-f%zkeyTquREN|0UBt594RHus?4g;L^I$F9ol&eI|54}w9bE8EcW zSx4AZn~DU?e1~x-CEYZ&?eQQ}FGN+I~!1 zWl7Egmj-PW?>AdeH3DY}IwwBDsFKMJw=N4tr;b#?)`~(lgZXF0Hd7``$`zRCFw0|n z(&bNmlk}h$IIB?99T3y96M_oAT7{?o0bb|>S$lZr7 zu)iKbwFi;+VRk|)V)qzC6kN)$T2TU z;vtP=LEJw|f=Lo(G|A6#+Pw2vS<`D|F9 z_sJn)1v+`6!bMf1iSU(bRf4Fo8Ml1|CkE@h9(Bmu*KM_-3~p$XDUMV3vtf3W5)weW6|Nx-uLT9?{=Va zO4g14n)O6vy{xgpg20q#-H+QI!CUK#7tM`xFc!-+`EcBAE2FX;R;C_^jwL_d7%)DhE5iAh_D2La(iGy7N7UDCI#bI4eobVt34 zCvGIS#H%ki2uZu2ej~Nofz3mScNw+x?3~)E#6~ZZ^}khdwOJFdpYK3CWY5d_EJIhD ztThxmt&(6|voyn;FqhJNw~FaKyh4sUc9`&G^<=vq-)K20Cz z5G5`xOfNS`3mo!PClfcQ8uxp?7Cic?SG1P@r7I^WMhz_0ScSL!`UdIx1Rk-A)LFP=x{tu zlWrwrsG3-d{k-;J{q1A@?E%RuK{V}Xhaw`=!a7K-9UI&ZSlbIH#M`Lx5NHw=Oy@g7 z@tu?s)xaULdca+c7NG*w)w}@>t$yHDxkY_lpcqm!^Wenr)6KCM9+K7l&Tf2Q^B0-v z9>r&{*QX0qm6yur&|xvK%m!;owu$=)=FaK|H=Bvs-3Q>YK1L2KV?-CIRl4ojx=9OE z`Z`Rkv>yCSb4cZ_+bF;&uR)t0wKx{{xd^KHv)n0|x4Z7Q&~h2h0}KItg%8Ant;_ z0-NB@kssFxZj`u%sKWuH{q<0&P?hcZuq7m1C^HhlB*WdRCcX4|84^^0L9<(jF)`f! zZDzP(H;Fq`AJlCyBe~2P;z+wW{0FjK(B`mPi_!gAg4+WdFOCg!b30;ilS5_A(et{W zSnlUA7Vpq(mNN+t7t@!!&{#Vha|ev7UJq2Mf6S&1o-5&)XA9u+3d|VO%YwLMBl*oJ zY0S!Rq|99_Hc|u}Pw&7u{t=bttS=mM%u|dG52ckrnMF5fc%OK_a~7>mpX-8DJ;Q*H zQx6@1lt1KT#u`#*33NvohROeZ5aVm!W9{Esi;0I35?`!A!i`A5&0EpE#U1vJ^!Uf= zHDH|=BH`FkBJq%)BB32#GTL*8{Ab7)np`$Y^cMS{n}8+O&zFiul`j!5Fj6)~`AEI# zju=^N$1GNpPCc%D9}2Amb@ZR<7+OkUP5Fy^zWM!@Wu6L=LMXC

`; - let list = document.querySelector("#plist"); + const list = document.querySelector("#plist"); list.innerHTML = line + list.innerHTML; }); } @@ -178,10 +183,9 @@ Hooks.payload = { // } if (enable_presence === "true") { const name = "user_name_" + Math.floor(Math.random() * 100); - this.channel.send({ - type: "presence", - event: "TRACK", - payload: { name: name, t: performance.now() }, + await this.channel.track({ + name: name, + t: performance.now(), }); } } else { @@ -214,7 +218,7 @@ Hooks.payload = { }, mounted() { - let params = { + const params = { log_level: localStorage.getItem("log_level"), token: localStorage.getItem("token"), host: localStorage.getItem("host"), @@ -250,9 +254,9 @@ Hooks.payload = { this.sendRealtime(message.event, message.payload) ); - this.handleEvent("disconnect", ({}) => this.disconnectRealtime()); + this.handleEvent("disconnect", () => this.disconnectRealtime()); - this.handleEvent("clear_local_storage", ({}) => this.clearLocalStorage()); + this.handleEvent("clear_local_storage", () => this.clearLocalStorage()); }, }; @@ -266,18 +270,18 @@ Hooks.latency = { }, }; -let csrfToken = document +const csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); -let liveSocket = new LiveSocket("/live", Socket, { +const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, params: { _csrf_token: csrfToken }, }); topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); -window.addEventListener("phx:page-loading-start", (info) => topbar.show()); -window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); +window.addEventListener("phx:page-loading-start", () => topbar.show()); +window.addEventListener("phx:page-loading-stop", () => topbar.hide()); liveSocket.connect(); diff --git a/assets/package.json b/assets/package.json index b718d4593..bf079a32c 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@supabase/supabase-js": "^2.50.0" + "@supabase/supabase-js": "^2.85.0" } } \ No newline at end of file diff --git a/mix.exs b/mix.exs index bd1294c58..5f628ab6a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.2", + version: "2.67.3", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From e1acf33e76f528e5ca49047b4a02b7d462ba06fe Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 9 Dec 2025 00:38:11 +1300 Subject: [PATCH 106/123] chore: use RateCounterHelper.tick! to avoid waiting on tests (#1650) --- .../extensions/cdc_rls/subscriptions_test.exs | 2 +- .../realtime_channel/broadcast_handler_test.exs | 9 ++++----- .../realtime_channel/presence_handler_test.exs | 12 ++++++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/test/realtime/extensions/cdc_rls/subscriptions_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_test.exs index fd2563cf4..975313861 100644 --- a/test/realtime/extensions/cdc_rls/subscriptions_test.exs +++ b/test/realtime/extensions/cdc_rls/subscriptions_test.exs @@ -1,4 +1,4 @@ -defmodule Realtime.Extensions.PostgresCdcRls.Subscriptions do +defmodule Realtime.Extensions.PostgresCdcRls.SubscriptionsTest do use RealtimeWeb.ChannelCase, async: true doctest Extensions.PostgresCdcRls.Subscriptions, import: true diff --git a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs index d15d8c753..d8f7cf829 100644 --- a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs @@ -363,14 +363,13 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do capture_log(fn -> {:noreply, _socket} = BroadcastHandler.handle(%{}, db_conn, socket) - # Enough for the RateCounter to calculate the last bucket - refute_receive _, 1200 + {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) + assert avg == 0.0 + + refute_receive _, 200 end) assert log =~ "RlsPolicyError" - - {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant)) - assert avg == 0.0 end test "handle payload size excedding limits in private channels", %{topic: topic, tenant: tenant, db_conn: db_conn} do diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index 7e998f5c5..7a621ca7d 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -438,7 +438,8 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -453,7 +454,8 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -521,7 +523,8 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) @@ -537,7 +540,8 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do log = capture_log(fn -> for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) - Process.sleep(1100) + + {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant)) assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket) end) From 03e2f7180524c2146be7199a53c0f42e03e57911 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 9 Dec 2025 11:25:38 +1300 Subject: [PATCH 107/123] fix: use default storage for peep (#1651) --- lib/realtime/metrics_cleaner.ex | 7 +++---- lib/realtime/monitoring/prom_ex.ex | 4 ++-- mix.exs | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/realtime/metrics_cleaner.ex b/lib/realtime/metrics_cleaner.ex index 649751b66..c81205e8e 100644 --- a/lib/realtime/metrics_cleaner.ex +++ b/lib/realtime/metrics_cleaner.ex @@ -38,11 +38,10 @@ defmodule Realtime.MetricsCleaner do defp loop_and_cleanup_metrics_table do tenant_ids = Realtime.Tenants.Connect.list_tenants() |> MapSet.new() - {_, tids} = Peep.Persistent.storage(Realtime.PromEx.Metrics) + {_, tid} = Peep.Persistent.storage(Realtime.PromEx.Metrics) - tids - |> Tuple.to_list() - |> Stream.flat_map(fn tid -> :ets.select(tid, @peep_filter_spec) end) + tid + |> :ets.select(@peep_filter_spec) |> Enum.uniq() |> Stream.reject(fn tenant_id -> MapSet.member?(tenant_ids, tenant_id) end) |> Enum.map(fn tenant_id -> %{tenant: tenant_id} end) diff --git a/lib/realtime/monitoring/prom_ex.ex b/lib/realtime/monitoring/prom_ex.ex index ecb8da653..088810dcd 100644 --- a/lib/realtime/monitoring/prom_ex.ex +++ b/lib/realtime/monitoring/prom_ex.ex @@ -66,7 +66,7 @@ defmodule Realtime.PromEx do defmodule Store do @moduledoc false - # Custom store to set global tags and striped storage + # Custom store to set global tags @behaviour PromEx.Storage @@ -82,7 +82,7 @@ defmodule Realtime.PromEx do name: name, metrics: metrics, global_tags: Application.get_env(:realtime, :metrics_tags, %{}), - storage: :striped + storage: :default ) end end diff --git a/mix.exs b/mix.exs index 5f628ab6a..4695c0bed 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.3", + version: "2.67.4", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 5eca638688c2fb2d7cc016559de9b5070fe27267 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 9 Dec 2025 16:27:51 +1300 Subject: [PATCH 108/123] fix: speed up tenant connected metrics (#1652) --- .../monitoring/prom_ex/plugins/tenant.ex | 10 +- lib/realtime/user_counter.ex | 48 +++++++++ mix.exs | 2 +- .../prom_ex/plugins/tenant_test.exs | 9 ++ test/realtime/user_counter_test.exs | 97 +++++++++++++------ 5 files changed, 130 insertions(+), 36 deletions(-) diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex index 167e5ed61..951e3fcdf 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex @@ -93,16 +93,20 @@ defmodule Realtime.PromEx.Plugins.Tenant do def execute_tenant_metrics do tenants = Tenants.Connect.list_tenants() + cluster_counts = UsersCounter.tenant_counts() + node_counts = UsersCounter.tenant_counts(Node.self()) for t <- tenants do - count = UsersCounter.tenant_users(Node.self(), t) - cluster_count = UsersCounter.tenant_users(t) tenant = Tenants.Cache.get_tenant_by_external_id(t) if tenant != nil do Telemetry.execute( [:realtime, :connections], - %{connected: count, connected_cluster: cluster_count, limit: tenant.max_concurrent_users}, + %{ + connected: Map.get(node_counts, t, 0), + connected_cluster: Map.get(cluster_counts, t, 0), + limit: tenant.max_concurrent_users + }, %{tenant: t} ) end diff --git a/lib/realtime/user_counter.ex b/lib/realtime/user_counter.ex index 9ea38c780..afcb357af 100644 --- a/lib/realtime/user_counter.ex +++ b/lib/realtime/user_counter.ex @@ -22,6 +22,54 @@ defmodule Realtime.UsersCounter do @spec tenant_users(atom, String.t()) :: non_neg_integer() def tenant_users(node_name, tenant_id), do: tenant_id |> scope() |> :syn.member_count(tenant_id, node_name) + @count_all_nodes_spec [ + { + # Match the tuple structure, capture group_name + {{:"$1", :_}, :_, :_, :_, :_}, + # No guards + [], + # Return only the group_name + [:"$1"] + } + ] + + @doc """ + Returns the counts of all connected clients for all tenants for the cluster. + """ + @spec tenant_counts() :: %{String.t() => non_neg_integer()} + def tenant_counts() do + scopes() + |> Stream.flat_map(fn scope -> + :syn_backbone.get_table_name(:syn_pg_by_name, scope) + |> :ets.select(@count_all_nodes_spec) + end) + |> Enum.frequencies() + end + + @doc """ + Returns the counts of all connected clients for all tenants for a single node. + """ + @spec tenant_counts(node) :: %{String.t() => non_neg_integer()} + def tenant_counts(node) do + count_single_node_spec = [ + { + # Match the tuple structure with specific node, capture group_name + {{:"$1", :_}, :_, :_, :_, node}, + # No guards + [], + # Return only the group_name + [:"$1"] + } + ] + + scopes() + |> Stream.flat_map(fn scope -> + :syn_backbone.get_table_name(:syn_pg_by_name, scope) + |> :ets.select(count_single_node_spec) + end) + |> Enum.frequencies() + end + @doc """ Returns the scope for a given tenant id. """ diff --git a/mix.exs b/mix.exs index 4695c0bed..41af78398 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.4", + version: "2.67.5", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs index 47d5285e7..bcfd75cfd 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs @@ -104,12 +104,21 @@ defmodule Realtime.PromEx.Plugins.TenantTest do UsersCounter.add(self(), bad_tenant_id) _ = Rpc.call(node, FakeUserCounter, :fake_add, [external_id]) + + # fake empty tenant_id + empty_tenant = tenant_fixture() + empty_tenant_id = empty_tenant.external_id + :syn.register(Realtime.Tenants.Connect, empty_tenant_id, self(), %{conn: nil}) + Process.sleep(500) Tenant.execute_tenant_metrics() assert_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, %{tenant: ^external_id}} + assert_receive {[:realtime, :connections], %{connected: 0, limit: 200, connected_cluster: 0}, + %{tenant: ^empty_tenant_id}} + refute_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, %{tenant: ^bad_tenant_id}} end diff --git a/test/realtime/user_counter_test.exs b/test/realtime/user_counter_test.exs index d93529764..d01b43640 100644 --- a/test/realtime/user_counter_test.exs +++ b/test/realtime/user_counter_test.exs @@ -3,6 +3,12 @@ defmodule Realtime.UsersCounterTest do alias Realtime.UsersCounter alias Realtime.Rpc + setup_all do + tenant_id = random_string() + {nodes, count} = generate_load(tenant_id) + %{tenant_id: tenant_id, count: count, nodes: nodes} + end + describe "add/1" do test "starts counter for tenant" do assert UsersCounter.add(self(), random_string()) == :ok @@ -14,7 +20,7 @@ defmodule Realtime.UsersCounterTest do def ping(), do: spawn(fn -> - Process.sleep(3000) + Process.sleep(15000) :pong end) end @@ -22,19 +28,41 @@ defmodule Realtime.UsersCounterTest do Code.eval_quoted(@aux_mod) + describe "tenant_counts/0" do + test "map of tenant and number of users", %{tenant_id: tenant_id, count: expected} do + assert UsersCounter.add(self(), tenant_id) == :ok + Process.sleep(1000) + counts = UsersCounter.tenant_counts() + assert counts[tenant_id] == expected + 1 + + assert map_size(counts) >= 41 + end + end + + describe "tenant_counts/1" do + test "map of tenant and number of users for a node only", %{tenant_id: tenant_id, nodes: nodes} do + assert UsersCounter.add(self(), tenant_id) == :ok + Process.sleep(1000) + my_counts = UsersCounter.tenant_counts(Node.self()) + # Only one connection from this test process on this node + assert my_counts == %{tenant_id => 1} + + another_node_counts = UsersCounter.tenant_counts(hd(nodes)) + assert another_node_counts[tenant_id] == 2 + + assert map_size(another_node_counts) == 21 + end + end + describe "tenant_users/1" do - test "returns count of connected clients for tenant on cluster node" do - tenant_id = random_string() - expected = generate_load(tenant_id) + test "returns count of connected clients for tenant on cluster node", %{tenant_id: tenant_id, count: expected} do Process.sleep(1000) assert UsersCounter.tenant_users(tenant_id) == expected end end describe "tenant_users/2" do - test "returns count of connected clients for tenant on target cluster" do - tenant_id = random_string() - generate_load(tenant_id) + test "returns count of connected clients for tenant on target cluster", %{tenant_id: tenant_id} do {:ok, node} = Clustered.start(@aux_mod) pid = Rpc.call(node, Aux, :ping, []) UsersCounter.add(pid, tenant_id) @@ -42,33 +70,38 @@ defmodule Realtime.UsersCounterTest do end end - defp generate_load(tenant_id, nodes \\ 2, processes \\ 2) do - for i <- 1..nodes do - # Avoid port collision - extra_config = [ - {:gen_rpc, :tcp_server_port, 15970 + i} - ] - - {:ok, node} = Clustered.start(@aux_mod, extra_config: extra_config, phoenix_port: 4012 + i) - - for _ <- 1..processes do - pid = Rpc.call(node, Aux, :ping, []) - - for _ <- 1..10 do - # replicate same pid added multiple times concurrently - Task.start(fn -> - UsersCounter.add(pid, tenant_id) - end) - - # noisy neighbors to test handling of bigger loads on concurrent calls - Task.start(fn -> - pid = Rpc.call(node, Aux, :ping, []) - UsersCounter.add(pid, random_string()) - end) + defp generate_load(tenant_id, n_nodes \\ 2, processes \\ 2) do + nodes = + for i <- 1..n_nodes do + # Avoid port collision + extra_config = [ + {:gen_rpc, :tcp_server_port, 15970 + i} + ] + + {:ok, node} = Clustered.start(@aux_mod, extra_config: extra_config, phoenix_port: 4012 + i) + + for _ <- 1..processes do + pid = Rpc.call(node, Aux, :ping, []) + + for _ <- 1..10 do + # replicate same pid added multiple times concurrently + Task.start(fn -> + UsersCounter.add(pid, tenant_id) + Process.sleep(10000) + end) + + # noisy neighbors to test handling of bigger loads on concurrent calls + Task.start(fn -> + pid = Rpc.call(node, Aux, :ping, []) + UsersCounter.add(pid, random_string()) + Process.sleep(10000) + end) + end end + + node end - end - nodes * processes + {nodes, n_nodes * processes} end end From 2994d65414012f10897dcf704fa2b1ac6cf6feff Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 9 Dec 2025 16:37:45 +1300 Subject: [PATCH 109/123] fix: Repo.Replica returns main Repo if master region (#1653) --- lib/realtime/repo_replica.ex | 4 ++++ mix.exs | 2 +- test/realtime/repo_replica_test.exs | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/realtime/repo_replica.ex b/lib/realtime/repo_replica.ex index 9d3c10de8..2a957c439 100644 --- a/lib/realtime/repo_replica.ex +++ b/lib/realtime/repo_replica.ex @@ -45,6 +45,7 @@ defmodule Realtime.Repo.Replica do end region = Application.get_env(:realtime, :region) + master_region = Application.get_env(:realtime, :master_region) || region replica = Map.get(replicas, region) replica_conf = Application.get_env(:realtime, replica) @@ -56,6 +57,9 @@ defmodule Realtime.Repo.Replica do is_nil(replica_conf) -> Realtime.Repo + region == master_region -> + Realtime.Repo + true -> # Check if module is present case Code.ensure_compiled(replica) do diff --git a/mix.exs b/mix.exs index 41af78398..085978a0a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.5", + version: "2.67.6", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/repo_replica_test.exs b/test/realtime/repo_replica_test.exs index 69de61e03..0b988205b 100644 --- a/test/realtime/repo_replica_test.exs +++ b/test/realtime/repo_replica_test.exs @@ -6,10 +6,12 @@ defmodule Realtime.Repo.ReplicaTest do setup do previous_platform = Application.get_env(:realtime, :platform) previous_region = Application.get_env(:realtime, :region) + previous_master_region = Application.get_env(:realtime, :master_region) on_exit(fn -> Application.put_env(:realtime, :platform, previous_platform) Application.put_env(:realtime, :region, previous_region) + Application.put_env(:realtime, :master_region, previous_master_region) end) end @@ -17,12 +19,20 @@ defmodule Realtime.Repo.ReplicaTest do for {region, mod} <- Replica.replicas_aws() do setup do Application.put_env(:realtime, :platform, :aws) + Application.put_env(:realtime, :master_region, "special-region") + :ok end test "handles #{region} region" do Application.put_env(:realtime, :region, unquote(region)) replica_asserts(unquote(mod), Replica.replica()) end + + test "defaults to Realtime.Repo if region is equal to master region on #{region}" do + Application.put_env(:realtime, :region, unquote(region)) + Application.put_env(:realtime, :master_region, unquote(region)) + replica_asserts(Realtime.Repo, Replica.replica()) + end end test "defaults to Realtime.Repo if region is not configured" do @@ -35,6 +45,8 @@ defmodule Realtime.Repo.ReplicaTest do for {region, mod} <- Replica.replicas_fly() do setup do Application.put_env(:realtime, :platform, :fly) + Application.put_env(:realtime, :master_region, "special-region") + :ok end test "handles #{region} region" do From 6a1d77a5420203dcc840c17c888d82dff37f69e6 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 10 Dec 2025 16:40:49 +1300 Subject: [PATCH 110/123] fix: improve metrics handling (#1654) * Allow 3x more max heap size than other processes * Avoid compressing given that gen_rpc does this for us already * fix: remove limit_concurrent metric * fix: revert the way we fetched tenants to report on connected metric --- lib/realtime/monitoring/prom_ex.ex | 7 -- .../monitoring/prom_ex/plugins/tenant.ex | 46 +------------ lib/realtime/tenants.ex | 9 +++ .../controllers/metrics_controller.ex | 68 +++++++++++-------- mix.exs | 2 +- test/integration/rt_channel_test.exs | 2 +- .../prom_ex/plugins/tenant_test.exs | 8 --- test/realtime/monitoring/prom_ex_test.exs | 20 ------ 8 files changed, 51 insertions(+), 111 deletions(-) diff --git a/lib/realtime/monitoring/prom_ex.ex b/lib/realtime/monitoring/prom_ex.ex index 088810dcd..3797bfd0c 100644 --- a/lib/realtime/monitoring/prom_ex.ex +++ b/lib/realtime/monitoring/prom_ex.ex @@ -134,11 +134,4 @@ defmodule Realtime.PromEx do metrics end - - @doc "Compressed metrics using :zlib.compress/1" - @spec get_compressed_metrics() :: binary() - def get_compressed_metrics do - get_metrics() - |> :zlib.compress() - end end diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex index 951e3fcdf..485be55c6 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenant.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenant.ex @@ -22,7 +22,6 @@ defmodule Realtime.PromEx.Plugins.Tenant do [ channel_events(), replication_metrics(), - subscription_metrics(), payload_size_metrics() ] end @@ -78,13 +77,6 @@ defmodule Realtime.PromEx.Plugins.Tenant do description: "The cluster total count of connected clients for a tenant.", measurement: :connected_cluster, tags: [:tenant] - ), - last_value( - [:realtime, :connections, :limit_concurrent], - event_name: [:realtime, :connections], - description: "The total count of connected clients for a tenant.", - measurement: :limit, - tags: [:tenant] ) ], detach_on_error: false @@ -92,7 +84,7 @@ defmodule Realtime.PromEx.Plugins.Tenant do end def execute_tenant_metrics do - tenants = Tenants.Connect.list_tenants() + tenants = Tenants.list_connected_tenants(Node.self()) cluster_counts = UsersCounter.tenant_counts() node_counts = UsersCounter.tenant_counts(Node.self()) @@ -136,28 +128,6 @@ defmodule Realtime.PromEx.Plugins.Tenant do ) end - defp subscription_metrics do - Event.build( - :realtime_tenant_channel_event_metrics, - [ - sum( - [:realtime, :subscriptions_checker, :pid_not_found], - event_name: [:realtime, :subscriptions_checker, :pid_not_found], - measurement: :sum, - description: "Sum of pids not found in Subscription tables.", - tags: [:tenant] - ), - sum( - [:realtime, :subscriptions_checker, :phantom_pid_detected], - event_name: [:realtime, :subscriptions_checker, :phantom_pid_detected], - measurement: :sum, - description: "Sum of phantom pids detected in Subscription tables.", - tags: [:tenant] - ) - ] - ) - end - defmodule PolicyAuthorization.Buckets do @moduledoc false use Peep.Buckets.Custom, buckets: [10, 250, 5000, 15_000] @@ -237,20 +207,6 @@ defmodule Realtime.PromEx.Plugins.Tenant do measurement: :size, tags: [:tenant] ), - last_value( - [:realtime, :channel, :events, :limit_per_second], - event_name: [:realtime, :rate_counter, :channel, :events], - measurement: :limit, - description: "Rate limit of messages per second sent on a Realtime Channel.", - tags: [:tenant] - ), - last_value( - [:realtime, :channel, :joins, :limit_per_second], - event_name: [:realtime, :rate_counter, :channel, :joins], - measurement: :limit, - description: "Rate limit of joins per second on a Realtime Channel.", - tags: [:tenant] - ), distribution( [:realtime, :tenants, :read_authorization_check], event_name: [:realtime, :tenants, :read_authorization_check], diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index 47e41f937..87d19cd65 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -15,6 +15,15 @@ defmodule Realtime.Tenants do alias Realtime.Tenants.Migrations alias Realtime.UsersCounter + @doc """ + Gets a list of connected tenant `external_id` strings in the cluster or a node. + """ + @spec list_connected_tenants(atom()) :: [String.t()] + def list_connected_tenants(node) do + UsersCounter.scopes() + |> Enum.flat_map(fn scope -> :syn.group_names(scope, node) end) + end + @doc """ Gets the database connection pid managed by the Tenants.Connect process. diff --git a/lib/realtime_web/controllers/metrics_controller.ex b/lib/realtime_web/controllers/metrics_controller.ex index 3eff2d95f..499d2cb98 100644 --- a/lib/realtime_web/controllers/metrics_controller.ex +++ b/lib/realtime_web/controllers/metrics_controller.ex @@ -4,40 +4,50 @@ defmodule RealtimeWeb.MetricsController do alias Realtime.PromEx alias Realtime.GenRpc + # We give more memory and time to collect metrics from all nodes as this is a lot of work def index(conn, _) do - timeout = Application.fetch_env!(:realtime, :metrics_rpc_timeout) - - cluster_metrics = - Node.list() - |> Task.async_stream( - fn node -> - {node, GenRpc.call(node, PromEx, :get_compressed_metrics, [], timeout: timeout)} - end, - timeout: :infinity - ) - |> Enum.reduce([PromEx.get_metrics()], fn {_, {node, response}}, acc -> - case response do - {:error, :rpc_error, reason} -> - Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") - acc - - metrics -> - [uncompress(metrics) | acc] - end - end) - |> Enum.reverse() + {time, metrics} = :timer.tc(&cluster_metrics/0, :millisecond) + Logger.info("Collected cluster metrics in #{time} milliseconds") conn |> put_resp_content_type("text/plain") - |> send_resp(200, cluster_metrics) + |> send_resp(200, metrics) + end + + defp cluster_metrics() do + bump_max_heap_size() + timeout = Application.fetch_env!(:realtime, :metrics_rpc_timeout) + + Node.list() + |> Task.async_stream( + fn node -> + {node, GenRpc.call(node, __MODULE__, :get_metrics, [], timeout: timeout)} + end, + timeout: :infinity + ) + |> Enum.reduce([PromEx.get_metrics()], fn {_, {node, response}}, acc -> + case response do + {:error, :rpc_error, reason} -> + Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") + acc + + metrics -> + [metrics | acc] + end + end) end - defp uncompress(compressed_data) do - :zlib.uncompress(compressed_data) - rescue - error -> - Logger.error("Failed to decompress metrics data: #{inspect(error)}") - # Return empty string to not impact the aggregated metrics - "" + def get_metrics() do + bump_max_heap_size() + PromEx.get_metrics() + end + + defp bump_max_heap_size() do + system_max_heap_size = :erlang.system_info(:max_heap_size)[:size] + + # it's 0 when there is no limit + if is_integer(system_max_heap_size) and system_max_heap_size > 0 do + Process.flag(:max_heap_size, system_max_heap_size * 3) + end end end diff --git a/mix.exs b/mix.exs index 085978a0a..8b11e1b19 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.6", + version: "2.67.7", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index a35c5ef43..5cfcaaf0b 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -2163,7 +2163,7 @@ defmodule Realtime.Integration.RtChannelTest do # Postgres Change events for _ <- 1..5, do: Postgrex.query!(conn, "insert into test (details) values ('test')", []) - for _ <- 1..5 do + for _ <- 1..10 do assert_receive %Message{ topic: ^topic, event: "postgres_changes", diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs index bcfd75cfd..84ca9b1fb 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs @@ -105,20 +105,12 @@ defmodule Realtime.PromEx.Plugins.TenantTest do _ = Rpc.call(node, FakeUserCounter, :fake_add, [external_id]) - # fake empty tenant_id - empty_tenant = tenant_fixture() - empty_tenant_id = empty_tenant.external_id - :syn.register(Realtime.Tenants.Connect, empty_tenant_id, self(), %{conn: nil}) - Process.sleep(500) Tenant.execute_tenant_metrics() assert_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, %{tenant: ^external_id}} - assert_receive {[:realtime, :connections], %{connected: 0, limit: 200, connected_cluster: 0}, - %{tenant: ^empty_tenant_id}} - refute_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2}, %{tenant: ^bad_tenant_id}} end diff --git a/test/realtime/monitoring/prom_ex_test.exs b/test/realtime/monitoring/prom_ex_test.exs index 29046a860..a466e5efd 100644 --- a/test/realtime/monitoring/prom_ex_test.exs +++ b/test/realtime/monitoring/prom_ex_test.exs @@ -20,24 +20,4 @@ defmodule Realtime.PromExTest do ) end end - - describe "get_compressed_metrics/0" do - test "builds metrics compressed using zlib" do - compressed_metrics = PromEx.get_compressed_metrics() - - metrics = :zlib.uncompress(compressed_metrics) - - assert String.contains?( - metrics, - "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online." - ) - - assert String.contains?(metrics, "# TYPE beam_system_schedulers_online_info gauge") - - assert String.contains?( - metrics, - "beam_system_schedulers_online_info{host=\"nohost\",id=\"nohost\",region=\"us-east-1\"}" - ) - end - end end From 4588af5edcb03ed59684a8ee25ffd4d190feb870 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 10 Dec 2025 19:45:30 +1300 Subject: [PATCH 111/123] fix: remove tenant tag on RPC metrics (#1655) * fix: remove tenant tag on RPC metrics * chore: bump version --- lib/realtime/gen_rpc.ex | 22 +++---- .../monitoring/prom_ex/plugins/tenants.ex | 9 --- lib/realtime/rpc.ex | 6 +- mix.exs | 2 +- .../extensions/cdc_rls/cdc_rls_test.exs | 2 - test/realtime/gen_rpc_test.exs | 15 ----- .../prom_ex/plugins/tenants_test.exs | 57 ++++--------------- test/realtime/rpc_test.exs | 6 +- 8 files changed, 27 insertions(+), 92 deletions(-) diff --git a/lib/realtime/gen_rpc.ex b/lib/realtime/gen_rpc.ex index c3af9b95b..4d931af23 100644 --- a/lib/realtime/gen_rpc.ex +++ b/lib/realtime/gen_rpc.ex @@ -81,7 +81,7 @@ defmodule Realtime.GenRpc do Options: - `:key` - Optional key to consistently select the same gen_rpc clients to guarantee message order between nodes - - `:tenant_id` - Tenant ID for telemetry and logging, defaults to nil + - `:tenant_id` - Tenant ID for logging, defaults to nil - `:timeout` - timeout in milliseconds for the RPC call, defaults to 5000ms """ @spec call(node, module, atom, list(any), keyword()) :: result @@ -120,16 +120,16 @@ defmodule Realtime.GenRpc do external_id: tenant_id ) - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {:error, :rpc_error, reason} {:error, _} -> - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) response _ -> - telemetry_success(node, latency, tenant_id) + telemetry_success(node, latency) response end end @@ -184,32 +184,32 @@ defmodule Realtime.GenRpc do external_id: tenant_id ) - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {node, result} {node, latency, {:ok, _} = result} -> - telemetry_success(node, latency, tenant_id) + telemetry_success(node, latency) {node, result} {node, latency, result} -> - telemetry_failure(node, latency, tenant_id) + telemetry_failure(node, latency) {node, result} end) end - defp telemetry_success(node, latency, tenant_id) do + defp telemetry_success(node, latency) do Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{origin_node: node(), target_node: node, success: true, tenant: tenant_id, mechanism: :gen_rpc} + %{origin_node: node(), target_node: node, success: true, mechanism: :gen_rpc} ) end - defp telemetry_failure(node, latency, tenant_id) do + defp telemetry_failure(node, latency) do Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{origin_node: node(), target_node: node, success: false, tenant: tenant_id, mechanism: :gen_rpc} + %{origin_node: node(), target_node: node, success: false, mechanism: :gen_rpc} ) end diff --git a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex index cfc6eb80e..f145af830 100644 --- a/lib/realtime/monitoring/prom_ex/plugins/tenants.ex +++ b/lib/realtime/monitoring/prom_ex/plugins/tenants.ex @@ -18,15 +18,6 @@ defmodule Realtime.PromEx.Plugins.Tenants do @impl true def event_metrics(_) do Event.build(:realtime, [ - distribution( - [:realtime, :rpc], - event_name: [:realtime, :rpc], - description: "Latency of rpc calls triggered by a tenant action", - measurement: :latency, - unit: {:microsecond, :millisecond}, - tags: [:success, :tenant, :mechanism], - reporter_options: [peep_bucket_calculator: Buckets] - ), distribution( [:realtime, :global, :rpc], event_name: [:realtime, :rpc], diff --git a/lib/realtime/rpc.ex b/lib/realtime/rpc.ex index c63b29f08..7e4095b95 100644 --- a/lib/realtime/rpc.ex +++ b/lib/realtime/rpc.ex @@ -10,14 +10,13 @@ defmodule Realtime.Rpc do """ @spec call(atom(), atom(), atom(), any(), keyword()) :: any() def call(node, mod, func, args, opts \\ []) do - tenant_id = Keyword.get(opts, :tenant_id) timeout = Keyword.get(opts, :timeout, Application.get_env(:realtime, :rpc_timeout)) {latency, response} = :timer.tc(fn -> :rpc.call(node, mod, func, args, timeout) end) Telemetry.execute( [:realtime, :rpc], %{latency: latency}, - %{mod: mod, func: func, target_node: node, origin_node: node(), mechanism: :rpc, tenant: tenant_id, success: nil} + %{mod: mod, func: func, target_node: node, origin_node: node(), mechanism: :rpc, success: nil} ) response @@ -45,7 +44,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: true, - tenant: tenant_id, mechanism: :erpc } ) @@ -62,7 +60,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: false, - tenant: tenant_id, mechanism: :erpc } ) @@ -87,7 +84,6 @@ defmodule Realtime.Rpc do target_node: node, origin_node: node(), success: false, - tenant: tenant_id, mechanism: :erpc } ) diff --git a/mix.exs b/mix.exs index 8b11e1b19..978f92df6 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.7", + version: "2.67.8", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs index cdda5bee8..77c54e4ae 100644 --- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs +++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs @@ -221,7 +221,6 @@ defmodule Realtime.Extensions.CdcRlsTest do [:realtime, :rpc], %{latency: _}, %{ - tenant: "dev_tenant", mechanism: :gen_rpc, success: true } @@ -381,7 +380,6 @@ defmodule Realtime.Extensions.CdcRlsTest do [:realtime, :rpc], %{latency: _}, %{ - tenant: "dev_tenant", mechanism: :gen_rpc, origin_node: _, success: true, diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs index 5fff6b082..fbbd155f4 100644 --- a/test/realtime/gen_rpc_test.exs +++ b/test/realtime/gen_rpc_test.exs @@ -28,7 +28,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -43,7 +42,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -57,7 +55,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -72,7 +69,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -94,7 +90,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -116,7 +111,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -131,7 +125,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -146,7 +139,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: "123", mechanism: :gen_rpc }} end @@ -168,7 +160,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -315,7 +306,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: true, - tenant: "123", mechanism: :gen_rpc }} @@ -324,7 +314,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: "123", mechanism: :gen_rpc }} end @@ -351,7 +340,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} @@ -360,7 +348,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: false, - tenant: 123, mechanism: :gen_rpc }} end @@ -385,7 +372,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^node, success: false, - tenant: 123, mechanism: :gen_rpc }} @@ -394,7 +380,6 @@ defmodule Realtime.GenRpcTest do origin_node: ^current_node, target_node: ^current_node, success: true, - tenant: 123, mechanism: :gen_rpc }} end diff --git a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs index b6e9ecc45..4ebd99388 100644 --- a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs +++ b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs @@ -30,16 +30,6 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do %{tenant: random_string()} end - test "success", %{tenant: tenant} do - metric = "realtime_rpc_count" - # Enough time for the poll rate to be triggered at least once - Process.sleep(200) - previous_value = metric_value(metric, mechanism: "erpc", success: true, tenant: tenant) || 0 - assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: tenant) - Process.sleep(200) - assert metric_value(metric, mechanism: "erpc", success: true, tenant: tenant) == previous_value + 1 - end - test "global success", %{tenant: tenant} do metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once @@ -50,16 +40,6 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do assert metric_value(metric, mechanism: "erpc", success: true) == previous_value + 1 end - test "failure", %{tenant: tenant} do - metric = "realtime_rpc_count" - # Enough time for the poll rate to be triggered at least once - Process.sleep(200) - previous_value = metric_value(metric, mechanism: "erpc", success: false, tenant: tenant) || 0 - assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: tenant) - Process.sleep(200) - assert metric_value(metric, mechanism: "erpc", success: false, tenant: tenant) == previous_value + 1 - end - test "global failure", %{tenant: tenant} do metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once @@ -70,19 +50,6 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do assert metric_value(metric, mechanism: "erpc", success: false) == previous_value + 1 end - test "exception", %{tenant: tenant} do - metric = "realtime_rpc_count" - # Enough time for the poll rate to be triggered at least once - Process.sleep(200) - previous_value = metric_value(metric, mechanism: "erpc", success: false, tenant: tenant) || 0 - - assert {:error, :rpc_error, %RuntimeError{message: "runtime error"}} = - Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: tenant) - - Process.sleep(200) - assert metric_value(metric, mechanism: "erpc", success: false, tenant: tenant) == previous_value + 1 - end - test "global exception", %{tenant: tenant} do metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once @@ -102,38 +69,38 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do %{tenant: random_string()} end - test "success", %{tenant: tenant} do - metric = "realtime_rpc_count" + test "global success", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(metric, mechanism: "gen_rpc", success: true, tenant: tenant) || 0 + previous_value = metric_value(metric, mechanism: "gen_rpc", success: true) || 0 assert GenRpc.multicall(Test, :success, [], tenant_id: tenant) == [{node(), {:ok, "success"}}] Process.sleep(200) - assert metric_value(metric, mechanism: "gen_rpc", success: true, tenant: tenant) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: true) == previous_value + 1 end - test "failure", %{tenant: tenant} do - metric = "realtime_rpc_count" + test "global failure", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(metric, mechanism: "gen_rpc", success: false, tenant: tenant) || 0 + previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0 assert GenRpc.multicall(Test, :failure, [], tenant_id: tenant) == [{node(), {:error, "failure"}}] Process.sleep(200) - assert metric_value(metric, mechanism: "gen_rpc", success: false, tenant: tenant) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1 end - test "exception", %{tenant: tenant} do - metric = "realtime_rpc_count" + test "global exception", %{tenant: tenant} do + metric = "realtime_global_rpc_count" # Enough time for the poll rate to be triggered at least once Process.sleep(200) - previous_value = metric_value(metric, mechanism: "gen_rpc", success: false, tenant: tenant) || 0 + previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0 node = node() assert assert [{^node, {:error, :rpc_error, {:EXIT, {%RuntimeError{message: "runtime error"}, _stacktrace}}}}] = GenRpc.multicall(Test, :exception, [], tenant_id: tenant) Process.sleep(200) - assert metric_value(metric, mechanism: "gen_rpc", success: false, tenant: tenant) == previous_value + 1 + assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1 end end diff --git a/test/realtime/rpc_test.exs b/test/realtime/rpc_test.exs index 221cd781b..9c83d7064 100644 --- a/test/realtime/rpc_test.exs +++ b/test/realtime/rpc_test.exs @@ -81,8 +81,7 @@ defmodule Realtime.RpcTest do func: :test_success, origin_node: ^origin_node, target_node: ^node, - success: true, - tenant: "123" + success: true }} end @@ -100,8 +99,7 @@ defmodule Realtime.RpcTest do func: :test_raise, origin_node: ^origin_node, target_node: ^node, - success: false, - tenant: "123" + success: false }} end From 93ce90ab6a41b6f3600116ef585f1b4f5b6f3dba Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 10 Dec 2025 20:08:40 +1300 Subject: [PATCH 112/123] feat: add /metrics/:region endpoint (#1656) --- .../controllers/metrics_controller.ex | 18 +++-- lib/realtime_web/router.ex | 1 + mix.exs | 2 +- .../controllers/metrics_controller_test.exs | 72 ++++++++++++++++++- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/lib/realtime_web/controllers/metrics_controller.ex b/lib/realtime_web/controllers/metrics_controller.ex index 499d2cb98..b542f6a85 100644 --- a/lib/realtime_web/controllers/metrics_controller.ex +++ b/lib/realtime_web/controllers/metrics_controller.ex @@ -6,7 +6,7 @@ defmodule RealtimeWeb.MetricsController do # We give more memory and time to collect metrics from all nodes as this is a lot of work def index(conn, _) do - {time, metrics} = :timer.tc(&cluster_metrics/0, :millisecond) + {time, metrics} = :timer.tc(fn -> metrics([Node.self() | Node.list()]) end, :millisecond) Logger.info("Collected cluster metrics in #{time} milliseconds") conn @@ -14,18 +14,28 @@ defmodule RealtimeWeb.MetricsController do |> send_resp(200, metrics) end - defp cluster_metrics() do + def region(conn, %{"region" => region}) do + nodes = Realtime.Nodes.region_nodes(region) + {time, metrics} = :timer.tc(fn -> metrics(nodes) end, :millisecond) + Logger.info("Collected metrics for region #{region} in #{time} milliseconds") + + conn + |> put_resp_content_type("text/plain") + |> send_resp(200, metrics) + end + + defp metrics(nodes) do bump_max_heap_size() timeout = Application.fetch_env!(:realtime, :metrics_rpc_timeout) - Node.list() + nodes |> Task.async_stream( fn node -> {node, GenRpc.call(node, __MODULE__, :get_metrics, [], timeout: timeout)} end, timeout: :infinity ) - |> Enum.reduce([PromEx.get_metrics()], fn {_, {node, response}}, acc -> + |> Enum.reduce([], fn {_, {node, response}}, acc -> case response do {:error, :rpc_error, reason} -> Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") diff --git a/lib/realtime_web/router.ex b/lib/realtime_web/router.ex index 1e368f6d2..77aded263 100644 --- a/lib/realtime_web/router.ex +++ b/lib/realtime_web/router.ex @@ -76,6 +76,7 @@ defmodule RealtimeWeb.Router do pipe_through(:metrics) get("/", MetricsController, :index) + get("/:region", MetricsController, :region) end scope "/api" do diff --git a/mix.exs b/mix.exs index 978f92df6..da904db2a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.67.8", + version: "2.68.0", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime_web/controllers/metrics_controller_test.exs b/test/realtime_web/controllers/metrics_controller_test.exs index 897979e55..52453271c 100644 --- a/test/realtime_web/controllers/metrics_controller_test.exs +++ b/test/realtime_web/controllers/metrics_controller_test.exs @@ -2,8 +2,10 @@ defmodule RealtimeWeb.MetricsControllerTest do # Usage of Clustered # Also changing Application env use RealtimeWeb.ConnCase, async: false + alias Realtime.GenRpc import ExUnit.CaptureLog + use Mimic setup_all do metrics_tags = %{ @@ -45,9 +47,13 @@ defmodule RealtimeWeb.MetricsControllerTest do end test "returns 200 and log on timeout", %{conn: conn} do - current_value = Application.get_env(:realtime, :metrics_rpc_timeout) - on_exit(fn -> Application.put_env(:realtime, :metrics_rpc_timeout, current_value) end) - Application.put_env(:realtime, :metrics_rpc_timeout, 0) + Mimic.stub(GenRpc, :call, fn node, mod, func, args, opts -> + if node != node() do + {:error, :rpc_error, :timeout} + else + call_original(GenRpc, :call, [node, mod, func, args, opts]) + end + end) log = capture_log(fn -> @@ -84,4 +90,64 @@ defmodule RealtimeWeb.MetricsControllerTest do |> response(403) end end + + describe "GET /metrics/:region" do + setup %{conn: conn} do + # The metrics pipeline requires authentication + jwt_secret = Application.fetch_env!(:realtime, :metrics_jwt_secret) + token = generate_jwt_token(jwt_secret, %{}) + authenticated_conn = put_req_header(conn, "authorization", "Bearer #{token}") + + {:ok, conn: authenticated_conn} + end + + test "returns 200", %{conn: conn} do + assert response = + conn + |> get(~p"/metrics/ap-southeast-2") + |> text_response(200) + + # Check prometheus like metrics + assert response =~ + "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online." + + assert response =~ "region=\"ap-southeast-2\"" + refute response =~ "region=\"us-east-1\"" + end + + test "returns 200 and log on timeout", %{conn: conn} do + Mimic.stub(GenRpc, :call, fn _node, _mod, _func, _args, _opts -> + {:error, :rpc_error, :timeout} + end) + + log = + capture_log(fn -> + assert response = + conn + |> get(~p"/metrics/ap-southeast-2") + |> text_response(200) + + assert response == "" + end) + + assert log =~ "Cannot fetch metrics from the node" + end + + test "returns 403 when authorization header is missing", %{conn: conn} do + assert conn + |> delete_req_header("authorization") + |> get(~p"/metrics/ap-southeast-2") + |> response(403) + end + + test "returns 403 when authorization header is wrong", %{conn: conn} do + token = generate_jwt_token("bad_secret", %{}) + + assert _ = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> get(~p"/metrics/ap-southeast-2") + |> response(403) + end + end end From 79da9b6f6b04084050d24be29446f860eb09375d Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 11 Dec 2025 10:47:27 +1300 Subject: [PATCH 113/123] fix: stream metrics data (#1658) --- .../controllers/metrics_controller.ex | 29 ++++++++++++------- mix.exs | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/realtime_web/controllers/metrics_controller.ex b/lib/realtime_web/controllers/metrics_controller.ex index b542f6a85..61b6fd613 100644 --- a/lib/realtime_web/controllers/metrics_controller.ex +++ b/lib/realtime_web/controllers/metrics_controller.ex @@ -6,25 +6,32 @@ defmodule RealtimeWeb.MetricsController do # We give more memory and time to collect metrics from all nodes as this is a lot of work def index(conn, _) do - {time, metrics} = :timer.tc(fn -> metrics([Node.self() | Node.list()]) end, :millisecond) + conn = + conn + |> put_resp_content_type("text/plain") + |> send_chunked(200) + + {time, conn} = :timer.tc(fn -> metrics([Node.self() | Node.list()], conn) end, :millisecond) Logger.info("Collected cluster metrics in #{time} milliseconds") conn - |> put_resp_content_type("text/plain") - |> send_resp(200, metrics) end def region(conn, %{"region" => region}) do + conn = + conn + |> put_resp_content_type("text/plain") + |> send_chunked(200) + nodes = Realtime.Nodes.region_nodes(region) - {time, metrics} = :timer.tc(fn -> metrics(nodes) end, :millisecond) + + {time, conn} = :timer.tc(fn -> metrics(nodes, conn) end, :millisecond) Logger.info("Collected metrics for region #{region} in #{time} milliseconds") conn - |> put_resp_content_type("text/plain") - |> send_resp(200, metrics) end - defp metrics(nodes) do + defp metrics(nodes, conn) do bump_max_heap_size() timeout = Application.fetch_env!(:realtime, :metrics_rpc_timeout) @@ -35,14 +42,16 @@ defmodule RealtimeWeb.MetricsController do end, timeout: :infinity ) - |> Enum.reduce([], fn {_, {node, response}}, acc -> + |> Enum.reduce(conn, fn {_, {node, response}}, acc_conn -> case response do {:error, :rpc_error, reason} -> Logger.error("Cannot fetch metrics from the node #{inspect(node)} because #{inspect(reason)}") - acc + acc_conn metrics -> - [metrics | acc] + {:ok, acc_conn} = chunk(acc_conn, metrics) + :erlang.garbage_collect() + acc_conn end end) end diff --git a/mix.exs b/mix.exs index da904db2a..b621b667a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.0", + version: "2.68.1", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 5817c1023ec53a7a17d64c364fc771eab1a06a66 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 11 Dec 2025 12:43:13 +1300 Subject: [PATCH 114/123] fix: coalesce wal data if not available on list_changes function (#1649) --- lib/extensions/postgres_cdc_rls/replications.ex | 6 +++--- mix.exs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/extensions/postgres_cdc_rls/replications.ex b/lib/extensions/postgres_cdc_rls/replications.ex index b84441169..be1f0acd9 100644 --- a/lib/extensions/postgres_cdc_rls/replications.ex +++ b/lib/extensions/postgres_cdc_rls/replications.ex @@ -76,9 +76,9 @@ defmodule Extensions.PostgresCdcRls.Replications do SELECT wal->>'type' as type, wal->>'schema' as schema, wal->>'table' as table, - wal->>'columns' as columns, - wal->>'record' as record, - wal->>'old_record' as old_record, + COALESCE(wal->>'columns', '[]') as columns, + COALESCE(wal->>'record', '{}') as record, + COALESCE(wal->>'old_record', '{}') as old_record, wal->>'commit_timestamp' as commit_timestamp, subscription_ids, errors diff --git a/mix.exs b/mix.exs index b621b667a..2243e8fff 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.1", + version: "2.68.2", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, From 75e5f99609b4cf036054fe733dcc25f42082409c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 11 Dec 2025 15:17:14 +0000 Subject: [PATCH 115/123] fix: ensure replication connection publication only has correct tables (#1657) --- .../tenants/replication_connection.ex | 63 ++++++-- mix.exs | 2 +- .../tenants/replication_connection_test.exs | 147 +++++++++++++++++- 3 files changed, 191 insertions(+), 21 deletions(-) diff --git a/lib/realtime/tenants/replication_connection.ex b/lib/realtime/tenants/replication_connection.ex index cab5ab62f..26650ad5e 100644 --- a/lib/realtime/tenants/replication_connection.ex +++ b/lib/realtime/tenants/replication_connection.ex @@ -39,6 +39,7 @@ defmodule Realtime.Tenants.ReplicationConnection do | :check_replication_slot | :create_publication | :check_publication + | :validate_publication | :create_slot | :start_replication_slot | :streaming, @@ -222,27 +223,61 @@ defmodule Realtime.Tenants.ReplicationConnection do end def handle_result([%Postgrex.Result{num_rows: 1}], %__MODULE__{step: :create_publication} = state) do - {:query, "SELECT 1", %{state | step: :start_replication_slot}} + %__MODULE__{publication_name: publication_name} = state + + Logger.info("Publication #{publication_name} exists, validating contents") + + query = """ + SELECT schemaname, tablename + FROM pg_publication_tables + WHERE pubname = '#{publication_name}' + """ + + {:query, query, %{state | step: :validate_publication}} end - def handle_result([%Postgrex.Result{}], %__MODULE__{step: :start_replication_slot} = state) do - %__MODULE__{ - proto_version: proto_version, - replication_slot_name: replication_slot_name, - publication_name: publication_name - } = state + def handle_result([%Postgrex.Result{rows: rows}], %__MODULE__{step: :validate_publication} = state) do + %__MODULE__{publication_name: publication_name} = state - Logger.info( - "Starting stream replication for slot #{replication_slot_name} using publication #{publication_name} and protocol version #{proto_version}" - ) + valid_tables = + Enum.all?(rows, fn [schema, table] -> + schema == @schema and (table == @table or String.starts_with?(table, "#{@table}_")) + end) - query = - "START_REPLICATION SLOT #{replication_slot_name} LOGICAL 0/0 (proto_version '#{proto_version}', publication_names '#{publication_name}', binary 'true')" + if valid_tables and rows != [] do + {:query, "SELECT 1", %{state | step: :start_replication_slot}} + else + query = + "DROP PUBLICATION IF EXISTS #{publication_name}; CREATE PUBLICATION #{publication_name} FOR TABLE #{@schema}.#{@table}" - {:stream, query, [], %{state | step: :streaming}} + Logger.warning("Publication #{publication_name} contains unexpected tables. Recreating...") + {:query, query, %{state | step: :start_replication_slot}} + end + end + + def handle_result(results, %__MODULE__{step: :start_replication_slot} = state) do + error = Enum.find(results, fn res -> match?(Postgrex.Error, res) end) + + if error do + {:disconnect, "Error starting replication: #{error.message}"} + else + %__MODULE__{ + proto_version: proto_version, + replication_slot_name: replication_slot_name, + publication_name: publication_name + } = state + + Logger.info( + "Starting stream replication for slot #{replication_slot_name} using publication #{publication_name} and protocol version #{proto_version}" + ) + + query = + "START_REPLICATION SLOT #{replication_slot_name} LOGICAL 0/0 (proto_version '#{proto_version}', publication_names '#{publication_name}', binary 'true')" + + {:stream, query, [], %{state | step: :streaming}} + end end - # %Postgrex.Error{message: nil, postgres: %{code: :configuration_limit_exceeded, line: "291", message: "all replication slots are in use", file: "slot.c", unknown: "ERROR", severity: "ERROR", hint: "Free one or increase max_replication_slots.", routine: "ReplicationSlotCreate", pg_code: "53400"}, connection_id: 217538, query: nil} def handle_result(%Postgrex.Error{postgres: %{pg_code: pg_code}}, _state) when pg_code in ~w(53300 53400) do {:disconnect, :max_wal_senders_reached} end diff --git a/mix.exs b/mix.exs index 2243e8fff..ed3cd7a61 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.2", + version: "2.68.3", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/tenants/replication_connection_test.exs b/test/realtime/tenants/replication_connection_test.exs index 479f6b1d4..031f3cae6 100644 --- a/test/realtime/tenants/replication_connection_test.exs +++ b/test/realtime/tenants/replication_connection_test.exs @@ -13,6 +13,8 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do alias RealtimeWeb.Endpoint alias Realtime.Tenants.Repo + @replication_slot_name "supabase_realtime_messages_replication_slot_test" + setup do slot = Application.get_env(:realtime, :slot_name_suffix) on_exit(fn -> Application.put_env(:realtime, :slot_name_suffix, slot) end) @@ -21,8 +23,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do tenant = Containers.checkout_tenant(run_migrations: true) {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - name = "supabase_realtime_messages_replication_slot_test" - Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [name]) + Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [@replication_slot_name]) %{tenant: tenant, db_conn: db_conn} end @@ -283,9 +284,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do "id" int4 NOT NULL default nextval('test_id_seq'::regclass), "details" text, PRIMARY KEY ("id")); - """, - "DROP PUBLICATION IF EXISTS supabase_realtime_messages_publication", - "CREATE PUBLICATION supabase_realtime_messages_publication FOR ALL TABLES" + """ ] Postgrex.transaction(db_conn, fn conn -> @@ -299,6 +298,11 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do restart: :transient ) + assert_replication_started(db_conn, @replication_slot_name) + assert_publication_contains_only_messages(db_conn, "supabase_realtime_messages_publication") + + # Add table to publication to test the error handling + Postgrex.query!(db_conn, "ALTER PUBLICATION supabase_realtime_messages_publication ADD TABLE public.test", []) %{rows: [[_id]]} = Postgrex.query!(db_conn, "insert into test (details) values ('test') returning id", []) topic = "db:job_scheduler" @@ -496,7 +500,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do test "fails on existing replication slot", %{tenant: tenant} do {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) - name = "supabase_realtime_messages_replication_slot_test" + name = @replication_slot_name Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", [name]) @@ -577,6 +581,98 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do end end + describe "publication validation steps" do + test "if proper tables are included, starts replication", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + refute logs =~ "Recreating" + end + + test "if includes unexpected tables, recreates publication", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.wrong_table (id int)", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE public.wrong_table", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + + test "recreates publication if it has no tables", %{tenant: tenant, db_conn: db_conn} do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name}", []) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + + test "recreates publication if it has expected tables and unexpected tables under same publication", %{ + tenant: tenant, + db_conn: db_conn + } do + publication_name = "supabase_realtime_messages_publication" + + Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", []) + Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.extra_table (id int)", []) + + Postgrex.query!( + db_conn, + "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages, public.extra_table", + [] + ) + + logs = + capture_log(fn -> + {:ok, pid} = ReplicationConnection.start(tenant, self()) + + assert_replication_started(db_conn, @replication_slot_name) + assert Process.alive?(pid) + assert_publication_contains_only_messages(db_conn, publication_name) + + Process.exit(pid, :shutdown) + end) + + assert logs =~ "Recreating" + end + end + describe "whereis/1" do @tag skip: "We are using a GenServer wrapper so the pid returned is not the same as the ReplicationConnection for now" @@ -669,4 +765,43 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do message end + + defp assert_publication_contains_only_messages(db_conn, publication_name) do + %{rows: rows} = + Postgrex.query!( + db_conn, + "SELECT schemaname, tablename FROM pg_publication_tables WHERE pubname = $1", + [publication_name] + ) + + valid_tables = + Enum.all?(rows, fn [schema, table] -> + schema == "realtime" and (table == "messages" or String.starts_with?(table, "messages_")) + end) + + assert valid_tables, "Expected only realtime.messages or its partitions, got: #{inspect(rows)}" + end + + defp assert_replication_started(db_conn, slot_name, retries \\ 10, interval_ms \\ 10) do + case check_replication_status(db_conn, slot_name, retries, interval_ms) do + :ok -> :ok + :error -> flunk("Replication slot #{slot_name} did not become active") + end + end + + defp check_replication_status(_db_conn, _slot_name, 0, _interval_ms), do: :error + + defp check_replication_status(db_conn, slot_name, retries_remaining, interval_ms) do + %{rows: rows} = + Postgrex.query!(db_conn, "SELECT active FROM pg_replication_slots WHERE slot_name = $1", [slot_name]) + + case rows do + [[true]] -> + :ok + + _ -> + Process.sleep(interval_ms) + check_replication_status(db_conn, slot_name, retries_remaining - 1, interval_ms) + end + end end From ad36f07e15e4041b79b450099cfe6ef8e062c13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Thu, 11 Dec 2025 23:09:04 +0000 Subject: [PATCH 116/123] fix: allow nullable jwt secret (#1646) --- lib/realtime/api/tenant.ex | 16 +++++++++------- mix.exs | 2 +- .../20251204170944_nullable_jwt_secrets.exs | 13 +++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs diff --git a/lib/realtime/api/tenant.ex b/lib/realtime/api/tenant.ex index 65b19c40c..17f6fedda 100644 --- a/lib/realtime/api/tenant.ex +++ b/lib/realtime/api/tenant.ex @@ -78,10 +78,11 @@ defmodule Realtime.Api.Tenant do :migrations_ran, :broadcast_adapter ]) - |> validate_required([ - :external_id, - :jwt_secret - ]) + |> validate_required([:external_id]) + |> check_constraint(:jwt_secret, + name: :jwt_secret_or_jwt_jwks_required, + message: "either jwt_secret or jwt_jwks must be provided" + ) |> unique_constraint([:external_id]) |> encrypt_jwt_secret() |> maybe_set_default(:max_bytes_per_second, :tenant_max_bytes_per_second) @@ -102,7 +103,8 @@ defmodule Realtime.Api.Tenant do end end - def encrypt_jwt_secret(changeset) do - update_change(changeset, :jwt_secret, &Crypto.encrypt!/1) - end + def encrypt_jwt_secret(%Ecto.Changeset{valid?: true} = changeset), + do: update_change(changeset, :jwt_secret, &Crypto.encrypt!/1) + + def encrypt_jwt_secret(changeset), do: changeset end diff --git a/mix.exs b/mix.exs index ed3cd7a61..0cd93843d 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.3", + version: "2.68.4", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs new file mode 100644 index 000000000..ad717d322 --- /dev/null +++ b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs @@ -0,0 +1,13 @@ +defmodule Realtime.Repo.Migrations.NullableJwtSecrets do + use Ecto.Migration + + def change do + alter table(:tenants) do + modify :jwt_secret, :string, null: true + end + + create constraint(:tenants, :jwt_secret_or_jwt_jwks_required, + check: "jwt_secret IS NOT NULL OR jwt_jwks IS NOT NULL" + ) + end +end From 95fe3ac0eae7ee9e8046e538ab923d7975206cd7 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Tue, 16 Dec 2025 12:18:12 +1300 Subject: [PATCH 117/123] fix: bump max heap size to 2500MB (#1660) --- mix.exs | 2 +- rel/vm.args.eex | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 0cd93843d..e1477890c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.4", + version: "2.68.5", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/rel/vm.args.eex b/rel/vm.args.eex index 9de4e952f..983e240c4 100644 --- a/rel/vm.args.eex +++ b/rel/vm.args.eex @@ -10,8 +10,8 @@ ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 -## Limit process heap for all procs to 500 MB. The number here is the number of words -+hmax <%= div(500_000_000, :erlang.system_info(:wordsize)) %> +## Limit process heap for all procs to 2500 MB. The number here is the number of words ++hmax <%= div(2_500_000_000, :erlang.system_info(:wordsize)) %> ## Set distribution buffer busy limit (default is 1024) +zdbbl 100000 From 075484633ce0a570d37bef9945dda94fa11a131e Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 17 Dec 2025 10:53:40 +1300 Subject: [PATCH 118/123] fix: use custom Prometheus exporter (#1661) It's usually 2x-3x faster than the Peep exporter due to the amount of repeated metrics and tags --- lib/realtime/monitoring/prom_ex.ex | 2 +- lib/realtime/monitoring/prometheus.ex | 193 +++++++++ mix.exs | 2 +- test/realtime/monitoring/prometheus_test.exs | 434 +++++++++++++++++++ 4 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 lib/realtime/monitoring/prometheus.ex create mode 100644 test/realtime/monitoring/prometheus_test.exs diff --git a/lib/realtime/monitoring/prom_ex.ex b/lib/realtime/monitoring/prom_ex.ex index 3797bfd0c..b3a970d7d 100644 --- a/lib/realtime/monitoring/prom_ex.ex +++ b/lib/realtime/monitoring/prom_ex.ex @@ -73,7 +73,7 @@ defmodule Realtime.PromEx do @impl true def scrape(name) do Peep.get_all_metrics(name) - |> Peep.Prometheus.export() + |> Realtime.Monitoring.Prometheus.export() end @impl true diff --git a/lib/realtime/monitoring/prometheus.ex b/lib/realtime/monitoring/prometheus.ex new file mode 100644 index 000000000..ef100f1bc --- /dev/null +++ b/lib/realtime/monitoring/prometheus.ex @@ -0,0 +1,193 @@ +# Based on https://github.com/rkallos/peep/blob/708546ed069aebdf78ac1f581130332bd2e8b5b1/lib/peep/prometheus.ex +defmodule Realtime.Monitoring.Prometheus do + @moduledoc """ + Prometheus exporter module + + Use a temporary ets table to cache formatted names and label values + """ + + alias Telemetry.Metrics.{Counter, Distribution, LastValue, Sum} + + def export(metrics) do + cache = :ets.new(:cache, [:set, :private, read_concurrency: false, write_concurrency: :auto]) + + result = [Enum.map(metrics, &format(&1, cache)), "# EOF\n"] + :ets.delete(cache) + result + end + + defp format({%Counter{}, _series} = metric, cache) do + format_standard(metric, "counter", cache) + end + + defp format({%Sum{} = spec, _series} = metric, cache) do + format_standard(metric, spec.reporter_options[:prometheus_type] || "counter", cache) + end + + defp format({%LastValue{} = spec, _series} = metric, cache) do + format_standard(metric, spec.reporter_options[:prometheus_type] || "gauge", cache) + end + + defp format({%Distribution{} = metric, tagged_series}, cache) do + name = format_name(metric.name, cache) + help = ["# HELP ", name, " ", escape_help(metric.description)] + type = ["# TYPE ", name, " histogram"] + + distributions = + Enum.map(tagged_series, fn {tags, buckets} -> + format_distribution(name, tags, buckets, cache) + end) + + [help, ?\n, type, ?\n, distributions] + end + + defp format_distribution(name, tags, buckets, cache) do + has_labels? = not Enum.empty?(tags) + + buckets_as_floats = + Map.drop(buckets, [:sum, :infinity]) + |> Enum.map(fn {bucket_string, count} -> {String.to_float(bucket_string), count} end) + |> Enum.sort() + + {prefix_sums, count} = prefix_sums(buckets_as_floats) + + {labels_done, bucket_partial} = + if has_labels? do + labels = format_labels(tags, cache) + {[?{, labels, "} "], [name, "_bucket{", labels, ",le=\""]} + else + {?\s, [name, "_bucket{le=\""]} + end + + samples = + prefix_sums + |> Enum.map(fn {upper_bound, count} -> + [bucket_partial, format_value(upper_bound), "\"} ", Integer.to_string(count), ?\n] + end) + + sum = Map.get(buckets, :sum, 0) + inf = Map.get(buckets, :infinity, 0) + + [ + samples, + [bucket_partial, "+Inf\"} ", Integer.to_string(count + inf), ?\n], + [name, "_sum", labels_done, Integer.to_string(sum), ?\n], + [name, "_count", labels_done, Integer.to_string(count + inf), ?\n] + ] + end + + defp format_standard({metric, series}, type, cache) do + name = format_name(metric.name, cache) + help = ["# HELP ", name, " ", escape_help(metric.description)] + type = ["# TYPE ", name, " ", to_string(type)] + + samples = + Enum.map(series, fn {labels, value} -> + has_labels? = not Enum.empty?(labels) + + if has_labels? do + [name, ?{, format_labels(labels, cache), ?}, " ", format_value(value), ?\n] + else + [name, " ", format_value(value), ?\n] + end + end) + + [help, ?\n, type, ?\n, samples] + end + + defp format_labels(labels, cache) do + labels + |> Enum.sort() + |> Enum.map_intersperse(?,, fn {k, v} -> [to_string(k), "=\"", escape(v, cache), ?"] end) + end + + defp format_name(name, cache) do + case :ets.lookup_element(cache, name, 2, nil) do + nil -> + result = + name + |> Enum.join("_") + |> format_name_start() + |> IO.iodata_to_binary() + + :ets.insert(cache, {name, result}) + result + + result -> + result + end + end + + # Name must start with an ascii letter + defp format_name_start(<>) when h not in ?A..?Z and h not in ?a..?z, + do: format_name_start(rest) + + defp format_name_start(<>), + do: format_name_rest(rest, <<>>) + + # Otherwise only letters, numbers, or _ + defp format_name_rest(<>, acc) + when h in ?A..?Z or h in ?a..?z or h in ?0..?9 or h == ?_, + do: format_name_rest(rest, [acc, h]) + + defp format_name_rest(<<_, rest::binary>>, acc), do: format_name_rest(rest, acc) + defp format_name_rest(<<>>, acc), do: acc + + defp format_value(true), do: "1" + defp format_value(false), do: "0" + defp format_value(nil), do: "0" + defp format_value(n) when is_integer(n), do: Integer.to_string(n) + defp format_value(f) when is_float(f), do: Float.to_string(f) + + defp escape(nil, _cache), do: "nil" + + defp escape(value, cache) do + case :ets.lookup_element(cache, value, 2, nil) do + nil -> + result = + value + |> safe_to_string() + |> do_escape(<<>>) + |> IO.iodata_to_binary() + + :ets.insert(cache, {value, result}) + result + + result -> + result + end + end + + defp safe_to_string(value) do + case String.Chars.impl_for(value) do + nil -> inspect(value) + _ -> to_string(value) + end + end + + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?\"]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?\\]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, ?\\, ?n]) + defp do_escape(<>, acc), do: do_escape(rest, [acc, h]) + defp do_escape(<<>>, acc), do: acc + + defp escape_help(value) do + value + |> to_string() + |> escape_help(<<>>) + end + + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<>, acc), do: escape_help(rest, <>) + defp escape_help(<<>>, acc), do: acc + + defp prefix_sums(buckets), do: prefix_sums(buckets, [], 0) + defp prefix_sums([], acc, sum), do: {Enum.reverse(acc), sum} + + defp prefix_sums([{bucket, count} | rest], acc, sum) do + new_sum = sum + count + new_bucket = {bucket, new_sum} + prefix_sums(rest, [new_bucket | acc], new_sum) + end +end diff --git a/mix.exs b/mix.exs index e1477890c..13d8f8126 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.5", + version: "2.68.6", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/realtime/monitoring/prometheus_test.exs b/test/realtime/monitoring/prometheus_test.exs new file mode 100644 index 000000000..c09a3b2bb --- /dev/null +++ b/test/realtime/monitoring/prometheus_test.exs @@ -0,0 +1,434 @@ +# Based on https://github.com/rkallos/peep/blob/708546ed069aebdf78ac1f581130332bd2e8b5b1/test/prometheus_test.exs +defmodule Realtime.Monitoring.PrometheusTest do + use ExUnit.Case, async: true + + alias Realtime.Monitoring.Prometheus + alias Telemetry.Metrics + + defmodule StorageCounter do + @moduledoc false + use Agent + + def start() do + Agent.start(fn -> 0 end, name: __MODULE__) + end + + def fresh_id() do + Agent.get_and_update(__MODULE__, fn i -> {:"#{i}", i + 1} end) + end + end + + # Test struct that doesn't implement String.Chars + defmodule TestError do + defstruct [:reason, :code] + end + + setup_all do + StorageCounter.start() + :ok + end + + @impls [:default, :striped] + + for impl <- @impls do + test "#{impl} - counter formatting" do + counter = Metrics.counter("prometheus.test.counter", description: "a counter") + name = StorageCounter.fresh_id() + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, counter, 1, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_counter a counter", + "# TYPE prometheus_test_counter counter", + ~s(prometheus_test_counter{baz="quux",foo="bar"} 1) + ] + + assert export(name) == lines_to_string(expected) + end + + describe "#{impl} - sum" do + test "sum formatting" do + name = StorageCounter.fresh_id() + sum = Metrics.sum("prometheus.test.sum", description: "a sum") + + opts = [ + name: name, + metrics: [sum], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"}) + Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_sum a sum", + "# TYPE prometheus_test_sum counter", + ~s(prometheus_test_sum{baz="quux",foo="bar"} 8) + ] + + assert export(name) == lines_to_string(expected) + end + + test "custom type" do + name = StorageCounter.fresh_id() + + sum = + Metrics.sum("prometheus.test.sum", + description: "a sum", + reporter_options: [prometheus_type: "gauge"] + ) + + opts = [ + name: name, + metrics: [sum], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"}) + Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"}) + + expected = [ + "# HELP prometheus_test_sum a sum", + "# TYPE prometheus_test_sum gauge", + ~s(prometheus_test_sum{baz="quux",foo="bar"} 8) + ] + + assert export(name) == lines_to_string(expected) + end + end + + describe "#{impl} - last_value" do + test "formatting" do + name = StorageCounter.fresh_id() + last_value = Metrics.last_value("prometheus.test.gauge", description: "a last_value") + + opts = [ + name: name, + metrics: [last_value], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"}) + + expected = [ + "# HELP prometheus_test_gauge a last_value", + "# TYPE prometheus_test_gauge gauge", + ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5) + ] + + assert export(name) == lines_to_string(expected) + end + + test "custom type" do + name = StorageCounter.fresh_id() + + last_value = + Metrics.last_value("prometheus.test.gauge", + description: "a last_value", + reporter_options: [prometheus_type: :sum] + ) + + opts = [ + name: name, + metrics: [last_value], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"}) + + expected = [ + "# HELP prometheus_test_gauge a last_value", + "# TYPE prometheus_test_gauge sum", + ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5) + ] + + assert export(name) == lines_to_string(expected) + end + end + + test "#{impl} - dist formatting" do + name = StorageCounter.fresh_id() + + dist = + Metrics.distribution("prometheus.test.distribution", + description: "a distribution", + reporter_options: [max_value: 1000] + ) + + opts = [ + name: name, + metrics: [dist], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + expected = [] + assert export(name) == lines_to_string(expected) + + Peep.insert_metric(name, dist, 1, %{glee: :gloo}) + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1), + ~s(prometheus_test_distribution_sum{glee="gloo"} 1), + ~s(prometheus_test_distribution_count{glee="gloo"} 1) + ] + + assert export(name) == lines_to_string(expected) + + for i <- 2..2000 do + Peep.insert_metric(name, dist, i, %{glee: :gloo}) + end + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 2), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 2), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 3), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 4), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 4), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 6), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 7), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 9), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 11), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 13), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 16), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 20), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 24), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 30), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 37), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 45), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 55), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 67), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 82), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 101), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 123), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 150), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 184), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 225), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 275), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 336), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 411), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 503), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 614), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 751), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 918), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1122), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 2000), + ~s(prometheus_test_distribution_sum{glee="gloo"} 2001000), + ~s(prometheus_test_distribution_count{glee="gloo"} 2000) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{impl} - dist formatting pow10" do + name = StorageCounter.fresh_id() + + dist = + Metrics.distribution("prometheus.test.distribution", + description: "a distribution", + reporter_options: [ + max_value: 1000, + peep_bucket_calculator: Peep.Buckets.PowersOfTen + ] + ) + + opts = [ + name: name, + metrics: [dist], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + expected = [] + assert export(name) == lines_to_string(expected) + + Peep.insert_metric(name, dist, 1, %{glee: :gloo}) + + expected = [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 1), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1), + ~s(prometheus_test_distribution_sum{glee="gloo"} 1), + ~s(prometheus_test_distribution_count{glee="gloo"} 1) + ] + + assert export(name) == lines_to_string(expected) + + f = fn -> + for i <- 1..2000 do + Peep.insert_metric(name, dist, i, %{glee: :gloo}) + end + end + + 1..20 |> Enum.map(fn _ -> Task.async(f) end) |> Task.await_many() + + expected = + [ + "# HELP prometheus_test_distribution a distribution", + "# TYPE prometheus_test_distribution histogram", + ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 181), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1981), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 19981), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 40001), + ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 40001), + ~s(prometheus_test_distribution_sum{glee="gloo"} 40020001), + ~s(prometheus_test_distribution_count{glee="gloo"} 40001) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{impl} - regression: label escaping" do + name = StorageCounter.fresh_id() + + counter = + Metrics.counter( + "prometheus.test.counter", + description: "a counter" + ) + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + Peep.insert_metric(name, counter, 1, %{atom: "\"string\""}) + Peep.insert_metric(name, counter, 1, %{"\"string\"" => :atom}) + Peep.insert_metric(name, counter, 1, %{"\"string\"" => "\"string\""}) + Peep.insert_metric(name, counter, 1, %{"string" => "string\n"}) + + expected = [ + "# HELP prometheus_test_counter a counter", + "# TYPE prometheus_test_counter counter", + ~s(prometheus_test_counter{atom="\\\"string\\\""} 1), + ~s(prometheus_test_counter{\"string\"="atom"} 1), + ~s(prometheus_test_counter{\"string\"="\\\"string\\\""} 1), + ~s(prometheus_test_counter{string="string\\n"} 1) + ] + + assert export(name) == lines_to_string(expected) + end + + test "#{impl} - regression: handle structs without String.Chars" do + name = StorageCounter.fresh_id() + + counter = + Metrics.counter( + "prometheus.test.counter", + description: "a counter" + ) + + opts = [ + name: name, + metrics: [counter], + storage: unquote(impl) + ] + + {:ok, _pid} = Peep.start_link(opts) + + # Create a struct that doesn't implement String.Chars + error_struct = %TestError{reason: :tcp_closed, code: 1001} + + Peep.insert_metric(name, counter, 1, %{error: error_struct}) + + result = export(name) + + # Should not crash and should contain the inspected struct representation + assert result =~ "prometheus_test_counter" + assert result =~ "TestError" + assert result =~ "tcp_closed" + end + end + + defp export(name) do + Peep.get_all_metrics(name) + |> Prometheus.export() + |> IO.iodata_to_binary() + end + + defp lines_to_string(lines) do + lines + |> Enum.map(&[&1, ?\n]) + |> Enum.concat(["# EOF\n"]) + |> IO.iodata_to_binary() + end +end From 2249c06995612ff3831a9c00df7eb50958ec7a13 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 18 Dec 2025 13:21:20 +1300 Subject: [PATCH 119/123] fix: jwt secret migration (#1662) --- mix.exs | 2 +- .../migrations/20251204170944_nullable_jwt_secrets.exs | 2 +- .../20251218000543_ensure_jwt_secret_is_text.exs | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs diff --git a/mix.exs b/mix.exs index 13d8f8126..a05a85bb9 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.6", + version: "2.68.7", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs index ad717d322..342a80ad9 100644 --- a/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs +++ b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs @@ -3,7 +3,7 @@ defmodule Realtime.Repo.Migrations.NullableJwtSecrets do def change do alter table(:tenants) do - modify :jwt_secret, :string, null: true + modify :jwt_secret, :text, null: true end create constraint(:tenants, :jwt_secret_or_jwt_jwks_required, diff --git a/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs new file mode 100644 index 000000000..008c9d7db --- /dev/null +++ b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs @@ -0,0 +1,9 @@ +defmodule Realtime.Repo.Migrations.EnsureJwtSecretIsText do + use Ecto.Migration + + def change do + alter table(:tenants) do + modify :jwt_secret, :text, null: true + end + end +end From 05df771e26c448a141ce9dc3be3b67813a05c580 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Fri, 19 Dec 2025 09:18:36 +1300 Subject: [PATCH 120/123] feat: use custom peep partitions (#1663) --- lib/realtime/metrics_cleaner.ex | 2 +- lib/realtime/monitoring/prom_ex.ex | 2 +- mix.exs | 4 ++-- mix.lock | 2 +- test/realtime/monitoring/prometheus_test.exs | 16 ++++++++-------- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/realtime/metrics_cleaner.ex b/lib/realtime/metrics_cleaner.ex index c81205e8e..20fd586d6 100644 --- a/lib/realtime/metrics_cleaner.ex +++ b/lib/realtime/metrics_cleaner.ex @@ -38,7 +38,7 @@ defmodule Realtime.MetricsCleaner do defp loop_and_cleanup_metrics_table do tenant_ids = Realtime.Tenants.Connect.list_tenants() |> MapSet.new() - {_, tid} = Peep.Persistent.storage(Realtime.PromEx.Metrics) + {_, {tid, _}} = Peep.Persistent.storage(Realtime.PromEx.Metrics) tid |> :ets.select(@peep_filter_spec) diff --git a/lib/realtime/monitoring/prom_ex.ex b/lib/realtime/monitoring/prom_ex.ex index b3a970d7d..f04d5ef01 100644 --- a/lib/realtime/monitoring/prom_ex.ex +++ b/lib/realtime/monitoring/prom_ex.ex @@ -82,7 +82,7 @@ defmodule Realtime.PromEx do name: name, metrics: metrics, global_tags: Application.get_env(:realtime, :metrics_tags, %{}), - storage: :default + storage: {:default, 4} ) end end diff --git a/mix.exs b/mix.exs index a05a85bb9..50dfb0b53 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.68.7", + version: "2.69.0", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -74,7 +74,7 @@ defmodule Realtime.MixProject do {:libcluster_postgres, "~> 0.2"}, {:uuid, "~> 1.1"}, {:prom_ex, "~> 1.10"}, - {:peep, "~> 4.0", override: true}, + {:peep, git: "https://github.com/supabase/peep.git", branch: "feat/partitions-ets", override: true}, {:joken, "~> 2.5.0"}, {:ex_json_schema, "~> 0.7"}, {:recon, "~> 2.5"}, diff --git a/mix.lock b/mix.lock index 4bea2e730..b106e9cd7 100644 --- a/mix.lock +++ b/mix.lock @@ -66,7 +66,7 @@ "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, - "peep": {:hex, :peep, "4.2.1", "dbb17e880acbe8e43cdc29b15576d736b613d412c94f9abae4a7a77d89aba98e", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2f966ac06c6a02bb3721e79a5f68fa769070eea60d44d210529c42d54623e97e"}, + "peep": {:git, "https://github.com/supabase/peep.git", "3ba8f8f77f4c8dae734f9d8f603c24c1046502da", [branch: "feat/partitions-ets"]}, "phoenix": {:git, "https://github.com/supabase/phoenix.git", "7b884cc0cc1a49ad2bc272acda2e622b3e11c139", [branch: "feat/presence-custom-dispatcher-1.7.19"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, diff --git a/test/realtime/monitoring/prometheus_test.exs b/test/realtime/monitoring/prometheus_test.exs index c09a3b2bb..ca7563ce0 100644 --- a/test/realtime/monitoring/prometheus_test.exs +++ b/test/realtime/monitoring/prometheus_test.exs @@ -28,10 +28,10 @@ defmodule Realtime.Monitoring.PrometheusTest do :ok end - @impls [:default, :striped] + @impls [:default, {:default, 4}, :striped] for impl <- @impls do - test "#{impl} - counter formatting" do + test "#{inspect(impl)} - counter formatting" do counter = Metrics.counter("prometheus.test.counter", description: "a counter") name = StorageCounter.fresh_id() @@ -54,7 +54,7 @@ defmodule Realtime.Monitoring.PrometheusTest do assert export(name) == lines_to_string(expected) end - describe "#{impl} - sum" do + describe "#{inspect(impl)} - sum" do test "sum formatting" do name = StorageCounter.fresh_id() sum = Metrics.sum("prometheus.test.sum", description: "a sum") @@ -109,7 +109,7 @@ defmodule Realtime.Monitoring.PrometheusTest do end end - describe "#{impl} - last_value" do + describe "#{inspect(impl)} - last_value" do test "formatting" do name = StorageCounter.fresh_id() last_value = Metrics.last_value("prometheus.test.gauge", description: "a last_value") @@ -162,7 +162,7 @@ defmodule Realtime.Monitoring.PrometheusTest do end end - test "#{impl} - dist formatting" do + test "#{inspect(impl)} - dist formatting" do name = StorageCounter.fresh_id() dist = @@ -281,7 +281,7 @@ defmodule Realtime.Monitoring.PrometheusTest do assert export(name) == lines_to_string(expected) end - test "#{impl} - dist formatting pow10" do + test "#{inspect(impl)} - dist formatting pow10" do name = StorageCounter.fresh_id() dist = @@ -354,7 +354,7 @@ defmodule Realtime.Monitoring.PrometheusTest do assert export(name) == lines_to_string(expected) end - test "#{impl} - regression: label escaping" do + test "#{inspect(impl)} - regression: label escaping" do name = StorageCounter.fresh_id() counter = @@ -388,7 +388,7 @@ defmodule Realtime.Monitoring.PrometheusTest do assert export(name) == lines_to_string(expected) end - test "#{impl} - regression: handle structs without String.Chars" do + test "#{inspect(impl)} - regression: handle structs without String.Chars" do name = StorageCounter.fresh_id() counter = From 50176189f0a0824cafe5cf79f9563763514d704c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Mon, 29 Dec 2025 10:48:47 +0000 Subject: [PATCH 121/123] fix: run migrations on tenants nearest region (#1659) --- lib/realtime/nodes.ex | 5 -- lib/realtime/tenants.ex | 15 ++-- lib/realtime/tenants/migrations.ex | 39 ++++++++--- mix.exs | 2 +- .../region_aware_migrations_test.exs | 70 +++++++++++++++++++ test/realtime/nodes_test.exs | 4 +- .../controllers/tenant_controller_test.exs | 2 +- 7 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 test/integration/region_aware_migrations_test.exs diff --git a/lib/realtime/nodes.ex b/lib/realtime/nodes.ex index 9203539b3..e57b9c7e0 100644 --- a/lib/realtime/nodes.ex +++ b/lib/realtime/nodes.ex @@ -94,11 +94,6 @@ defmodule Realtime.Nodes do @spec launch_node(String.t(), String.t() | nil, atom()) :: atom() def launch_node(tenant_id, region, default) do case region_nodes(region) do - [node] -> - Logger.warning("Only one region node (#{inspect(node)}) for #{region} using default #{inspect(default)}") - - default - [] -> Logger.warning("Zero region nodes for #{region} using #{inspect(default)}") default diff --git a/lib/realtime/tenants.ex b/lib/realtime/tenants.ex index 87d19cd65..2cf2dec34 100644 --- a/lib/realtime/tenants.ex +++ b/lib/realtime/tenants.ex @@ -98,13 +98,11 @@ defmodule Realtime.Tenants do connected_cluster when is_integer(connected_cluster) -> tenant = Cache.get_tenant_by_external_id(external_id) - {:ok, db_conn} = Database.connect(tenant, "realtime_health_check") - Process.alive?(db_conn) && GenServer.stop(db_conn) - Migrations.run_migrations(tenant) + result? = Migrations.run_migrations(tenant) {:ok, %{ - healthy: true, + healthy: result? == :ok || result? == :noop, db_connected: false, connected_cluster: connected_cluster, region: region, @@ -475,10 +473,11 @@ defmodule Realtime.Tenants do @doc """ Checks if migrations for a given tenant need to run. """ - @spec run_migrations?(Tenant.t()) :: boolean() - def run_migrations?(%Tenant{} = tenant) do - tenant.migrations_ran < Enum.count(Migrations.migrations()) - end + @spec run_migrations?(Tenant.t() | integer()) :: boolean() + def run_migrations?(%Tenant{} = tenant), do: run_migrations?(tenant.migrations_ran) + + def run_migrations?(migrations_ran) when is_integer(migrations_ran), + do: migrations_ran < Enum.count(Migrations.migrations()) @doc """ Broadcasts an operation event to the tenant's operations channel. diff --git a/lib/realtime/tenants/migrations.ex b/lib/realtime/tenants/migrations.ex index 46ea7864f..47f4ad718 100644 --- a/lib/realtime/tenants/migrations.ex +++ b/lib/realtime/tenants/migrations.ex @@ -11,6 +11,8 @@ defmodule Realtime.Tenants.Migrations do alias Realtime.Repo alias Realtime.Api.Tenant alias Realtime.Api + alias Realtime.Nodes + alias Realtime.GenRpc alias Realtime.Tenants.Migrations.{ CreateRealtimeSubscriptionTable, @@ -148,7 +150,7 @@ defmodule Realtime.Tenants.Migrations do {20_251_103_001_201, BroadcastSendIncludePayloadId} ] - defstruct [:tenant_external_id, :settings] + defstruct [:tenant_external_id, :settings, migrations_ran: 0] @type t :: %__MODULE__{ tenant_external_id: binary(), @@ -160,24 +162,39 @@ defmodule Realtime.Tenants.Migrations do """ @spec run_migrations(Tenant.t()) :: :ok | :noop | {:error, any()} def run_migrations(%Tenant{} = tenant) do - %{extensions: [%{settings: settings} | _]} = tenant - attrs = %__MODULE__{tenant_external_id: tenant.external_id, settings: settings} + if Tenants.run_migrations?(tenant) do + %{extensions: [%{settings: settings} | _]} = tenant - supervisor = - {:via, PartitionSupervisor, {Realtime.Tenants.Migrations.DynamicSupervisor, tenant.external_id}} + attrs = %__MODULE__{ + tenant_external_id: tenant.external_id, + settings: settings, + migrations_ran: tenant.migrations_ran + } - spec = {__MODULE__, attrs} + node = + case Nodes.get_node_for_tenant(tenant) do + {:ok, node, _} -> node + {:error, _} -> node() + end - if Tenants.run_migrations?(tenant) do - case DynamicSupervisor.start_child(supervisor, spec) do - :ignore -> :ok - error -> error - end + GenRpc.call(node, __MODULE__, :start_migration, [attrs], tenant_id: tenant.external_id) else :noop end end + def start_migration(attrs) do + supervisor = + {:via, PartitionSupervisor, {Realtime.Tenants.Migrations.DynamicSupervisor, attrs.tenant_external_id}} + + spec = {__MODULE__, attrs} + + case DynamicSupervisor.start_child(supervisor, spec) do + :ignore -> :ok + error -> error + end + end + def start_link(%__MODULE__{tenant_external_id: tenant_external_id} = attrs) do name = {:via, Registry, {Unique, {__MODULE__, :host, tenant_external_id}}} GenServer.start_link(__MODULE__, attrs, name: name) diff --git a/mix.exs b/mix.exs index 50dfb0b53..7ae856195 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.69.0", + version: "2.69.1", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/region_aware_migrations_test.exs b/test/integration/region_aware_migrations_test.exs new file mode 100644 index 000000000..892ed2382 --- /dev/null +++ b/test/integration/region_aware_migrations_test.exs @@ -0,0 +1,70 @@ +defmodule Realtime.Integration.RegionAwareMigrationsTest do + use Realtime.DataCase, async: false + use Mimic + + alias Containers + alias Realtime.Tenants + alias Realtime.Tenants.Migrations + + setup do + {:ok, port} = Containers.checkout() + + settings = [ + %{ + "type" => "postgres_cdc_rls", + "settings" => %{ + "db_host" => "127.0.0.1", + "db_name" => "postgres", + "db_user" => "supabase_admin", + "db_password" => "postgres", + "db_port" => "#{port}", + "poll_interval" => 100, + "poll_max_changes" => 100, + "poll_max_record_bytes" => 1_048_576, + "region" => "ap-southeast-2", + "publication" => "supabase_realtime_test", + "ssl_enforced" => false + } + } + ] + + tenant = tenant_fixture(%{extensions: settings}) + region = Application.get_env(:realtime, :region) + + {:ok, node} = + Clustered.start(nil, + extra_config: [ + {:realtime, :region, Tenants.region(tenant)}, + {:realtime, :master_region, region} + ] + ) + + Process.sleep(100) + + %{tenant: tenant, node: node} + end + + test "run_migrations routes to node in tenant's region with expected arguments", %{tenant: tenant, node: node} do + assert tenant.migrations_ran == 0 + + Realtime.GenRpc + |> Mimic.expect(:call, fn called_node, mod, func, args, opts -> + assert called_node == node + assert mod == Migrations + assert func == :start_migration + assert opts[:tenant_id] == tenant.external_id + + arg = hd(args) + assert arg.tenant_external_id == tenant.external_id + assert arg.migrations_ran == tenant.migrations_ran + assert arg.settings == hd(tenant.extensions).settings + + call_original(Realtime.GenRpc, :call, [node, mod, func, args, opts]) + end) + + assert :ok = Migrations.run_migrations(tenant) + Process.sleep(1000) + tenant = Realtime.Repo.reload!(tenant) + refute tenant.migrations_ran == 0 + end +end diff --git a/test/realtime/nodes_test.exs b/test/realtime/nodes_test.exs index f768bb89f..b127ed605 100644 --- a/test/realtime/nodes_test.exs +++ b/test/realtime/nodes_test.exs @@ -108,7 +108,7 @@ defmodule Realtime.NodesTest do assert region == expected_region end - test "on existing tenant id, and a single node for a given region, returns default", %{ + test "on existing tenant id, and a single node for a given region, returns single node", %{ tenant: tenant, region: region } do @@ -117,7 +117,7 @@ defmodule Realtime.NodesTest do expected_region = Tenants.region(tenant) - assert node == node() + assert node != node() assert region == expected_region end diff --git a/test/realtime_web/controllers/tenant_controller_test.exs b/test/realtime_web/controllers/tenant_controller_test.exs index 86186245b..95c7ab762 100644 --- a/test/realtime_web/controllers/tenant_controller_test.exs +++ b/test/realtime_web/controllers/tenant_controller_test.exs @@ -419,7 +419,7 @@ defmodule RealtimeWeb.TenantControllerTest do conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health") data = json_response(conn, 200)["data"] - Process.sleep(2000) + Process.sleep(1000) assert {:ok, %{rows: []}} = Postgrex.query(db_conn, "SELECT * FROM realtime.messages", []) From bb1c2b6fb77fb8051429a6e791b9d586c96e6d74 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Mon, 5 Jan 2026 11:28:33 +1300 Subject: [PATCH 122/123] fix: fix tenant cache handling (#1665) --- lib/realtime/api.ex | 24 ++++-- lib/realtime/context_cache.ex | 21 ----- lib/realtime/tenants/cache.ex | 49 +++++++----- .../controllers/tenant_controller.ex | 2 +- mix.exs | 2 +- test/integration/rt_channel_test.exs | 8 +- test/realtime/api_test.exs | 29 +++++-- test/realtime/tenants/authorization_test.exs | 4 +- .../realtime/tenants/batch_broadcast_test.exs | 2 +- test/realtime/tenants/cache_test.exs | 77 +++++++++++++++---- .../tenants/connect/register_process_test.exs | 2 +- .../tenants/janitor/maintenance_task_test.exs | 4 +- test/realtime/tenants/rebalancer_test.exs | 2 +- .../broadcast_handler_test.exs | 2 +- .../presence_handler_test.exs | 2 +- .../channels/realtime_channel_test.exs | 4 +- .../controllers/broadcast_controller_test.exs | 2 +- 17 files changed, 150 insertions(+), 86 deletions(-) delete mode 100644 lib/realtime/context_cache.ex diff --git a/lib/realtime/api.ex b/lib/realtime/api.ex index 79850d0f5..22c64f34d 100644 --- a/lib/realtime/api.ex +++ b/lib/realtime/api.ex @@ -119,6 +119,14 @@ defmodule Realtime.Api do %Tenant{} |> Tenant.changeset(attrs) |> Repo.insert() + |> case do + {:ok, tenant} -> + Cache.global_cache_update(tenant) + {:ok, tenant} + + error -> + error + end else call(:create_tenant, [attrs], tenant_id) end @@ -144,7 +152,7 @@ defmodule Realtime.Api do case updated do {:ok, tenant} -> - maybe_invalidate_cache(changeset) + maybe_update_cache(tenant, changeset) maybe_trigger_disconnect(changeset) maybe_restart_db_connection(changeset) Logger.debug("Tenant updated: #{inspect(tenant, pretty: true)}") @@ -216,7 +224,12 @@ defmodule Realtime.Api do tenant |> Tenant.changeset(%{migrations_ran: count}) |> Repo.update() - |> tap(fn _ -> Cache.distributed_invalidate_tenant_cache(external_id) end) + |> tap(fn result -> + case result do + {:ok, tenant} -> Cache.global_cache_update(tenant) + _ -> :ok + end + end) else call(:update_migrations_ran, [external_id, count], external_id) end @@ -241,12 +254,11 @@ defmodule Realtime.Api do |> Map.put(:events_per_second_now, current) end - defp maybe_invalidate_cache(%Changeset{changes: changes, valid?: true, data: %{external_id: external_id}}) - when changes != %{} do - Tenants.Cache.distributed_invalidate_tenant_cache(external_id) + defp maybe_update_cache(tenant, %Changeset{changes: changes, valid?: true}) when changes != %{} do + Tenants.Cache.global_cache_update(tenant) end - defp maybe_invalidate_cache(_changeset), do: nil + defp maybe_update_cache(_tenant, _changeset), do: :ok defp maybe_trigger_disconnect(%Changeset{data: %{external_id: external_id}} = changeset) when requires_disconnect(changeset) do diff --git a/lib/realtime/context_cache.ex b/lib/realtime/context_cache.ex deleted file mode 100644 index afacf4ce1..000000000 --- a/lib/realtime/context_cache.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Realtime.ContextCache do - @moduledoc """ - Read through cache for hot database paths. - """ - - require Logger - - def apply_fun(context, {fun, arity}, args) do - cache = cache_name(context) - cache_key = {{fun, arity}, args} - - case Cachex.fetch(cache, cache_key, fn {{_fun, _arity}, args} -> {:commit, {:cached, apply(context, fun, args)}} end) do - {:commit, {:cached, value}} -> value - {:ok, {:cached, value}} -> value - end - end - - defp cache_name(context) do - Module.concat(context, Cache) - end -end diff --git a/lib/realtime/tenants/cache.ex b/lib/realtime/tenants/cache.ex index aead951a3..cc02e0538 100644 --- a/lib/realtime/tenants/cache.ex +++ b/lib/realtime/tenants/cache.ex @@ -5,6 +5,7 @@ defmodule Realtime.Tenants.Cache do require Cachex.Spec require Logger + alias Realtime.GenRpc alias Realtime.Tenants def child_spec(_) do @@ -16,32 +17,42 @@ defmodule Realtime.Tenants.Cache do } end - def get_tenant_by_external_id(keyword), do: apply_repo_fun(__ENV__.function, [keyword]) + def get_tenant_by_external_id(tenant_id) do + case Cachex.fetch(__MODULE__, cache_key(tenant_id), fn _key -> + case Tenants.get_tenant_by_external_id(tenant_id) do + nil -> {:ignore, nil} + tenant -> {:commit, tenant} + end + end) do + {:commit, value} -> value + {:ok, value} -> value + {:ignore, value} -> value + end + end + + defp cache_key(tenant_id), do: {:get_tenant_by_external_id, tenant_id} @doc """ Invalidates the cache for a tenant in the local node """ - def invalidate_tenant_cache(tenant_id), do: Cachex.del(__MODULE__, {{:get_tenant_by_external_id, 1}, [tenant_id]}) + def invalidate_tenant_cache(tenant_id), do: Cachex.del(__MODULE__, cache_key(tenant_id)) + + def distributed_invalidate_tenant_cache(tenant_id) when is_binary(tenant_id) do + GenRpc.multicast(__MODULE__, :invalidate_tenant_cache, [tenant_id]) + end @doc """ - Broadcasts a message to invalidate the tenant cache to all connected nodes + Update the cache for a tenant """ - @spec distributed_invalidate_tenant_cache(String.t()) :: boolean() - def distributed_invalidate_tenant_cache(tenant_id) when is_binary(tenant_id) do - nodes = [Node.self() | Node.list()] - results = :erpc.multicall(nodes, __MODULE__, :invalidate_tenant_cache, [tenant_id], 1000) - - results - |> Enum.map(fn - {res, _} -> - res - - exception -> - Logger.error("Failed to invalidate tenant cache: #{inspect(exception)}") - :error - end) - |> Enum.all?(&(&1 == :ok)) + def update_cache(tenant) do + Cachex.put(__MODULE__, cache_key(tenant.external_id), tenant) end - defp apply_repo_fun(arg1, arg2), do: Realtime.ContextCache.apply_fun(Tenants, arg1, arg2) + @doc """ + Update the cache for a tenant in all nodes + """ + @spec global_cache_update(Realtime.Api.Tenant.t()) :: :ok + def global_cache_update(tenant) do + GenRpc.multicast(__MODULE__, :update_cache, [tenant]) + end end diff --git a/lib/realtime_web/controllers/tenant_controller.ex b/lib/realtime_web/controllers/tenant_controller.ex index 2b8474800..5444eee69 100644 --- a/lib/realtime_web/controllers/tenant_controller.ex +++ b/lib/realtime_web/controllers/tenant_controller.ex @@ -195,7 +195,7 @@ defmodule RealtimeWeb.TenantController do with %Tenant{} = tenant <- Api.get_tenant_by_external_id(tenant_id, use_replica: false), _ <- Tenants.suspend_tenant_by_external_id(tenant_id), true <- Api.delete_tenant_by_external_id(tenant_id), - true <- Cache.distributed_invalidate_tenant_cache(tenant_id), + :ok <- Cache.distributed_invalidate_tenant_cache(tenant_id), :ok <- PostgresCdc.stop_all(tenant, stop_all_timeout), :ok <- Database.replication_slot_teardown(tenant) do send_resp(conn, 204, "") diff --git a/mix.exs b/mix.exs index 7ae856195..baff36637 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.69.1", + version: "2.69.2", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs index 5cfcaaf0b..c4160e4e4 100644 --- a/test/integration/rt_channel_test.exs +++ b/test/integration/rt_channel_test.exs @@ -419,11 +419,7 @@ defmodule Realtime.Integration.RtChannelTest do test "broadcast to another tenant does not get mixed up", %{tenant: tenant, serializer: serializer} do other_tenant = Containers.checkout_tenant(run_migrations: true) - Cachex.put!( - Realtime.Tenants.Cache, - {{:get_tenant_by_external_id, 1}, [other_tenant.external_id]}, - {:cached, other_tenant} - ) + Realtime.Tenants.Cache.update_cache(other_tenant) {socket, _} = get_connection(tenant, serializer) config = %{broadcast: %{self: false}, private: false} @@ -2406,7 +2402,7 @@ defmodule Realtime.Integration.RtChannelTest do |> Realtime.Api.Tenant.changeset(%{limit => value}) |> Realtime.Repo.update!() - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) end defp assert_process_down(pid, timeout \\ 1000) do diff --git a/test/realtime/api_test.exs b/test/realtime/api_test.exs index e3a78dd27..06d554110 100644 --- a/test/realtime/api_test.exs +++ b/test/realtime/api_test.exs @@ -55,6 +55,10 @@ defmodule Realtime.ApiTest do external_id = random_string() + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.external_id == external_id + end) + valid_attrs = %{ external_id: external_id, name: external_id, @@ -89,6 +93,7 @@ defmodule Realtime.ApiTest do end test "invalid data returns error changeset" do + reject(&Realtime.Tenants.Cache.global_cache_update/1) assert {:error, %Ecto.Changeset{}} = Api.create_tenant(%{external_id: nil, jwt_secret: nil, name: nil}) end end @@ -197,10 +202,14 @@ defmodule Realtime.ApiTest do test "valid data and tenant data change will not restart the database connection" do tenant = Containers.checkout_tenant(run_migrations: true) - expect(Realtime.Tenants.Cache, :distributed_invalidate_tenant_cache, fn _ -> :ok end) + + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.max_concurrent_users == 101 + end) + {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id) - assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 100}) + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 101}) refute_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500 assert Process.alive?(old_pid) assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id) @@ -241,12 +250,15 @@ defmodule Realtime.ApiTest do end test "valid data and change to tenant data will refresh cache", %{tenants: [tenant | _]} do + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.name == "new_name" + end) + assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: "new_name"}) - assert %Tenant{name: "new_name"} = Realtime.Tenants.Cache.get_tenant_by_external_id(tenant.external_id) end test "valid data and no changes to tenant will not refresh cache", %{tenants: [tenant | _]} do - reject(&Realtime.Tenants.Cache.distributed_invalidate_tenant_cache/1) + reject(&Realtime.Tenants.Cache.global_cache_update/1) assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: tenant.name}) end end @@ -367,8 +379,13 @@ defmodule Realtime.ApiTest do describe "update_migrations_ran/1" do test "updates migrations_ran to the count of all migrations" do tenant = tenant_fixture(%{migrations_ran: 0}) - Api.update_migrations_ran(tenant.external_id, 1) - tenant = Repo.reload!(tenant) + + expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant -> + assert tenant.migrations_ran == 1 + :ok + end) + + assert {:ok, tenant} = Api.update_migrations_ran(tenant.external_id, 1) assert tenant.migrations_ran == 1 end end diff --git a/test/realtime/tenants/authorization_test.exs b/test/realtime/tenants/authorization_test.exs index 8b57135ef..10c9c0e09 100644 --- a/test/realtime/tenants/authorization_test.exs +++ b/test/realtime/tenants/authorization_test.exs @@ -282,7 +282,7 @@ defmodule Realtime.Tenants.AuthorizationTest do def rls_context(context) do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop) topic = context[:topic] || random_string() @@ -323,6 +323,6 @@ defmodule Realtime.Tenants.AuthorizationTest do {:ok, tenant} = Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) end end diff --git a/test/realtime/tenants/batch_broadcast_test.exs b/test/realtime/tenants/batch_broadcast_test.exs index 2071aac64..f5fa42764 100644 --- a/test/realtime/tenants/batch_broadcast_test.exs +++ b/test/realtime/tenants/batch_broadcast_test.exs @@ -16,7 +16,7 @@ defmodule Realtime.Tenants.BatchBroadcastTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, tenant: tenant} end diff --git a/test/realtime/tenants/cache_test.exs b/test/realtime/tenants/cache_test.exs index f9e0b288b..46577b802 100644 --- a/test/realtime/tenants/cache_test.exs +++ b/test/realtime/tenants/cache_test.exs @@ -21,6 +21,12 @@ defmodule Realtime.Tenants.CacheTest do assert %Api.Tenant{name: "new name"} = Tenants.get_tenant_by_external_id(external_id) assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id) end + + test "does not cache when tenant is not found" do + assert Cache.get_tenant_by_external_id("not found") == nil + + assert Cachex.exists?(Cache, {:get_tenant_by_external_id, "not found"}) == {:ok, false} + end end describe "invalidate_tenant_cache/1" do @@ -40,6 +46,18 @@ defmodule Realtime.Tenants.CacheTest do end end + describe "update_cache/1" do + test "updates the cache given a tenant", %{tenant: tenant} do + external_id = tenant.external_id + assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id) + # Update a tenant + updated_tenant = %{tenant | name: "updated name"} + # Update cache + Cache.update_cache(updated_tenant) + assert %Api.Tenant{name: "updated name"} = Cache.get_tenant_by_external_id(external_id) + end + end + describe "distributed_invalidate_tenant_cache/1" do setup do {:ok, node} = Clustered.start() @@ -53,25 +71,21 @@ defmodule Realtime.Tenants.CacheTest do dummy_name = random_string() # Ensure cache has the values - Cachex.put!( - Realtime.Tenants.Cache, - {{:get_tenant_by_external_id, 1}, [external_id]}, - {:cached, %{tenant | name: dummy_name}} - ) - - Rpc.enhanced_call(node, Cachex, :put!, [ - Realtime.Tenants.Cache, - {{:get_tenant_by_external_id, 1}, [external_id]}, - {:cached, %{tenant | name: dummy_name}} - ]) + Realtime.Tenants.Cache.update_cache(%{tenant | name: dummy_name}) + + Rpc.enhanced_call(node, Realtime.Tenants.Cache, :update_cache, [%{tenant | name: dummy_name}]) # Cache showing old value - assert %Api.Tenant{name: ^dummy_name} = Cache.get_tenant_by_external_id(external_id) - assert %Api.Tenant{name: ^dummy_name} = Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) + assert {:ok, %Api.Tenant{name: ^dummy_name}} = Cachex.get(Cache, {:get_tenant_by_external_id, external_id}) + + assert {:ok, %Api.Tenant{name: ^dummy_name}} = + Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}]) # Invalidate cache - assert true = Cache.distributed_invalidate_tenant_cache(external_id) + assert :ok = Cache.distributed_invalidate_tenant_cache(external_id) + # wait for cache to be invalidated in both nodes + Process.sleep(200) # Cache showing new value assert %Api.Tenant{name: ^expected_name} = Cache.get_tenant_by_external_id(external_id) @@ -79,4 +93,39 @@ defmodule Realtime.Tenants.CacheTest do Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) end end + + describe "global_cache_update/1" do + setup do + {:ok, node} = Clustered.start() + %{node: node} + end + + test "update the cache given a tenant_id", %{node: node} do + external_id = "dev_tenant" + %Api.Tenant{name: expected_name} = tenant = Tenants.get_tenant_by_external_id(external_id) + + dummy_name = random_string() + + # Ensure cache has the values + Realtime.Tenants.Cache.update_cache(%{tenant | name: dummy_name}) + + Rpc.enhanced_call(node, Cache, :update_cache, [%{tenant | name: dummy_name}]) + + # Cache showing old value + assert %Api.Tenant{name: ^dummy_name} = Cache.get_tenant_by_external_id(external_id) + assert %Api.Tenant{name: ^dummy_name} = Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id]) + + # Update cache + assert :ok = Cache.global_cache_update(tenant) + + # wait for cache to be updated in both nodes + Process.sleep(200) + + # Cache showing new value + assert {:ok, %Api.Tenant{name: ^expected_name}} = Cachex.get(Cache, {:get_tenant_by_external_id, external_id}) + + assert {:ok, %Api.Tenant{name: ^expected_name}} = + Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}]) + end + end end diff --git a/test/realtime/tenants/connect/register_process_test.exs b/test/realtime/tenants/connect/register_process_test.exs index d4227996f..02cc33391 100644 --- a/test/realtime/tenants/connect/register_process_test.exs +++ b/test/realtime/tenants/connect/register_process_test.exs @@ -7,7 +7,7 @@ defmodule Realtime.Tenants.Connect.RegisterProcessTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, conn} = Database.connect(tenant, "realtime_test") %{tenant_id: tenant.external_id, db_conn_pid: conn} end diff --git a/test/realtime/tenants/janitor/maintenance_task_test.exs b/test/realtime/tenants/janitor/maintenance_task_test.exs index 12f1f06ca..5d4aea474 100644 --- a/test/realtime/tenants/janitor/maintenance_task_test.exs +++ b/test/realtime/tenants/janitor/maintenance_task_test.exs @@ -9,7 +9,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) %{tenant: tenant} end @@ -68,7 +68,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do tenant = tenant_fixture(%{extensions: extensions}) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) Process.flag(:trap_exit, true) diff --git a/test/realtime/tenants/rebalancer_test.exs b/test/realtime/tenants/rebalancer_test.exs index ac8e1ea36..d91e7e675 100644 --- a/test/realtime/tenants/rebalancer_test.exs +++ b/test/realtime/tenants/rebalancer_test.exs @@ -9,7 +9,7 @@ defmodule Realtime.Tenants.RebalancerTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) %{tenant: tenant} end diff --git a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs index d8f7cf829..b2aa9b90e 100644 --- a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs @@ -445,7 +445,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) rate = Tenants.events_per_second_rate(tenant) RateCounter.new(rate, tick: 100) diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs index 7a621ca7d..1ef635838 100644 --- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs +++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs @@ -553,7 +553,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do defp initiate_tenant(context) do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id) assert Connect.ready?(tenant.external_id) diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs index d1530c5d3..c92e8779a 100644 --- a/test/realtime_web/channels/realtime_channel_test.exs +++ b/test/realtime_web/channels/realtime_channel_test.exs @@ -22,7 +22,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do setup do tenant = Containers.checkout_tenant(run_migrations: true) - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, tenant: tenant} end @@ -1263,7 +1263,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do ] with {:ok, tenant} <- Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) do - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) {:ok, tenant} end end diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs index edc18b04f..73ab4148e 100644 --- a/test/realtime_web/controllers/broadcast_controller_test.exs +++ b/test/realtime_web/controllers/broadcast_controller_test.exs @@ -18,7 +18,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do setup %{conn: conn} do tenant = Containers.checkout_tenant(run_migrations: true) # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues - Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant}) + Realtime.Tenants.Cache.update_cache(tenant) conn = generate_conn(conn, tenant) From bdadcecf944525919f749aa06775658c3fefc563 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Thu, 15 Jan 2026 20:51:22 +1300 Subject: [PATCH 123/123] feat: beacon (#1664) Create new process group library called Beacon that broadcast only group counts to other nodes. Actual pids are only available to the local node. It also supports custom adapter so that we can use PubSub for the broadcasting (including regional broadcasting). --- .github/workflows/beacon_tests.yml | 46 +++ Dockerfile | 1 + beacon/.formatter.exs | 4 + beacon/.gitignore | 23 ++ beacon/README.md | 60 ++++ beacon/config/config.exs | 4 + beacon/lib/beacon.ex | 153 ++++++++ beacon/lib/beacon/adapter.ex | 17 + beacon/lib/beacon/adapter/erl_dist.ex | 30 ++ beacon/lib/beacon/partition.ex | 147 ++++++++ beacon/lib/beacon/scope.ex | 208 +++++++++++ beacon/lib/beacon/supervisor.ex | 61 ++++ beacon/mix.exs | 34 ++ beacon/mix.lock | 5 + beacon/test/beacon/partition_test.exs | 185 ++++++++++ beacon/test/beacon_test.exs | 469 +++++++++++++++++++++++++ beacon/test/support/peer.ex | 89 +++++ beacon/test/test_helper.exs | 3 + lib/realtime/application.ex | 11 + lib/realtime/beacon_pub_sub_adapter.ex | 33 ++ lib/realtime/user_counter.ex | 15 +- mix.exs | 3 +- test/realtime/gen_rpc_pub_sub_test.exs | 2 +- test/realtime/user_counter_test.exs | 127 ++++--- 24 files changed, 1681 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/beacon_tests.yml create mode 100644 beacon/.formatter.exs create mode 100644 beacon/.gitignore create mode 100644 beacon/README.md create mode 100644 beacon/config/config.exs create mode 100644 beacon/lib/beacon.ex create mode 100644 beacon/lib/beacon/adapter.ex create mode 100644 beacon/lib/beacon/adapter/erl_dist.ex create mode 100644 beacon/lib/beacon/partition.ex create mode 100644 beacon/lib/beacon/scope.ex create mode 100644 beacon/lib/beacon/supervisor.ex create mode 100644 beacon/mix.exs create mode 100644 beacon/mix.lock create mode 100644 beacon/test/beacon/partition_test.exs create mode 100644 beacon/test/beacon_test.exs create mode 100644 beacon/test/support/peer.ex create mode 100644 beacon/test/test_helper.exs create mode 100644 lib/realtime/beacon_pub_sub_adapter.ex diff --git a/.github/workflows/beacon_tests.yml b/.github/workflows/beacon_tests.yml new file mode 100644 index 000000000..bf2f8fae8 --- /dev/null +++ b/.github/workflows/beacon_tests.yml @@ -0,0 +1,46 @@ +name: Beacon Tests +defaults: + run: + shell: bash + working-directory: ./beacon +on: + pull_request: + paths: + - "beacon/**" + + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + MIX_ENV: test + +jobs: + tests: + name: Tests & Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup elixir + id: beam + uses: erlef/setup-beam@v1 + with: + otp-version: 27.x # Define the OTP version [required] + elixir-version: 1.18.x # Define the elixir version [required] + - name: Install dependencies + run: mix deps.get + - name: Start epmd + run: epmd -daemon + - name: Run tests + run: MIX_ENV=test mix test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Check for warnings + run: mix compile --force --warnings-as-errors + - name: Run format check + run: mix format --check-formatted diff --git a/Dockerfile b/Dockerfile index 547fc5709..6eb90206b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN mix local.hex --force && \ # install mix dependencies COPY mix.exs mix.lock ./ +COPY beacon beacon RUN mix deps.get --only $MIX_ENV RUN mkdir config diff --git a/beacon/.formatter.exs b/beacon/.formatter.exs new file mode 100644 index 000000000..d2cda26ed --- /dev/null +++ b/beacon/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/beacon/.gitignore b/beacon/.gitignore new file mode 100644 index 000000000..65fb2f5eb --- /dev/null +++ b/beacon/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +beacon-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/beacon/README.md b/beacon/README.md new file mode 100644 index 000000000..b89093b1e --- /dev/null +++ b/beacon/README.md @@ -0,0 +1,60 @@ +# Beacon + +Beacon is a scalable process group manager. The main use case for this library is to have membership counts available on the cluster without spamming whenever a process joins or leaves a group. A node can have thousands of processes joining and leaving hundreds of groups while sending just the membership count to other nodes. + +The main features are: + +* Process pids are available only to the node the where the processes reside; +* Groups are partitioned locally to allow greater concurrency while joining different groups; +* Group counts are periodically broadcasted (defaults to every 5 seconds) to update group membership numbers to all participating nodes; +* Sub-cluster nodes join by using same scope; + +## Installation + +The package can be installed by adding `beacon` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:beacon, "~> 1.0"} + ] +end +``` + +## Using + +Add Beacon to your application's supervision tree specifying a scope name (here it's `:users`) + +```elixir +def start(_type, _args) do + children = + [ + {Beacon, :users}, + # Or passing options: + # {Beacon, [:users, opts]} + # See Beacon.start_link/2 for the options +``` + +Now process can join groups + +```elixir +iex> pid = self() +#PID<0.852.0> +iex> Beacon.join(:users, {:tenant, 123}, pid) +:ok +iex> Beacon.local_member_count(:users, {:tenant, 123}) +1 +iex> Beacon.local_members(:users, {:tenant, 123}) +[#PID<0.852.0>] +iex> Beacon.local_member?(:users, {:tenant, 123}, pid) +true +``` + +From another node part of the same scope: + +```elixir +iex> Beacon.member_counts(:users) +%{{:tenant, 123} => 1} +iex> Beacon.member_count(:users, {:tenant, 123}) +1 +``` diff --git a/beacon/config/config.exs b/beacon/config/config.exs new file mode 100644 index 000000000..e17c52707 --- /dev/null +++ b/beacon/config/config.exs @@ -0,0 +1,4 @@ +import Config + +# Print nothing during tests unless captured or a test failure happens +config :logger, backends: [], level: :debug diff --git a/beacon/lib/beacon.ex b/beacon/lib/beacon.ex new file mode 100644 index 000000000..ba8e7987c --- /dev/null +++ b/beacon/lib/beacon.ex @@ -0,0 +1,153 @@ +defmodule Beacon do + @moduledoc """ + Distributed process group membership tracking. + """ + + alias Beacon.Partition + alias Beacon.Scope + + @type group :: any + @type start_option :: + {:partitions, pos_integer()} | {:broadcast_interval_in_ms, non_neg_integer()} + + @doc "Returns a supervisor child specification for a Beacon scope" + def child_spec([scope]) when is_atom(scope), do: child_spec([scope, []]) + def child_spec(scope) when is_atom(scope), do: child_spec([scope, []]) + + def child_spec([scope, opts]) when is_atom(scope) and is_list(opts) do + %{ + id: Beacon, + start: {__MODULE__, :start_link, [scope, opts]}, + type: :supervisor + } + end + + @doc """ + Starts the Beacon supervision tree for `scope`. + + Options: + + * `:partitions` - number of partitions to use (default: number of schedulers online) + * `:broadcast_interval_in_ms`: - interval in milliseconds to broadcast membership counts to other nodes (default: 5000 ms) + * `:message_module` - module implementing `Beacon.Adapter` behaviour (default: `Beacon.Adapter.ErlDist`) + """ + @spec start_link(atom, [start_option]) :: Supervisor.on_start() + def start_link(scope, opts \\ []) when is_atom(scope) do + {partitions, opts} = Keyword.pop(opts, :partitions, System.schedulers_online()) + broadcast_interval_in_ms = Keyword.get(opts, :broadcast_interval_in_ms) + + if not (is_integer(partitions) and partitions >= 1) do + raise ArgumentError, + "expected :partitions to be a positive integer, got: #{inspect(partitions)}" + end + + if broadcast_interval_in_ms != nil and + not (is_integer(broadcast_interval_in_ms) and broadcast_interval_in_ms > 0) do + raise ArgumentError, + "expected :broadcast_interval_in_ms to be a positive integer, got: #{inspect(broadcast_interval_in_ms)}" + end + + Beacon.Supervisor.start_link(scope, partitions, opts) + end + + @doc "Join pid to group in scope" + @spec join(atom, any, pid) :: :ok | {:error, :not_local} + def join(_scope, _group, pid) when is_pid(pid) and node(pid) != node(), do: {:error, :not_local} + + def join(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.join(Beacon.Supervisor.partition(scope, group), group, pid) + end + + @doc "Leave pid from group in scope" + @spec leave(atom, group, pid) :: :ok + def leave(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.leave(Beacon.Supervisor.partition(scope, group), group, pid) + end + + @doc "Get total members count per group in scope" + @spec member_counts(atom) :: %{group => non_neg_integer} + def member_counts(scope) when is_atom(scope) do + remote_counts = Scope.member_counts(scope) + + scope + |> local_member_counts() + |> Map.merge(remote_counts, fn _k, v1, v2 -> v1 + v2 end) + end + + @doc "Get total member count of group in scope" + @spec member_count(atom, group) :: non_neg_integer + def member_count(scope, group) do + local_member_count(scope, group) + Scope.member_count(scope, group) + end + + @doc "Get total member count of group in scope on specific node" + @spec member_count(atom, group, node) :: non_neg_integer + def member_count(scope, group, node) when node == node(), do: local_member_count(scope, group) + def member_count(scope, group, node), do: Scope.member_count(scope, group, node) + + @doc "Get local members of group in scope" + @spec local_members(atom, group) :: [pid] + def local_members(scope, group) when is_atom(scope) do + Partition.members(Beacon.Supervisor.partition(scope, group), group) + end + + @doc "Get local member count of group in scope" + @spec local_member_count(atom, group) :: non_neg_integer + def local_member_count(scope, group) when is_atom(scope) do + Partition.member_count(Beacon.Supervisor.partition(scope, group), group) + end + + @doc "Get local members count per group in scope" + @spec local_member_counts(atom) :: %{group => non_neg_integer} + def local_member_counts(scope) when is_atom(scope) do + Enum.reduce(Beacon.Supervisor.partitions(scope), %{}, fn partition_name, acc -> + Map.merge(acc, Partition.member_counts(partition_name)) + end) + end + + @doc "Check if pid is a local member of group in scope" + @spec local_member?(atom, group, pid) :: boolean + def local_member?(scope, group, pid) when is_atom(scope) and is_pid(pid) do + Partition.member?(Beacon.Supervisor.partition(scope, group), group, pid) + end + + @doc "Get all local groups in scope" + @spec local_groups(atom) :: [group] + def local_groups(scope) when is_atom(scope) do + Enum.flat_map(Beacon.Supervisor.partitions(scope), fn partition_name -> + Partition.groups(partition_name) + end) + end + + @doc "Get local group count in scope" + @spec local_group_count(atom) :: non_neg_integer + def local_group_count(scope) when is_atom(scope) do + Enum.sum_by(Beacon.Supervisor.partitions(scope), fn partition_name -> + Partition.group_count(partition_name) + end) + end + + @doc "Get groups in scope" + @spec groups(atom) :: [group] + def groups(scope) when is_atom(scope) do + remote_groups = Scope.groups(scope) + + scope + |> local_groups() + |> MapSet.new() + |> MapSet.union(remote_groups) + |> MapSet.to_list() + end + + @doc "Get group count in scope" + @spec group_count(atom) :: non_neg_integer + def group_count(scope) when is_atom(scope) do + remote_groups = Scope.groups(scope) + + scope + |> local_groups() + |> MapSet.new() + |> MapSet.union(remote_groups) + |> MapSet.size() + end +end diff --git a/beacon/lib/beacon/adapter.ex b/beacon/lib/beacon/adapter.ex new file mode 100644 index 000000000..cc3fb6abf --- /dev/null +++ b/beacon/lib/beacon/adapter.ex @@ -0,0 +1,17 @@ +defmodule Beacon.Adapter do + @moduledoc """ + Behaviour module for Beacon messaging adapters. + """ + + @doc "Register the current process to receive messages for the given scope" + @callback register(scope :: atom) :: :ok + + @doc "Broadcast a message to all nodes in the given scope" + @callback broadcast(scope :: atom, message :: term) :: any + + @doc "Broadcast a message to specific nodes in the given scope" + @callback broadcast(scope :: atom, [node], message :: term) :: any + + @doc "Send a message to a specific node in the given scope" + @callback send(scope :: atom, node, message :: term) :: any +end diff --git a/beacon/lib/beacon/adapter/erl_dist.ex b/beacon/lib/beacon/adapter/erl_dist.ex new file mode 100644 index 000000000..4f3c2b55a --- /dev/null +++ b/beacon/lib/beacon/adapter/erl_dist.ex @@ -0,0 +1,30 @@ +defmodule Beacon.Adapter.ErlDist do + @moduledoc false + + import Kernel, except: [send: 2] + + @behaviour Beacon.Adapter + + @impl true + def register(scope) do + Process.register(self(), Beacon.Supervisor.name(scope)) + :ok + end + + @impl true + def broadcast(scope, message) do + name = Beacon.Supervisor.name(scope) + Enum.each(Node.list(), fn node -> :erlang.send({name, node}, message, [:noconnect]) end) + end + + @impl true + def broadcast(scope, nodes, message) do + name = Beacon.Supervisor.name(scope) + Enum.each(nodes, fn node -> :erlang.send({name, node}, message, [:noconnect]) end) + end + + @impl true + def send(scope, node, message) do + :erlang.send({Beacon.Supervisor.name(scope), node}, message, [:noconnect]) + end +end diff --git a/beacon/lib/beacon/partition.ex b/beacon/lib/beacon/partition.ex new file mode 100644 index 000000000..e494562bc --- /dev/null +++ b/beacon/lib/beacon/partition.ex @@ -0,0 +1,147 @@ +defmodule Beacon.Partition do + @moduledoc false + + use GenServer + require Logger + + defmodule State do + @moduledoc false + @type t :: %__MODULE__{ + name: atom, + scope: atom, + entries_table: atom, + monitors: %{{Beacon.group(), pid} => reference} + } + defstruct [:name, :scope, :entries_table, monitors: %{}] + end + + @spec join(atom, Beacon.group(), pid) :: :ok + def join(partition_name, group, pid), do: GenServer.call(partition_name, {:join, group, pid}) + + @spec leave(atom, Beacon.group(), pid) :: :ok + def leave(partition_name, group, pid), do: GenServer.call(partition_name, {:leave, group, pid}) + + @spec members(atom, Beacon.group()) :: [pid] + def members(partition_name, group) do + partition_name + |> Beacon.Supervisor.partition_entries_table() + |> :ets.select([{{{group, :"$1"}}, [], [:"$1"]}]) + end + + @spec member_count(atom, Beacon.group()) :: non_neg_integer + def member_count(partition_name, group), do: :ets.lookup_element(partition_name, group, 2, 0) + + @spec member_counts(atom) :: %{Beacon.group() => non_neg_integer} + def member_counts(partition_name) do + partition_name + |> :ets.tab2list() + |> Map.new() + end + + @spec member?(atom, Beacon.group(), pid) :: boolean + def member?(partition_name, group, pid) do + partition_name + |> Beacon.Supervisor.partition_entries_table() + |> :ets.lookup({group, pid}) + |> case do + [{{^group, ^pid}}] -> true + [] -> false + end + end + + @spec groups(atom) :: [Beacon.group()] + def groups(partition_name), do: :ets.select(partition_name, [{{:"$1", :_}, [], [:"$1"]}]) + + @spec group_count(atom) :: non_neg_integer + def group_count(partition_name), do: :ets.info(partition_name, :size) + + @spec start_link(atom, atom, atom) :: GenServer.on_start() + def start_link(scope, partition_name, partition_entries_table), + do: + GenServer.start_link(__MODULE__, [scope, partition_name, partition_entries_table], + name: partition_name + ) + + @impl true + @spec init(any) :: {:ok, State.t()} + def init([scope, name, entries_table]) do + {:ok, %State{scope: scope, name: name, entries_table: entries_table}, + {:continue, :rebuild_monitors_and_counters}} + end + + @impl true + @spec handle_continue(:rebuild_monitors_and_counters, State.t()) :: {:noreply, State.t()} + def handle_continue(:rebuild_monitors_and_counters, state) do + # Here we delete all counters and rebuild them based on entries table + :ets.delete_all_objects(state.name) + + monitors = + :ets.tab2list(state.entries_table) + |> Enum.reduce(%{}, fn {{group, pid}}, monitors_acc -> + ref = Process.monitor(pid, tag: {:DOWN, group}) + :ets.update_counter(state.name, group, {2, 1}, {group, 0}) + Map.put(monitors_acc, {group, pid}, ref) + end) + + {:noreply, %{state | monitors: monitors}} + end + + @impl true + @spec handle_call({:join, Beacon.group(), pid}, GenServer.from(), State.t()) :: + {:reply, :ok, State.t()} + def handle_call({:join, group, pid}, _from, state) do + if :ets.insert_new(state.entries_table, {{group, pid}}) do + # Increment existing or create + :ets.update_counter(state.name, group, {2, 1}, {group, 0}) + ref = Process.monitor(pid, tag: {:DOWN, group}) + monitors = Map.put(state.monitors, {group, pid}, ref) + {:reply, :ok, %{state | monitors: monitors}} + else + {:reply, :ok, state} + end + end + + def handle_call({:leave, group, pid}, _from, state) do + state = remove(group, pid, state) + {:reply, :ok, state} + end + + @impl true + @spec handle_info({{:DOWN, Beacon.group()}, reference, :process, pid, term}, State.t()) :: + {:noreply, State.t()} + def handle_info({{:DOWN, group}, _ref, :process, pid, _reason}, state) do + state = remove(group, pid, state) + {:noreply, state} + end + + def handle_info(_, state), do: {:noreply, state} + + defp remove(group, pid, state) do + case :ets.lookup(state.entries_table, {group, pid}) do + [{{^group, ^pid}}] -> + :ets.delete(state.entries_table, {group, pid}) + + # Delete or decrement counter + case :ets.lookup_element(state.name, group, 2, 0) do + 1 -> :ets.delete(state.name, group) + count when count > 1 -> :ets.update_counter(state.name, group, {2, -1}) + end + + [] -> + Logger.warning( + "Beacon[#{node()}|#{state.scope}] Trying to remove an unknown process #{inspect(pid)}" + ) + + :ok + end + + case Map.pop(state.monitors, {group, pid}) do + {nil, _} -> + state + + {ref, new_monitors} -> + Process.demonitor(ref, [:flush]) + %{state | monitors: new_monitors} + end + end +end diff --git a/beacon/lib/beacon/scope.ex b/beacon/lib/beacon/scope.ex new file mode 100644 index 000000000..72a43ba1c --- /dev/null +++ b/beacon/lib/beacon/scope.ex @@ -0,0 +1,208 @@ +defmodule Beacon.Scope do + @moduledoc false + # Responsible to discover and keep track of all Beacon peers in the cluster + + use GenServer + require Logger + + @default_broadcast_interval 5_000 + + @spec member_counts(atom) :: %{Beacon.group() => non_neg_integer} + def member_counts(scope) do + scope + |> table_name() + |> :ets.select([{{:_, :"$1"}, [], [:"$1"]}]) + |> Enum.reduce(%{}, fn member_counts, acc -> + Map.merge(acc, member_counts, fn _k, v1, v2 -> v1 + v2 end) + end) + end + + @spec member_count(atom, Beacon.group()) :: non_neg_integer + def member_count(scope, group) do + scope + |> table_name() + |> :ets.select([{{:_, :"$1"}, [], [:"$1"]}]) + |> Enum.sum_by(fn member_counts -> Map.get(member_counts, group, 0) end) + end + + @spec member_count(atom, Beacon.group(), node) :: non_neg_integer + def member_count(scope, group, node) do + case :ets.lookup(table_name(scope), node) do + [{^node, member_counts}] -> Map.get(member_counts, group, 0) + [] -> 0 + end + end + + @spec groups(atom) :: MapSet.t(Beacon.group()) + def groups(scope) do + scope + |> table_name() + |> :ets.select([{{:_, :"$1"}, [], [:"$1"]}]) + |> Enum.reduce(MapSet.new(), fn member_counts, acc -> + member_counts + |> Map.keys() + |> MapSet.new() + |> MapSet.union(acc) + end) + end + + @typep member_counts :: %{Beacon.group() => non_neg_integer} + + defp table_name(scope), do: :"#{scope}_beacon_peer_counts" + + defmodule State do + @moduledoc false + @type t :: %__MODULE__{ + scope: atom, + message_module: module, + broadcast_interval: non_neg_integer, + peer_counts_table: :ets.tid(), + peers: %{pid => reference} + } + defstruct [ + :scope, + :message_module, + :broadcast_interval, + :peer_counts_table, + peers: %{} + ] + end + + @spec start_link(atom, Keyword.t()) :: GenServer.on_start() + def start_link(scope, opts \\ []), do: GenServer.start_link(__MODULE__, [scope, opts]) + + @impl true + def init([scope, opts]) do + :ok = :net_kernel.monitor_nodes(true) + + peer_counts_table = + :ets.new(table_name(scope), [:set, :protected, :named_table, read_concurrency: true]) + + broadcast_interval = + Keyword.get(opts, :broadcast_interval_in_ms, @default_broadcast_interval) + + message_module = Keyword.get(opts, :message_module, Beacon.Adapter.ErlDist) + + Logger.info("Beacon[#{node()}|#{scope}] Starting") + + :ok = message_module.register(scope) + + {:ok, + %State{ + scope: scope, + message_module: message_module, + broadcast_interval: broadcast_interval, + peer_counts_table: peer_counts_table + }, {:continue, :discover}} + end + + @impl true + @spec handle_continue(:discover, State.t()) :: {:noreply, State.t()} + def handle_continue(:discover, state) do + state.message_module.broadcast(state.scope, {:discover, self()}) + Process.send_after(self(), :broadcast_counts, state.broadcast_interval) + {:noreply, state} + end + + @impl true + @spec handle_info( + {:discover, pid} + | {:sync, pid, member_counts} + | :broadcast_counts + | {:nodeup, node} + | {:nodedown, node} + | {:DOWN, reference, :process, pid, term}, + State.t() + ) :: {:noreply, State.t()} + # A remote peer is discovering us + def handle_info({:discover, peer}, state) do + Logger.info( + "Beacon[#{node()}|#{state.scope}] Received DISCOVER request from node #{node(peer)}" + ) + + state.message_module.send( + state.scope, + node(peer), + {:sync, self(), Beacon.local_member_counts(state.scope)} + ) + + # We don't do anything if we already know about this peer + if Map.has_key?(state.peers, peer) do + Logger.debug( + "Beacon[#{node()}|#{state.scope}] already know peer #{inspect(peer)} from node #{node(peer)}" + ) + + {:noreply, state} + else + Logger.debug( + "Beacon[#{node()}|#{state.scope}] discovered peer #{inspect(peer)} from node #{node(peer)}" + ) + + ref = Process.monitor(peer) + new_peers = Map.put(state.peers, peer, ref) + state.message_module.send(state.scope, node(peer), {:discover, self()}) + {:noreply, %State{state | peers: new_peers}} + end + end + + # A remote peer has sent us its local member counts + def handle_info({:sync, peer, member_counts}, state) do + :ets.insert(state.peer_counts_table, {node(peer), member_counts}) + {:noreply, state} + end + + # Periodic broadcast of our local member counts to all known peers + def handle_info(:broadcast_counts, state) do + nodes = + state.peers + |> Map.keys() + |> Enum.map(&node/1) + + state.message_module.broadcast( + state.scope, + nodes, + {:sync, self(), Beacon.local_member_counts(state.scope)} + ) + + Process.send_after(self(), :broadcast_counts, state.broadcast_interval) + {:noreply, state} + end + + # Do nothing if the node that came up is our own node + def handle_info({:nodeup, node}, state) when node == node(), do: {:noreply, state} + + # Send a discover message to the node that just connected + def handle_info({:nodeup, node}, state) do + :telemetry.execute([:beacon, state.scope, :node, :up], %{}, %{node: node}) + + Logger.info( + "Beacon[#{node()}|#{state.scope}] Node #{node} has joined the cluster, sending discover message" + ) + + state.message_module.send(state.scope, node, {:discover, self()}) + {:noreply, state} + end + + # Do nothing and wait for the DOWN message from monitor + def handle_info({:nodedown, _node}, state), do: {:noreply, state} + + # A remote peer has disconnected/crashed + # We forget about it and remove its member counts + def handle_info({:DOWN, ref, :process, peer, reason}, state) do + Logger.info( + "Beacon[#{node()}|#{state.scope}] Scope process is DOWN on node #{node(peer)}: #{inspect(reason)}" + ) + + case Map.pop(state.peers, peer) do + {nil, _} -> + {:noreply, state} + + {^ref, new_peers} -> + :ets.delete(state.peer_counts_table, node(peer)) + :telemetry.execute([:beacon, state.scope, :node, :down], %{}, %{node: node(peer)}) + {:noreply, %State{state | peers: new_peers}} + end + end + + def handle_info(_msg, state), do: {:noreply, state} +end diff --git a/beacon/lib/beacon/supervisor.ex b/beacon/lib/beacon/supervisor.ex new file mode 100644 index 000000000..fae322813 --- /dev/null +++ b/beacon/lib/beacon/supervisor.ex @@ -0,0 +1,61 @@ +defmodule Beacon.Supervisor do + @moduledoc false + use Supervisor + + def name(scope), do: :"#{scope}_beacon" + def supervisor_name(scope), do: :"#{scope}_beacon_supervisor" + def partition_name(scope, partition), do: :"#{scope}_beacon_partition_#{partition}" + def partition_entries_table(partition_name), do: :"#{partition_name}_entries" + + @spec partition(atom, Scope.group()) :: atom + def partition(scope, group) do + case :persistent_term.get(scope, :unknown) do + :unknown -> raise "Beacon for scope #{inspect(scope)} is not started" + partition_names -> elem(partition_names, :erlang.phash2(group, tuple_size(partition_names))) + end + end + + @spec partitions(atom) :: [atom] + def partitions(scope) do + case :persistent_term.get(scope, :unknown) do + :unknown -> raise "Beacon for scope #{inspect(scope)} is not started" + partition_names -> Tuple.to_list(partition_names) + end + end + + @spec start_link(atom, pos_integer(), Keyword.t()) :: Supervisor.on_start() + def start_link(scope, partitions, opts \\ []) do + args = [scope, partitions, opts] + Supervisor.start_link(__MODULE__, args, name: supervisor_name(scope)) + end + + @impl true + def init([scope, partitions, opts]) do + children = + for i <- 0..(partitions - 1) do + partition_name = partition_name(scope, i) + partition_entries_table = partition_entries_table(partition_name) + + ^partition_entries_table = + :ets.new(partition_entries_table, [:set, :public, :named_table, read_concurrency: true]) + + ^partition_name = + :ets.new(partition_name, [:set, :public, :named_table, read_concurrency: true]) + + %{ + id: i, + start: {Beacon.Partition, :start_link, [scope, partition_name, partition_entries_table]} + } + end + + partition_names = for i <- 0..(partitions - 1), do: partition_name(scope, i) + + :persistent_term.put(scope, List.to_tuple(partition_names)) + + children = [ + %{id: :scope, start: {Beacon.Scope, :start_link, [scope, opts]}} | children + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/beacon/mix.exs b/beacon/mix.exs new file mode 100644 index 000000000..4448f5f1e --- /dev/null +++ b/beacon/mix.exs @@ -0,0 +1,34 @@ +defmodule Beacon.MixProject do + use Mix.Project + + def project do + [ + app: :beacon, + version: "1.0.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:telemetry, "~> 1.3"}, + {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false} + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/beacon/mix.lock b/beacon/mix.lock new file mode 100644 index 000000000..2ba2a6c23 --- /dev/null +++ b/beacon/mix.lock @@ -0,0 +1,5 @@ +%{ + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, +} diff --git a/beacon/test/beacon/partition_test.exs b/beacon/test/beacon/partition_test.exs new file mode 100644 index 000000000..0b105d771 --- /dev/null +++ b/beacon/test/beacon/partition_test.exs @@ -0,0 +1,185 @@ +defmodule Beacon.PartitionTest do + use ExUnit.Case, async: true + alias Beacon.Partition + + setup do + scope = __MODULE__ + partition_name = Beacon.Supervisor.partition_name(scope, System.unique_integer([:positive])) + entries_table = Beacon.Supervisor.partition_entries_table(partition_name) + + ^partition_name = + :ets.new(partition_name, [:set, :public, :named_table, read_concurrency: true]) + + ^entries_table = + :ets.new(entries_table, [:set, :public, :named_table, read_concurrency: true]) + + spec = %{ + id: partition_name, + start: {Partition, :start_link, [scope, partition_name, entries_table]}, + type: :supervisor, + restart: :temporary + } + + pid = start_supervised!(spec) + + {:ok, partition_name: partition_name, partition_pid: pid} + end + + test "members/2 returns empty list for non-existent group", %{partition_name: partition} do + assert Partition.members(partition, :nonexistent) == [] + end + + test "member_count/2 returns 0 for non-existent group", %{partition_name: partition} do + assert Partition.member_count(partition, :nonexistent) == 0 + end + + test "member?/3 returns false for non-member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + refute Partition.member?(partition, :group1, pid) + end + + test "join and query member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Partition.join(partition, :group1, pid) + + assert Partition.member?(partition, :group1, pid) + assert Partition.member_count(partition, :group1) == 1 + assert pid in Partition.members(partition, :group1) + end + + test "join multiple times and query member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Partition.join(partition, :group1, pid) + assert :ok = Partition.join(partition, :group1, pid) + assert :ok = Partition.join(partition, :group1, pid) + + assert Partition.member?(partition, :group1, pid) + assert Partition.member_count(partition, :group1) == 1 + assert pid in Partition.members(partition, :group1) + end + + test "leave removes member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Partition.leave(partition, :group1, pid) + refute Partition.member?(partition, :group1, pid) + end + + test "leave multiple times removes member", %{partition_name: partition} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Partition.leave(partition, :group1, pid) + Partition.leave(partition, :group1, pid) + Partition.leave(partition, :group1, pid) + refute Partition.member?(partition, :group1, pid) + end + + test "member_counts returns counts for all groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + Partition.join(partition, :group2, pid3) + + counts = Partition.member_counts(partition) + assert map_size(counts) == 2 + assert counts[:group1] == 2 + assert counts[:group2] == 1 + end + + test "groups returns all groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group2, pid2) + + groups = Partition.groups(partition) + assert :group1 in groups + assert :group2 in groups + end + + test "group_counts returns number of groups", %{partition_name: partition} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + pid4 = spawn_link(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group1, pid2) + Partition.join(partition, :group2, pid3) + Partition.join(partition, :group3, pid4) + + assert Partition.group_count(partition) == 3 + end + + test "process death removes member from group", %{partition_name: partition} do + pid = spawn(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid) + assert Partition.member?(partition, :group1, pid) + + Process.exit(pid, :kill) + Process.sleep(50) + + refute Partition.member?(partition, :group1, pid) + assert Partition.member_count(partition, :group1) == 0 + end + + test "partition recovery monitors processes again", %{ + partition_name: partition, + partition_pid: partition_pid + } do + pid1 = spawn(fn -> Process.sleep(:infinity) end) + pid2 = spawn(fn -> Process.sleep(:infinity) end) + + Partition.join(partition, :group1, pid1) + Partition.join(partition, :group2, pid2) + + monitors = Process.info(partition_pid, [:monitors])[:monitors] |> Enum.map(&elem(&1, 1)) + assert length(monitors) + assert monitors |> Enum.member?(pid1) + assert monitors |> Enum.member?(pid2) + + assert %{{:group1, ^pid1} => _ref1, {:group2, ^pid2} => _ref2} = + :sys.get_state(partition_pid).monitors + + Process.monitor(partition_pid) + Process.exit(partition_pid, :kill) + assert_receive {:DOWN, _ref, :process, ^partition_pid, :killed} + + spec = %{ + id: :recover, + start: + {Partition, :start_link, + [__MODULE__, partition, Beacon.Supervisor.partition_entries_table(partition)]}, + type: :supervisor + } + + partition_pid = start_supervised!(spec) + + assert %{{:group1, ^pid1} => _ref1, {:group2, ^pid2} => _ref2} = + :sys.get_state(partition_pid).monitors + + monitors = Process.info(partition_pid, [:monitors])[:monitors] |> Enum.map(&elem(&1, 1)) + assert length(monitors) + assert monitors |> Enum.member?(pid1) + assert monitors |> Enum.member?(pid2) + + assert Partition.member_count(partition, :group1) == 1 + assert Partition.member_count(partition, :group2) == 1 + + assert Partition.member?(partition, :group1, pid1) + assert Partition.member?(partition, :group2, pid2) + end +end diff --git a/beacon/test/beacon_test.exs b/beacon/test/beacon_test.exs new file mode 100644 index 000000000..f82270e1f --- /dev/null +++ b/beacon/test/beacon_test.exs @@ -0,0 +1,469 @@ +defmodule BeaconTest do + use ExUnit.Case, async: true + + setup do + scope = :"test_scope#{System.unique_integer([:positive])}" + + %{scope: scope} + end + + defp spec(scope, opts) do + %{ + id: scope, + start: {Beacon, :start_link, [scope, opts]}, + type: :supervisor + } + end + + describe "start_link/2" do + test "starts beacon with default partitions", %{scope: scope} do + pid = start_supervised!({Beacon, [scope, []]}) + assert Process.alive?(pid) + assert is_list(Beacon.Supervisor.partitions(scope)) + assert length(Beacon.Supervisor.partitions(scope)) == System.schedulers_online() + end + + test "starts beacon with custom partition count", %{scope: scope} do + pid = start_supervised!(spec(scope, partitions: 3)) + assert Process.alive?(pid) + assert length(Beacon.Supervisor.partitions(scope)) == 3 + end + + test "raises on invalid partition count", %{scope: scope} do + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Beacon.start_link(scope, partitions: 0) + end + + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Beacon.start_link(scope, partitions: -1) + end + + assert_raise ArgumentError, ~r/expected :partitions to be a positive integer/, fn -> + Beacon.start_link(scope, partitions: :invalid) + end + end + + test "raises on invalid broadcast_interval_in_ms", %{scope: scope} do + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Beacon.start_link(scope, broadcast_interval_in_ms: 0) + end + + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Beacon.start_link(scope, broadcast_interval_in_ms: -1) + end + + assert_raise ArgumentError, + ~r/expected :broadcast_interval_in_ms to be a positive integer/, + fn -> + Beacon.start_link(scope, broadcast_interval_in_ms: :invalid) + end + end + end + + describe "join/3 and leave/3" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "can join a group", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Beacon.join(scope, :group1, pid) + assert Beacon.local_member?(scope, :group1, pid) + end + + test "can leave a group", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Beacon.join(scope, :group1, pid) + assert Beacon.local_member?(scope, :group1, pid) + + assert :ok = Beacon.leave(scope, :group1, pid) + refute Beacon.local_member?(scope, :group1, pid) + end + + test "joining same group twice is idempotent", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + assert :ok = Beacon.join(scope, :group1, pid) + assert :ok = Beacon.join(scope, :group1, pid) + assert Beacon.local_member_count(scope, :group1) == 1 + end + + test "multiple processes can join same group", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Beacon.join(scope, :group1, pid1) + assert :ok = Beacon.join(scope, :group1, pid2) + + members = Beacon.local_members(scope, :group1) + assert length(members) == 2 + assert pid1 in members + assert pid2 in members + end + + test "process can join multiple groups", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + assert :ok = Beacon.join(scope, :group1, pid) + assert :ok = Beacon.join(scope, :group2, pid) + + assert Beacon.local_member?(scope, :group1, pid) + assert Beacon.local_member?(scope, :group2, pid) + end + + test "automatically removes member when process dies", %{scope: scope} do + pid = spawn(fn -> Process.sleep(:infinity) end) + assert :ok = Beacon.join(scope, :group1, pid) + assert Beacon.local_member?(scope, :group1, pid) + + Process.exit(pid, :kill) + Process.sleep(50) + + refute Beacon.local_member?(scope, :group1, pid) + assert Beacon.local_member_count(scope, :group1) == 0 + end + end + + describe "local_members/2" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty list for non-existent group", %{scope: scope} do + assert Beacon.local_members(scope, :nonexistent) == [] + end + + test "returns all members of a group", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + Beacon.join(scope, :group2, pid3) + + members = Beacon.local_members(scope, :group1) + assert length(members) == 2 + assert pid1 in members + assert pid2 in members + refute pid3 in members + end + end + + describe "local_member_count/2" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns 0 for non-existent group", %{scope: scope} do + assert Beacon.local_member_count(scope, :nonexistent) == 0 + end + + test "returns correct count", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + assert Beacon.local_member_count(scope, :group1) == 0 + + Beacon.join(scope, :group1, pid1) + assert Beacon.local_member_count(scope, :group1) == 1 + + Beacon.join(scope, :group1, pid2) + assert Beacon.local_member_count(scope, :group1) == 2 + + Beacon.leave(scope, :group1, pid1) + assert Beacon.local_member_count(scope, :group1) == 1 + end + end + + describe "local_member_counts/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty map when no groups exist", %{scope: scope} do + assert Beacon.local_member_counts(scope) == %{} + end + + test "returns counts for all groups", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + pid3 = spawn_link(fn -> Process.sleep(:infinity) end) + + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + Beacon.join(scope, :group2, pid3) + + assert Beacon.local_member_counts(scope) == %{ + group1: 2, + group2: 1 + } + end + end + + describe "local_member?/3" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns false for non-member", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + refute Beacon.local_member?(scope, :group1, pid) + end + + test "returns true for member", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid) + assert Beacon.local_member?(scope, :group1, pid) + end + + test "returns false after leaving", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + + Beacon.join(scope, :group1, pid) + Beacon.leave(scope, :group1, pid) + + refute Beacon.local_member?(scope, :group1, pid) + end + end + + describe "local_groups/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns empty list when no groups exist", %{scope: scope} do + assert Beacon.local_groups(scope) == [] + end + + test "returns all groups with members", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group2, pid2) + Beacon.join(scope, :group3, pid1) + + groups = Beacon.local_groups(scope) + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + assert length(groups) == 3 + end + + test "removes group from list when last member leaves", %{scope: scope} do + pid = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid) + assert :group1 in Beacon.local_groups(scope) + + Beacon.leave(scope, :group1, pid) + refute :group1 in Beacon.local_groups(scope) + end + end + + describe "local_group_count/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns 0 when no groups exist", %{scope: scope} do + assert Beacon.local_group_count(scope) == 0 + end + + test "returns correct count of groups", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group2, pid2) + Beacon.join(scope, :group3, pid2) + Beacon.join(scope, :group3, pid1) + assert Beacon.local_group_count(scope) == 3 + Beacon.leave(scope, :group2, pid2) + assert Beacon.local_group_count(scope) == 2 + end + end + + describe "member_counts/1" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 2)) + :ok + end + + test "returns local counts when no peers", %{scope: scope} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + + counts = Beacon.member_counts(scope) + assert counts[:group1] == 2 + end + end + + describe "partition distribution" do + setup %{scope: scope} do + start_supervised!(spec(scope, partitions: 4)) + :ok + end + + test "distributes groups across partitions", %{scope: scope} do + # Create multiple processes and verify they're split against different partitions + pids = for _ <- 1..20, do: spawn_link(fn -> Process.sleep(:infinity) end) + + Enum.each(pids, fn pid -> + Beacon.join(scope, pid, pid) + end) + + # Check that multiple partitions are being used + partition_names = Beacon.Supervisor.partitions(scope) + + Enum.map(partition_names, fn partition_name -> + assert Beacon.Partition.member_counts(partition_name) > 1 + end) + end + + test "same group always maps to same partition", %{scope: scope} do + partition1 = Beacon.Supervisor.partition(scope, :my_group) + partition2 = Beacon.Supervisor.partition(scope, :my_group) + partition3 = Beacon.Supervisor.partition(scope, :my_group) + + assert partition1 == partition2 + assert partition2 == partition3 + end + end + + @aux_mod (quote do + defmodule PeerAux do + def start(scope) do + spawn(fn -> + {:ok, _} = Beacon.start_link(scope, broadcast_interval_in_ms: 50) + + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group2, pid2) + Beacon.join(scope, :group3, pid2) + + Process.sleep(:infinity) + end) + end + end + end) + + describe "distributed tests" do + setup do + scope = :"broadcast_scope#{System.unique_integer([:positive])}" + supervisor_pid = start_supervised!(spec(scope, partitions: 2, broadcast_interval_in_ms: 50)) + {:ok, peer, node} = Peer.start_disconnected(aux_mod: @aux_mod) + + ref = + :telemetry_test.attach_event_handlers(self(), [ + [:beacon, scope, :node, :up], + [:beacon, scope, :node, :down] + ]) + + %{scope: scope, supervisor_pid: supervisor_pid, peer: peer, node: node, telemetry_ref: ref} + end + + test "node up", %{scope: scope, peer: peer, node: node, telemetry_ref: telemetry_ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + Beacon.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + + assert_receive {[:beacon, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + + # Wait for at least one broadcast interval + Process.sleep(150) + assert Beacon.group_count(scope) == 3 + groups = Beacon.groups(scope) + + assert length(groups) == 3 + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + + assert Beacon.member_counts(scope) == %{group1: 3, group2: 2, group3: 1} + assert Beacon.member_count(scope, :group1) == 3 + assert Beacon.member_count(scope, :group3, node) == 1 + assert Beacon.member_count(scope, :group1, node()) == 2 + end + + test "node down", %{scope: scope, peer: peer, node: node, telemetry_ref: telemetry_ref} do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + Beacon.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + assert_receive {[:beacon, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + # Wait for remote scope to communicate with local + Process.sleep(150) + + true = Node.disconnect(node) + + assert_receive {[:beacon, ^scope, :node, :down], ^telemetry_ref, %{}, %{node: ^node}} + + assert Beacon.member_counts(scope) == %{group1: 2, group2: 1} + assert Beacon.member_count(scope, :group1) == 2 + end + + test "scope restart can recover", %{ + scope: scope, + supervisor_pid: supervisor_pid, + peer: peer, + node: node, + telemetry_ref: telemetry_ref + } do + pid1 = spawn_link(fn -> Process.sleep(:infinity) end) + pid2 = spawn_link(fn -> Process.sleep(:infinity) end) + Beacon.join(scope, :group1, pid1) + Beacon.join(scope, :group1, pid2) + Beacon.join(scope, :group2, pid2) + + true = Node.connect(node) + :peer.call(peer, PeerAux, :start, [scope]) + assert_receive {[:beacon, ^scope, :node, :up], ^telemetry_ref, %{}, %{node: ^node}} + + # Wait for remote scope to communicate with local + Process.sleep(150) + + [ + {1, _, :worker, [Beacon.Partition]}, + {0, _, :worker, [Beacon.Partition]}, + {:scope, scope_pid, :worker, [Beacon.Scope]} + ] = Supervisor.which_children(supervisor_pid) + + # Restart the scope process + Process.monitor(scope_pid) + Process.exit(scope_pid, :kill) + assert_receive {:DOWN, _ref, :process, ^scope_pid, :killed} + # Wait for recovery and communication + Process.sleep(200) + assert Beacon.group_count(scope) == 3 + groups = Beacon.groups(scope) + assert length(groups) == 3 + assert :group1 in groups + assert :group2 in groups + assert :group3 in groups + assert Beacon.member_counts(scope) == %{group1: 3, group2: 2, group3: 1} + end + end +end diff --git a/beacon/test/support/peer.ex b/beacon/test/support/peer.ex new file mode 100644 index 000000000..42ab7e8cd --- /dev/null +++ b/beacon/test/support/peer.ex @@ -0,0 +1,89 @@ +defmodule Peer do + @moduledoc """ + Uses the gist https://gist.github.com/ityonemo/177cbc96f8c8722bfc4d127ff9baec62 to start a node for testing + """ + + @doc """ + Starts a node for testing. + + Can receive an auxiliary module to be evaluated in the node so you are able to setup functions within the test context and outside of the normal code context + + e.g. + ``` + @aux_mod (quote do + defmodule Aux do + def checker(res), do: res + end + end) + + Code.eval_quoted(@aux_mod) + test "clustered call" do + {:ok, node} = Clustered.start(@aux_mod) + assert ok = :rpc.call(node, Aux, :checker, [:ok]) + end + ``` + """ + @spec start(Keyword.t()) :: {:ok, :peer.server_ref(), node} + def start(opts \\ []) do + {:ok, peer, node} = start_disconnected(opts) + + true = Node.connect(node) + + {:ok, peer, node} + end + + @doc """ + Similar to `start/2` but the node is not connected automatically + """ + @spec start_disconnected(Keyword.t()) :: {:ok, :peer.server_ref(), node} + def start_disconnected(opts \\ []) do + extra_config = Keyword.get(opts, :extra_config, []) + name = Keyword.get(opts, :name, :peer.random_name()) + aux_mod = Keyword.get(opts, :aux_mod, nil) + + true = :erlang.set_cookie(:cookie) + + {:ok, pid, node} = + ExUnit.Callbacks.start_supervised(%{ + id: {:peer, name}, + start: + {:peer, :start_link, + [ + %{ + name: name, + host: ~c"127.0.0.1", + longnames: true, + connection: :standard_io + } + ]} + }) + + :peer.call(pid, :erlang, :set_cookie, [:cookie]) + + :ok = :peer.call(pid, :code, :add_paths, [:code.get_path()]) + + for {app_name, _, _} <- Application.loaded_applications(), + {key, value} <- Application.get_all_env(app_name) do + :ok = :peer.call(pid, Application, :put_env, [app_name, key, value]) + end + + # Override with extra config + for {app_name, key, value} <- extra_config do + :ok = :peer.call(pid, Application, :put_env, [app_name, key, value]) + end + + {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:mix]) + :ok = :peer.call(pid, Mix, :env, [Mix.env()]) + + Enum.map( + [:logger, :runtime_tools, :mix, :os_mon, :beacon], + fn app -> {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [app]) end + ) + + if aux_mod do + {{:module, _, _, _}, []} = :peer.call(pid, Code, :eval_quoted, [aux_mod]) + end + + {:ok, pid, node} + end +end diff --git a/beacon/test/test_helper.exs b/beacon/test/test_helper.exs new file mode 100644 index 000000000..eea6cb589 --- /dev/null +++ b/beacon/test/test_helper.exs @@ -0,0 +1,3 @@ +ExUnit.start(capture_log: true) + +:net_kernel.start([:"beacon@127.0.0.1"]) diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 482ffb798..00058acc9 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -53,6 +53,8 @@ defmodule Realtime.Application do connect_partition_slots = Application.get_env(:realtime, :connect_partition_slots) no_channel_timeout_in_ms = Application.get_env(:realtime, :no_channel_timeout_in_ms) master_region = Application.get_env(:realtime, :master_region) || region + user_scope_shards = Application.fetch_env!(:realtime, :users_scope_shards) + user_scope_broadast_interval_in_ms = Application.get_env(:realtime, :users_scope_broadcast_interval_in_ms, 10_000) :syn.join(RegionNodes, region, self(), node: node()) @@ -66,6 +68,15 @@ defmodule Realtime.Application do {Cluster.Supervisor, [topologies, [name: Realtime.ClusterSupervisor]]}, {Phoenix.PubSub, name: Realtime.PubSub, pool_size: 10, adapter: pubsub_adapter(), broadcast_pool_size: broadcast_pool_size}, + {Beacon, + [ + :users, + [ + partitions: user_scope_shards, + broadcast_interval_in_ms: user_scope_broadast_interval_in_ms, + message_module: Realtime.BeaconPubSubAdapter + ] + ]}, {Cachex, name: Realtime.RateCounter}, Realtime.Tenants.Cache, Realtime.RateCounter.DynamicSupervisor, diff --git a/lib/realtime/beacon_pub_sub_adapter.ex b/lib/realtime/beacon_pub_sub_adapter.ex new file mode 100644 index 000000000..f4b551f6d --- /dev/null +++ b/lib/realtime/beacon_pub_sub_adapter.ex @@ -0,0 +1,33 @@ +defmodule Realtime.BeaconPubSubAdapter do + @moduledoc "Beacon adapter to use PubSub" + + import Kernel, except: [send: 2] + + @behaviour Beacon.Adapter + + @impl true + def register(scope) do + :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, topic(scope)) + end + + @impl true + def broadcast(scope, message) do + Phoenix.PubSub.broadcast_from(Realtime.PubSub, self(), topic(scope), message) + end + + @impl true + def broadcast(scope, _nodes, message) do + # Notice here that we don't filter by nodes, as PubSub broadcasts to all subscribers + # We are broadcasting to everyone because we want to use the fact that Realtime.PubSub uses + # regional broadcasting which is more efficient in this multi-region setup + + broadcast(scope, message) + end + + @impl true + def send(scope, node, message) do + Phoenix.PubSub.direct_broadcast(node, Realtime.PubSub, topic(scope), message) + end + + defp topic(scope), do: "beacon:#{scope}" +end diff --git a/lib/realtime/user_counter.ex b/lib/realtime/user_counter.ex index afcb357af..b6f85a920 100644 --- a/lib/realtime/user_counter.ex +++ b/lib/realtime/user_counter.ex @@ -8,7 +8,16 @@ defmodule Realtime.UsersCounter do Adds a RealtimeChannel pid to the `:users` scope for a tenant so we can keep track of all connected clients for a tenant. """ @spec add(pid(), String.t()) :: :ok - def add(pid, tenant_id), do: tenant_id |> scope() |> :syn.join(tenant_id, pid) + def add(pid, tenant_id) when is_pid(pid) and is_binary(tenant_id) do + beacon_join(pid, tenant_id) + tenant_id |> scope() |> :syn.join(tenant_id, pid) + end + + defp beacon_join(pid, tenant_id) do + :ok = Beacon.join(:users, tenant_id, pid) + rescue + _ -> Logger.error("Failed to join Beacon users scope for tenant #{tenant_id}") + end @doc """ Returns the count of all connected clients for a tenant for the cluster. @@ -75,13 +84,13 @@ defmodule Realtime.UsersCounter do """ @spec scope(String.t()) :: atom() def scope(tenant_id) do - shards = Application.get_env(:realtime, :users_scope_shards) + shards = Application.fetch_env!(:realtime, :users_scope_shards) shard = :erlang.phash2(tenant_id, shards) :"users_#{shard}" end def scopes() do - shards = Application.get_env(:realtime, :users_scope_shards) + shards = Application.fetch_env!(:realtime, :users_scope_shards) Enum.map(0..(shards - 1), fn shard -> :"users_#{shard}" end) end end diff --git a/mix.exs b/mix.exs index baff36637..9fb7c80a3 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Realtime.MixProject do def project do [ app: :realtime, - version: "2.69.2", + version: "2.70.0", elixir: "~> 1.18", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -81,6 +81,7 @@ defmodule Realtime.MixProject do {:mint, "~> 1.4"}, {:logflare_logger_backend, "~> 0.11"}, {:syn, "~> 3.3"}, + {:beacon, path: "./beacon"}, {:cachex, "~> 4.0"}, {:open_api_spex, "~> 3.16"}, {:corsica, "~> 2.0"}, diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs index 297394b14..4c5ded562 100644 --- a/test/realtime/gen_rpc_pub_sub_test.exs +++ b/test/realtime/gen_rpc_pub_sub_test.exs @@ -71,7 +71,7 @@ defmodule Realtime.GenRpcPubSubTest do # Avoid port collision client_config_per_node = %{ - :"main@127.0.0.1" => 5369, + :"main@127.0.0.1" => 5969, :"#{us_node}@127.0.0.1" => 16970, :"#{ap2_nodeX}@127.0.0.1" => 16971, :"#{ap2_nodeY}@127.0.0.1" => 16972 diff --git a/test/realtime/user_counter_test.exs b/test/realtime/user_counter_test.exs index d01b43640..f7725885d 100644 --- a/test/realtime/user_counter_test.exs +++ b/test/realtime/user_counter_test.exs @@ -5,8 +5,9 @@ defmodule Realtime.UsersCounterTest do setup_all do tenant_id = random_string() - {nodes, count} = generate_load(tenant_id) - %{tenant_id: tenant_id, count: count, nodes: nodes} + count = generate_load(tenant_id) + + %{tenant_id: tenant_id, count: count, nodes: Node.list()} end describe "add/1" do @@ -17,12 +18,13 @@ defmodule Realtime.UsersCounterTest do @aux_mod (quote do defmodule Aux do - def ping(), - do: - spawn(fn -> - Process.sleep(15000) - :pong - end) + def ping() do + spawn(fn -> Process.sleep(:infinity) end) + end + + def join(pid, group) do + UsersCounter.add(pid, group) + end end end) @@ -33,9 +35,19 @@ defmodule Realtime.UsersCounterTest do assert UsersCounter.add(self(), tenant_id) == :ok Process.sleep(1000) counts = UsersCounter.tenant_counts() + assert counts[tenant_id] == expected + 1 + assert map_size(counts) >= 61 + + counts = Beacon.local_member_counts(:users) + + assert counts[tenant_id] == 1 + assert map_size(counts) >= 1 - assert map_size(counts) >= 41 + counts = Beacon.member_counts(:users) + + assert counts[tenant_id] == expected + 1 + assert map_size(counts) >= 61 end end @@ -51,6 +63,8 @@ defmodule Realtime.UsersCounterTest do assert another_node_counts[tenant_id] == 2 assert map_size(another_node_counts) == 21 + + assert Beacon.local_member_counts(:users) == %{tenant_id => 1} end end @@ -62,46 +76,71 @@ defmodule Realtime.UsersCounterTest do end describe "tenant_users/2" do - test "returns count of connected clients for tenant on target cluster", %{tenant_id: tenant_id} do - {:ok, node} = Clustered.start(@aux_mod) - pid = Rpc.call(node, Aux, :ping, []) - UsersCounter.add(pid, tenant_id) - assert UsersCounter.tenant_users(node, tenant_id) == 1 + test "returns count of connected clients for tenant on target cluster", %{tenant_id: tenant_id, nodes: nodes} do + node = hd(nodes) + assert UsersCounter.tenant_users(node, tenant_id) == 2 + + assert Beacon.member_count(:users, tenant_id, node) == 2 end end - defp generate_load(tenant_id, n_nodes \\ 2, processes \\ 2) do - nodes = - for i <- 1..n_nodes do - # Avoid port collision - extra_config = [ - {:gen_rpc, :tcp_server_port, 15970 + i} - ] - - {:ok, node} = Clustered.start(@aux_mod, extra_config: extra_config, phoenix_port: 4012 + i) - - for _ <- 1..processes do - pid = Rpc.call(node, Aux, :ping, []) - - for _ <- 1..10 do - # replicate same pid added multiple times concurrently - Task.start(fn -> - UsersCounter.add(pid, tenant_id) - Process.sleep(10000) - end) - - # noisy neighbors to test handling of bigger loads on concurrent calls - Task.start(fn -> - pid = Rpc.call(node, Aux, :ping, []) - UsersCounter.add(pid, random_string()) - Process.sleep(10000) - end) - end - end - + defp generate_load(tenant_id) do + processes = 2 + + nodes = %{ + :"main@127.0.0.1" => 5969, + :"us_node@127.0.0.1" => 16980, + :"ap2_nodeX@127.0.0.1" => 16981, + :"ap2_nodeY@127.0.0.1" => 16982 + } + + regions = %{ + :"us_node@127.0.0.1" => "us-east-1", + :"ap2_nodeX@127.0.0.1" => "ap-southeast-2", + :"ap2_nodeY@127.0.0.1" => "ap-southeast-2" + } + + on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end) + Application.put_env(:gen_rpc, :client_config_per_node, {:internal, nodes}) + + nodes + |> Enum.filter(fn {node, _port} -> node != Node.self() end) + |> Enum.with_index(1) + |> Enum.each(fn {{node, gen_rpc_port}, i} -> + # Avoid port collision + extra_config = [ + {:gen_rpc, :tcp_server_port, gen_rpc_port}, + {:gen_rpc, :client_config_per_node, {:internal, nodes}}, + {:realtime, :users_scope_broadcast_interval_in_ms, 100}, + {:realtime, :region, regions[node]} + ] + + node_name = node + |> to_string() + |> String.split("@") + |> hd() + |> String.to_atom() + + {:ok, node} = Clustered.start(@aux_mod, name: node_name, extra_config: extra_config, phoenix_port: 4012 + i) + + for _ <- 1..processes do + pid = Rpc.call(node, Aux, :ping, []) + + for _ <- 1..10 do + # replicate same pid added multiple times concurrently + Task.start(fn -> + Rpc.call(node, Aux, :join, [pid, tenant_id]) + end) + + # noisy neighbors to test handling of bigger loads on concurrent calls + Task.start(fn -> + Rpc.call(node, Aux, :join, [pid, random_string()]) + end) + end end + end) - {nodes, n_nodes * processes} + 3 * processes end end