tile38.go
Health Warn
- License — License: MPL-2.0
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 7 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
A dependency-free Go client for the Tile38 geospatial database — RESP over net.Conn, with searches, hooks, channels, and live geofence streaming.
tile38.go
A Tile38 client for Go with a fluent query builder and
live geofence streaming. No dependencies outside the standard library.
st, _ := c.Nearby("fleet").Point(33.5, -115.5).Radius(5000).
Detect(tile38.Enter, tile38.Exit).Fence(ctx)
defer st.Close()
for {
ev, err := st.Next()
if err != nil {
return err
}
log.Printf("%s %s", ev.Detect, ev.ID) // enter truck1
}
Why
Most Go clients drive Tile38 through a Redis library, because Tile38 speaks
RESP. That works for request/response commands and stops at the interesting
part: a live geofence turns the connection into a one-way event stream, and
a connection-pooling Redis client cannot hold one open.
This client speaks RESP over net.Conn directly, so live geofences and channel
subscriptions are first-class — the same thing tile38-cli does when you addFENCE to a query. Talking to the wire directly also means the whole library is
the standard library: no go-redis, no transitive tree.
Install
go get github.com/GO-VIRTUAL-bv/tile38.go
The import path ends in .go; the package is named tile38:
import "github.com/GO-VIRTUAL-bv/tile38.go"
Requires Go 1.25+ and any recent Tile38.
Getting started
c := tile38.New("localhost:9851")
defer c.Close()
if err := c.Ping(ctx); err != nil {
return err
}
// Write a point with a field and a 60s TTL.
err := c.Set("fleet", "truck1").EX(60).Field("speed", 42).Point(33.5, -115.5).Do(ctx)
lat, lon, err := c.Get("fleet", "truck1").Point(ctx)
Commands are built by chaining and executed by the terminal call, which is the
one that takes a context.Context. Chain the parts in whatever order reads
best — they are assembled into protocol order when the command runs.
A search ends in Do, and an output-format method decides what Do gives back:Points() makes it a Points ([]NearbyResult), Objects() an Objects
([]SearchObject), and so on. Leave the format out and you get IDs
([]string). Those names are aliases for the slices themselves, so a result is
an ordinary slice — range it, index it, pass it where its element type is
wanted:
pts, err := c.Nearby("fleet").Limit(10).Point(33.5, -115.5).Radius(5000).Points().Do(ctx)
ids, err := c.Nearby("fleet").Where("speed > 40").Point(33.5, -115.5).Radius(5000).Do(ctx)
n, err := c.Within("fleet").Bounds(33, -116, 34, -115).Count(ctx)
objs, err := c.Intersects("fleet").Circle(33.5, -115.5, 5000).Objects().Do(ctx)
near, err := c.Nearby("fleet").Point(33.5, -115.5).Radius(5000).PointsWithDistance().Do(ctx)
trucks, err := c.Scan("fleet").Match("truck:*").Do(ctx)
Every search verb offers the output formats IDs, Points, Objects,Rects (BOUNDS), Hashes, and A5Cells — plus PointsWithDistance onNearby and Strings on Search — and every search area
Tile38 supports: Bounds, Circle, Sector, Object (GeoJSON), Get (an
object already stored), Hash, QuadKey, Tile, and A5. Nearby takesPoint + Radius instead of an area, and Scan and Search take none.
Filters are Where, WhereIn, WhereEval, WhereEvalSha, and Match, which
accumulate; Limit, Cursor, Sparse, NoFields, Clip, and Asc/Desc
are single-use and overwrite. They chain either side of the output format:.Limit(10).Points() and .Points().Limit(10) emit the same bytes.
Count and Fence are terminals rather than output formats, so they end a
chain in place of Do: c.Within("fleet").Bounds(…).Count(ctx) returns anint, and Fence(ctx) returns a live *Stream. Count never reportsErrTruncated, because Tile38 exempts COUNT from the result cap.
Points and Objects results carry the object's Fields beside its geometry,
so reading a collection's state is one round trip rather than an FGet per
field per object. Geofence notifications carry them too:
for _, p := range pts {
log.Printf("%s at %v,%v doing %s", p.ID, p.Lat, p.Lon, p.Fields["speed"])
}
A point may carry a third ordinate, written with PointZ and read back asNearbyResult.Z or through Get(...).PointZ(ctx). Tile38 omits it from a reply
when it is zero, so a zero Z and a two-dimensional point are the same thing.
Values are Tile38's own text encoding — the decimal form of a number, the
verbatim JSON text of a JSON field — and are absent for an object whose fields
are all zero or when the query used NoFields. One object's fields come back
with its geometry through Get(...).WithFields():
g := c.Get("fleet", "truck1").WithFields()
lat, lon, err := g.Point(ctx)
speed := g.Fields()["speed"]
Other commands
Search matches the string values Set(...).String(...) stores, rather than
geometry. Test compares two areas without touching stored objects:
ok, err := c.Test(tile38.AreaGet("fleet", "truck1")).
Within(tile38.AreaBounds(tile38.GlobalBounds())).Do(ctx)
Field, collection and server commands: FGet, FSet, FExists, JGet/JSet/JDel, Keys, Bounds, Stats, DBSize, Drop, PDel, Rename, Expire,Persist, TTL, Exists, FlushDB, ConfigGet/ConfigSet/ConfigRewrite,GC, Healthz, AOFShrink, ReadOnly, Follow/FollowNone, and Timeout.
Result limits
Tile38 caps every search except Count at 100 results when the command
carries no LIMIT. It reports that it stopped early by returning a non-zero
cursor, which this client surfaces as ErrTruncated:
ids, err := c.Scan("fleet").Do(ctx)
if errors.Is(err, ErrTruncated) {
// ids holds the first 100; more objects match.
}
The results returned alongside the error are valid, just incomplete.
To take everything, range Iter instead of calling Do. It follows the cursor
itself, yields one result at a time so you never see a page boundary, and never
reports ErrTruncated — paging is what it does instead of complaining:
for id, err := range c.Scan("fleet").Iter(ctx) {
if err != nil {
return err
}
use(id)
}
Iter is on every search verb and follows the output format, so the range
variable is whatever that format yields — a SearchObject here:
for obj, err := range c.Nearby("fleet").Point(33.5, -112.2).Radius(5000).Objects().Iter(ctx) {
…
}
Breaking out of the range just stops asking for pages; each one is an ordinary
pooled round trip, so nothing is left open.
Otherwise, set an explicit Limit to say the cap is intended — an explicitLimit or Cursor silences the error, since then the bound is yours, and it
bounds Iter the same way: one page, not the whole collection. Cursor andNextCursor are still there for driving the paging by hand.
Live geofences
Adding Fence to a search opens a stream instead of returning results. TheStream owns its own connection and has no read timeout, so a quiet fence can
sit idle for hours.
st, err := c.Within("zones").Bounds(-90, -180, 90, 180).
Detect(tile38.Enter, tile38.Exit).
Commands(tile38.CommandSet).
Fence(ctx)
if err != nil {
return err
}
defer st.Close()
for {
ev, err := st.Next() // io.EOF after Close, ctx.Err() on cancel
if err != nil {
return err
}
fmt.Println(ev.Detect, ev.ID, string(ev.Object))
}
Detect takes Inside, Outside, Enter, Exit, Cross; Commands
filters by what caused the event (CommandSet, CommandDel, CommandDrop,CommandFSet, …). Both are named string types, so a typo is a compile error
rather than a server error. Fence is available on Nearby, Within, andIntersects.
A Nearby fence can also roam — firing as objects come within range of another
collection — which Tile38 allows only on a live fence:
st, err := c.Nearby("fleet").Roam("targets", 250).NoDwell().Fence(ctx)
Channels and hooks
A geofence can also be registered on the server and delivered to subscribers
(SETCHAN) or pushed to an endpoint (SETHOOK).
// Server-side fence, delivered to subscribers.
err := c.SetChan("zone1").Within("fleet").
Detect(tile38.Inside).
Bounds(tile38.GlobalBounds()).
Do(ctx)
sub, err := c.Subscribe(ctx, "zone1") // or c.PSubscribe(ctx, "zone*")
defer sub.Close()
ev, err := sub.Next() // same *FenceEvent as a live fence
// Server-side fence, pushed to an endpoint.
err := c.SetHook("alerts").Endpoint("http://example.com", "events").
Within("fleet").
Detect(tile38.Enter, tile38.Exit).
Meta("team", "ops").
Circle(33.5, -115.5, 5000).
Do(ctx)
hooks, err := c.Hooks("*").Do(ctx)
Endpoint joins a base URL and a subject with /. For schemes that do not fit
that shape, or to register several endpoints on one hook, use EndpointURL:
c.SetHook("alerts").EndpointURL("kafka://k:9092/events", "http://x/y?token=1")
Hooks and channels trigger on Nearby, Within, or Intersects, take the same
fence areas as a search — Bounds, Circle, Object, Get, A5 — plusRoam, and accept Meta and EX. GlobalBounds() returns the whole-world box
as four values, which Go binds straight onto Bounds' parameters.
A roaming fence reports objects that stay in range on every update. ChainNoDwell to suppress those — on hooks, channels, and live Nearby fences
alike:
err := c.SetChan("proximity").Nearby("fleet").NoDwell().Roam("targets", 250).Do(ctx)
Hooks and Chans both return []HookInfo — name, watched collection,
endpoints, and the fence command the hook was created with.
Pipelining
Batch writes into a single round trip:
p := c.Pipeline()
for _, t := range trucks {
p.Set("fleet", t.ID).EX(300).Field("speed", t.Speed).Point(t.Lat, t.Lon).Queue()
}
err := p.Flush(ctx)
Configuration
The address is required; everything else is an option.
c := tile38.New("localhost:9851",
tile38.WithPassword("secret"), // AUTH on each new connection
tile38.WithMaxIdle(16), // idle connections kept for reuse
tile38.WithMaxActive(64), // commands in flight at once
tile38.WithDialTimeout(5*time.Second),
tile38.WithTimeout(2*time.Second), // per-command deadline
)
Commands take a connection from an idle pool. A rejected command returns aServerError and the connection stays in the pool; a transport failure drops
it. Streams always get their own connection and are not counted againstWithMaxActive, since they hold it for as long as they run.
WithMaxIdle bounds only the connections kept for reuse, so withoutWithMaxActive a burst of concurrent commands opens a socket per goroutine.WithTimeout defaults to DefaultTimeout (30s) so a command against a wedged
server cannot hang forever on a context with no deadline; pass a negative
duration to rely on the context alone.
For anything this library does not model:
v, err := c.Do(ctx, "SERVER") // string, int64, []any, or nil
Testing
make test # unit tests against a scripted server, no Docker
make test-integration # against a real Tile38 in Docker via testcontainers
make lint
Integration tests are behind the integration build tag, so go get of this
library never pulls testcontainers into your build.
Contributing
make lint needs golangci-lint v2:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
.claude/settings.json is checked in, so Claude Code
picks up two repo hooks automatically: Go files are formatted on edit, and a new
direct dependency in go.mod is refused — this client is deliberately
dependency-free. The format hook shells out to golangci-lint so it agrees withmake lint rather than approximating it, which means it fails on every edit if
the binary is not on your PATH. Install it first, or drop .claude/settings.json
from your working copy.
Notes
This client targets upstream Tile38 only. A command upstream does not accept is
not exposed here — it goes upstream first, and lands here once accepted.
One nuance: Within(…).A5, Intersects(…).A5, and Get(…).A5 are merged into
upstream Tile38 but have shipped in no release tag as of 1.38.0, so they need a
server built from upstream master. That is why .version pins atile38/tile38:edge digest rather than a release tag.
Claude Code skill
This repo ships a Claude Code skill that teaches
the agent to use this client. Install it as a plugin:
/plugin marketplace add GO-VIRTUAL-bv/tile38.go
/plugin install tile38@go-virtual
Or with the skills CLI:
npx skills add GO-VIRTUAL-bv/tile38.go@tile38
The skill lives at .claude/skills/tile38 (SKILL.md plus
a reference.md command catalog); you can also copy that folder into any~/.claude/skills/ directly.
License
Mozilla Public License 2.0 — see LICENSE.
MPL-2.0 is file-level copyleft: you can import this client into proprietary
software freely. If you modify one of its files and distribute the result, you
have to make that file's source available to whoever you distributed it to.
Files you add alongside it are yours.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found