<!-- Markdown mirror of https://justscale.sh/docs/processes/signals -->

# Signals

Event-driven suspension and resumption of durable processes

Signals are typed events that suspend a durable process until they're emitted. They enable long-running workflows to wait for external events like webhook callbacks, user actions, or scheduled triggers. Signals are declared with `defineSignals`, a class factory that groups related signals and makes them injectable.

## Defining Signals

Each signal is a `signal('/path')` inside a `defineSignals` factory. Path params become the routing identity. Attach model types with `.types({ Model })` so matching params carry `Locked` at emit/receive time. Add extra payload fields with `.data()`.

Files

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

src/order-signals.tsTypeScript

```typescript
import { defineSignals } from '@justscale/core/process';
import { Order, User } from './models';

/**
 * Signal groups are injectable services. defineSignals wires the executor
 * internally — you never see createSignal or AbstractProcessExecutor.
 */
export class OrderSignals extends defineSignals((signal) => ({
  // :order matches Order in .types() → r.order is Locked<Order>
  paymentReceived: signal('/order/:order/payment/received')
    .data<{ txId: string; amount: number }>()
    .types({ Order }),

  // No extra data — just the typed entity
  delivered: signal('/order/:order/delivered')
    .types({ Order }),

  // Multiple typed path params
  itemReturned: signal('/order/:order/user/:user/item-returned')
    .data<{ reason: string }>()
    .types({ Order, User }),

  // String identity (path param not in .types() stays as string)
  batchProcessed: signal('/order/batch/:batchId')
    .data<{ count: number }>(),
})) {}
```

### What each piece does

- Path — defines the routing namespace. Params are extracted and used as the signal's identity (subscribers with matching identity wake up).
- .types({ Model }) — path param keys matched against model class names (direct, lowercased, or lowercased + "Ref") become Locked in emit/receive.
- .data() — declares extra payload fields merged into the emit argument and the received race result.
- Unmatched path params — stay as string. Useful for opaque IDs that don't correspond to a model.

## Registering the Signal Group

Because `defineSignals` returns a class, you register it in the builder like any other service and inject it into processes, services, or controllers:

bootstrap.tsTypeScript

```typescript
JustScale()
  .add(InMemoryLockFeature)
  .add(InMemoryProcessFeature)
  .add(OrderSignals)          // Register the signal group
  .add(OrderFulfillment)      // Process that uses it
  .build()
```

## Waiting for Signals

Inside a process handler, race multiple signals with `race()` + `switch(true)`. The `signal(r, target)` case narrows `r` to that signal's payload (typed path params as Locked, plus data fields).

Files

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

src/fulfillment.process.tsTypeScript

```typescript
import { createProcess, signal, race, delay } from '@justscale/core/process';
import { Order } from './models';
import { OrderSignals } from './order-signals';

export const FulfillmentProcess = createProcess({
  path: '/order/:order/fulfillment',
  types: { Order },                              // :order → Ref<Order>
  inject: { orders: OrderSignals },

  async handler({ orders }, { order }) {
    const r = race();

    switch (true) {
      case signal(r, orders.paymentReceived):
        // r.order → Locked<Order>
        // r.txId → string, r.amount → number
        break;

      case delay.days(r, 3):
        return { status: 'payment_timeout' as const, order };
    }

    // Subsequent suspension — await form (no race, single signal)
    await signal(orders.delivered);

    return { status: 'delivered' as const };
  },
});
```

💡Tip

Even though the process's own `types: { Order }` only gives `Ref`, a signal's typed path params arrive as `Locked`. The lock was acquired by the emitter and the receiver gets proof of it — no re-locking required inside the case block.

## Emitting Signals

Signals are callable with a single object argument. Typed path params take `Locked` entities — the emitter must hold a lock on each, and the lock travels through to any receiver. Data fields are supplied alongside.

Files

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

srcfulfillment.process.tsmodels.tsorder-signals.tswebhook.controller.ts

src/webhook.controller.tsTypeScript

