tako docs guide reference api
GitHub

2 · shortlink — state, work, and exactly-once

examples/shortlink/app.go adds the two primitives most apps need next: durable state and background work.

var links  = tako.Entity[Link]("links")
var clicks = tako.Queue[Click]("clicks")

type Link struct {
    Slug    string    `entity:"id" json:"slug"`
    URL     string    `json:"url"`
    Owner   string    `entity:"index" json:"owner"`
    Created time.Time `entity:"index" json:"created"`
    Hits    int       `json:"hits"`
}

var CountClick = tako.Worker(clicks, countClick)

Entities

tako.Entity[T] is an entity store (Datastore in the cloud, bbolt locally) — deliberately not "the database". The struct is the schema: entity:"id" is the key, entity:"index" marks what you may filter and order by. Querying an unindexed field is an ANALYSIS error, not a production surprise; combined query shapes derive their Datastore composite indexes automatically, per query.

"Per query" means per SHAPE, not per function. A filter added inside an if makes two shapes, and both get an index — a composite serves only queries that constrain its leading properties, so the narrow one would otherwise have none and answer 500 in production while every local test passed. Helpers work too — this one is journal's, from chapter 4:

func published(q *tako.EntityQuery[Post]) *tako.EntityQuery[Post] {
    return q.Where("Published", "=", true)
}

list, err := published(posts.Query(ctx)).OrderBy("-Created").Limit(50).All()

A function that takes or returns a query is part of its caller's query, not a query of its own. A query tako cannot follow — stored in a struct field, handed to a function value — is refused at analysis time rather than under-indexed silently.

Claiming a slug atomically is a transaction on the store:

err := links.Tx(ctx, func(tx tako.EntityTx[Link]) error {
    if _, err := tx.Get(req.Slug); err == nil {
        return tako.Errorf(409, "slug %q is taken", req.Slug)
    }
    ...
    return tx.Put(link)
})

Queues and workers

clicks.Publish(ctx, Click{...}) hands work off; the queue's ONE worker (two is an analysis error — fan-out is what topics are for) processes with the universal async contract: return nil to ack, an error to retry with backoff, tako.Permanent(err) to give up. After retries exhaust, the message lands in a dead letter you can inspect (tako dlq) and drain (--ack).

Delivery is at-least-once — so shortlink counts hits through an idempotency ledger keyed by the click's ID. The test suite proves it the honest way: app.Chaos() injects seeded duplicates and redeliveries, and the count must still be exact. Gut the ledger and the test fails with a seed that reproduces the run.

Time and pace (T3)

Two more queues showed up when Cloud Tasks landed: TTL'd links publish their OWN expiry with expiries.PublishAfter(ctx, e, ttl) — delivered when due, by a timer locally and Cloud Tasks in the cloud, with a re-claim guard because an expiry is a claim about one link, not a slug — and the archive queue declares tako.Delivery{RateLimit: 2, …}, pacing dispatch at 2/s everywhere. Same Publish/Worker API; the provisioner picks the broker. One trade to know: Tasks-routed messages have no dead letter (queue.md).

Run it

cd examples/shortlink
tako dev
curl -X POST localhost:8080/api/links -d '{"slug":"go","url":"https://go.dev","owner":"ada"}'
curl localhost:8080/api/links/go        # hits count exactly once per resolve
tako test                               # incl. the chaos test

In the cloud, the same declarations become Datastore, Pub/Sub with an OIDC-authenticated push worker (private — curl gets 403 at Google's front door), a composite index, and per-service IAM — all visible in the deploy plan first.