diff --git a/apps/dashboard/app/new/hooks/use-workspace-step.tsx b/apps/dashboard/app/new/hooks/use-workspace-step.tsx index 2d9dffc53f..c99152bbc3 100644 --- a/apps/dashboard/app/new/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx @@ -1,4 +1,5 @@ import { setSessionCookie } from "@/lib/auth/cookies"; +import { reset } from "@/lib/collections"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { StackPerspective2 } from "@unkey/icons"; @@ -33,6 +34,7 @@ export const useWorkspaceStep = (): OnboardingStep => { const [workspaceCreated, setWorkspaceCreated] = useState(false); const formRef = useRef(null); const router = useRouter(); + const trpcUtils = trpc.useUtils(); const form = useForm({ resolver: zodResolver(workspaceSchema), @@ -61,6 +63,8 @@ export const useWorkspaceStep = (): OnboardingStep => { onSuccess: async ({ organizationId }) => { setWorkspaceCreated(true); switchOrgMutation.mutate(organizationId); + trpcUtils.user.listMemberships.invalidate(); + await reset(); }, onError: (error) => { if (error.data?.code === "METHOD_NOT_SUPPORTED") { diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx index 0afc7e7c3a..c174f8461c 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx @@ -8,8 +8,10 @@ import { useMemo } from "react"; export const useProjectNavigation = (baseNavItems: NavItem[]) => { const segments = useSelectedLayoutSegments() ?? []; - const { data, isLoading } = useLiveQuery((q) => - q.from({ project: collection.projects }).orderBy(({ project }) => project.id, "desc"), + const { data, isLoading } = useLiveQuery( + (q) => q.from({ project: collection.projects }).orderBy(({ project }) => project.id, "desc"), + // Deps are required here otherwise it won't get rerendered + [collection.projects], ); const projectNavItems = useMemo(() => { diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx index 2bd1d2a5f5..1899c6c84d 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx @@ -15,10 +15,13 @@ import { useMemo } from "react"; export const useRatelimitNavigation = (baseNavItems: NavItem[]) => { const segments = useSelectedLayoutSegments() ?? []; - const { data } = useLiveQuery((q) => - q - .from({ namespace: collection.ratelimitNamespaces }) - .orderBy(({ namespace }) => namespace.id, "desc"), + const { data } = useLiveQuery( + (q) => + q + .from({ namespace: collection.ratelimitNamespaces }) + .orderBy(({ namespace }) => namespace.id, "desc"), + // Deps are required here otherwise it won't get rerendered + [collection.ratelimitNamespaces], ); // Convert ratelimit namespaces data to navigation items with sub-items diff --git a/apps/dashboard/components/navigation/sidebar/team-switcher.tsx b/apps/dashboard/components/navigation/sidebar/team-switcher.tsx index 2a3e6c72b2..3951d5f8cb 100644 --- a/apps/dashboard/components/navigation/sidebar/team-switcher.tsx +++ b/apps/dashboard/components/navigation/sidebar/team-switcher.tsx @@ -83,6 +83,14 @@ export const WorkspaceSwitcher: React.FC = (props): JSX.Element => { toast.error("Failed to switch workspace. Contact support if error persists."); }, }); + const handleWorkspaceSwitch = async (targetOrgId: string) => { + // Prevent switch if already on the target workspace + if (targetOrgId === currentOrgMembership?.organization.id) { + return; + } + + await changeWorkspace.mutateAsync(targetOrgId); + }; const [search, _setSearch] = useState(""); const filteredOrgs = useMemo(() => { @@ -146,7 +154,7 @@ export const WorkspaceSwitcher: React.FC = (props): JSX.Element => { changeWorkspace.mutateAsync(membership.organization.id)} + onClick={() => handleWorkspaceSwitch(membership.organization.id)} > ; export type CreateProjectRequestSchema = z.infer; -export const projects = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["projects"], - retry: 3, - queryFn: async () => { - return await trpcClient.deploy.project.list.query(); - }, - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const { changes } = transaction.mutations[0]; +const ERROR_MESSAGES = { + CONFLICT: { + message: "Project Already Exists", + description: "A project with this slug already exists in your workspace.", + }, + FORBIDDEN: { + message: "Permission Denied", + description: "You don't have permission to create projects in this workspace.", + }, + BAD_REQUEST: { + message: "Invalid Configuration", + description: "Please check your project settings.", + }, + INTERNAL_SERVER_ERROR: { + message: "Server Error", + description: + "We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev", + }, + NOT_FOUND: { + message: "Project Creation Failed", + description: "Unable to find the workspace. Please refresh and try again.", + }, + DEFAULT: { + message: "Failed to Create Project", + description: "An unexpected error occurred. Please try again later.", + }, +} as const; - const createInput = createProjectRequestSchema.parse({ - name: changes.name, - slug: changes.slug, - gitRepositoryUrl: changes.gitRepositoryUrl, - }); - const mutation = trpcClient.deploy.project.create.mutate(createInput); +export function createProjectsCollection() { + return createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ["projects"], + retry: 3, + queryFn: async () => { + return await trpcClient.deploy.project.list.query(); + }, + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const { changes } = transaction.mutations[0]; - toast.promise(mutation, { - loading: "Creating project...", - success: "Project created successfully", - error: (err) => { - console.error("Failed to create project", err); + const createInput = createProjectRequestSchema.parse({ + name: changes.name, + slug: changes.slug, + gitRepositoryUrl: changes.gitRepositoryUrl, + }); - switch (err.data?.code) { - case "CONFLICT": - return { - message: "Project Already Exists", - description: - err.message || "A project with this slug already exists in your workspace.", - }; - case "FORBIDDEN": - return { - message: "Permission Denied", - description: - err.message || "You don't have permission to create projects in this workspace.", - }; - case "BAD_REQUEST": - return { - message: "Invalid Configuration", - description: `Please check your project settings. ${err.message || ""}`, - }; - case "INTERNAL_SERVER_ERROR": - return { - message: "Server Error", - description: - "We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev", - }; - case "NOT_FOUND": - return { - message: "Project Creation Failed", - description: "Unable to find the workspace. Please refresh and try again.", - }; - default: - return { - message: "Failed to Create Project", - description: err.message || "An unexpected error occurred. Please try again later.", - }; - } - }, - }); - await mutation; - }, - }), -); + const mutation = trpcClient.deploy.project.create.mutate(createInput); + + toast.promise(mutation, { + loading: "Creating project...", + success: "Project created successfully", + error: (err) => { + console.error("Failed to create project", err); + + const errorConfig = + ERROR_MESSAGES[err.data?.code as keyof typeof ERROR_MESSAGES] || + ERROR_MESSAGES.DEFAULT; + + return { + message: errorConfig.message, + description: err.message || errorConfig.description, + }; + }, + }); + + await mutation; + }, + }), + ); +} diff --git a/apps/dashboard/lib/collections/index.ts b/apps/dashboard/lib/collections/index.ts index dd323fa98e..e583400b38 100644 --- a/apps/dashboard/lib/collections/index.ts +++ b/apps/dashboard/lib/collections/index.ts @@ -2,9 +2,9 @@ import { createDeploymentsCollection } from "./deploy/deployments"; import { createDomainsCollection } from "./deploy/domains"; import { createEnvironmentsCollection } from "./deploy/environments"; -import { projects } from "./deploy/projects"; -import { ratelimitNamespaces } from "./ratelimit/namespaces"; -import { ratelimitOverrides } from "./ratelimit/overrides"; +import { createProjectsCollection } from "./deploy/projects"; +import { createRatelimitNamespacesCollection } from "./ratelimit/namespaces"; +import { createRatelimitOverridesCollection } from "./ratelimit/overrides"; // Export types export type { Deployment } from "./deploy/deployments"; @@ -14,13 +14,30 @@ export type { RatelimitNamespace } from "./ratelimit/namespaces"; export type { RatelimitOverride } from "./ratelimit/overrides"; export type { Environment } from "./deploy/environments"; +// Collection factory definitions - only project-scoped collections +const PROJECT_COLLECTION_FACTORIES = { + environments: createEnvironmentsCollection, + domains: createDomainsCollection, + deployments: createDeploymentsCollection, +} as const; + +const GLOBAL_COLLECTION_FACTORIES = { + projects: createProjectsCollection, + ratelimitNamespaces: createRatelimitNamespacesCollection, + ratelimitOverrides: createRatelimitOverridesCollection, +} as const; + +// ProjectCollections only contains project-scoped collections type ProjectCollections = { - environments: ReturnType; - domains: ReturnType; - deployments: ReturnType; - projects: typeof projects; + [K in keyof typeof PROJECT_COLLECTION_FACTORIES]: ReturnType< + (typeof PROJECT_COLLECTION_FACTORIES)[K] + >; }; +async function cleanupCollections(collections: Record }>) { + await Promise.all(Object.values(collections).map((c) => c.cleanup())); +} + class CollectionManager { private projectCollections = new Map(); @@ -28,13 +45,17 @@ class CollectionManager { if (!projectId) { throw new Error("projectId is required"); } + if (!this.projectCollections.has(projectId)) { - this.projectCollections.set(projectId, { - environments: createEnvironmentsCollection(projectId), - domains: createDomainsCollection(projectId), - deployments: createDeploymentsCollection(projectId), - projects, - }); + // Create collections using factories - only project-scoped ones + const newCollections = Object.fromEntries( + Object.entries(PROJECT_COLLECTION_FACTORIES).map(([key, factory]) => [ + key, + factory(projectId), + ]), + ) as ProjectCollections; + + this.projectCollections.set(projectId, newCollections); } // biome-ignore lint/style/noNonNullAssertion: Its okay return this.projectCollections.get(projectId)!; @@ -43,44 +64,52 @@ class CollectionManager { async cleanup(projectId: string) { const collections = this.projectCollections.get(projectId); if (collections) { - await Promise.all([ - collections.environments.cleanup(), - collections.domains.cleanup(), - collections.deployments.cleanup(), - // Note: projects is shared, don't clean it up per project - ]); + // All collections in ProjectCollections are cleanupable + await cleanupCollections(collections); this.projectCollections.delete(projectId); } } async cleanupAll() { // Clean up all project collections - const projectCleanupPromises = Array.from(this.projectCollections.keys()).map((projectId) => - this.cleanup(projectId), + const projectPromises = Array.from(this.projectCollections.entries()).map( + async ([_, collections]) => { + await cleanupCollections(collections); + }, ); + // Clean up global collections, this has to run sequentially + for (const c of Object.values(collection)) { + await c.cleanup(); + } - // Clean up global collections - const globalCleanupPromises = Object.values(collection).map((c) => c.cleanup()); - - await Promise.all([...projectCleanupPromises, ...globalCleanupPromises]); + await Promise.all([...projectPromises]); + this.projectCollections.clear(); } } export const collectionManager = new CollectionManager(); -// Global collections -export const collection = { - projects, - ratelimitNamespaces, - ratelimitOverrides, -} as const; +// Global collections, create using factories +export const collection = Object.fromEntries( + Object.entries(GLOBAL_COLLECTION_FACTORIES).map(([key, factory]) => [key, factory()]), +) as { + [K in keyof typeof GLOBAL_COLLECTION_FACTORIES]: ReturnType< + (typeof GLOBAL_COLLECTION_FACTORIES)[K] + >; +}; export async function reset() { + // This is GC cleanup only useful for better memory management await collectionManager.cleanupAll(); - // Preload global collections after cleanup - await Promise.all( - Object.values(collection).map(async (c) => { - await c.preload(); - }), + // Without these components still subscribed to old collections, so create new instances for each reset. Mostly used when switching workspaces + Object.assign( + collection, + Object.fromEntries( + Object.entries(GLOBAL_COLLECTION_FACTORIES).map(([key, factory]) => [key, factory()]), + ), ); + // Preload all collections, please keep this sequential. Otherwise UI acts weird. react-query already takes care of batching. + for (const c of Object.values(collection)) { + await c.preload(); + } } diff --git a/apps/dashboard/lib/collections/ratelimit/namespaces.ts b/apps/dashboard/lib/collections/ratelimit/namespaces.ts index ee37bbd9fe..37ad324074 100644 --- a/apps/dashboard/lib/collections/ratelimit/namespaces.ts +++ b/apps/dashboard/lib/collections/ratelimit/namespaces.ts @@ -12,69 +12,71 @@ const schema = z.object({ export type RatelimitNamespace = z.infer; -export const ratelimitNamespaces = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["ratelimitNamespaces"], - retry: 3, - queryFn: async () => { - return await trpcClient.ratelimit.namespace.list.query(); - }, - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const { changes: newNamespace } = transaction.mutations[0]; - if (!newNamespace.name) { - throw new Error("Namespace name is required"); - } +export function createRatelimitNamespacesCollection() { + const collection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ["ratelimitNamespaces"], + retry: 3, + queryFn: async () => { + return await trpcClient.ratelimit.namespace.list.query(); + }, + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const { changes: newNamespace } = transaction.mutations[0]; + if (!newNamespace.name) { + throw new Error("Namespace name is required"); + } + const mutation = trpcClient.ratelimit.namespace.create.mutate({ + name: newNamespace.name, + }); + toast.promise(mutation, { + loading: "Creating namespace...", + success: "Namespace created", + error: (res) => { + console.error("Failed to create namespace", res); + return { + message: "Failed to create namespace", + description: res.message, + }; + }, + }); + await mutation; + }, + onUpdate: async ({ transaction }) => { + const { original, modified } = transaction.mutations[0]; + const mutation = trpcClient.ratelimit.namespace.update.name.mutate({ + namespaceId: original.id, + name: modified.name, + }); + toast.promise(mutation, { + loading: "Updating namespace...", + success: "Namespace updated", + error: "Failed to update namespace", + }); + await mutation; + }, + onDelete: async ({ transaction }) => { + const { original } = transaction.mutations[0]; + const mutation = trpcClient.ratelimit.namespace.delete.mutate({ + namespaceId: original.id, + }); + toast.promise(mutation, { + loading: "Deleting namespace...", + success: "Namespace deleted", + error: "Failed to delete namespace", + }); + await mutation; + }, + }), + ); - const mutation = trpcClient.ratelimit.namespace.create.mutate({ - name: newNamespace.name, - }); - toast.promise(mutation, { - loading: "Creating namespace...", - success: "Namespace created", - error: (res) => { - console.error("Failed to create namespace", res); - return { - message: "Failed to create namespace", - description: res.message, - }; - }, - }); - await mutation; + collection.createIndex((row) => row.name, { + name: "name", + options: { + unique: true, }, - onUpdate: async ({ transaction }) => { - const { original, modified } = transaction.mutations[0]; + }); - const mutation = trpcClient.ratelimit.namespace.update.name.mutate({ - namespaceId: original.id, - name: modified.name, - }); - toast.promise(mutation, { - loading: "Updating namespace...", - success: "Namespace updated", - error: "Failed to update namespace", - }); - await mutation; - }, - onDelete: async ({ transaction }) => { - const { original } = transaction.mutations[0]; - const mutation = trpcClient.ratelimit.namespace.delete.mutate({ - namespaceId: original.id, - }); - toast.promise(mutation, { - loading: "Deleting namespace...", - success: "Namespace deleted", - error: "Failed to delete namespace", - }); - await mutation; - }, - }), -); - -ratelimitNamespaces.createIndex((row) => row.name, { - name: "name", - options: { - unique: true, - }, -}); + return collection; +} diff --git a/apps/dashboard/lib/collections/ratelimit/overrides.ts b/apps/dashboard/lib/collections/ratelimit/overrides.ts index 3ae65677b8..80744c25f8 100644 --- a/apps/dashboard/lib/collections/ratelimit/overrides.ts +++ b/apps/dashboard/lib/collections/ratelimit/overrides.ts @@ -12,66 +12,70 @@ const schema = z.object({ limit: z.number(), duration: z.number(), }); + export type RatelimitOverride = z.infer; -export const ratelimitOverrides = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["ratelimitOverrides"], - queryFn: async () => { - console.info("DB fetching ratelimitOverrides"); - return await trpcClient.ratelimit.override.list.query(); - }, - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const { changes } = transaction.mutations[0]; +export function createRatelimitOverridesCollection() { + const collection = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ["ratelimitOverrides"], + queryFn: async () => { + console.info("DB fetching ratelimitOverrides"); + return await trpcClient.ratelimit.override.list.query(); + }, + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const { changes } = transaction.mutations[0]; + const mutation = trpcClient.ratelimit.override.create.mutate(schema.parse(changes)); + toast.promise(mutation, { + loading: "Creating override...", + success: "Override created", + error: (res) => { + console.error("Failed to create override", res); + return { + message: "Failed to create override", + description: res.message, + }; + }, + }); + await mutation; + }, + onUpdate: async ({ transaction }) => { + const { original, modified } = transaction.mutations[0]; + const mutation = trpcClient.ratelimit.override.update.mutate({ + id: original.id, + limit: modified.limit, + duration: modified.duration, + }); + toast.promise(mutation, { + loading: "Updating override...", + success: "Override updated", + error: "Failed to update override", + }); + await mutation; + }, + onDelete: async ({ transaction }) => { + const { original } = transaction.mutations[0]; + const mutation = trpcClient.ratelimit.override.delete.mutate({ + id: original.id, + }); + toast.promise(mutation, { + loading: "Deleting override...", + success: "Override deleted", + error: "Failed to delete override", + }); + await mutation; + }, + }), + ); - const mutation = trpcClient.ratelimit.override.create.mutate(schema.parse(changes)); - toast.promise(mutation, { - loading: "Creating override...", - success: "Override created", - error: (res) => { - console.error("Failed to create override", res); - return { - message: "Failed to create override", - description: res.message, - }; - }, - }); - await mutation; - }, - onUpdate: async ({ transaction }) => { - const { original, modified } = transaction.mutations[0]; - const mutation = trpcClient.ratelimit.override.update.mutate({ - id: original.id, - limit: modified.limit, - duration: modified.duration, - }); - toast.promise(mutation, { - loading: "Updating override...", - success: "Override updated", - error: "Failed to update override", - }); - await mutation; + collection.createIndex((row) => [row.namespaceId, row.identifier], { + name: "unique_identifier_per_namespace", + options: { + unique: true, }, - onDelete: async ({ transaction }) => { - const { original } = transaction.mutations[0]; - const mutation = trpcClient.ratelimit.override.delete.mutate({ - id: original.id, - }); - toast.promise(mutation, { - loading: "Deleting override...", - success: "Override deleted", - error: "Failed to delete override", - }); - await mutation; - }, - }), -); + }); -ratelimitOverrides.createIndex((row) => [row.namespaceId, row.identifier], { - name: "unique_identifier_per_namespace", - options: { - unique: true, - }, -}); + return collection; +}