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<T> at emit/receive time. Add extra payload fields with .data<T>().
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") becomeLocked<T>in emit/receive. - .data<T>() — 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:
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).
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
types: { Order } only gives Ref<Order>, a signal's typed path params arrive as Locked<T>. 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<T> entities — the emitter must hold a lock on each, and the lock travels through to any receiver. Data fields are supplied alongside.
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:
// 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 waitingMulti-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.
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.
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 existBest Practices
- Name paths, not events —
/order/:order/shippedreads better thanorders.shippedand encodes routing identity. - One signal group per domain — group related signals into a single
defineSignalsclass (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<T>()) 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.