Skip to content

Latest commit

 

History

History
725 lines (581 loc) · 16.3 KB

typescript.md

File metadata and controls

725 lines (581 loc) · 16.3 KB
title TypeScript
description The Volcano SDK includes comprehensive TypeScript definitions for type-safe development. This guide covers type usage, generics, and best practices.

The Volcano SDK includes comprehensive TypeScript definitions for type-safe development. This guide covers type usage, generics, and best practices.

Installation

TypeScript definitions are included with the SDK - no additional packages needed:

npm install @volcano.dev/sdk

Basic Usage

Import types alongside the SDK:

import { VolcanoAuth } from '@volcano.dev/sdk';
import type { User, Session, AuthResponse } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: process.env.VOLCANO_API_URL!,
  anonKey: process.env.VOLCANO_ANON_KEY!,
});

// Types are inferred automatically
const { user, session, error } = await volcano.auth.signIn({
  email: 'user@example.com',
  password: 'password123',
});

if (user) {
  console.log(user.email); // TypeScript knows this is a string
}

Core Types

Configuration

interface VolcanoAuthConfig {
  /** API URL (defaults to https://api.volcano.dev) */
  apiUrl?: string;
  /** Project anon key (required) */
  anonKey: string;
  /** Access token for server-side use */
  accessToken?: string;
  /** Refresh token for server-side use */
  refreshToken?: string;
}

User (root SDK)

The root SDK and the Next.js middleware entrypoint export separate User types.

type UserStatus = 'active' | 'banned' | 'deleted';

interface User {
  id: string;
  project_id?: string;
  email: string;
  email_confirmed?: boolean;
  user_metadata?: Record<string, JsonValue>;
  app_metadata?: Record<string, JsonValue>;
  avatar_url?: string;
  status?: UserStatus;
  banned_until?: string | null;
  last_sign_in_at?: string;
  created_at: string;
  updated_at: string;
}

Session

interface Session {
  access_token: string;
  refresh_token: string;
  expires_in: number;
}

Response Types

interface AuthResponse {
  user: User | null;
  session: Session | null;
  error: Error | null;
}

interface UserResponse {
  user: User | null;
  error: Error | null;
}

interface SessionResponse {
  session: Session | null;
  error: Error | null;
}

Database Queries

Typed Query Results

Use generics for type-safe query results:

interface Post {
  id: string;
  title: string;
  content: string;
  status: 'draft' | 'published' | 'archived';
  user_id: string;
  created_at: string;
}

// Query returns Post[]
const { data, error } = await volcano.from<Post>('posts').select('*').eq('status', 'published');

if (data) {
  data.forEach((post) => {
    console.log(post.title); // TypeScript knows this is string
    console.log(post.status); // TypeScript knows this is 'draft' | 'published' | 'archived'
  });
}

Insert with Types

// TypeScript validates the insert values
const { data, error } = await volcano.insert<Post>('posts', {
  title: 'New Post',
  content: 'Content here',
  status: 'draft',
  // user_id is likely auto-set by RLS
});

Update with Types

const { data, error } = await volcano
  .update<Post>('posts', {
    status: 'published', // TypeScript ensures this is a valid status
  })
  .eq('id', postId);

Query Builder Types

