okay-effects

okay-serverless: a cloud as effects

Everything a Cloudflare Worker can use — KV, R2, D1, Hyperdrive, queues, the cache, rate limits, Durable Objects, WebSockets, cron — is an effect a program names; each cloud is a set of handlers. The same program runs on Cloudflare Workers, on AWS Lambda, and in memory for tests. State is kept without replay.

Durable state, without replay

A serverless instance is evicted, restarted, or run twice at once. Temporal and Azure Durable Functions answer with replay: a journal, the code run again, and code that must not change while a run lives. Here what is kept is the state itself, written atomically with each step — so a new version of the code goes on from the old state.

A step is a transaction

Durable.step reads a snapshot and its own writes, keeps its writes and commits them all at once with its alarm; nothing written if it fails; run again if what it read changed meanwhile.

A process is a machine

Durable.machine(name, init)(step): the state loaded, the step run, the new state committed. Durable.once: an external call once per step, with a stable idempotency key. Durable.wakeAt: an alarm, set only if the step commits.

An account as a durable process, in a Durable Object (from the example Worker, run end to end on wrangler dev):

def event(name: String, e: Json): Account ! A =
  Durable.machine[Account, Json, AD, A]("account", Account(0, Nil)) { (a, e) =>
    (e.field("op"), e.field("amount")) match
      case (Some(Json.Str("deposit")), Some(Json.Num(n))) => pure(a.copy(balance = a.balance + n.toLong))
      case (Some(Json.Str("pay")), Some(Json.Num(n))) =>
        if n.toLong > a.balance then Fail.raise[String, AD](s"${n.toLong} is more than the balance ${a.balance}")
        else Durable.once[String, AD]("payment")(key => pure(s"pay-$key")).map(id =>
          a.copy(balance = a.balance - n.toLong, payments = a.payments :+ id))
      case (Some(Json.Str("interest-in")), Some(Json.Num(ms))) =>
        Clock.now[AD].flatMap(t => Durable.wakeAt[AD](t + ms.toLong)).as(a)
      case (Some(Json.Str("interest")), _) => pure(a.copy(balance = a.balance + a.balance / 100))
      case _ => Fail.raise[String, AD](s"an event not known: ${Json.print(e)}")
  }(e)

Platforms of the state: a Durable Object's SQLite (one transaction a commit), DynamoDB (a conditional TransactWriteItems; two instances at once lose nothing), memory with faults (a crash before or after a commit, a conflicting writer, an eviction) — one set of scenarios every platform passes. A twin on okay-simple, for Scala 2.13 and 3, keeps the same stored format.

A cloud as effects

Each effect is the program's; each cloud is a Platform of ports, one an effect. A resource is a logical name the platform maps to its binding. What a cloud lacks, its port says by name.

EffectCloudflareAWSMemory
KvKVDynamoDB (TTL)memory
BlobsR2S3 — not yetmemory
SqlD1; Postgres through HyperdrivePostgres (RDS Proxy)SQLite file (JVM)
QueuesQueues, a consumerSQS events in; sending — not yetmemory, dead letters
Fetchfetchfetch (Node)a function
CacheCache API—memory
Envvars, secretsprocess environmentmaps
Servicesservice bindingsLambda invoke — not yetapps by name
LimitsRate Limitinga DynamoDB counterfixed window
AiWorkers AI (written, not run locally)Bedrock — not yeta function
VectorsVectorize (written, not run locally)not yetcosine
AnalyticsAnalytics EngineCloudWatch metrics (EMF)kept
Mailssend_emailSES — not yetkept
ObjectsDurable Objectsthe same Lambda, its state a DynamoDB spacein process
SocketsWebSockets (hibernation)not yetin memory

HTTP is okay-http's model, a body text or bytes; JSON is okay-simple's; Env, Log, Clock, Async are okay-scala's own. A Worker is an App (fetch, scheduled, queue, mail), an object class an Actor (fetch, alarm, socket messages); a Lambda takes API Gateway's, SQS's and EventBridge's events. Excerpts of the example Worker:

override def fetch(r: Request): Response ! Cloud = r.path match
  case "/" => Objects.call[Cloud]("ACCOUNTS", r.param("account").getOrElse("main"), r)
  case "/queue" => Queues.send[Cloud]("JOBS", q(r, "body")).as(Response.text("queued"))
  case "/cached" => Cache.through[Cloud]("https://okay.example/cached", 60)(
      Clock.now[Cloud].map(t => Response.text(s"made at $t")))
  case "/limit" => Limits.take[Cloud]("LIMITER", q(r, "key")).map(ok => Response.text(ok.toString))
  case _ => pure(Response.notFound())

