Skip to content

thoven87/strand

Strand

Strand

Postgres-native durable workflow engine for Swift 6.3.

No separate coordination service. No Redis. No Cassandra. Just Swift workers and Postgres.

@Workflow
struct OrderWorkflow {
    struct Input:  Codable, Sendable { let amount: Int; let orderID: String }
    struct Output: Codable, Sendable { let trackingNumber: String }

    mutating func run(
        context: WorkflowContext<Self>,
        input: Input
    ) async throws -> Output {
        let charge = try await context.runActivity(
            ChargeCardActivity.self,
            input: .init(amount: input.amount)
        )
        return try await context.runActivity(
            ShipOrderActivity.self,
            input: .init(paymentID: charge.paymentID)
        )
    }
}

If a worker crashes mid-workflow the next worker that picks it up resumes from the last checkpoint. No work is duplicated, no state is lost.

Strand dashboard

Documentation

Quick start

1. Start Postgres

# docker-compose.yml
services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: strand
      POSTGRES_PASSWORD: strand
      POSTGRES_DB: strand_dev
    ports:
      - "5499:5432"
docker compose up -d
psql "postgresql://strand:strand@localhost:5499/strand_dev" -f strand.sql

2. Add to Package.swift

.package(url: "https://github.com/thoven87/strand", from: "0.1.0"),

3. Run

import Logging
import PostgresNIO
import ServiceLifecycle
import Strand

var logger = Logger(label: "my-app")

let postgres = PostgresClient(
    configuration: .init(
        host: "localhost", port: 5499,
        username: "strand", password: "strand",
        database: "strand_dev", tls: .disable
    ),
    backgroundLogger: logger
)

var strand = StrandService(
    postgres: postgres,
    options: .init(
        queues: [
            .init(
                name: "default",
                workflows: [OrderWorkflow.self],
                activities: [ChargeCardActivity(), ShipOrderActivity()]
            )
        ]
    )
)

// Trigger a workflow from anywhere — returns a handle you can await:
let client = strand.client(queue: "default")
Task {
    let handle = try await client.startWorkflow(
        OrderWorkflow.self,
        input: OrderWorkflow.OrderInput(amount: 99_00, orderID: "ord-1")
    )
    let result = try await handle.result()
    print(result)
}

let group = ServiceGroup(
    services: [postgres, strand],
    gracefulShutdownSignals: [.sigterm, .sigint],
    logger: logger
)
try await group.run()

Scheduling

Declare recurring schedules on StrandService — they are upserted to the database when the service starts:

var strand = StrandService(
    postgres: postgres,
    options: .init(
        queues: [
            .init(
                name: "default",
                workflows: [DailyReportWorkflow.self, MarketOpenWorkflow.self]
            )
        ],
        scheduler: .init()
    )
)

strand.addSchedule(
    .workflow(
        "daily-report",
        pattern: .daily(offset: "PT9H"),          // 09:00 UTC every day
        workflowType: DailyReportWorkflow.self,
        input: ReportInput()
    )
)
strand.addSchedule(
    .workflow(
        "market-open",
        pattern: .cron("30 8 * * 1-5",
                       timezone: TimeZone(identifier: "America/New_York")!),
        workflowType: MarketOpenWorkflow.self,
        input: StrandVoid()
    )
)

// Custom timetable — fire on any calendar logic you can express in Swift
struct UKBankHolidayFreeSchedule: StrandTimeTable {
    let holidays: Set<DateComponents>   // loaded at init time
    var description: String { "UK working days, 09:00 London" }

    func nextRunTime(after _: Date?, earliest: Date) -> Date? {
        var greg = Calendar(identifier: .gregorian)
        greg.timeZone = TimeZone(identifier: "Europe/London")!
        var candidate = greg.startOfDay(for: earliest)
        while !isWorkingDay(candidate, calendar: greg) {
            candidate = greg.date(byAdding: .day, value: 1, to: candidate)!
        }
        var comps = greg.dateComponents(in: greg.timeZone, from: candidate)
        comps.hour = 9; comps.minute = 0; comps.second = 0
        return greg.date(from: comps)
    }
    // ...
}

strand.addSchedule(
    .workflow(
        "daily-settlement",
        timetable: UKBankHolidayFreeSchedule(holidays: holidays),
        workflowType: SettlementWorkflow.self,
        input: StrandVoid()
    )
)

let group = ServiceGroup(
    services: [postgres, strand],
    gracefulShutdownSignals: [.sigterm, .sigint],
    logger: logger
)
try await group.run()

For schedules created at runtime (e.g. from an HTTP API), call client.schedule(name:pattern:workflowType:input:) directly — it is always a live database write.

See Scheduling for patterns, catch-up behaviour, time zones, and runtime management.

Examples

See Examples/ for complete runnable examples:

Example What it shows
HackerNewsSummary Multi-child fan-out with Ollama summarisation; @ActivityContainer
GroundwaterPipeline 6.2 M-row data pipeline, parallel download, Ollama trend analysis
CIPipeline DAG-style CI workflow with human-in-the-loop approval signal
SmartBuilding IoT sensor aggregation, multi-tenant, scheduled reports
DevServer Local dev server with seeded workflows and the Loom dashboard

Requirements

  • Swift 6.3+
  • PostgreSQL 17+
  • Runs on Linux and macOS — any platform supported by SwiftNIO and PostgresNIO

License

Apache 2.0

Releases

No releases published

Packages

 
 
 

Contributors