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.
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:
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:
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)ada: lunch?
grace: hello
2To 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.