Skip to content

How it works ​

Everything on this page is something you can do on the first afternoon. One endpoint, one token, and the database underneath already remembers.

Back to the overview

Your databases ​

You have an account, and you create databases in it. The CRM, the shop, the thing you started on Sunday.

A database holds your rows. If you sell to companies, it also holds a tenant for each one: their own store of data, not a label on shared rows.

That is why "can you delete our data" is a command and not a project. And why the thing you started on Sunday costs you nothing until it holds something.

One language ​

Piped left to right. You read what you asked for, in the order you asked for it.

eel
orders
|> filter status == .pending && placed_at > now() - 7d
|> join buyer
|> select { buyer.name, total, placed_at }
|> sort placed_at desc
|> take 50

Writes take the same shape and return what they touched, so they compose.

eel
orders |> insert { buyer: bid, total: 49.99$, status: .pending }

orders |> filter id == oid |> update { status: .shipped } |> select { id }

From your app, or over plain HTTP:

ts
const rows = await db.execute(
  eelgrass`orders |> filter status == .pending |> take 50`,
  { tenant: "acme" },
);
POST /api/v1/t/acme/query
Authorization: Bearer <token>

Any moment in the past ​

eel
orders
|> asof "2026-09-01T09:00:00Z"
|> filter id == oid

Every version is kept, so a question about the past is a clause and not a restore. Ask for a date we no longer hold and we name the oldest one we do. You never get an empty page, which looks exactly like a quiet week.

Precise undo ​

Someone imported the wrong file at breakfast. Four thousand records are wrong. Every order placed since is fine and has to stay that way.

eel
orders |> filter batch == "import-7" |> revert to now() - 6h

That is a plan. It shows you what would change and writes nothing.

eel
orders
|> filter batch == "import-7"
|> revert to now() - 6h
   confirm "<the token the plan gave you>"

If the data moved while you were reading the plan, the confirmation is refused and you look again. Rewinding an entire tenant to Tuesday is a bigger command with a different name, because it should never happen by accident.

Instant copies ​

eel
fork tenant "acme" as "acme-staging"

A copy you can write to, ready before you have finished typing the next command, however large the tenant is. Production never feels it. Point staging at the copy, break it, throw it away, take another.

eel
snapshot tenant "acme" as "pre-launch"

Tenants kept apart ​

eel
create tenant "acme"
suspend tenant "acme"

A tenant is a boundary, not a column. Deleting one deletes everything of theirs. Exporting one exports theirs and nobody else's. A token carries what it may reach, so a query cannot return the wrong tenant's rows even when somebody writes the wrong filter.

Reading across them is separate and deliberate:

eel
across tenants (name == "crm/*") orders
|> group status aggregate { count: count() }

A live change feed ​

GET /api/v1/t/acme/tail?after=408
json
{
  "cursor": 412,
  "gap": false,
  "events": [
    { "lsn": 409, "op": "upsert", "collection": "orders", "id": "01J7…",
      "after": { "status": "shipped", "total": "49.99 usd" } },
    { "lsn": 412, "op": "delete", "collection": "orders", "id": "01J8…" }
  ]
}

Each change, with the record as that commit left it. This is the feed a webhook, a search index or another system would otherwise need a pipeline to produce. A feed that quietly skips is worse than one that says it skipped, so it tells you.

Reporting, no warehouse ​

eel
rollup DailyRevenue {
  across tenants (name == "crm/*") orders
  |> filter status == .shipped
  |> group placed_at aggregate { revenue: sum(total) }
}

Declare the number you want and it is waiting whenever you ask, already current. Nobody waits for last night's job to finish, and nobody spends a morning working out whether the dashboard or the app is telling the truth.

Money that stays money ​

eel
orders |> insert { total: 49.99$usd }
orders |> insert { total: 500$jpy }

Add dollars to yen and it will not compile. Every amount carries its own currency and stays whole, so the rounding error that turns up three quarters later in somebody's reconciliation has nowhere to start.

Two tenants, one operation ​

A seller reserves stock and a buyer's wallet is debited. Two tenants, so there is no single transaction to hide behind. Name the undo and the type checker holds you to it.

eel
flow ReserveSale {
  step reserve tenant "seller-4021" atomic {
    listings |> filter sku == "sku-9" && stock > 0 |> update { stock: stock - 1 }
  } compensate atomic {
    listings |> filter sku == "sku-9" |> update { stock: stock + 1 }
  }
  step debit tenant "buyer-17" atomic {
    wallet_events |> insert { account: "buyer-17", cents: -2500, reason: "reserve sku-9" }
  } compensate atomic {
    wallet_events |> insert { account: "buyer-17", cents: 2500, reason: "compensate sku-9" }
  }
}

If the debit fails, the reservation goes back, in reverse order, and you are told. A step with no undo does not compile.

Inside one tenant you need none of that. A block is atomic, and reads in it see one frozen picture, so two buyers cannot both take the last item.

eel
atomic {
  accounts |> filter id == from |> update { balance: balance - 100$ }
  accounts |> filter id == to   |> update { balance: balance + 100$ }
}

Your data, out ​

eelgrass export ./crm acme acme.eelpkg

One file, readable without our software, with the history in it and not just today's values. A copy of your data with the past removed is not your data.

No exit fee, and export is not a premium feature. We would rather earn the renewal.


If any of this sounds like your Monday morning, we would like to talk.

Last updated:

A database that remembers, so mistakes are fixable and every customer's data stays separate.