Quick start
Declare a capsule with one private table and a typed service, compose it over a host-owned Bun SQLite client, and run it end to end.
You will write a capsule the way a package author does, then compose it the way a host
application does, and run both against an in-memory database. Every block on this page is a region
of examples/quick-start.ts,
which CI typechecks and runs against the built package.
Prerequisites
- Bun 1.4.4 or newer. The example uses Bun SQLite; the same capsule runs on PostgreSQL, libSQL and Cloudflare D1 with a different provider profile.
- Effect 4.1.2 or newer.
Install
npm install capsuledb effect @effect/sql-sqlite-bunpnpm add capsuledb effect @effect/sql-sqlite-bunyarn add capsuledb effect @effect/sql-sqlite-bunbun add capsuledb effect @effect/sql-sqlite-bunnub add capsuledb effect @effect/sql-sqlite-bunaube add capsuledb effect @effect/sql-sqlite-bunImport
Start a file, visits.ts. The package side imports CapsuleDB’s builders; the host side imports the
Bun SQLite client it owns.
import { SqliteClient } from "@effect/sql-sqlite-bun";
import { Context, Data, Effect, Layer } from "effect";
import * as SqlClient from "effect/sql/SqlClient";
import { Capsule, Migration, Registry, Schema, SqliteBun } from "capsuledb";Declare the table
The package declares its table once. CapsuleDB renders the DDL for whichever dialect the host runs. This capsule prefixes its table with the namespace CapsuleDB derives from its ID, so its tables are easy to spot in a shared database.
const visits = Schema.table("capsule_example_2e_visits", {
columns: {
path: Schema.text(),
hits: Schema.integer(),
},
primaryKey: ["path"],
});Write the service
The capsule’s public surface is an Effect service. Its SQL and row shapes stay inside the package, and the only failure the host sees is the capsule’s own error.
/** The only failure the host sees; SQL errors stay inside the capsule. */
export class VisitsUnavailable extends Data.TaggedError("VisitsUnavailable")<{
readonly cause: unknown;
}> {}
export class Visits extends Context.Service<
Visits,
{ readonly record: (path: string) => Effect.Effect<number, VisitsUnavailable> }
>()("example/Visits") {}
const visitsLayer = Layer.effect(
Visits,
Effect.map(Effect.service(SqlClient.SqlClient), (sql) => ({
record: (path: string) =>
sql<{ readonly hits: number }>`INSERT INTO ${sql("capsule_example_2e_visits")} (path, hits)
VALUES (${path}, 1)
ON CONFLICT (path) DO UPDATE SET hits = ${sql("capsule_example_2e_visits")}.hits + 1
RETURNING hits`.pipe(
Effect.map((rows) => rows[0]?.hits ?? 0),
Effect.mapError((cause) => new VisitsUnavailable({ cause })),
),
})),
);Export the capsule
Capsule.make bundles the table, an append-only migration history and the service layer into one
module constant. It is pure, so a host can import it without running anything.
export const capsule = Capsule.make({
id: "example.visits",
tables: [visits],
migrations: [
Migration.make({
id: 1,
name: "create-visits",
risk: "additive",
steps: [Migration.createTable(visits)],
}),
],
layer: visitsLayer,
});Compose it in the host
The host lists the capsules it trusts and picks the provider profile that matches its client.
Registry.layer still requires the host’s SqlClient; CapsuleDB never opens or closes it.
export const CapsulesLive = Registry.layer({
provider: SqliteBun.profile,
capsules: [capsule],
});Run it
Building the layer prepares the registry first: it creates CapsuleDB’s ledger and metadata tables,
applies the capsule’s pending migration, and only then provides Visits.
const program = Effect.gen(function* () {
const visits = yield* Effect.service(Visits);
yield* visits.record("/docs");
return yield* visits.record("/docs");
});
// The host owns the client: here an in-memory Bun SQLite database.
export const main = program.pipe(
Effect.provide(CapsulesLive),
Effect.provide(SqliteClient.layer({ filename: ":memory:" })),
);
if (import.meta.main) {
console.log(`/docs has ${await Effect.runPromise(main)} visits`);
}Run it with bun visits.ts. It prints /docs has 2 visits: main returns 2, the second visit
to /docs. The database now holds
capsule_example_2e_visits beside capsuledb_registry_ledger and capsuledb_registry_metadata.
Running it again against the same database reads the ledger once, finds it current, and applies
nothing.
Next steps
- Capsule authors: IDs, migration rules, testing a capsule with the exported kit.
- Host applications: readiness, emitting SQL for an existing migration pipeline, and preparing at deploy time on serverless hosts.
- Migrations and recovery: what to do when preparation fails.
- The docs overview: which guide covers which side of the boundary, and every entry point.