```typescript
import { createController } from '@justscale/core';
import { ModelRepository } from '@justscale/core/models';
import { Post } from '@justscale/http';
import { z } from 'zod';
import { Order } from './models';
import { OrderSignals } from './order-signals';

const StripePayload = z.object({
  type:          z.string(),
  orderId:       z.string(),
  transactionId: z.string(),
  amount:        z.number(),
});

export const StripeWebhookController = createController('/webhooks/stripe', {
  inject: {
    orders:    OrderSignals,
    orderRepo: ModelRepository.of(Order),
  },

  routes: ({ orders, orderRepo }) => ({
    paymentIntent: Post('/payment-intent')
      .body(StripePayload)
      .handle(async ({ body, res }) => {
        // Lock the order before emitting — the receiver will see Locked<Order>.
        using order = await orderRepo.lock(Order.ref(body.orderId));
        if (!order) { res.status(404).end(); return; }

        if (body.type === 'payment_intent.succeeded') {
          await orders.paymentReceived({
            order,
            txId:   body.transactionId,
            amount: body.amount,
          });
        }

        res.json({ received: true });
      }),
  }),
});
```

## Signal Routing

When a signal fires, the runtime matches it against subscribed processes by identity — each path param of the signal is compared to the identity of each waiting process. Only processes with *all* path params matching receive it:

routing-example.tsTypeScript

```typescript
// Start two processes, each with a different order ref
await FulfillmentProcess([Order.ref('order-123')])
await FulfillmentProcess([Order.ref('order-456')])

// Emit for order-123 — the identity { order: 'order-123' } routes
// only to the first process.
using order123 = await orderRepo.lock(Order.ref('order-123'))
await orders.paymentReceived({ order: order123!, txId: 'tx_abc', amount: 100 })
// → first process resumes, second keeps waiting
```

### Multi-param routing

Signals with multiple typed params must have all of them match the subscribing process's identity. Good for scoping returns to a specific item in an order, a specific user in a tenant, etc.

## Signal vs Direct Await

The `signal()` wrapper is required for the compiler to recognize suspension points. Directly awaiting a signal object throws at runtime.

signal-usage.tsTypeScript

```typescript
async handler({ orders }, { order }) {
  const r = race()
  switch (true) {
    // CORRECT: signal(r, target) in a switch-true case
    case signal(r, orders.paymentReceived):
      return { paid: true }
  }

  // CORRECT: await signal(target) — single-signal suspension
  await signal(orders.delivered)

  // WRONG: direct await throws
  // await orders.paymentReceived    // runtime error

  // WRONG: stashing the wrapper
  // const s = signal(orders.paymentReceived)  // cannot be awaited later
}
```

## Type Safety

The builder chain carries types end to end: emitters are checked against the declared shape, and the `switch(true)` narrowing picks up exactly what the signal delivers.

type-safety.tsTypeScript

```typescript
class PaymentSignals extends defineSignals(signal => ({
  received: signal('/payment/:order/received')
    .data<{ txId: string; amount: number }>()
    .types({ Order }),
})) {}

// Emitting — type-checked:

// Error: 'order' must be Locked<Order>, not a string
await svc.received({ order: 'order-123', txId: 't', amount: 1 })

// Error: missing required 'amount' field
await svc.received({ order: locked, txId: 't' })

// Error: 'amount' is number, not string
await svc.received({ order: locked, txId: 't', amount: '1' })

// In a race — narrowed by switch(true) + signal(r, target):
case signal(r, svc.received):
  r.order    // Locked<Order>
  r.txId     // string
  r.amount   // number
  r.other    // Error: property 'other' does not exist
```

## Best Practices

- Name paths, not events — /order/:order/shippedreads better than orders.shipped and encodes routing identity.
- One signal group per domain — group related signals into a single defineSignals class (e.g. OrderSignals, PaymentSignals).
- Lock before emit — if a signal has a typed path param, the emitter must using x = await repo.lock(ref) beforehand. Receivers rely on that lock.
- Keep payloads serializable — data fields (from .data()) must round-trip through storage; stick to primitives, arrays, plain objects, and declared Processable types.
- Emit for the identity you want — re-emitting a signal is safe; only waiting processes with matching identity will resume.

## Next Steps

- Runtime & Testing
- Compiler
- Services
