Skip to main content

Overview

Polaris IDE uses Convex as its real-time database and backend. Convex provides instant synchronization, reactive queries, and a type-safe TypeScript API that makes building collaborative features effortless.

Why Convex?

Real-time Sync

Changes sync instantly across all connected clients with zero configuration

Optimistic Updates

UI updates immediately while mutations run in the background

Type Safety

Full TypeScript support with auto-generated types

Serverless

No infrastructure to manage - scales automatically

File Storage

Built-in file storage for binary files and images

Stack Auth Integration

Native JWT authentication with Stack Auth

Setup

Install Convex

1

Sign up for Convex

Go to https://dashboard.convex.dev and create an account.
2

Create a New Project

Click “Create a project” and name it (e.g., “polaris-ide”).
3

Get Deployment URL

Copy your deployment URL from the dashboard (e.g., https://happy-animal-123.convex.cloud).
4

Add to Environment

Add to .env.local:
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
POLARIS_CONVEX_INTERNAL_KEY=your-random-secret-key
5

Start Convex Dev Server

Run the Convex development server:
npx convex dev
This watches for schema changes and deploys automatically.

Environment Variables

# Convex
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_DEPLOYMENT=your-deployment-name  # Auto-set by Convex CLI
POLARIS_CONVEX_INTERNAL_KEY=random-secret-for-internal-api
POLARIS_CONVEX_INTERNAL_KEY is used for internal API mutations from Inngest background jobs. Generate a strong random string.

Database Schema

Convex schema defines all database tables and their structure (convex/schema.ts:7):

Users Table

Stores user profiles and subscription information:
users: defineTable({
  stackUserId: v.string(),  // Stack Auth user ID
  email: v.string(),
  autumnCustomerId: v.optional(v.string()),  // Billing integration
  subscriptionStatus: v.optional(
    v.union(
      v.literal("free"),
      v.literal("trialing"),
      v.literal("active"),
      v.literal("paused"),
      v.literal("canceled"),
      v.literal("past_due")
    )
  ),
  subscriptionTier: v.optional(
    v.union(
      v.literal("free"),
      v.literal("pro_monthly"),
      v.literal("pro_yearly")
    )
  ),
  projectLimit: v.number(),  // -1 for unlimited
  trialEndsAt: v.optional(v.number()),
  createdAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_stack_user", ["stackUserId"])
  .index("by_autumn_customer", ["autumnCustomerId"])
Indexes:
  • by_stack_user - Lookup user by Stack Auth ID
  • by_autumn_customer - Lookup user by billing customer ID

Projects Table

Stores IDE projects:
projects: defineTable({
  name: v.string(),
  ownerId: v.string(),  // Stack Auth user ID
  userId: v.optional(v.id("users")),  // Link to users table
  updatedAt: v.number(),
  importStatus: v.optional(
    v.union(
      v.literal("importing"),
      v.literal("completed"),
      v.literal("failed")
    )
  ),
  exportStatus: v.optional(
    v.union(
      v.literal("exporting"),
      v.literal("completed"),
      v.literal("failed"),
      v.literal("cancelled")
    )
  ),
  exportRepoUrl: v.optional(v.string()),
})
  .index("by_owner", ["ownerId"])
  .index("by_user", ["userId"])
Indexes:
  • by_owner - Query projects by owner (Stack Auth user ID)
  • by_user - Query projects by Convex user record

Files Table

Stores project files and folders:
files: defineTable({
  projectId: v.id("projects"),
  parentId: v.optional(v.id("files")),  // Parent folder
  name: v.string(),
  type: v.union(v.literal("file"), v.literal("folder")),
  content: v.optional(v.string()),  // Text files only
  storageId: v.optional(v.id("_storage")),  // Binary files
  updatedAt: v.number(),
})
  .index("by_project", ["projectId"])
  .index("by_parent", ["parentId"])
  .index("by_project_parent", ["projectId", "parentId"])
Indexes:
  • by_project - Get all files in a project
  • by_parent - Get children of a folder
  • by_project_parent - Efficient folder navigation

Conversations Table

Stores AI conversation metadata:
conversations: defineTable({
  projectId: v.id("projects"),
  title: v.string(),
  updatedAt: v.number(),
})
  .index("by_project", ["projectId"])

Messages Table

Stores conversation messages:
messages: defineTable({
  conversationId: v.id("conversations"),
  projectId: v.id("projects"),
  role: v.union(v.literal("user"), v.literal("assistant")),
  content: v.string(),
  status: v.optional(
    v.union(
      v.literal("processing"),
      v.literal("completed"),
      v.literal("cancelled"),
      v.literal("failed")
    )
  ),
  triggerRunId: v.optional(v.string()),  // Inngest job ID
  toolCalls: v.optional(
    v.array(
      v.object({
        id: v.string(),
        name: v.string(),
        args: v.any(),
        result: v.optional(v.any()),
      })
    )
  ),
})
  .index("by_conversation", ["conversationId"])
  .index("by_project_status", ["projectId", "status"])

Generation Events Table

Tracks AI generation progress:
generationEvents: defineTable({
  projectId: v.id("projects"),
  type: v.union(
    v.literal("step"),
    v.literal("file"),
    v.literal("info"),
    v.literal("error")
  ),
  message: v.string(),
  filePath: v.optional(v.string()),
  preview: v.optional(v.string()),
  createdAt: v.number(),
})
  .index("by_project_created_at", ["projectId", "createdAt"])

Queries

Queries read data from the database and automatically re-run when data changes.

Example: Get User Projects

export const get = query({
  args: {},
  handler: async (ctx) => {
    const identity = await verifyAuth(ctx);
    
    return await ctx.db
      .query("projects")
      .withIndex("by_owner", (q) => q.eq("ownerId", identity.subject))
      .order("desc")
      .collect();
  },
});
File: convex/projects.ts:79

Using Queries in React

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

function ProjectsList() {
  const projects = useQuery(api.projects.get);
  
  if (projects === undefined) {
    return <div>Loading...</div>;
  }
  
  return (
    <ul>
      {projects.map(project => (
        <li key={project._id}>{project.name}</li>
      ))}
    </ul>
  );
}

Parameterized Queries

export const getById = query({
  args: { id: v.id("projects") },
  handler: async (ctx, args) => {
    const identity = await verifyAuth(ctx);
    const project = await ctx.db.get(args.id);
    
    if (!project) {
      throw new Error("Project not found");
    }
    
    if (project.ownerId !== identity.subject) {
      throw new Error("Unauthorized");
    }
    
    return project;
  },
});
Usage:
const project = useQuery(api.projects.getById, { id: projectId });

Mutations

Mutations modify database state.

Example: Create Project

export const create = mutation({
  args: {
    name: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await verifyAuth(ctx);
    const stackUserId = identity.subject;
    
    // Get or create user
    let user = await ctx.db
      .query("users")
      .withIndex("by_stack_user", (q) => q.eq("stackUserId", stackUserId))
      .first();
    
    if (!user) {
      const userId = await ctx.db.insert("users", {
        stackUserId,
        email: identity.email || "",
        subscriptionStatus: "free",
        subscriptionTier: "free",
        projectLimit: 10,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });
      user = await ctx.db.get(userId);
    }
    
    // Check project limit
    if (user!.projectLimit !== -1) {
      const existingProjects = await ctx.db
        .query("projects")
        .withIndex("by_owner", (q) => q.eq("ownerId", stackUserId))
        .collect();
      
      if (existingProjects.length >= user!.projectLimit) {
        throw new Error("Project limit reached");
      }
    }
    
    // Create project
    const projectId = await ctx.db.insert("projects", {
      name: args.name,
      ownerId: stackUserId,
      userId: user!._id,
      updatedAt: Date.now(),
    });
    
    return projectId;
  },
});
File: convex/projects.ts:7

Using Mutations in React

import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";

function CreateProjectButton() {
  const createProject = useMutation(api.projects.create);
  
  const handleCreate = async () => {
    try {
      const projectId = await createProject({ name: "My Project" });
      console.log("Created project:", projectId);
    } catch (error) {
      console.error("Failed to create project:", error);
    }
  };
  
  return <button onClick={handleCreate}>Create Project</button>;
}

File Operations

Polaris uses Convex for both text and binary file storage.

Text Files

Stored directly in the content field:
// Write text file
export const writeFile = mutation({
  args: {
    projectId: v.id("projects"),
    parentId: v.optional(v.id("files")),
    name: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await verifyAuth(ctx);
    
    const fileId = await ctx.db.insert("files", {
      projectId: args.projectId,
      parentId: args.parentId,
      name: args.name,
      type: "file",
      content: args.content,
      updatedAt: Date.now(),
    });
    
    return fileId;
  },
});

Binary Files

Stored in Convex file storage:
// Upload binary file
export const uploadFile = mutation({
  args: {
    projectId: v.id("projects"),
    parentId: v.optional(v.id("files")),
    name: v.string(),
    storageId: v.id("_storage"),
  },
  handler: async (ctx, args) => {
    const identity = await verifyAuth(ctx);
    
    const fileId = await ctx.db.insert("files", {
      projectId: args.projectId,
      parentId: args.parentId,
      name: args.name,
      type: "file",
      storageId: args.storageId,
      updatedAt: Date.now(),
    });
    
    return fileId;
  },
});
File: convex/files.ts

Path-based File Access

Internal API for file operations by path (convex/system.ts):
export const writeFileByPath = mutation({
  args: {
    internalKey: v.string(),
    projectId: v.id("projects"),
    path: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    validateInternalKey(args.internalKey);
    
    const pathParts = args.path.split('/').filter(Boolean);
    let parentId: Id<"files"> | undefined = undefined;
    
    // Create parent folders if needed
    for (let i = 0; i < pathParts.length - 1; i++) {
      const folderName = pathParts[i];
      
      let folder = await ctx.db
        .query("files")
        .withIndex("by_project_parent", (q) =>
          q.eq("projectId", args.projectId).eq("parentId", parentId)
        )
        .filter((q) => q.eq(q.field("name"), folderName))
        .first();
      
      if (!folder) {
        const folderId = await ctx.db.insert("files", {
          projectId: args.projectId,
          parentId,
          name: folderName,
          type: "folder",
          updatedAt: Date.now(),
        });
        folder = await ctx.db.get(folderId);
      }
      
      parentId = folder!._id;
    }
    
    // Create or update file
    const fileName = pathParts[pathParts.length - 1];
    
    const existingFile = await ctx.db
      .query("files")
      .withIndex("by_project_parent", (q) =>
        q.eq("projectId", args.projectId).eq("parentId", parentId)
      )
      .filter((q) => q.eq(q.field("name"), fileName))
      .first();
    
    if (existingFile) {
      await ctx.db.patch(existingFile._id, {
        content: args.content,
        updatedAt: Date.now(),
      });
      return existingFile._id;
    } else {
      return await ctx.db.insert("files", {
        projectId: args.projectId,
        parentId,
        name: fileName,
        type: "file",
        content: args.content,
        updatedAt: Date.now(),
      });
    }
  },
});

Internal API

The internal API (convex/system.ts) provides privileged operations for background jobs:

Validation

function validateInternalKey(key: string) {
  const internalKey = process.env.POLARIS_CONVEX_INTERNAL_KEY;
  
  if (!internalKey) {
    throw new Error("Internal key not configured");
  }
  
  if (key !== internalKey) {
    throw new Error("Invalid internal key");
  }
}

Available Operations

  • writeFileByPath - Write file by path (creates parent folders)
  • getAllProjectFiles - Get all files in a project
  • updateProjectImportStatus - Update GitHub import status
  • updateProjectExportStatus - Update GitHub export status
Internal API mutations bypass authentication. Only call from trusted server-side code (Inngest jobs).

Real-time Subscriptions

Convex queries are reactive - they automatically re-run when data changes:
function LiveProjectsList() {
  // Automatically updates when projects change
  const projects = useQuery(api.projects.get);
  
  return (
    <ul>
      {projects?.map(project => (
        <li key={project._id}>
          {project.name}
          <span>Updated: {new Date(project.updatedAt).toLocaleString()}</span>
        </li>
      ))}
    </ul>
  );
}
When another client creates, updates, or deletes a project:
  1. Convex detects the change
  2. Re-runs affected queries
  3. Sends new data to subscribed clients
  4. React re-renders with updated data
All of this happens automatically with zero configuration!

Optimistic Updates

Mutations update the UI immediately while running in the background:
const createProject = useMutation(api.projects.create);

const handleCreate = async () => {
  // UI updates immediately with optimistic ID
  const projectId = await createProject({ name: "New Project" });
  
  // Navigate immediately - don't wait for server
  router.push(`/projects/${projectId}`);
};
If the mutation fails, Convex automatically reverts the optimistic update.

Type Safety

Convex generates TypeScript types for all queries and mutations:
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";

// Full type safety
const projects = useQuery(api.projects.get);
//    ^? const projects: { _id: Id<"projects">, name: string, ... }[] | undefined

const createProject = useMutation(api.projects.create);
//    ^? (args: { name: string }) => Promise<Id<"projects">>

Performance Optimization

Convex includes several performance optimizations:
  • Automatic Indexing - Queries use indexes for fast lookups
  • Query Caching - Results are cached and reused across components
  • Batched Updates - Multiple mutations are batched into a single network request
  • Lazy Loading - Only fetch data when components render
  • Pagination - Use .take() to limit results:
const recentProjects = await ctx.db
  .query("projects")
  .withIndex("by_owner", (q) => q.eq("ownerId", userId))
  .order("desc")
  .take(10);  // Only fetch 10 most recent

Next Steps

Stack Auth

Learn how authentication works with Convex

GitHub Integration

Import and export projects to GitHub

Build docs developers (and LLMs) love