Skip to content
CapsuleDB
Esc
↑↓navigate↵open⌘Jpreview
On this page

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-bun
pnpm add capsuledb effect @effect/sql-sqlite-bun
yarn add capsuledb effect @effect/sql-sqlite-bun
bun add capsuledb effect @effect/sql-sqlite-bun
nub add capsuledb effect @effect/sql-sqlite-bun
aube add capsuledb effect @effect/sql-sqlite-bun

Import

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

Last updated on

Was this page helpful?