interface QueryBuilder<T> {
  select(columns: string): QueryBuilder<T>;
  eq(column: string, value: FilterValue): QueryBuilder<T>;
  neq(column: string, value: FilterValue): QueryBuilder<T>;
  gt(column: string, value: FilterValue): QueryBuilder<T>;
  gte(column: string, value: FilterValue): QueryBuilder<T>;
  lt(column: string, value: FilterValue): QueryBuilder<T>;
  lte(column: string, value: FilterValue): QueryBuilder<T>;
  like(column: string, pattern: string): QueryBuilder<T>;
  ilike(column: string, pattern: string): QueryBuilder<T>;
  is(column: string, value: null): QueryBuilder<T>;
  in(column: string, values: FilterValue[]): QueryBuilder<T>;
  order(column: string, options?: { ascending?: boolean }): QueryBuilder<T>;
  limit(count: number): QueryBuilder<T>;
  offset(count: number): QueryBuilder<T>;
  execute(): Promise<QueryResult<T>>;
  then<TResult1 = QueryResult<T>, TResult2 = never>(
    resolve?: ((value: QueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
    reject?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
  ): Promise<TResult1 | TResult2>;
}

interface QueryResult<T> {
  data: T[] | null;
  error: Error | null;
  count?: number;
}

Query and mutation builders are thenable: await volcano.from<Post>('posts').select('*') and await volcano.insert<Post>('posts', values) infer QueryResult<Post>. Calling .execute() explicitly returns the same result type.

Storage Types

interface StorageObject {
  id: string;
  bucket_id: string;
  name: string;
  owner_id?: string;
  is_public: boolean;
  size: number;
  mime_type: string;
  etag?: string;
  metadata?: Record<string, JsonValue>;
  created_at: string;
  updated_at: string;
  public_url?: string;
}

interface StorageUploadOptions {
  contentType?: string;
}

interface StorageUploadResponse {
  data: StorageObject | null;
  error: Error | null;
}

interface StorageDownloadResponse {
  data: Blob | null;
  error: Error | null;
}

interface StorageListResponse {
  data: StorageObject[] | null;
  error: Error | null;
  nextCursor: string | null;
}

Usage

const storage = volcano.storage.from('avatars');

// Upload
const { data, error }: StorageUploadResponse = await storage.upload('user/avatar.jpg', file, {
  contentType: 'image/jpeg',
});

// Download
const { data: blob }: StorageDownloadResponse = await storage.download('user/avatar.jpg');

// List
const { data: files, nextCursor }: StorageListResponse = await storage.list('user/', {
  limit: 100,
});

Realtime Types

Import realtime types from the realtime module:

import { VolcanoRealtime, RealtimeChannel } from '@volcano.dev/sdk/realtime';
import type {
  PostgresChange,
  PresenceState,
  ConnectContext,
  DisconnectContext,
  ErrorContext,
  RealtimeConfig,
  ChannelOptions,
  WebSocketConstructor,
} from '@volcano.dev/sdk/realtime';

Realtime Configuration

interface RealtimeConfig {
  apiUrl: string;
  anonKey: string;
  accessToken?: string;
  getToken?: () => Promise<string>;
  volcanoClient?: VolcanoAuth;
  fetchConfig?: FetchConfig;
  webSocket?: WebSocketConstructor;
}

interface ChannelOptions {
  type?: 'broadcast' | 'presence' | 'postgres';
  autoFetch?: boolean;
  fetchBatchWindowMs?: number;
  fetchMaxBatchSize?: number;
}

Event Contexts

interface ConnectContext {
  client?: string;
  latency?: number;
}

interface DisconnectContext {
  code?: number;
  reason?: string;
  reconnect?: boolean;
}

interface ErrorContext {
  error?: unknown;
  message?: string;
  code?: number;
}

message and code are validated fields from the connection event. error is the original transport value, which may be a plain object rather than an Error; narrow it before reading transport-specific fields.

Postgres Changes

interface PostgresChange {
  table: string;
  schema: string;
  type: 'INSERT' | 'UPDATE' | 'DELETE';
  record?: Record<string, unknown>;
  old_record?: Record<string, unknown>;
  columns?: string[];
  timestamp: string;
  id?: string | number;
  mode?: 'lightweight';
}

Typed Postgres Listeners

interface Message {
  id: string;
  content: string;
  user_id: string;
  created_at: string;
}

channel.onPostgresChanges('INSERT', 'public', 'messages', (change: PostgresChange) => {
  const message = change.record as Message;
  console.log('New message:', message.content);
});

Presence State

import type { PresenceState } from '@volcano.dev/sdk/realtime';

channel.onPresenceSync((state: PresenceState) => {
  for (const connection of Object.values(state)) {
    console.log(connection.client, connection.user, connection.connInfo);
  }
});

Each entry is a server connection record. One user can have multiple connections. connInfo and chanInfo contain server metadata when present. track() keeps local application state; it does not place custom fields on these records.

Functions Types

Typed Function Invocation

interface InvokeParams {
  userId: string;
  action: 'fetch' | 'update' | 'delete';
}

interface InvokeResult {
  success: boolean;
  data?: Record<string, unknown>;
  message?: string;
}

const { data, error } = await volcano.functions.invoke<InvokeParams, InvokeResult>('process-user', {
  userId: '123',
  action: 'fetch',
});

if (data) {
  console.log(data.success); // TypeScript knows this is boolean
  console.log(data.message); // TypeScript knows this is string | undefined
}

A platform-layer invocation failure is a VolcanoSystemError (exported from @volcano.dev/sdk). Narrow with the VolcanoSystemError.is() type guard — it gives full type narrowing without a cast and holds across duplicate SDK copies in a bundle:

import { VolcanoSystemError } from '@volcano.dev/sdk';

if (error && VolcanoSystemError.is(error)) {
  // deploy down/failed, gateway error, or network failure — infra, retryable
  console.error(error.status, error.message); // `status` is typed here
}

Durable Function Types

Writing a durable function and calling one are two different type surfaces. The handler's types come from the /durable subpath, which is where the authoring API lives:

import { durable, type DurableContext, type DurableHandler } from '@volcano.dev/sdk/durable';

interface OrderInput {
  order_id: number;
}

interface OrderResult {
  order_id: number;
  outcome: 'shipped' | 'refunded';
}

// `DurableContext` is what a helper takes when steps live outside the handler.
async function charge(ctx: DurableContext, order: Order) {
  return ctx.step('charge', () => chargeCard(order));
}

const run: DurableHandler<OrderInput, OrderResult> = async (input, ctx) => {
  const order = await ctx.step('load', () => loadOrder(input.order_id));
  await charge(ctx, order);
  await ctx.wait('settle', '30s');
  return { order_id: order.id, outcome: 'shipped' };
};

export const handler = durable(run);

ctx.step, ctx.map and ctx.parallel are generic in what they return, so a step's result type flows into the rest of the handler without a cast. StepOptions, WaitUntilOptions, BatchOptions, BatchResult, Retry and DurableDuration are exported alongside them for anything you build on top, as are WaitForApprovalOptions and the ApprovalDecision that ctx.waitForApproval resolves with.

Starting and reading executions uses the main entry point instead:

import type { DurableExecutionStatus } from '@volcano.dev/sdk';

const running: DurableExecutionStatus = 'running';
const { data } = await volcano.durable.list(projectId, 'order-pipeline', { status: running });

const execution = await volcano.durable.get(projectId, 'order-pipeline', executionId);
if (execution.data?.status === 'succeeded') {
  const result = execution.data.result as OrderResult;
}

DurableExecution is the handle start, get and stop resolve with, and PaginatedDurableExecutions is what list returns. Both are generated from the API contract, so they follow the wire's snake_case (function_id, created_at, result_expired). result is unknown there — the platform returns whatever the handler produced and cannot know its type, so narrow or assert it on the way out.

volcano.durable.approvals resolves with DurableApproval, PaginatedDurableApprovals, and DurableApprovalStats, generated the same way, and takes DurableApprovalListOptions and DurableApprovalStatsOptions.

OAuth Types

type OAuthProviderName = 'google' | 'github' | 'microsoft' | 'apple';

interface OAuthProvider {
  provider: OAuthProviderName;
  linked_at: string;
  updated_at: string;
}

interface OAuthAPIParams {
  endpoint: string;
  method?: string;
  body?: JsonValue;
}

Usage

// TypeScript ensures valid provider names
volcano.auth.signInWithOAuth('google'); // OK
volcano.auth.signInWithOAuth('invalid'); // Error: not assignable to 'OAuthProviderName'

// Get linked providers
const { providers } = await volcano.auth.getLinkedOAuthProviders();
if (providers) {
  providers.forEach((p: OAuthProvider) => {
    console.log(`${p.provider} linked at ${p.linked_at}`);
  });
}

Session Management Types

interface AuthSession {
  id: string;
  user_id: string;
  provider: 'email' | 'google' | 'github' | 'microsoft' | 'apple' | 'anonymous';
  user_agent?: string;
  ip_address?: string;
  last_ip_address?: string;
  expires_at: string;
  last_activity_at?: string;
  session_started_at?: string;
  is_active: boolean;
  is_current: boolean;
  created_at: string;
  updated_at: string;
}

interface SessionsResponse {
  sessions: AuthSession[] | null;
  total: number;
  page: number;
  limit: number;
  total_pages: number;
  error: Error | null;
}

Middleware Types

The middleware User requires id, email, and status. Its timestamps are optional, matching the auth response contract.

import type {
  ServerClientConfig,
  ServerClient,
  User,
  GetUserResult,
  RefreshTokenResult,
} from '@volcano.dev/sdk/next/middleware';

interface User {
  id: string;
  email: string;
  status: 'active' | 'banned' | 'deleted';
  user_metadata?: Record<string, unknown>;
  created_at?: string;
  updated_at?: string;
}

interface ServerClientConfig {
  anonKey: string;
  apiUrl?: string;
  accessToken?: string;
}

interface ServerClient {
  getUser(accessToken: string): Promise<GetUserResult>;
  refreshToken(refreshToken: string): Promise<RefreshTokenResult>;
}

interface GetUserResult {
  user: User | null;
  error: Error | null;
}

interface RefreshTokenResult {
  accessToken: string | null;
  refreshToken: string | null;
  error: Error | null;
}

Utility Types

JsonValue

A recursive type for JSON-serializable values:

type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

FilterValue

Values accepted by query filters:

type FilterValue = string | number | boolean | null | Date;

UserMetadata

Type for user metadata:

type UserMetadata = Record<string, JsonValue>;

// Usage
await volcano.auth.signUp({
  email: 'user@example.com',
  password: 'password',
  metadata: {
    name: 'Alice',
    preferences: {
      theme: 'dark',
      notifications: true,
    },
  } as UserMetadata,
});

Best Practices

1. Define Your Data Models

Create interfaces for your database tables:

// types/database.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  status: 'draft' | 'published' | 'archived';
  user_id: string;
  created_at: string;
  updated_at: string;
}

export interface Comment {
  id: string;
  post_id: string;
  user_id: string;
  content: string;
  created_at: string;
}

export interface User {
  id: string;
  email: string;
  name: string;
  avatar_url?: string;
}

2. Use Type Guards

function isPost(data: unknown): data is Post {
  return (
    typeof data === 'object' &&
    data !== null &&
    'id' in data &&
    'title' in data &&
    'content' in data
  );
}

const { data } = await volcano.from('posts').select('*');

if (data && data.every(isPost)) {
  // data is typed as Post[]
}

3. Handle Null Errors

Always check for errors and null data:

const { data, error } = await volcano.from<Post>('posts').select('*');

if (error) {
  // Handle error
  console.error(error.message);
  return;
}

if (!data) {
  // Handle empty result
  return;
}

// data is guaranteed to be Post[]
data.forEach((post) => {
  console.log(post.title);
});

4. Type Your Callbacks

const unsubscribe = volcano.auth.onAuthStateChange((user: User | null) => {
  if (user) {
    // TypeScript knows user properties
    console.log(user.email);
  }
});

5. Use Strict Mode

Enable strict TypeScript for best type checking:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true
  }
}

Next Steps