override def queue(queue: String, m: Message): Settle ! Cloud =
  Kv.put[Cloud]("KV", s"job:${m.body}", s"done after ${m.attempts}").andThen(Log.info[Cloud](s"job ${m.body}"))
    .as(Settle.Ack)

Workflows, as data

Cloudflare Workflows replay a function. Here a workflow is a list of stages — a step retried with backoff, a sleep, a wait for an event — run by an object: each stage one durable step, a sleep an alarm, an event a request. Restarted anywhere, it goes on from the stage committed.

val Orders: Workflow[Json] = Workflow[Json](identity)(
  Stage.Do("charge", o => Kv.put[InActor + Fail % String]("KV",
    s"charged:${o.field("id").fold("?")(j => Json.print(j))}", "yes").as(o)),
  Stage.Sleep("pause", 1000),
  Stage.Wait("approval", "approved", (o, j) => o match
    case Json.Obj(fs) => Json.Obj(fs :+ ("approvedBy" -> j.field("by").getOrElse(Json.Null)))
    case other => other),
  Stage.Do("mail", o => Mails.send[InActor + Fail % String]("MAIL", Mail("shop@okay.example", "ann@okay.example",
    "approved", Json.print(o))).as(o)))

Data: okay-store in the cloud

okay-store's effects are data, so a cloud program performs them as they are: a type's repository (Repo.save, find, create…) and documents with a query (Docs), handled on SQL (D1, Postgres through Hyperdrive or RDS Proxy, SQLite), on KV, or in an object's own durable state — a store per tenant, a step one transaction. The SQL is okay-store's own.

final case class Sale(id: String, item: String, qty: Int)
val sales: Mapping[Sale] = Mapping.keyed("sales", Shape.derived[Sale])(_.id)
type Sales[X] = Repo.Of[Sale][X]
def selling[R[_]](item: String, qty: Int)(using In[Sales, R]): String ! R =
  Repo.create[Sale, R](k => Sale(k, item, qty)).flatMap(s =>
    Repo.find[Sale, R](sales.at(_.qty) > 1, List(sales.at(_.qty).desc)).flatMap(big =>
      Repo.count[Sale, R]().map(n => s"${s.id} of $n; big: ${big.map(_.item).mkString(",")}")))
type CD[X] = (Cloud + Data)[X]
type CS[X] = (CD + Sales)[X]
def sold(r: Request, db: String, dialect: Dialect): Response ! Cloud =
  StoreOn.sql[CD, Cloud, String](db, dialect)(StoreOn.data[Sale, CS, CD, String](sales)(
    selling[CS](r.param("item").getOrElse("?"), r.param("qty").map(_.toInt).getOrElse(1)))).map(Response.text(_))

okay-store's journaled records (Store: kinds, versions, the journal, the audit) run here by okay-store's own handler, put into the cloud program by okay-simple's bridge (OnCek.into) — one machine, one run, and in an object one transaction with the rest of the step. Checked against okay-store's own reference on memory, SQLite, Postgres and wrangler dev.

A type served: REST and MCP from a Worker

The same mapped type, served by okay-faces' own REST resources and MCP tools — described, not written — its operations on D1:

/** THE TYPE SERVED: okay-faces' REST resources and MCP tools of `sales`, from its description alone */
val salesRest = Faces.rest(Mapping.describe(sales), "/api/sales")
val salesMcp = Faces.mcp(Mapping.describe(sales), "shop")
/** the face's program in the cloud's row, the type on D1 */
def served(face: Request => OkayCek[Response], r: Request): Response ! Cloud =
  StoreOn.sql[CD, Cloud, Response]("DB", Dialect.sqlite)(StoreOn.data[Sale, CS, CD, Response](sales)(
    OnCek.into[CS](face(r))))
type OkayCek[A] = OnCek.![A, Faces.Only[Repo.Of[Sale]]]

On wrangler dev: the description, a create with a key from D1, a search by a condition; MCP's initialize, the tools and a call on the same D1; a shop's REST on its own Durable Object. A Lambda serves it from Postgres the same way.

Status

Released: 0.1.0, a Maven repository on GitHub Pages (how to add it). Scala 3.3 LTS; Scala.js 1.22 or newer.