Akter

Queries

A query reads one actor and returns a value. It runs on a read-only connection to the actor's SQLite file, so it can't change anything, and it sees only what committed commands wrote.

Declare a query⁠

Akter.query takes the same options as Akter.command: the payload's fields, and the success and error schemas.

room.contract.ts
import { Schema } from "effect"
import { Akter } from "@akterjs/effect/contract"

export const Message = Schema.Struct({ seq: Schema.Int, author: Schema.String, text: Schema.String })

export const Room = Akter.make("Room", {
  key: Schema.String,
  state: Schema.Struct({ title: Schema.String }),
  initial: () => ({ title: "Untitled" }),
  commands: {
    post: Akter.command({ payload: { author: Schema.String, text: Schema.String }, success: Schema.Void }),
  },
  queries: {
    recent: Akter.query({ payload: { limit: Schema.Int }, success: Schema.Array(Message) }),
    count: Akter.query({ payload: { author: Schema.String }, success: Schema.Int }),
  },
})

Keep growing data in a table⁠

An actor's state is read whole by every command and query, so keep it small. Data that keeps growing, like a room's messages, goes in a table in the actor's own database. A command inserts rows with Akter.sql, and a query reads only the rows it asks for:

room.actor.ts
import { Effect } from "effect"
import { Akter } from "@akterjs/effect/contract"
import { Room } from "./room.contract.ts"
import type { Message } from "./room.contract.ts"

export const RoomLive = Room.toLayer({
  migrations: {
    1: (sql) => sql`CREATE TABLE messages (seq INTEGER PRIMARY KEY, author TEXT NOT NULL, text TEXT NOT NULL)`,
  },
  commands: {
    post: (_room, { author, text }) =>
      Effect.flatMap(Akter.sql, (sql) => sql`INSERT INTO messages ${sql.insert({ author, text })}`),
  },
  queries: {
    recent: (_room, { limit }) =>
      Effect.flatMap(
        Akter.sql,
        (sql) => sql<typeof Message.Type>`SELECT seq, author, text FROM messages ORDER BY seq DESC LIMIT ${limit}`,
      ),
    count: (_room, { author }) =>
      Effect.flatMap(Akter.sql, (sql) =>
        Effect.map(
          sql<{ total: number }>`SELECT count(*) AS total FROM messages WHERE author = ${author}`,
          (rows) => rows[0]?.total ?? 0,
        ),
      ),
  },
})

The query's payload is checked against its schema before the handler runs, and the result is checked against success before it leaves the host.

Call it⁠

A query is a method on the actor's client, like a command:

room.story.ts
import { Effect } from "effect"
import { Story } from "@akterjs/effect/testing"
import { Room } from "./room.contract.ts"
import { RoomLive } from "./room.actor.ts"

const program = Story.run(RoomLive, function* (story) {
  const room = yield* story.actor(Room, "lobby")
  yield* room.post({ author: "ada", text: "hi" })
  yield* room.post({ author: "grace", text: "hello" })
  yield* room.post({ author: "ada", text: "lunch?" })

  const recent = yield* room.recent({ limit: 2 })
  console.log(recent.map((message) => `${message.author}: ${message.text}`).join("\n"))
  console.log(yield* room.count({ author: "ada" }))
})

Effect.runPromise(program)
Text
ada: lunch?
grace: hello
2

To get a new value each time the answer changes, subscribe to the query instead. See Live queries.

No query across actors⁠

A query reads one actor. Akter has no lookup across actors, because each actor's data is its own SQLite file.

When you need a list of actors, keep it in the actor that owns them. For example, a workspace actor can keep a rooms table that its commands update, and a query on the workspace lists its rooms. For queries across many owners, like reports, Postgres is a better home for that data.