Retrofit an Existing App (full guide)
New here? Read the five-minute retrofit overview first, then return for DSL and production detail.
Quick start (5 minutes)
Prerequisites: Node.js 18+, @causet/sdk-node 0.2.0, a running Causet runtime (causet local up or Managed Cloud), and env vars CAUSET_API_URL, CAUSET_PLATFORM, CAUSET_APPLICATION.
Goal: keep POST /api/orders/:id/refund (or one endpoint). Submit a REFUND_REQUESTED intent. Return 202 Accepted. Let Causet own the workflow timeline.
When to use this: multi-step workflows with external calls, partial failures, or audit/replay requirements.
Architecture (one endpoint)
Client → your API route → validate + write local row → client.submitIntent(...) → 202
↓
Causet runtime → rules → events → projections / webhooksBefore / after
| Before | After |
|---|---|
| Route owns validate → DB → provider → email inline | Route submits intent, returns 202 |
| Failures reconstructed from logs | Timeline shows each committed step |
Minimal SDK submission
const result = await client.submitIntent(
'refund_stream', refund.id, 'REFUND_REQUESTED',
{ refund_id: refund.id, order_id, amount, reason },
refund.id, // idempotency key
);
return Response.json({ status: 'accepted' }, { status: 202 });client.submitIntent() (or language equivalents) to submit intents from your application.Verify
causet inspect timeline --entity <refund-id> --stream refund_stream --fork sandboxFull DSL, webhooks, retries, and rollout detail continues below.
Causet sits beside your app. Keep the frontend, API routes, database, queues, and third-party clients. Move one fragile workflow into a durable, inspectable timeline.
Your API route should accept the request. Causet should own what happens next.
Examples below come from the real retrofit-commerce app in the Causet repo — only POST /api/orders/:id/refund is retrofitted.
What problem this solves
Existing endpoints often hide multi-step workflows:
POST /api/orders/:id/refund
→ validate refund
→ write refund row
→ call payment provider
→ update order status
→ send webhook / email
→ maybe fail halfway (timeout, no auto-retry)When a step fails, it is hard to know what completed. Logs are not a timeline. Retry and repair become guesswork.
Causet turns that into a replayable timeline of intents and events — without rewriting the product.
When to use Causet
- Multiple steps can partially fail
- External services are involved (payments, email, CRM, approvals)
- You need to explain what happened after an incident
- You want replay, fork, or repair later
- Webhooks or jobs will advance the same workflow later
When not to use Causet
- Simple CRUD with no meaningful lifecycle
- No need for audit, replay, or timeline inspection
- Failures are cheap and easy to retry by hand
- Everything must stay in one DB transaction you will not split
Full guidance: When Not to Use Causet.
Concrete example: refund endpoint
Source of truth: examples/retrofit-commerce in the Causet repo.
Existing endpoint
POST /api/orders/:id/refund
Everything else (products, customers, orders, inventory, payments) stays in the existing app.
Old behavior
// Fragile: route owns the whole refund workflow
await refundService.processDirectly(refund);Provider timeout → refund marked failed → manual retry only.
Causet behavior
POST /api/orders/:id/refund
→ validate + write local refund row (existing DB)
→ submit REFUND_REQUESTED intent
→ return 202 Accepted
Causet
→ validates policy / risk
→ emits REFUND_REQUESTED → REFUND_APPROVED → …
→ schedules SUBMIT_REFUND_TO_PROVIDER
→ retries on retryable provider failure
→ emits REFUND_COMPLETED / REFUND_FAILED
→ webhook updates existing app DBHow to wrap the endpoint
Use @causet/sdk-node from your existing server (Express, Next.js Route Handler, etc.):
import { createCausetClient } from '@causet/sdk-node';
const client = createCausetClient({
apiUrl: process.env.CAUSET_API_URL, // local: http://localhost:8085
platformSlug: process.env.CAUSET_PLATFORM,
appSlug: process.env.CAUSET_APPLICATION,
forkId: process.env.CAUSET_FORK ?? 'sandbox',
apiKey: process.env.CAUSET_API_KEY,
});
await client.init();
const result = await client.submitIntent(
'refund_stream',
refund.id,
'REFUND_REQUESTED',
{
refund_id: refund.id,
order_id: refund.orderId,
customer_id: order.customerId,
amount: refund.amount,
reason: refund.reason,
requested_at: refund.createdAt,
},
refund.id, // idempotency key
);
if (!result.accepted) {
throw new Error(result.rejectionMessage || 'Causet intent rejected');
}
return Response.json(
{ status: 'accepted', refundId: refund.id },
{ status: 202 },
);Point CAUSET_API_URL at local (causet local up → http://localhost:8085) or Causet Cloud. See CLI and What runs today?.
Real Causet DSL (refund workflow)
Layout (from examples/retrofit-commerce/causet):
causet/
app.causet
states/refund.state.causet
events/refund.events.causet
actions/refund.actions.causet
sagas/refund.sagas.causet
projections/refund.projections.causet
queries/refund.queries.causetApp manifest
dsl_version: 1
app: retrofit_commerce
includes:
states:
- ./states/**/*.state.causet
events:
- ./events/**/*.events.causet
actions:
- ./actions/**/*.actions.causet
listeners:
- ./listeners/**/*.listeners.causet
projections:
- ./projections/**/*.projections.causet
queries:
- ./queries/**/*.queries.causet
sagas:
- ./sagas/**/*.sagas.causetState
state:
refund:
entity_key: refund_id
description: Durable refund execution workflow entity
fields:
- name: refund_id
type: string
default: ""
- name: order_id
type: string
default: ""
- name: customer_id
type: string
default: ""
- name: amount
type: number
default: 0
- name: reason
type: string
default: ""
- name: status
type: string
default: requested
- name: risk_level
type: string
default: ""
- name: attempts
type: int
default: 0
- name: provider_reference
type: string
default: ""
- name: last_error_code
type: string
default: ""
- name: provider_submission_status
type: string
default: idleEvents
events:
REFUND_REQUESTED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
customer_id: string
amount: number
reason: string
requested_at: string
REFUND_POLICY_VALIDATED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
amount: number
REFUND_RISK_EVALUATED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
risk_level: string
amount: number
REFUND_APPROVED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
amount: number
risk_level: string
REFUND_PROVIDER_SUBMISSION_STARTED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
attempt: integer
REFUND_PROVIDER_SUBMISSION_FAILED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
attempt: integer
error_code: string
error_message: string
retryable: boolean
REFUND_COMPLETED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
amount: number
provider_reference: string
attempts: integer
last_error_code: string
REFUND_FAILED:
state: refund
entity_expr: event.refund_id
payload:
refund_id: string
order_id: string
amount: number
error_code: string
error_message: string
attempts: integerIntent: REFUND_REQUESTED (entry point)
actions:
REFUND_REQUESTED:
state: refund
entity_id_expr: event.refund_id
description: Existing app submits a refund request — Causet owns durable execution
input:
refund_id: { type: string, required: true }
order_id: { type: string, required: true }
customer_id: { type: string, required: true }
amount: { type: number, required: true }
reason: { type: string, required: true }
requested_at: { type: string, required: true }
preflight:
rules:
- name: reject_invalid_amount
when:
expr: "event.amount <= 0"
then:
- op: reject
code: INVALID_AMOUNT
message: "Refund amount must be positive"
- name: reject_high_risk_amount
when:
expr: "event.amount > 400"
then:
- op: reject
code: REFUND_HIGH_RISK_REJECTED
message: "Refund amount exceeds high-risk threshold"
core:
rules:
- name: initialize_refund_entity
then:
- op: set
path: /refund_id
value: event.refund_id
- op: set
path: /order_id
value: event.order_id
- op: set
path: /amount
value: event.amount
- op: set
path: /status
value: validated
side_effects:
rules:
- name: emit_refund_requested
then:
- op: emit
event_type: REFUND_REQUESTED
payload:
refund_id: entity.refund_id
order_id: entity.order_id
customer_id: entity.customer_id
amount: entity.amount
reason: entity.reason
requested_at: entity.requested_at
- op: emit
event_type: REFUND_APPROVED
payload:
refund_id: entity.refund_id
order_id: entity.order_id
amount: entity.amount
risk_level: entity.risk_level
- op: schedule
intent_type: SUBMIT_REFUND_TO_PROVIDER
delay_seconds: 1
payload:
refund_id: entity.refund_id
order_id: entity.order_id
amount: entity.amountRetryable provider failure schedules RETRY_REFUND_TO_PROVIDER — see the full actions file in the Causet repo.
Saga
sagas:
refund_execution:
state: refund
state_path: _tmp/refund_saga
steps:
- name: idle
set: { status: idle }
- name: requested
on: REFUND_REQUESTED
set: { status: requested }
- name: approved
on: REFUND_APPROVED
set: { status: approved }
- name: processing
on: REFUND_PROVIDER_SUBMISSION_STARTED
set: { status: processing }
- name: complete
on: REFUND_COMPLETED
set: { status: complete }
end: true
- name: failed
on: REFUND_FAILED
set: { status: failed }
end: trueProjection + query
projections:
refund_timeline:
source_events:
- REFUND_REQUESTED
- REFUND_APPROVED
- REFUND_PROVIDER_SUBMISSION_STARTED
- REFUND_PROVIDER_SUBMISSION_FAILED
- REFUND_COMPLETED
- REFUND_FAILED
target:
table: refund_timeline
primary_key: [refund_id, event_type, event_ts]
fields:
refund_id: TEXT
order_id: TEXT
event_type: TEXT
event_ts: BIGINT
risk_level: TEXT
attempt: BIGINT
status: TEXT
error_code: TEXT
derive:
refund_id: event.refund_id
order_id: event.order_id
event_type: event.type
event_ts: event.ts
mutations:
REFUND_REQUESTED: { op: upsert }
REFUND_APPROVED: { op: upsert }
REFUND_PROVIDER_SUBMISSION_STARTED: { op: upsert }
REFUND_PROVIDER_SUBMISSION_FAILED: { op: upsert }
REFUND_COMPLETED: { op: upsert }
REFUND_FAILED: { op: upsert }
queries:
refund_timeline_by_id:
from: refund_timeline
input:
refund_id: { type: string, required: true }
where:
refund_id: { eq: input.refund_id }
order_by:
event_ts: asc
limit: 100Compatibility mode vs true async mode
Compatibility mode
Keep the same success JSON shape the frontend already expects; run the workflow after accept.
True async mode (recommended for refunds)
{
"status": "accepted",
"refundId": "rfnd_123"
}- Prefer
202 Accepted - Return the entity / refund ID
- Update the existing app via outbound webhook when status changes
How webhooks update the existing app
Register a webhook so Causet can push completion events back to your API:
causet webhook add \
--name "Refund completion" \
--url http://localhost:3001/api/webhooks/causet/refunds \
--events event:REFUND_COMPLETED,event:REFUND_FAILED,event:REFUND_REJECTED \
--fork sandboxIdempotent handler pattern:
async function onRefundCompleted(event: {
eventId: string
refundId: string
orderId: string
status: string
}) {
if (await alreadyProcessed(event.eventId)) return
await db.refunds.update(event.refundId, { status: event.status })
await markProcessed(event.eventId)
}Details: Use Webhooks to Update Existing Flows.
Build, deploy, and inspect with the CLI
# Install
curl -fsSL https://install.causet.io/install.sh | bash
causet version
# Local runtime
causet local up
causet context use env local
# Compile + deploy the refund workflow
cd examples/retrofit-commerce
causet build compile --runtime causet --out dist
causet deploy --fork sandbox --yes
# After a refund is submitted from your app
causet inspect timeline --entity <refund-id> --stream refund_stream --fork sandbox
causet inspect entity <refund-id> --stream refund_stream --fork sandbox
causet query refund_timeline_by_id --param refund_id=<refund-id>Full command reference: CLI.
Timeline you should see
REFUND_REQUESTED
REFUND_POLICY_VALIDATED
REFUND_RISK_EVALUATED
REFUND_APPROVED
REFUND_PROVIDER_SUBMISSION_STARTED
REFUND_PROVIDER_SUBMISSION_FAILED ← first provider attempt (retryable)
REFUND_PROVIDER_SUBMISSION_RETRIED
REFUND_PROVIDER_SUBMISSION_STARTED
REFUND_COMPLETEDReplay / repair
causet fork create refund-debug --parent sandbox
causet inspect timeline --entity <refund-id> --stream refund_stream --fork refund-debug
causet recovery replay --stream refund_streamWalkthrough: Replay.
How to roll out safely
- Pick one fragile workflow (refund is a good first pick)
- Keep the existing endpoint and public contract
- Submit one intent from the route (
REFUND_REQUESTED) - Move downstream steps into Causet actions / sagas / schedules
- Bridge status back with webhooks if the frontend still reads your DB
- Run locally (
causet local up) or against Causet Cloud - Inspect the timeline; simulate a provider failure; verify retry + webhook
- Ship behind a flag or single route
- Repeat for the next workflow
Checklist: Production Rollout Checklist.
What changed?
- Frontend did not need a rewrite
- Endpoint still exists — it is a boundary, not a workflow engine
- Causet owns the durable refund workflow
- Failures are visible on the timeline
- Work can be inspected, forked, replayed, and repaired
Related
- CLI — install, local, cloud, build, deploy, inspect
- Use Webhooks to Update Existing Flows
- Mental model
- What Causet handles
- Access request example — alternate domain shape