Process Compiler

How durable processes are transformed at build time

The process compiler transforms your async handler functions into switch-based state machines at build time. This enables processes to suspend, persist their state, and resume from exactly where they left off - while keeping the generated code readable and debuggable.

Compilation Pipeline

When you run the build, createProcess() calls are transformed through several stages:

  • Detection - Find all createProcess() calls in source files
  • Analysis - Parse the handler to find suspension points (signals, delays, races)
  • Step extraction - Each suspension point becomes a separate case in the switch
  • Variable rewriting - Local variables become state.vars.xxx lookups
  • Rehydration insertion - using declarations are re-fetched at each step
  • Code generation - Output the compiled __createSwitchProcess call

Before and After

Here's what the compiler does to your code:

Files
forgot-password.process.tsTypeScript
import { createProcess, signal, race, delay } from '@justscale/process'
import { UserService, NotificationService, AuthSignals } from './services'

export const forgotPassword = createProcess({
  path: '/auth/forgot-password/:email',
  inject: { users: UserService, notifications: NotificationService, signals: AuthSignals },

  async handler({ users, notifications, signals }, [email]) {
    // 'using' marks values for rehydration on resume
    using user = await users.findByEmail(email)

    if (!user) {
      return { success: true, message: 'If account exists, email sent.' }
    }

    const resetToken = generateToken()
    await notifications.sendPasswordResetEmail(email, resetToken)

    // Race between signal and timeout - suspends here
    const r = race()
    switch (true) {
      case signal(r, signals.passwordResetVerified):
        await users.updatePassword(user.id, r.newPassword)
        return { success: true, message: 'Password reset.' }

      case delay.minutes(r, 15):
        return { success: false, error: 'timeout' }
    }
  },
})

The State Machine

Instead of opcodes, the compiler generates a while(true) + switch(step) state machine. Each suspension point becomes a separate case:

  • Case 0 - Entry point, runs until first suspension
  • Case 1, 2, ... - Resume points after each signal/timer

Return Tuple

The execute function returns [status, value]:

return-values.tsTypeScript
// Status 0: Process completed with result
__r[0] = 0
__r[1] = { success: true }
break main_loop

// Status 1: Process suspended, waiting for signal/timer
__r[0] = 1
__r[1] = { race: state.vars.__raceBranches }
break main_loop

// The executor uses this to:
// - status 0: Mark process as 'completed', resolve handle.wait()
// - status 1: Mark process as 'suspended', subscribe to signals/timers

Rehydration

Variables declared with using are non-serializable (like database entities with methods). The compiler ensures they're re-fetched at the start of each case:

rehydration.tsTypeScript
// Source code:
using user = await users.findByEmail(email)

// In EVERY case that uses 'user':
case 0: {
  user = await services.users.findByEmail(state.vars.email)
  __dispose[__dispose_i++] = user  // Track for cleanup
  // ... rest of case
}

case 1: {
  // Re-fetch on resume!
  user = await services.users.findByEmail(state.vars.email)
  __dispose[__dispose_i++] = user
  // ... rest of case
}

This ensures the user object is fresh after suspension, not stale serialized data.

Race Handling

The race() pattern compiles to branch setup and suspension:

race-compilation.tsTypeScript
// Source:
const r = race()
switch (true) {
  case signal(r, signals.passwordResetVerified):
    return { verified: true, token: r.token }
  case delay.minutes(r, 15):
    return { expired: true }
}

// Compiled:
state.vars.__raceBranches = [
  { id: "signals.passwordResetVerified", signal: "...", resumeStep: 1 },
  { id: "__timer__", timer: { minutes: 15 }, resumeStep: 2 }
]
__r[0] = 1
__r[1] = { race: state.vars.__raceBranches }
break main_loop

// When signal fires, executor sets:
state.vars.__raceResult = payload
state.step = 1  // or 2 for timer
// Then re-runs execute()

Variable Storage

Local variables that cross suspension points are stored in state.vars:

variable-storage.tsTypeScript
// Source:
const resetToken = generateToken()
const expiresAt = new Date(Date.now() + 15 * 60 * 1000)

// After suspension, access via state.vars:
state.vars.resetToken = generateToken()
state.vars.expiresAt = new Date(Date.now() + 15 * 60 * 1000)

// Variables NOT crossing suspensions stay as locals:
const localTemp = compute()  // Not stored in state

Metadata

The compiled process includes metadata for debugging and versioning:

metadata.tsTypeScript
{
  // Unique process identifier
  id: "auth_forgot-password__email",

  // Route pattern
  path: "/auth/forgot-password/:email",

  // Version hash - changes when suspension structure changes
  version: "v_9b6066ad",

  // Maps step IDs to case numbers (for debugging)
  stepMap: {
    "entry_4311c3e9": 0,
    "branch_747c6f71": 1,
    "branch_c496ea1b": 2
  },

  // Maps cases to source line numbers (for stack traces)
  sourceMap: {
    0: [11, 26],   // Entry spans lines 11-26
    1: [21, 21],   // Signal branch at line 21
    2: [24, 24]    // Timeout branch at line 24
  },

  // Signal definitions for runtime subscription
  signals: {
    "signals.passwordResetVerified": {
      identity: [],
      payloadType: "{ token: string; newPassword: string }"
    }
  }
}

Dispose Pattern

The compiler tracks using declarations and calls Symbol.dispose on cleanup:

dispose.tsTypeScript
// Track disposables
const __dispose = [undefined]
let __dispose_i = 0

// When 'using' value is assigned
user = await services.users.findByEmail(state.vars.email)
__dispose[__dispose_i++] = user

// After main_loop exits (completion or suspension)
while (__dispose_i > 0) {
  __dispose?.[--__dispose_i]?.[Symbol.dispose]?.()
}

Compile-Time Errors

The compiler detects patterns that can't be safely suspended:

errors.tsTypeScript
// ERROR: try/catch around suspension
try {
  await signal(orders.paid)  // Can't serialize exception state
} catch (e) { }

// ERROR: for-of loop with suspension
for (const item of items) {
  await signal(item.processed)  // Iterator not serializable
}

// ERROR: Promise.all with signals
await Promise.all([signal(a), signal(b)])  // Use race() instead

// ERROR: Storing signal in variable
const sig = signal(orders.paid)  // Must await directly
await sig

Delay Syntax

The compiler supports a cleaner delay syntax:

delay-syntax.tsTypeScript
// New syntax - method on delay
case delay.minutes(r, 15):
case delay.hours(r, 24):
case delay.days(r, 7):
case delay.seconds(r, 30):

// Also works standalone
await delay.hours(24)
await delay.minutes(5)

Version Hashing

The version hash changes when the suspension structure changes:

versioning.tsTypeScript
version: "v_9b6066ad"  // Hash of suspension structure

// Hash CHANGES when:
// - Signal names change
// - Suspension points added/removed
// - Race branches change
// - Step order changes

// Hash does NOT change when:
// - Code inside cases changes (logic, comments)
// - Variable names change
// - Non-suspending code changes

Version mismatches are detected at resume time, enabling migration strategies for in-flight processes.

Runtime Execution

At runtime, the executor manages the state machine:

  • Calls execute(ctx) with current state and services
  • On [0, result] - marks process complete, resolves handle.wait()
  • On [1, {race: branches}] - persists state, subscribes to signals/timers
  • When signal fires - sets state.step to resume step, re-runs execute()
  • Uses sourceMap to provide meaningful stack traces