Get startedRetrofit an existing application

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 / webhooks

Before / after

BeforeAfter
Route owns validate → DB → provider → email inlineRoute submits intent, returns 202
Failures reconstructed from logsTimeline 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 });
Mental model: Applications submit intents. Causet emits committed business events. Use client.submitIntent() (or language equivalents) to submit intents from your application.

Verify

causet inspect timeline --entity <refund-id> --stream refund_stream --fork sandbox

Full 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 DB

How 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 uphttp://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.causet

App 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.causet

State

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: idle

Events

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: integer

Intent: 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.amount

Retryable 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: true

Projection + 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: 100

Compatibility mode vs true async mode

Compatibility mode

Keep the same success JSON shape the frontend already expects; run the workflow after accept.

{
  "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 sandbox

Idempotent 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_COMPLETED

Replay / 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_stream

Walkthrough: Replay.


How to roll out safely

  1. Pick one fragile workflow (refund is a good first pick)
  2. Keep the existing endpoint and public contract
  3. Submit one intent from the route (REFUND_REQUESTED)
  4. Move downstream steps into Causet actions / sagas / schedules
  5. Bridge status back with webhooks if the frontend still reads your DB
  6. Run locally (causet local up) or against Causet Cloud
  7. Inspect the timeline; simulate a provider failure; verify retry + webhook
  8. Ship behind a flag or single route
  9. 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