gist-server documentation
This is the technical documentation of every service supported by gist-server.
The running example throughout is orderflow, a small order-processing
backend — Order and OrderItem rows in MySQL, a payment-gateway
call, order search, receipt storage, a lifecycle state machine, a nightly reconciliation
job, and an event bus. It exists purely to give every service a concrete call site.
Prerequisites:
| # | Service | Surface |
|---|---|---|
| Logging | Always on | |
| 1 | gist-api-server | API |
| 2 | gist-auth | No API |
| 3 | gist-aws-s3-client | API |
| 4 | gist-aws-ses-client | API |
| 5 | gist-edge-proxy | Config only |
| 6 | gist-elasticsearch-client | API |
| 7 | gist-fixtures | API |
| 8 | gist-google-cloud-storage-client | API |
| 9 | gist-http-client | API |
| 10 | gist-http-server | Config only |
| 11 | gist-mcp-bridge | Config only |
| 12 | gist-mysql-client | API |
| 13 | gist-parameter-store | API |
| 14 | gist-postgres-client | API |
| 15 | gist-pub-sub-client | API |
| 16 | gist-push-client | API |
| 17 | gist-rabbit-mq-client | API |
| 18 | gist-redis-client | API |
| 19 | gist-scheduler | API |
| 20 | gist-state-machine | API |
API The service's own exposed API, i.e the Go
functions/types your code calls directly (e.g. gist-mysql-client's
Find/Save)
Config only No Go API — you point config at it and it runs on its own.
No API gist-server does something with it
internally, but there's no injectable Service type or package your code can
call at all.
Always on Not a configurable service — no config
entry, no enabled flag, always running.
🔒 This field can hold a resolvable reference instead of a raw value — see the Secrets section.
Accessing a service
By grouping services you can create a collection of services tailored for the specific needs by your code.
Every example below assumes the same servicesGroup pattern — a plain
struct whose fields are name-tagged to match a service's config id.
type ServicesGroup struct {
Logger logging.Logger
OrdersDB *gistmysqlclient.Service `name:"orders-db"`
OrdersIndex *gistelasticsearchclient.Service `name:"order-es-client"`
ReceiptsStorage *gistgooglecloudstorageclient.Service `name:"receipts-gcp-storage"`
// ...one field per service instance you actually use
}
gist.NewApp builds one ServicesGroup (or several, if
different handler groups need different subsets) via reflection over these tags, and
hands it to every EndpointHandler, RegisterTriggerFunc,
RegisterScheduleFunc, etc.
The examples below accept this type as a parameter. None of the
examples redeclare this struct — assume sg ServicesGroup throughout.
gist-server command line
The gist-server binary has three independent modes, selected by which
flags are set. Flag list verbatim from gist-server -h.
1. Normal run — what gist.NewApp invokes for you
gist-server -admin-socket <path> -callback-socket <path> -config <path>
| Flag | Description |
|---|---|
-admin-socket <path> | Path to the admin gRPC unix socket. Your own process registers its endpoint/trigger/schedule handlers here. |
-callback-socket <path> | Path to the callback gRPC unix socket — the other half of the handshake; gist-server calls back into your process here. |
-config <path> | Path to your config.json. |
You won't usually type this yourself: gist.NewApp reads
config.json's own top-level "gist-binary" field for the
binary path, generates both socket paths per run, and execs this exact
command. Only reach for it directly when you're driving gist-server outside the intended way
(debugging, a non-Go customer process), i.e., not using the Gist package.
2. Scaffold a new Go project
gist-server -new-project orderflow
gist-server -new-project orderflow -project-path ./somewhere/else
| Flag | Description |
|---|---|
-new-project <name> | Generates a starting go.mod + main.go; <name> becomes the Go module name. Refuses to run if a go.mod already exists at the target path. |
-project-path <dir> | Where to scaffold it. Defaults to ./<name>. |
3. Generate a starting config.json
gist-server -generate-config gist-api-server,gist-mysql-client -out config.json
gist-server -generate-config all -out config.json
gist-server -generate-config all -config-detail full -out config.json
| Flag | Description |
|---|---|
-generate-config <kinds> | Comma-separated service kinds, or all. |
-config-detail <minimal|full> | Which of each requested kind's own fields to write out. Default minimal. |
-out <path> | Output path. Default config.json. |
-config-detail minimal (the default) writes out only each requested
kind's own required fields - no "default" tag, and not a
pointer (the same fields docs.html's own Minimal tab shows for that
kind) - a short, fill-in-the-blanks starting point instead of a wall of fields you'd
otherwise have to read past. -config-detail full writes out every field
of every requested kind's real Config struct instead, each with a
placeholder value (its own real default when it has one) - useful when you want to
see, and override, every knob a service exposes without cross-referencing the docs
for each one.
4. Combining scaffold + config
gist-server -new-project orderflow -generate-config gist-api-server,gist-mysql-client
Scaffolds the project and writes config.json into it in one call. If
-out was left at its bare default, the config lands at
<project-path>/config/config.json. -config-detail
applies here too - the generated config.json is minimal by default,
same as calling -generate-config on its own.
Logging
Always on
Logging is a core feature of gist-server. It's shared across all services, and
your own process can use it to log to the same place. Selects the backend via
config.json's own top-level logger field:
| Field | Type | Description |
|---|---|---|
logger |
string | Backend gist-server (and this SDK) log through: slog-text — stdlib log/slog, human-readable text lines; slog-json — stdlib log/slog, one JSON object per line; logrus — sirupsen/logrus, text formatter; zap — uber-go/zap, JSON output. (default: slog-text) |
{
"name": "orderflow",
"gist-binary": "./bin/gist-server",
"logger": "slog-json",
"services": { }
}
Usage
Five package-level functions, one per level — Debug/Info/
Warn/Error/Panic — each
(msg string, fields map[string]any). fields carries
whatever structured context matters for that line, rather than string-formatting
it into msg itself; pass nil when there's none.
Panic sends its line first, then panics locally with msg,
same as a bare panic() call.
logging.Info("order approved", map[string]any{
"order_id": order.ID,
"customer_id": order.CustomerID,
})
logging.Error("charge failed", map[string]any{
"order_id": order.ID,
"error": err.Error(),
})
logging.Debug("cache miss", nil)
Or declare a Logger field on your own ServicesGroup —
no name tag needed, unlike every other injected service, since there's
exactly one logger per process:
type ServicesGroup struct {
Logger logging.Logger
// ...
}
func (sg ServicesGroup) something() {
sg.Logger.Warn("retrying payment", map[string]any{"attempt": 2})
}
Secrets
Any config.json field marked 🔒 elsewhere in these docs (a password, an API key, a signing secret) can hold a resolvable reference instead of its raw value, resolved once at startup before that field is ever used - the raw value is never stored in config.json at all. Three schemes:
| Scheme | Resolves to |
|---|---|
env://VAR_NAME | The named environment variable's own value, read from the process's own environment. Errors at startup if unset. |
gcp-sm://projects/P/secrets/S/versions/V | A Google Cloud Secret Manager secret, fetched directly using the process's own ambient Application Default Credentials - independent of any configured service instance. |
gist-ps://<instance-id>/<source>/<alias> | An alias in a configured gist-parameter-store instance - source is plain, env, or gcp-sm, matching that instance's own three backends. The one place to update a value when several fields reference it. |
A plain literal value still works too - nothing about this is required, and every existing config.json stays valid as-is. See gist-parameter-store's own Usage section for a full worked example of each scheme.
01
gist-api-server
Runs your public HTTP API. You declare each endpoint's routing and metadata in endpoints.json, then bind a Go handler to it at startup via Attach. An endpoint with no handler yet just answers 501 automatically, so you can plan routes before the code behind them exists. A full OpenAPI spec and docs UI are generated automatically from endpoints.json and your handlers' own input/output struct tags — nothing to hand-write or keep in sync.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
title |
string | API title shown in the OpenAPI/docs schema. |
description |
string | API description shown in the OpenAPI/docs schema. |
endpoints-file |
string | Path to the endpoints JSON file (route/doc metadata) loaded into Endpoints. |
"gist-api-server": [
{
"id": "customer-login",
"enabled": true,
"title": "orderflow API",
"description": "Order processing API for orderflow.",
"endpoints-file": "./config/endpoints.json"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
title |
string | API title shown in the OpenAPI/docs schema. |
description |
string | API description shown in the OpenAPI/docs schema. |
version |
Version | API version (ma/mi/pa) shown as vMA.MI.PA in docs. (default values of Version) |
prepend-version-to-path |
bool | null | If true, routes are mounted at /v{version}{path} instead of {path}. (default: true) |
hostname |
string | null | 🔒 Bind address for the HTTP listener. (default: 0.0.0.0) |
port |
int | null | Bind port for the HTTP listener. (default: 8080) |
timeouts |
Timeouts | Server read/write/idle timeouts (read/write/idle, each int | string). (default values of Timeouts) |
headers |
map[string]string | Static response headers added to every response. |
api-key |
string | null | 🔒 If set, requires clients to send this value in the x-api-key header on every request. (default: empty string) |
rate-limiter |
int | null | Max requests allowed before responses are rate-limited; 0 disables it. (default: 0) |
ping-endpoint |
string | null | Path that serves a trivial liveness/heartbeat response (skipped if empty or /). (default: /ping) |
endpoints-file |
string | Path to the endpoints JSON file (route/doc metadata) loaded into Endpoints. |
middlewares |
[]string | Instance-wide middleware names applied to every endpoint - gzip compresses responses, logging logs every request (method/path/status/duration) instance-wide, all-or-nothing (no per-endpoint override yet); any other name is dispatched to a callback registered via gistapiserver.AttachMiddlewares in the customer's own process, which can inspect the request and block it before the endpoint's own handler runs. |
auths |
[]AuthConf | Catalog of JWT auth schemes endpoints can reference by token-type. |
docs |
Docs | OpenAPI documentation UI settings. (default values of Docs) |
pagination |
PaginationConfig | Default page-size settings for every gistapiserver.PaginatedEndpointHandler endpoint on this instance, unless a specific endpoint overrides it with its own pagination entry in endpoints-file. (default values of PaginationConfig) |
max-body-bytes |
int | null | Largest request body accepted, in bytes; a bigger one is answered 413 without being read. Defaults to 1 MiB. (default: 1048576) |
max-streams-per-user |
int | null | How many Server-Sent Event streams one signed-in user (or, on an endpoint without user auth, one IP) may have open at once; one more is answered 429. (default: 10) |
rate-limits |
RateLimits | Per-IP and per-user request limits, on by default (enabled: false turns them off). Endpoints without user auth (sign-in, callbacks) get the tighter sign-in-per-minute per IP. (default values of RateLimits) |
"gist-api-server": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"title": "orderflow API",
"description": "Order processing API for orderflow.",
"version": {
"ma": 1,
"mi": 0,
"pa": 0
},
"prepend-version-to-path": true,
"hostname": "0.0.0.0",
"port": 8080,
"timeouts": {
"read": 10,
"write": 10,
"idle": 5
},
"headers": {
"X-Service": "orderflow-api"
},
"api-key": "orderflow-internal-key",
"rate-limiter": 100,
"ping-endpoint": "/ping",
"endpoints-file": "./config/endpoints.json",
"middlewares": ["gzip"],
"auths": [
{
"token-type": "session",
"issuer": "orderflow-auth",
"audience": "orderflow-api",
"signing-string": "...",
"max-age": 3600,
"expired-message": "access token expired - send the refresh token to POST /v1/signin/refresh"
}
],
"docs": {
"enabled": true,
"ui": "scalar",
"path": "/docs",
"servers": [
{
"description": "Production",
"protocol": "https",
"hostname": "api.orderflow.example.com",
"port": 443
}
],
"tag-unimplemented-endpoints": true,
"tag-mockable-endpoints": true,
"tag-mcp-endpoints": true,
"basic-auth": {
"username": "docs",
"password": "env://DOCS_PASSWORD"
}
},
"pagination": {
"default-limit": 50,
"max-limit": 200
},
"max-body-bytes": 1048576,
"max-streams-per-user": 10,
"rate-limits": {
"enabled": true,
"per-ip-per-minute": 1200,
"per-ip-burst": 100,
"per-user-per-minute": 1200,
"per-user-burst": 100,
"sign-in-per-minute": 10,
"sign-in-burst": 3
}
}
]
Version
| Field | Type | Description |
|---|---|---|
ma |
int | null | Major version. (default: 1) |
mi |
int | null | Minor version. (default: 0) |
pa |
int | null | Patch version. (default: 0) |
Timeouts
| Field | Type | Description |
|---|---|---|
read |
int | string | null | Max seconds (or duration string) to read an incoming request. (default: 10) |
write |
int | string | null | Max seconds (or duration string) to write a response. (default: 10) |
idle |
int | string | null | Max seconds (or duration string) a keep-alive connection can stay idle. (default: 5) |
AuthConf
| Field | Type | Description |
|---|---|---|
token-type |
string | Name endpoints use (via endpoints-file's auths) to require this scheme. |
issuer |
string | Expected JWT iss claim. |
audience |
string | Expected JWT aud claim. |
signing-string |
string | 🔒 Secret/key used to verify the JWT signature. |
max-age |
int64 | Max token age (seconds) before it's rejected as expired. |
expired-message |
string | Message returned with the 401 when this scheme's token has expired - typically where to get a new one; the response also carries a WWW-Authenticate header naming the expiry. Defaults to the JWT library's own wording. |
Docs
| Field | Type | Description |
|---|---|---|
enabled |
bool | Turns on the OpenAPI spec + docs UI. |
ui |
string | null | Docs UI renderer (swagger, redoc, scalar, openapi-ui). (default: swagger) |
path |
string | null | Base path the docs UI and openapi.json are served under. (default: /docs) |
servers |
[]Server | List of servers shown in the OpenAPI spec (e.g. staging/prod URLs). |
tag-unimplemented-endpoints |
bool | null | Tags endpoints with no registered handler as "🚧 Unimplemented" in docs. (default: true) |
tag-mockable-endpoints |
bool | null | Tags endpoints that have mock data configured as "🐦 Mockable" in docs. (default: true) |
tag-mcp-endpoints |
bool | null | Tags endpoints with enable-mcp set as "🤖 MCP" in docs. (default: true) |
basic-auth |
DocsBasicAuth | If set, the docs UI asks for this login (HTTP Basic Auth). openapi.json then accepts either the x-api-key or this login, and answers 418 without them. (default values of DocsBasicAuth) |
Server
| Field | Type | Description |
|---|---|---|
description |
string | Label for this server entry in the OpenAPI spec. |
protocol |
string | Scheme for this server's URL. Ignored when hostname is empty. |
hostname |
string | Hostname for this server's URL. Leave empty for a relative entry that resolves to whatever host the docs page was opened through. |
port |
int | Port for this server's URL. Ignored when hostname is empty. |
DocsBasicAuth
| Field | Type | Description |
|---|---|---|
username |
string | 🔒 Docs login username. |
password |
string | 🔒 Docs login password. |
PaginationConfig
| Field | Type | Description |
|---|---|---|
default-limit |
int | null | Items per page when the caller's request doesn't specify limit. (default: 50) |
max-limit |
int | null | Upper bound a requested limit is clamped to, never exceeded. (default: 200) |
RateLimits
| Field | Type | Description |
|---|---|---|
enabled |
bool | null | Turns the limits on. (default: true) |
per-ip-per-minute |
int | null | Requests per minute one IP may make to endpoints with user auth. (default: 1200) |
per-ip-burst |
int | null | Requests one IP may make at once, before per-ip-per-minute applies. (default: 100) |
per-user-per-minute |
int | null | Requests per minute one signed-in user may make, from any IP. (default: 1200) |
per-user-burst |
int | null | Requests one signed-in user may make at once, before per-user-per-minute applies. (default: 100) |
sign-in-per-minute |
int | null | Requests per minute one IP may make to endpoints without user auth (sign-in, OAuth callbacks). (default: 10) |
sign-in-burst |
int | null | Requests one IP may make at once to endpoints without user auth, before sign-in-per-minute applies. (default: 3) |
endpoints.json
The file endpoints-file points at is a JSON array — one entry per
route, matched to a real handler at startup by id. An id
with no matching Attach'd handler serves 501 Unimplemented
automatically.
| Field | Type | Description |
|---|---|---|
id | string | Matched against the handler id passed to EndpointHandler/Attach. |
deprecated | bool | Marks the endpoint deprecated in the OpenAPI/docs schema. |
method | string | HTTP method (GET, POST, PUT, DELETE, etc.). |
version | int | Used to build the route path when the instance's prepend-version-to-path is on (/v{version}{path}). |
title | string | Short title shown in the docs UI. |
description | string | Description shown in the docs UI. |
path | string | URL path, e.g. /orders/{id}. |
tags | []string | Grouping tags shown in the docs UI. |
auths | []string | token-type values (from the instance's own auths catalog) required to call this endpoint. |
middlewares | []string | Per-endpoint middleware names, merged with the instance's own middlewares - gzip compresses this endpoint's response; any other name is dispatched to a callback registered via gistapiserver.AttachMiddlewares, which can inspect the request and block it before this endpoint's own handler runs. |
[
{
"id": "create-order",
"deprecated": false,
"method": "POST",
"version": 1,
"title": "Create order",
"description": "Files a new order for a customer.",
"path": "/orders",
"tags": ["orders"],
"auths": ["session"],
"middlewares": ["gzip"]
}
]
API (package gistapiserver)
func EndpointHandler[servicesGroup any, in any, out any](
id string,
expectedErrors []ExpectedError,
fn func(sg servicesGroup, ctx context.Context, in in) (out, error),
mockData out,
) *Handler[servicesGroup]
func Attach[servicesGroup any](serviceID string, handlers ...*Handler[servicesGroup]) gist.Option
in/out are plain structs; field tags drive both the
OpenAPI schema and wire decoding — path:"id", query:"status",
json:"items", plus required:"true",
minLength/maxLength, pattern, and
minimum/maximum. mockData out is required,
not optional — it's taken by value, not *out, so there's no nil to
check: the compiler won't accept a call without one. It's returned verbatim on
?mock=true, and it's also this endpoint's own Swagger/OpenAPI response
example (a real value, not a separately-maintained example tag per
field, which no longer exists for output types — one place to keep accurate
instead of two).
type CreateOrderInput struct {
CustomerID int `json:"customer_id" required:"true" example:"42"`
Items []OrderItemInput `json:"items" required:"true"`
}
type CreateOrderOutput struct {
OrderID int `json:"order_id" example:"1001"`
Status string `json:"status" example:"pending"`
}
var CreateOrder = gistapiserver.EndpointHandler("create-order",
[]gistapiserver.ExpectedError{
gistapiserver.InvalidArgument,
gistapiserver.Internal,
},
func(sg ServicesGroup, ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) {
order := model.Order{
CustomerID: in.CustomerID,
Status: "pending",
}
if _, err := sg.OrdersDB.Save(&order); err != nil {
return CreateOrderOutput{}, err
}
return CreateOrderOutput{OrderID: order.ID, Status: order.Status}, nil
},
CreateOrderOutput{ // mock, served on ?mock=true
OrderID: 1,
Status: "pending",
},
)
func AttachEndpointHandlers() gist.Option {
return gistapiserver.Attach("orderflow-api", CreateOrder /* , GetOrder, ... */)
}
ExpectedError (package gistapiserver, prebuilt vars, one
per gRPC-style status code): FailedPrecondition, InvalidArgument,
OutOfRange, Unauthenticated, PermissionDenied,
NotFound, Aborted, AlreadyExists,
ResourceExhausted, Canceled, DataLoss,
Internal, Unknown, Unimplemented,
Unavailable, DeadlineExceeded — each with
.WithMessage(fmt string, values ...any) ExpectedError and
.WithoutMessage().
Anything a handler or middleware panics with, or returns as a plain
(non-ExpectedError) error, is swallowed and surfaced to
the client as a generic Internal error rather than the real message —
but not silently: it's logged with a v4 UUID trace ID, and that same ID rides along
in the response's own traceId field, so you can find the real failure
behind a given response. Opt back into forwarding the real error text verbatim with
gist.BubbleUpErrors() (code) or config.json's top-level
bubble-up-errors: true (environment-dependent, e.g. on for local dev) —
off by default, since a raw error was only ever vetted as safe for your own logs,
not an external caller.
Custom middleware
A named middleware runs once per matching request, right after auth checks
(x-api-key/JWT) and before the endpoint's own handler — it inspects
the request and either lets it through or blocks it, before any business logic runs.
Reference it by name from middlewares (instance-wide or per-endpoint,
see endpoints.json above), the same way you already
reference gzip; any other name is dispatched to a callback your own
process registers with MiddlewareHandler/AttachMiddlewares,
running in your own process.
type MiddlewareRequest struct {
EndpointID string
Method string
Path string
Headers http.Header
QueryParams url.Values
}
type MiddlewareResponse struct {
Blocked bool // true stops the request here - StatusCode/Headers/Body are written back verbatim
StatusCode int
Headers http.Header
Body []byte
}
func MiddlewareHandler[servicesGroup any](
name string,
fn func(sg servicesGroup, ctx context.Context, req *MiddlewareRequest) (*MiddlewareResponse, error),
) *Middleware[servicesGroup]
func AttachMiddlewares[servicesGroup any](serviceID string, middlewares ...*Middleware[servicesGroup]) gist.Option
The request gives the middleware the method, path, headers, and query params.
A block sets its own response status, headers, and body verbatim.
AttachMiddlewares is a separate call from Attach, so
middlewares and endpoint handlers are registered independently of each other.
var HeaderCheck = gistapiserver.MiddlewareHandler("header-check",
func(sg ServicesGroup, ctx context.Context, req *gistapiserver.MiddlewareRequest) (*gistapiserver.MiddlewareResponse, error) {
if req.Headers.Get("X-Api-Secret") != sg.Config.APISecret {
return &gistapiserver.MiddlewareResponse{
Blocked: true,
StatusCode: http.StatusUnauthorized,
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: []byte(`{"error":"missing or wrong X-Api-Secret header"}`),
}, nil
}
return &gistapiserver.MiddlewareResponse{Blocked: false}, nil
},
)
func AttachMiddlewares() gist.Option {
return gistapiserver.AttachMiddlewares("orderflow-api", HeaderCheck)
}
{
"id": "create-order",
"path": "/orders",
"middlewares": ["header-check"]
}
At the end of your own gist.NewApp(...).Run(), every configured
middleware name is confirmed to have a registered callback — a name with none
stops your process at startup with a message naming the exact
service/endpoint/middleware.
Go API
package gistapiserver
Attach[servicesGroup any](serviceID string, handlers ...*Handler[servicesGroup]) gist.Option
// AttachMiddlewares wires one or more MiddlewareHandler declarations into server, the same way Attach wires EndpointHandler declarations - a separate function, not a parameter on Attach, so an existing Attach(serviceID, handlers...) call site never has to change shape to add a middleware alongside its handlers.
AttachMiddlewares[servicesGroup any](serviceID string, middlewares ...*Middleware[servicesGroup]) gist.Option
NewExpectedError(code status.Code, message string) ExpectedError
(e ExpectedError) Error() string
(e ExpectedError) StatusCode() int32
(e ExpectedError) WithMessage(message string, values ...any) ExpectedError
(e ExpectedError) WithoutMessage() ExpectedError
// EndpointHandler registers id's real handler, fn - see Attach.
EndpointHandler[servicesGroup any, in any, out any](
id string,
expectedErrors []ExpectedError,
fn func(sg servicesGroup, ctx context.Context, in in) (out, error),
mockData out,
) *Handler[servicesGroup]
// PaginatedEndpointHandler is EndpointHandler's counterpart for a list endpoint whose result set can be arbitrarily large - the customer's own fn receives already-resolved, already-clamped page/limit values (read from the request's own ?page=/?limit= query params, which this adds to the endpoint's schema automatically - no need to declare them on in) and is expected to push them down into its own query (e.g.
PaginatedEndpointHandler[servicesGroup any, in any, out any](
id string,
expectedErrors []ExpectedError,
fn func(sg servicesGroup, ctx context.Context, in in, page, limit int) (items []out, total int64, err error),
mockItems []out,
) *Handler[servicesGroup]
// RawEndpointHandler is EndpointHandler's counterpart for a response that isn't a JSON-encoded struct at all - an SVG diagram, a PDF, a plain image - anything whose bytes need to reach the caller exactly as the handler produced them, under a Content-Type other than "application/json".
RawEndpointHandler[servicesGroup any, in any](
id string,
expectedErrors []ExpectedError,
contentType string,
fn func(sg servicesGroup, ctx context.Context, in in) ([]byte, error),
) *Handler[servicesGroup]
// RawHTTPEndpointHandler is EndpointHandler's/RawEndpointHandler's counterpart for a handler that needs a genuine http.ResponseWriter/*http.Request pair - a real redirect, cookies, arbitrary headers, anything a plain net/http.HandlerFunc can do - instead of a JSON in/out contract or a fixed-status byte blob.
RawHTTPEndpointHandler[servicesGroup any](
id string,
fn func(sg servicesGroup, w http.ResponseWriter, r *http.Request),
) *Handler[servicesGroup]
// StreamEndpointHandler is EndpointHandler for an endpoint that answers with Server-Sent Events: fn gets its input like any endpoint, checks it (returning an ExpectedError before opening fails the request as usual), then writes events to stream until it returns or ctx is cancelled - the client disconnecting cancels it.
StreamEndpointHandler[servicesGroup any, in any](
id string,
expectedErrors []ExpectedError,
fn func(sg servicesGroup, ctx context.Context, in in, stream *Stream) error,
) *Handler[servicesGroup]
// MiddlewareHandler declares one named middleware - name is whatever string an endpoint's (or an instance's own) config.json "middlewares" list references.
MiddlewareHandler[servicesGroup any](
name string,
fn func(sg servicesGroup, ctx context.Context, req *MiddlewareRequest) (*MiddlewareResponse, error),
) *Middleware[servicesGroup]
// Open accepts the request: the client gets 200 text/event-stream right away, before any event.
(s *Stream) Open() error
// Send writes one event: event names it (empty for the default "message" event) and data is JSON-encoded as its payload.
(s *Stream) Send(event string, data any) error
02gist-auth
No API
Handles OAuth login against google, gitea, or
apple, built directly on goth's own transport-agnostic
Provider/Session interfaces rather than its
http.ResponseWriter/*http.Request/cookie-session sugar layer -
so gist-server needs no server-side session at all: CSRF protection is a signed,
stateless state token instead (state-secret below, valid 10
minutes). Your own process calls two plain typed Go methods -
Begin/Complete - from inside whichever HTTP endpoints it
already runs, exactly like any other injected client service; see
Wiring begin/callback below. Configure one instance per
provider, since provider registration is shared across the whole process.
Providers
provider/callback-url/state-secret apply the
same way regardless of provider; each provider's own client-id/scopes/etc.
live in a nested block named after it (google/gitea/apple)
- only the block matching provider needs to be set, the other two are
ignored.
| Field | Behavior |
|---|---|
google.client-id | OAuth client ID. |
google.client-secret | OAuth client secret issued by Google. |
google.scopes | OAuth scopes requested. |
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID. |
enabled | bool | If false, this entry is skipped entirely. |
provider | string | google. |
callback-url | string | OAuth callback/redirect URL registered with the provider — wherever your own process mounts the handler that calls Complete. |
state-secret | string | Key signing the stateless OAuth state CSRF token. |
google.client-id | string | OAuth client ID. |
google.client-secret | string | OAuth client secret issued by Google. |
google.scopes | []string | OAuth scopes requested. |
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"provider": "google",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"google": {
"client-id": "...",
"client-secret": "...",
"scopes": ["email", "profile"]
}
}
]
Same fields as Minimal, plus delayed-start/wait-for and the optional token/subject pair (see Minting a token below).
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"provider": "google",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"google": {
"client-id": "...",
"client-secret": "...",
"scopes": ["email", "profile"]
},
"token": { "token-type": "" },
"subject": "user_id"
}
]
gitea
| Field | Behavior |
|---|---|
gitea.server-url | Base URL of your self-hosted Gitea instance - the authorize/token/profile endpoints are derived from this at Gitea's own fixed paths (/login/oauth/authorize, /login/oauth/access_token, /api/v1/user), not independently configurable. |
gitea.client-secret | OAuth client secret issued by that Gitea instance. |
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID. |
enabled | bool | If false, this entry is skipped entirely. |
provider | string | gitea. |
callback-url | string | OAuth callback/redirect URL registered with the provider — wherever your own process mounts the handler that calls Complete. |
state-secret | string | Key signing the stateless OAuth state CSRF token. |
gitea.client-id | string | OAuth client ID. |
gitea.client-secret | string | OAuth client secret issued by that Gitea instance. |
gitea.server-url | string | Base URL of your self-hosted Gitea instance. |
gitea.scopes | []string | OAuth scopes requested. |
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"provider": "gitea",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"gitea": {
"client-id": "...",
"client-secret": "...",
"server-url": "https://git.orderflow.example.com",
"scopes": ["read:user"]
}
}
]
Same fields as Minimal, plus delayed-start/wait-for and the optional token/subject pair (see Minting a token below).
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"provider": "gitea",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"gitea": {
"client-id": "...",
"client-secret": "...",
"server-url": "https://git.orderflow.example.com",
"scopes": ["read:user"]
},
"token": { "token-type": "" },
"subject": "user_id"
}
]
apple
| Field | Behavior |
|---|---|
apple.team-id | Apple Developer Team ID. |
apple.key-id | Sign in with Apple key ID. |
apple.private-key | Apple private key (PEM) — derives the OAuth client secret directly. |
apple.private-key generates the OAuth client secret for apple
(no separate client-secret field - Apple never issues a static one).
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID. |
enabled | bool | If false, this entry is skipped entirely. |
provider | string | apple. |
callback-url | string | OAuth callback/redirect URL registered with the provider — wherever your own process mounts the handler that calls Complete. |
state-secret | string | Key signing the stateless OAuth state CSRF token. |
apple.client-id | string | OAuth client ID (Services ID). |
apple.team-id | string | Apple Developer Team ID. |
apple.key-id | string | Sign in with Apple key ID. |
apple.private-key | string | Apple private key (PEM) — derives the OAuth client secret. |
apple.scopes | []string | OAuth scopes requested. |
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"provider": "apple",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"apple": {
"client-id": "...",
"team-id": "...",
"key-id": "...",
"private-key": "-----BEGIN PRIVATE KEY-----...",
"scopes": ["name", "email"]
}
}
]
Same fields as Minimal, plus delayed-start/wait-for and the optional token/subject pair (see Minting a token below).
"gist-auth": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"provider": "apple",
"callback-url": "https://orderflow.example.com/auth/callback",
"state-secret": "...",
"apple": {
"client-id": "...",
"team-id": "...",
"key-id": "...",
"private-key": "-----BEGIN PRIVATE KEY-----...",
"scopes": ["name", "email"]
},
"token": { "token-type": "" },
"subject": "user_id"
}
]
Minting a token
Leave token.token-type empty (the default) and
Complete just returns the raw OAuth profile - no token minted. Set it to
match one of your gist-api-server instance's own auths catalog entries
(same environment/issuer/audience/
signing-string/max-age) and Complete also mints a
JWT that passes that endpoint's own auth check unchanged - no gist-api-server code
change needed. subject picks which profile field becomes the JWT subject:
user_id (default) or email.
Wiring begin/callback
gist-server can't serve customer-facing HTTP itself, so
Begin/Complete live on the SDK's own gistauth.Service
(injected into your ServicesGroup the same way every other client service
is - a plain GoogleAuth *gistauth.Service field tagged
name:"customer-login") as two plain typed methods, not a route gist-server
owns or a raw http.ResponseWriter/*http.Request pair to
forward - call them from inside whichever HTTP endpoint you already write, exactly like
sg.OrdersDB.Find(...) or any other injected client service:
type BeginAuthOutput struct {
RedirectURL string `json:"redirect_url"`
}
var BeginGoogleAuth = gistapiserver.EndpointHandler("begin-google-auth",
[]gistapiserver.ExpectedError{gistapiserver.Internal},
func(sg ServicesGroup, ctx context.Context, _ struct{}) (BeginAuthOutput, error) {
redirectURL, err := sg.GoogleAuth.Begin(ctx)
return BeginAuthOutput{RedirectURL: redirectURL}, err
},
BeginAuthOutput{RedirectURL: "https://accounts.google.com/o/oauth2/auth?..."},
)
type CompleteAuthInput struct {
Code string `json:"code" query:"code"`
State string `json:"state" query:"state"`
}
type CompleteAuthOutput struct {
Token string `json:"token"`
}
var CompleteGoogleAuth = gistapiserver.EndpointHandler("complete-google-auth",
[]gistapiserver.ExpectedError{gistapiserver.Internal},
func(sg ServicesGroup, ctx context.Context, in CompleteAuthInput) (CompleteAuthOutput, error) {
result, err := sg.GoogleAuth.Complete(ctx, in.Code, in.State)
if err != nil {
return CompleteAuthOutput{}, err
}
return CompleteAuthOutput{Token: result.Token}, nil
},
CompleteAuthOutput{Token: "..."},
)
result (gistauth.CompleteResult)
also carries Profile (the OAuth provider's user profile) and
ProviderAccessToken/ProviderRefreshToken (the provider's own
OAuth tokens - not the minted Token) - shape your own
CompleteAuthOutput around whichever of those your endpoint should expose.
Register begin-google-auth/complete-google-auth in
endpoints.json exactly like any other endpoint.
Logout is best-effort provider-side
token revocation (only Google supports it today - Gitea/Apple return a clear "not
supported" error) - call sg.GoogleAuth.Logout(ctx, providerAccessToken)
with the ProviderAccessToken a previous Complete call
returned, the same way.
Point this instance's own callback-url at
wherever you mounted the complete-google-auth handler above (register that
same URL with the OAuth provider too, e.g. in the Google/Gitea/Apple developer console) -
through gist-api-server, that's wherever endpoints.json mounts
it, prefixed with /v{version} if this instance's own
prepend-version-to-path is on.
03
gist-aws-s3-client
Create, read, update, and delete buckets and objects in Amazon S3, or a local emulator (MinIO/LocalStack) for testing. You refer to a bucket by a short name from your own config, not S3's real bucket name. That mapping happens on the server side, so the real name never has to appear in your code.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
"gist-aws-s3-client": [
{
"id": "customer-login",
"enabled": true
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
buckets |
map[string]string | Maps a customer-chosen bucket ID to the real S3 bucket name. |
region |
string | null | AWS region used for bucket creation and request signing. (default: us-east-1) |
endpoint |
string | null | When set, points the client at a local emulator (e.g. MinIO/LocalStack) instead of real S3, using path-style addressing; left empty, uses real S3 with the process's own default AWS credential chain. (default: empty string) |
"gist-aws-s3-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"buckets": {
"receipts": "orderflow-receipts"
},
"region": "us-east-1",
"endpoint": "http://127.0.0.1:9000/"
}
]
Go API
package gistawss3client
NewService(server *gist.Server, serviceID string) *Service
// CreateBucket creates a new bucket - see gist-server's CreateBucket for the underlying call and its already-taken-name-is-not-an-error convention; created reports whether this call was the one that created it.
(s *Service) CreateBucket(ctx context.Context, bucketID, location string) (created bool, err error)
// DeleteBucket deletes the bucket - S3 requires it to be empty first (see gist-server's DeleteBucket).
(s *Service) DeleteBucket(ctx context.Context, bucketID string) (found bool, err error)
// DeleteObject deletes fileName from bucketID.
(s *Service) DeleteObject(ctx context.Context, bucketID, fileName string) (found bool, err error)
(s *Service) Exists(ctx context.Context, bucketID, fileName string) (bool, error)
// GetBucket retrieves the bucket's metadata, decoding it into out (a pointer, same convention json.Unmarshal uses) - a {"name":..., "tags": {...}} shaped document (S3 has no single "bucket resource" the way GCS does).
(s *Service) GetBucket(ctx context.Context, bucketID string, out any) (found bool, err error)
// GetObject downloads fileName's raw content from bucketID.
(s *Service) GetObject(ctx context.Context, bucketID, fileName string) (found bool, content []byte, err error)
(s *Service) Store(ctx context.Context, bucketID, path string, fileHeader *multipart.FileHeader) (*string, *string, error)
// UpdateBucket merges attrs' own "tags" field into the bucket's existing tag set - e.g.
(s *Service) UpdateBucket(ctx context.Context, bucketID string, attrs any) (found bool, err error)
// UpdateObjectMetadata merges metadata into fileName's custom key/value metadata.
(s *Service) UpdateObjectMetadata(ctx context.Context, bucketID, fileName string, metadata map[string]string) (found bool, updated map[string]string, err error)
04
gist-aws-ses-client
Send transactional email via Amazon SES, or a local emulator for testing, and manage verified sender identities. You refer to a sender by a short name from your own config, not the real SES-verified "From" address. That mapping happens on the server side, so the real address never has to appear in your code.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
"gist-aws-ses-client": [
{
"id": "customer-login",
"enabled": true
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
senders |
map[string]string | Maps a customer-chosen sender ID to a real SES-verified "From" email address. |
region |
string | null | AWS region SES operates in. (default: us-east-1) |
endpoint |
string | null | When set, points SendEmail (and the identity-management calls) at a local emulator instead of the real SESv2 REST API; left empty, uses real SES with the process's own default AWS credential chain. Not used when smtp-endpoint is set. (default: empty string) |
smtp-endpoint |
string | null | When set, SendEmail is sent over SMTP to this host:port instead of the SESv2 REST API - real SES's own SMTP interface (e.g. email-smtp.<region>.amazonaws.com:587) for production, or a local emulator (e.g. gist-fixtures' own mailpit container) for testing. Authenticates with SES-derived SMTP credentials, resolved from the same AWS credential chain as the REST API, only if the server actually advertises SMTP AUTH support - skipped entirely against an unauthenticated local server. (default: empty string) |
"gist-aws-ses-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"senders": {
"receipts": "receipts@orderflow.example.com"
},
"region": "us-east-1",
"endpoint": "http://127.0.0.1:8005/",
"smtp-endpoint": "email-smtp.us-east-1.amazonaws.com:587"
}
]
Go API
package gistawssesclient
NewService(server *gist.Server, serviceID string) *Service
// CreateEmailIdentity begins verifying emailIdentity (an email address or domain) with SES - see gist-server's CreateEmailIdentity for the underlying call and its already-verified-is-not-an-error convention; created reports whether this call was the one that started verification.
(s *Service) CreateEmailIdentity(ctx context.Context, emailIdentity string) (created bool, err error)
// DeleteEmailIdentity removes emailIdentity from SES.
(s *Service) DeleteEmailIdentity(ctx context.Context, emailIdentity string) (found bool, err error)
// GetEmailIdentity retrieves emailIdentity's own verification status.
(s *Service) GetEmailIdentity(ctx context.Context, emailIdentity string) (found, verified bool, verificationStatus string, err error)
// SendEmail sends a simple (non-templated) email from senderID's own verified "From" address.
(s *Service) SendEmail(ctx context.Context, senderID string, to, cc, bcc, replyTo []string, subject, textBody, htmlBody string) (messageID string, err error)
05
gist-edge-proxy
gist-server's own network edge — terminates TLS in front of your HTTP(S) backends, round-robin load-balancing across health-checked servers per load balancer, with certificates issued and renewed automatically (self-signed locally, Let's Encrypt in staging/production). One instance can front several domains at once, each routed by hostname. It also forwards raw TCP traffic straight through to anything else reachable from gist-server via port-exposes, with no TLS or HTTP awareness. Use load-balancers for HTTP(S); port-exposes for everything else.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
acme |
ACMEConfig | Certificate-issuance settings. |
"gist-edge-proxy": [
{
"id": "customer-login",
"enabled": true,
"acme": {
"ca": "production",
"email": "ops@orderflow.example",
"cert-storage-path": "./data/gist-edge-proxy/public-edge",
"timeout": "60s"
}
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
hostname |
string | null | Bind address for both listeners below; defaults to 0.0.0.0. (default: 0.0.0.0) |
https-port |
int | null | Bind port for the TLS listener; defaults to 443. (default: 443) |
http-port |
int | null | Bind port for the plain-HTTP listener - serves ACME HTTP-01 challenges and, unless disable-http-redirect is set, redirects everything else to HTTPS. Defaults to 80. Ignored entirely if disable-http-listener is set. (default: 80) |
disable-http-listener |
bool | null | If true, no plain-HTTP listener is started at all - only the TLS listener on https-port. Note this also disables ACME's HTTP-01 challenge, so acme.ca must be able to complete a TLS-ALPN-01 challenge instead (or use local). (default: false) |
disable-http-redirect |
bool | null | If true, non-challenge requests on http-port are proxied in the clear instead of getting a 301 to the HTTPS URL. Defaults to false (redirect). (default: false) |
load-balancers |
[]LoadBalancer | Virtual hosts served by this instance's shared listeners - each is a domain set and the backend pool it round-robins/health-checks traffic across, dispatched by SNI/Host header. One instance covers every domain fronted from one IP; a second domain set needing a different IP needs a separate instance with its own hostname. |
port-exposes |
[]PortExpose | Raw TCP port forwards, unrelated to load-balancers/TLS/SNI - each opens its own plain TCP listener on this instance's own hostname and pipes bytes straight through to internal, whatever that is (a database, a service on this same host, anything reachable from it). |
acme |
ACMEConfig | Certificate-issuance settings. |
timeouts |
Timeouts | Connection timeouts for both listeners, so slow or idle clients can't hold connections open forever (slowloris). (default values of Timeouts) |
"gist-edge-proxy": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"hostname": "0.0.0.0",
"https-port": 443,
"http-port": 80,
"disable-http-listener": false,
"disable-http-redirect": false,
"load-balancers": [
{
"description": "Orderflow's public API",
"domains": ["api.orderflow.example"],
"backends": ["127.0.0.1:8080"],
"health-check-path": "/health"
}
],
"port-exposes": [
{
"description": "Postgres, exposed for the ops team's own database client",
"external": 5432,
"internal": "127.0.0.1:6543",
"allow-from": ["203.0.113.7", "10.0.0.0/8"]
}
],
"acme": {
"ca": "production",
"email": "ops@orderflow.example",
"cert-storage-path": "./data/gist-edge-proxy/public-edge",
"timeout": "60s"
},
"timeouts": {
"read-header": 10,
"idle": 120
}
}
]
LoadBalancer
| Field | Type | Description |
|---|---|---|
description |
string | Short, human-readable description of what this load balancer is for. |
domains |
[]string | Domain names this load balancer serves, as TLS SANs on one certificate. Each needs a public DNS A/AAAA record pointed at this host before ACME issuance can succeed - see acme.ca for testing this without a real domain. |
backends |
[]string | Backend addresses this load balancer's traffic is proxied to on a round-robin basis (e.g. a gist-api-server instance's own plain-HTTP listener). Every backend starts optimistically healthy; health-check-path then keeps that judgment current. |
health-check-path |
string | null | Path polled (HTTP HEAD) on every one of this load balancer's backends to mark it up/down. Leave empty to skip health checking entirely - every backend then stays optimistically healthy. |
PortExpose
| Field | Type | Description |
|---|---|---|
description |
string | Short, human-readable description of what this port-expose is for. |
external |
int | Port this instance listens on, on its own hostname. |
internal |
string | Literal host:port every connection accepted on external is forwarded to, byte for byte, in both directions. |
allow-from |
[]string | Optional: the only client IPs or CIDR ranges allowed to connect (e.g. 203.0.113.7, 10.0.0.0/8). Every other connection is closed at once. Empty means anyone may connect. |
ACMEConfig
| Field | Type | Description |
|---|---|---|
ca |
string | local (in-process self-signed certificate, no network, no external server - for local development), staging (Let's Encrypt staging: a real ACME flow, untrusted certs, high rate limit - for exercising the pipeline before switching to production), production (real Let's Encrypt, trusted certs, rate-limited), or a literal ACME directory URL (e.g. a local Pebble/step-ca test server). |
email |
string | Contact email registered with the ACME account; the CA uses it for expiry/problem notifications. Required unless ca is local. |
cert-storage-path |
string | Directory issued certificates (and, for ACME, the account key) persist under, so a restart reuses them instead of re-requesting - Let's Encrypt production allows only 50 certificates per registered domain per week. Defaults to ./data/gist-edge-proxy/<id>. |
timeout |
int | string | null | Max seconds (or duration string) Start waits for certificate issuance/renewal to complete before failing startup outright, rather than hanging indefinitely on a stuck ACME handshake. Defaults to 60s. (default: 60s) |
Timeouts
| Field | Type | Description |
|---|---|---|
read-header |
int | string | null | Max seconds (or duration string) a client may take to send its request headers. (default: 10) |
idle |
int | string | null | Max seconds (or duration string) a keep-alive connection may stay idle between requests. (default: 120) |
06
gist-elasticsearch-client
Index and query one Elasticsearch cluster through seven simple methods. Every method fails the same consistent way, so error handling stays predictable across all of them. A missing document is never treated as an error — it just comes back as a plain bool instead.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
user |
string | 🔒 Username for basic auth against the cluster. |
password |
string | 🔒 Password for basic auth against the cluster. |
"gist-elasticsearch-client": [
{
"id": "customer-login",
"enabled": true,
"user": "elastic",
"password": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
hosts |
[]string | Elasticsearch node URLs the client connects to. |
user |
string | 🔒 Username for basic auth against the cluster. |
password |
string | 🔒 Password for basic auth against the cluster. |
timeout |
int | string | null | Seconds (or duration string) before a request is cancelled via context timeout. (default: 10s) |
"gist-elasticsearch-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"hosts": ["http://127.0.0.1:9200"],
"user": "elastic",
"password": "orderflow",
"timeout": "10s"
}
]
Go API
package gistelasticsearchclient
NewService(server *gist.Server, serviceID string) *Service
// BulkIndex indexes every item into index in one round trip.
(s *Service) BulkIndex(ctx context.Context, index string, items []BulkItem) (hasErrors bool, results []BulkResult, err error)
// CreateItem inserts item into index - never overwrites.
(s *Service) CreateItem(ctx context.Context, index, id string, item any) (created bool, actualID string, err error)
// DeleteItem deletes the document at id.
(s *Service) DeleteItem(ctx context.Context, index, id string) (found bool, err error)
// GetItem retrieves the document at id, decoding its stored source into out (a pointer, same convention json.Unmarshal uses).
(s *Service) GetItem(ctx context.Context, index, id string, out any) (found bool, err error)
// IndexItem creates or replaces the document at id - a full create-or-replace, unlike CreateItem's insert-only semantics.
(s *Service) IndexItem(ctx context.Context, index, id string, item any) error
// Search runs query - a full Elasticsearch search request body, e.g.
(s *Service) Search(ctx context.Context, index string, query any) (*SearchResult, error)
// UpdateItem merges doc's fields into the existing document at id - a partial update, not a full replace (see IndexItem for that).
(s *Service) UpdateItem(ctx context.Context, index, id string, doc any) (found bool, err error)
07
gist-fixtures
Spins up a real test dependency in Docker — MySQL, Postgres, MongoDB, Redis, RabbitMQ, and several others. It seeds that container with data straight from your own tagged Go structs, in one call. Handy for integration tests that need a real database instead of a mock.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
type |
string | Container dialect to run (mysql / postgres / mariadb / mssql / mongodb / gcs / s3 / elasticsearch / rabbitmq / pubsub / mailpit / redis). |
"gist-fixtures": [
{
"id": "customer-login",
"enabled": true,
"type": "mysql"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
type |
string | Container dialect to run (mysql / postgres / mariadb / mssql / mongodb / gcs / s3 / elasticsearch / rabbitmq / pubsub / mailpit / redis). |
restart-after |
int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. (default: 48h) |
image |
string | null | Docker image override; defaults to the dialect's own default image when omitted. |
database |
string | null | Creates (or, for mssql/mongodb, selects) the target database — must end in _test. Not used by gcs/s3/elasticsearch/rabbitmq/pubsub/mailpit/redis. |
username |
string | null | 🔒 Creates this user in the container (mysql/postgres/mariadb/mongodb), or sets the admin/SMTP-auth username (rabbitmq defaults to guest; mailpit only takes effect together with password). Not used by mssql/gcs/s3/elasticsearch/pubsub/redis. |
password |
string | null | 🔒 Sets that user's/account's password — meaning depends on type: the created user (mysql/postgres/mariadb/mongodb), the fixed sa account (mssql), the fixed elastic superuser (elasticsearch), the admin password defaulting to guest (rabbitmq), SMTP auth (mailpit), or --requirepass (redis). Not used by gcs/s3/pubsub. |
host-port |
int | null | Fixed host port to bind the container's default port to, instead of a random one. |
project-id |
string | null | GCP project ID the pubsub emulator's topics/subscriptions are created under. pubsub only. |
"gist-fixtures": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"type": "mysql",
"restart-after": 86400,
"image": "mysql:8.4",
"database": "orderflow_test",
"username": "orderflow",
"password": "orderflow",
"host-port": 3306,
"project-id": "orderflow"
}
]
API
Reflection reads three tags, ignoring json/example (those
are for docs elsewhere, inert here): source:"schema.table" (required,
panics if missing), db:"column", and join:"..." on a
[]OtherStruct field to recurse into it as a relation rather than treat it
as one column — same model structs gist-mysql-client already uses work
here unchanged:
gistfixtures.GenerateFixtures("orderflow-fixtures",
[]model.Order{
{
ID: 1,
CustomerID: 42,
Status: "pending",
Items: []model.OrderItem{
{
OrderID: 1,
SKU: "WIDGET-1",
Qty: 3,
},
},
},
},
[]model.Customer{ // flat table, no join needed
{
ID: 42,
Name: "Acme Co",
},
},
)
Multiple rows...any arguments in one call can freely mix joined and flat
tables; unexported fields are skipped even with a db tag present.
Container types
id/enabled/delayed-start/wait-for/
image/host-port/restart-after apply the same way
to every type; what follows per dialect is only the fields specific to it.
None of them are strictly required by the code — every one is a nil-checked pointer
field, and an unset one just falls back to the image's own default — but each dialect
has fields worth setting for the container to do anything useful, which is what each
dialect's own Minimal below sets.
restart-after tears a booted container down and boots a fresh one from
the same fixture data, on a timer — 48h by default. Every duration field across every
service
(delayed-start, timeout, restart-after,
connection-ping, query-timeout,
conn-max-lifetime-minutes, gist-api-server's
timeouts.read/write/idle) accepts either a bare
number in its own pre-existing unit (seconds, except
conn-max-lifetime-minutes, which is minutes) or a unit-suffixed string —
w/W, d/D, h/H,
m/M, s/S — e.g.
"restart-after": "2d" instead of "restart-after": 172800.
type values
| Value | Description |
|---|---|
mysql | MySQL container. |
postgres | PostgreSQL container. |
mariadb | MariaDB container. |
mssql | Microsoft SQL Server container (EULA auto-accepted). |
mongodb | MongoDB container. |
gcs | Google Cloud Storage emulator container; only image/host-port apply — buckets/objects come from fixture rows, not config. |
s3 | Amazon S3 emulator container; only image/host-port apply — buckets/objects come from fixture rows, not config, seeded live after boot (no reliable init-mount path, unlike gcs). |
elasticsearch | Elasticsearch container; only password applies (username is the fixed elastic superuser). |
rabbitmq | RabbitMQ container; username/password default to guest/guest and are also baked into the generated definitions.json. |
pubsub | GCP Pub/Sub emulator container; only project-id/host-port apply. |
mailpit | Mailpit fake-SMTP/email-testing container; username/password are optional SMTP auth credentials (both required together to take effect) — no database concept. |
redis | Redis container; only password applies (sets --requirepass) — no username/database concept. |
mysql / postgres / mariadb
Identical field behavior across all three — only the package/image differ.
| Field | Type | Description |
|---|---|---|
database | string | Creates this database in the container. Must end in _test. |
username | string | Creates this user in the container. |
password | string | Sets that user's password. |
"gist-fixtures": [
{
"id": "orders-db-fixtures",
"enabled": true,
"type": "mysql",
"database": "orderflow_test",
"username": "orderflow",
"password": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
database | string | Creates this database in the container. Must end in _test. |
username | string | Creates this user in the container. |
password | string | Sets that user's password. |
"gist-fixtures": [
{
"id": "orders-db-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 3306,
"image": null,
"restart-after": "24h",
"type": "mysql",
"database": "orderflow_test",
"username": "orderflow",
"password": "orderflow"
}
]
mssql
SQL Server's admin account is always sa. EULA acceptance happens
automatically.
| Field | Type | Description |
|---|---|---|
database | string | Selects the target database. Must end in _test. |
password | string | Sets the fixed sa account's password. |
"gist-fixtures": [
{
"id": "orders-db-fixtures",
"enabled": true,
"type": "mssql",
"database": "orderflow_test",
"password": "OrderflowStrong!1"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
database | string | Selects the target database. Must end in _test. |
password | string | Sets the fixed sa account's password. |
"gist-fixtures": [
{
"id": "orders-db-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 1433,
"image": null,
"restart-after": "24h",
"type": "mssql",
"database": "orderflow_test",
"password": "OrderflowStrong!1"
}
]
mongodb
| Field | Type | Description |
|---|---|---|
database | string | Selects the target database. Must end in _test. |
username | string | Creates this user in the container. |
password | string | Sets that user's password. |
"gist-fixtures": [
{
"id": "orders-doc-fixtures",
"enabled": true,
"type": "mongodb",
"database": "orderflow_test",
"username": "orderflow",
"password": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
database | string | Selects the target database. Must end in _test. |
username | string | Creates this user in the container. |
password | string | Sets that user's password. |
"gist-fixtures": [
{
"id": "orders-doc-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 27017,
"image": null,
"restart-after": "24h",
"type": "mongodb",
"database": "orderflow_test",
"username": "orderflow",
"password": "orderflow"
}
]
gcs
Buckets and objects come entirely from GenerateFixtures' own rows
(bucket/name/optional data per object) —
Minimal/Full here differ only in the shared fields already covered above.
| Field | Type | Description |
|---|
"gist-fixtures": [
{
"id": "receipts-fixtures",
"enabled": true,
"type": "gcs"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
"gist-fixtures": [
{
"id": "receipts-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 4443,
"image": null,
"restart-after": "24h",
"type": "gcs"
}
]
s3
Buckets and objects come entirely from GenerateFixtures' own rows
(bucket/name/optional data per object, the same
shape as gcs) — Minimal/Full here differ only in the shared fields already
covered above. Unlike gcs, seeding happens as a live call against the
container's real S3 REST API once it's up, not a pre-boot file mount — S3Mock's
own initial-bucket/object file layout isn't a stable convention across its supported
image versions the way fake-gcs-server's is.
| Field | Type | Description |
|---|
"gist-fixtures": [
{
"id": "receipts-fixtures",
"enabled": true,
"type": "s3"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
"gist-fixtures": [
{
"id": "receipts-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 9090,
"image": null,
"restart-after": "24h",
"type": "s3"
}
]
elasticsearch
The elastic superuser account name is fixed. The container's HTTP TLS
layer is disabled, matching gist-elasticsearch-client; see
Pointing a real client at your own fixtures container
below.
| Field | Type | Description |
|---|---|---|
password | string | Sets the fixed elastic superuser's password. |
"gist-fixtures": [
{
"id": "order-index-fixtures",
"enabled": true,
"type": "elasticsearch",
"password": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
password | string | Sets the fixed elastic superuser's password. |
"gist-fixtures": [
{
"id": "order-index-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 9200,
"image": null,
"restart-after": "24h",
"type": "elasticsearch",
"password": "orderflow"
}
]
rabbitmq
| Field | Type | Description |
|---|
"gist-fixtures": [
{
"id": "bus-fixtures",
"enabled": true,
"type": "rabbitmq"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
username | string | Admin username. Defaults to guest if unset. |
password | string | Admin password. Defaults to guest if unset. |
"gist-fixtures": [
{
"id": "bus-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 5672,
"image": null,
"restart-after": "24h",
"type": "rabbitmq",
"username": "orderflow",
"password": "orderflow"
}
]
pubsub
Names the project a real gist-pub-sub-client client points at to reach
the same topics/subscriptions/messages rows.
| Field | Type | Description |
|---|---|---|
project-id | string | GCP project ID the emulator's topics/subscriptions are created under. |
"gist-fixtures": [
{
"id": "order-events-fixtures",
"enabled": true,
"type": "pubsub",
"project-id": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
project-id | string | GCP project ID the emulator's topics/subscriptions are created under. |
"gist-fixtures": [
{
"id": "order-events-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 8085,
"image": null,
"restart-after": "24h",
"type": "pubsub",
"project-id": "orderflow"
}
]
mailpit
Leaving username and password unset, the container accepts unauthenticated SMTP.
| Field | Type | Description |
|---|
"gist-fixtures": [
{
"id": "order-emails-fixtures",
"enabled": true,
"type": "mailpit"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
username | string | SMTP auth username. Only takes effect when password is also set. |
password | string | SMTP auth password. Only takes effect when username is also set. |
"gist-fixtures": [
{
"id": "order-emails-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 1025,
"image": null,
"restart-after": "24h",
"type": "mailpit",
"username": "orderflow",
"password": "orderflow"
}
]
redis
Redis's classic AUTH is password-only. A logical DB index (0-15) is selected per-connection, not per-container.
| Field | Type | Description |
|---|---|---|
password | string | Sets --requirepass directly on the container's own command line. |
"gist-fixtures": [
{
"id": "order-cache-fixtures",
"enabled": true,
"type": "redis",
"password": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id | string | Unique instance ID for this fixture container. |
enabled | bool | If false, this entry is skipped entirely (no container started). |
delayed-start | int | string | Seconds (or duration string) to wait/delay before starting this instance. |
wait-for | []string | IDs of other services this instance must wait to become ready before starting. |
host-port | int | null | Fixed host port to bind the container's default port to, instead of a random one. |
image | string | null | Docker image override; defaults to the dialect's own default image when omitted. |
restart-after | int | string | null | Seconds (or duration string) a booted container runs before it's automatically restarted from the same fixture data. Omitted defaults to 48h; 0 disables auto-restart. |
type | string | Container dialect to run. |
password | string | Sets --requirepass directly on the container's own command line. |
"gist-fixtures": [
{
"id": "order-cache-fixtures",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host-port": 6379,
"image": null,
"restart-after": "24h",
"type": "redis",
"password": "orderflow"
}
]
elasticsearch, pubsub, rabbitmq,
mailpit, redis, and s3 containers get their
seed data delivered by a live client call right after boot — same
rows...any call, different table conventions per dialect.
mailpit's is a single
"messages" table: to
(required — a row with none is skipped), from (defaults to
fixtures@localhost), subject, body; each row is
sent as one real SMTP delivery, visible through the running container's own web
UI/REST API. redis's is a
single "strings" table: key (required — a row with none is
skipped), value (stringified, defaults to ""); each row is a
real SET, sent over a direct RESP connection. s3's is the
same single "objects" table gcs uses
(bucket/name/optional data, a row missing
either bucket or name is skipped) — each bucket is created
once (an already-owned bucket is not an error) and each object is a real
PutObject call.
Pointing a real client at your own fixtures container
Beyond seeding through GenerateFixtures, it's common to point the
matching real client service — gist-google-cloud-storage-client,
gist-aws-s3-client, gist-redis-client,
gist-pub-sub-client, gist-elasticsearch-client, or
gist-rabbit-mq-client — straight at a
gist-fixtures-booted container for local dev. Pin the port with
gist-fixtures' own host-port first, then:
| Client | Field(s) | Notes |
|---|---|---|
gist-google-cloud-storage-client |
endpoint: "http://127.0.0.1:<host-port>" |
endpoint skips ADC auth and talks straight to the fake server. |
gist-aws-s3-client |
endpoint: "http://127.0.0.1:<host-port>", buckets mapping your own alias to the bucket name(s) you seeded |
endpoint switches the client to anonymous credentials and path-style addressing, matching S3Mock. |
gist-redis-client |
host: "127.0.0.1", port: <host-port>, password matching the fixtures entry's own password |
Config.Password is wired into redis.Options.Password, authenticating against a --requirepass container. |
gist-pub-sub-client |
endpoint: "127.0.0.1:<host-port>" (no scheme) |
Same host:port format as PUBSUB_EMULATOR_HOST, which this field takes precedence over. |
gist-elasticsearch-client |
hosts: ["http://127.0.0.1:<host-port>"], user: "elastic", password matching the fixtures entry's own password (or "changeme" if left unset) |
gist-fixtures disables the container's HTTP TLS layer, matching the real client's plain http.Client. |
gist-rabbit-mq-client |
url: "amqp://<user>:<password>@127.0.0.1:<host-port>/" — guest:guest unless the fixtures entry's own username/password were set |
A plain connection string, no TLS involved. |
Go API
package gistfixtures
GenerateFixtures(id string, rows ...any) gist.Option
08
gist-google-cloud-storage-client
Create, read, update, and delete buckets and objects in Google Cloud Storage, or a local emulator for testing. You refer to a bucket by a short name from your own config, not GCS's real bucket name. That mapping happens on the server side, so the real name never has to appear in your code.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
scope |
string | OAuth scope used to build the Application Default Credentials token source. |
"gist-google-cloud-storage-client": [
{
"id": "customer-login",
"enabled": true,
"scope": "https://www.googleapis.com/auth/devstorage.read_write"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
buckets |
map[string]string | Maps a customer-chosen bucket ID to the real GCS bucket name. |
scope |
string | OAuth scope used to build the Application Default Credentials token source. |
project-id |
string | null | GCS project bucket creation is billed/attributed to; only needed for CreateBucket. (default: empty string) |
endpoint |
string | null | When set, points the client at a local emulator instead of real GCS; left empty, uses real GCS with real ADC auth. (default: empty string) |
"gist-google-cloud-storage-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"buckets": {
"receipts": "orderflow-receipts"
},
"scope": "https://www.googleapis.com/auth/devstorage.read_write",
"project-id": "orderflow-prod",
"endpoint": "http://0.0.0.0:4443/storage/v1/"
}
]
Go API
package gistgooglecloudstorageclient
NewService(server *gist.Server, serviceID string) *Service
// CreateBucket creates a new bucket - see gist-server's CreateBucket for the underlying REST call and its 409-is-not-an-error convention; created reports whether this call was the one that created it.
(s *Service) CreateBucket(ctx context.Context, bucketID, location string) (created bool, err error)
// DeleteBucket deletes the bucket - Cloud Storage requires it to be empty first (see gist-server's DeleteBucket).
(s *Service) DeleteBucket(ctx context.Context, bucketID string) (found bool, err error)
// DeleteObject deletes fileName from bucketID.
(s *Service) DeleteObject(ctx context.Context, bucketID, fileName string) (found bool, err error)
(s *Service) Exists(ctx context.Context, bucketID, fileName string) (bool, error)
// GetBucket retrieves the bucket's metadata, decoding it into out (a pointer, same convention json.Unmarshal uses).
(s *Service) GetBucket(ctx context.Context, bucketID string, out any) (found bool, err error)
// GetObject downloads fileName's raw content from bucketID.
(s *Service) GetObject(ctx context.Context, bucketID, fileName string) (found bool, content []byte, err error)
(s *Service) Store(ctx context.Context, bucketID, path string, fileHeader *multipart.FileHeader) (*string, *string, error)
// UpdateBucket merges attrs' fields into the bucket's metadata - e.g.
(s *Service) UpdateBucket(ctx context.Context, bucketID string, attrs any) (found bool, err error)
// UpdateObjectMetadata merges metadata into fileName's custom key/value metadata.
(s *Service) UpdateObjectMetadata(ctx context.Context, bucketID, fileName string, metadata map[string]string) (found bool, updated map[string]string, err error)
09
gist-http-client
A simple outbound HTTP client with one method per verb — GET, POST, PUT, PATCH, DELETE. Point it at a base URL once in config, then call it with just the path you want. Per-request details like headers or query params are passed in as small, typed options.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
base-url |
string | 🔒 Base URL every request from this client is made against. |
"gist-http-client": [
{
"id": "customer-login",
"enabled": true,
"base-url": "https://api.paymentgateway.example.com"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
base-url |
string | 🔒 Base URL every request from this client is made against. |
default-headers |
map[string]string | 🔒 Headers merged into every outgoing request. Each value may itself be an env://, gcp-sm://, or gist-ps:// reference (see internal/secretref) instead of a literal - the natural place for an outbound Authorization header's own token. |
timeout |
int | string | null | Request timeout (or duration string). (default: 5s) |
tls |
TLSConfig | Custom TLS transport settings — enabled (bool) and insecure-skip-verify (bool, trust self-signed certs). (default values of TLSConfig) |
"gist-http-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"base-url": "https://api.paymentgateway.example.com",
"default-headers": {
"X-Client": "orderflow"
},
"timeout": "5s",
"tls": {
"enabled": true,
"insecure-skip-verify": false
}
}
]
TLSConfig
| Field | Type | Description |
|---|---|---|
enabled |
bool | null | If true, requests use a custom TLS transport (otherwise Go's default transport is used, unmodified). (default: true) |
insecure-skip-verify |
bool | null | If true (with tls.enabled), skips server certificate verification — for trusting self-signed certs. (default: false) |
Go API
package gisthttpclient
NewService(server *gist.Server, serviceID string) *Service
(s *Service) Delete(ctx context.Context, endpoint string, options ...delete.Option) Response
(s *Service) Get(ctx context.Context, endpoint string, options ...get.Option) Response
(s *Service) Patch(ctx context.Context, endpoint string, options ...patch.Option) Response
(s *Service) Post(ctx context.Context, endpoint string, options ...post.Option) Response
(s *Service) Put(ctx context.Context, endpoint string, options ...put.Option) Response
10
gist-http-server
Serves a directory of static files over HTTP. Point www-files at a folder — generated PDFs, images, whatever ends up there — and it's served as-is. No routing or code needed on your side.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
www-files |
string | Directory path served as static files (http.FileServer). |
"gist-http-server": [
{
"id": "customer-login",
"enabled": true,
"www-files": "./public/invoices"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
hostname |
string | null | Bind address for the HTTP listener; defaults to 0.0.0.0. (default: 0.0.0.0) |
port |
int | null | Bind port for the HTTP listener; defaults to 80. (default: 80) |
www-files |
string | Directory path served as static files (http.FileServer). |
"gist-http-server": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"hostname": "0.0.0.0",
"port": 8081,
"www-files": "./public/invoices"
}
]
11
gist-mcp-bridge
Exposes other already-configured service instances' own operations as MCP (Model Context Protocol) tools, over its own standalone JSON-RPC-over-HTTP listener - point an AI agent (or any MCP client) at it instead of, or alongside, driving those instances directly.
bridged-services is a flat list of already-configured gist-api-server config ids to expose - one entry per instance you want reachable this way. Which of that instance's own endpoints actually show up is controlled per endpoint, by its own enable-mcp flag in endpoints.json - referencing an instance here exposes none of its endpoints until at least one is explicitly flagged.
Tool calls carry through whatever Authorization/x-api-key header the caller sent - the target endpoint's own real auth requirement (unchanged) is the only gate on what a tool call can actually do; this service performs no authorization of its own.
Setting access-key adds one coarse gate in front of that: every request must present it in an X-Mcp-Key header or get a plain 401, before tools/list or tools/call ever runs. It's not per-caller auth - anyone holding the key can call anything the bridge exposes - it exists to keep random/anonymous callers from even discovering what tools are available.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
bridged-services |
[]string | Config ids of already-configured gist-api-server instances this MCP server exposes tools from - each endpoint still needs its own enable-mcp flag to actually show up. |
"gist-mcp-bridge": [
{
"id": "customer-login",
"enabled": true,
"bridged-services": ["orders-api"]
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
hostname |
string | null | 🔒 Bind address for the MCP HTTP listener. (default: 0.0.0.0) |
port |
int | null | Bind port for the MCP HTTP listener. (default: 8090) |
bridged-services |
[]string | Config ids of already-configured gist-api-server instances this MCP server exposes tools from - each endpoint still needs its own enable-mcp flag to actually show up. |
access-key |
string | null | 🔒 Optional shared secret every request must present in the X-Mcp-Key header. This is not per-caller authorization - the real auth for what a tool actually does stays with the bridged endpoint's own auths - it exists only to stop random/anonymous callers from even discovering what tools are exposed (tools/list) before that real auth is ever checked. Empty (default) means no check. |
"gist-mcp-bridge": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"hostname": "0.0.0.0",
"port": 8090,
"bridged-services": ["orders-api"],
"access-key": "..."
}
]
12
gist-mysql-client
A MySQL connection pool with a small generic repo layer built in — Find, Save, Update, Delete — plus transactions and a simple condition/sort DSL. Call it straight off the injected service, e.g. sg.OrdersDB.Find[Order](...). gist-postgres-client (next) works exactly the same way, so switching databases barely changes your code. Set ssh to tunnel every connection (primary and replicas) through a bastion instead of dialing the database host directly.
Backups. Give the entry a backup object with enabled: true and it backs itself up on a schedule — off unless you do. Each backup is a mysqldump dump, compressed with zstd and encrypted with age to the public keys in recipients: the server can write backups but never read them, and the private key needed to restore stays offline. Everything is streamed, so memory use doesn't grow with the database.
Rotation. tiers keep the newest backup of each of the last keep periods — by default 7 daily, 5 weekly and 3 monthly, so deleted data is gone from every backup within about 3 months. One backup can count for several tiers and is stored once.
Where. Backups are made in local-path; with cloud (a gist-aws-s3-client or gist-google-cloud-storage-client entry of the same config) they are also copied there and rotated there too, and the local copy is deleted once the upload is complete unless cloud.keep-local. A backup is checked for room first (plus 512 MB always left free), made under a temporary name, verified before it's sealed, and only counts once its manifest exists — a crash or a full disk never leaves a broken backup, and leftovers are cleaned up before the next run.
Deleted personal data. List the tables to track and call RecordDeletion from the app just before deleting a row (e.g. a user). It's recorded in a tracking table, with the hash columns stored only as a keyed hash, and at once in a deletion journal next to the backups (and in the cloud). A restore then deletes those rows again even if they were deleted after the backup was taken — even when the database itself was lost — so restoring never brings deleted people back.
Commands. Run by the app binary (./app -backup=<entry id> <command>) or gist-server (-config config.json -backup <entry id> <command>): run (a backup now), list, find <table> <column>=<value> (which backups still hold a deleted row, and when the last expires), restore -identity <age key file> -into <new database> [-at <backup>] (always into a new database, which is dropped again if anything fails), record-deletion and drill-done (records a restore drill; one is asked for every drill-reminder). Needs mysqldump and mysql on the server; entries that connect through ssh can't be backed up.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
username |
string | 🔒 Primary database username. |
password |
string | 🔒 Primary database password. |
database |
string | 🔒 Primary database name. |
"gist-mysql-client": [
{
"id": "customer-login",
"enabled": true,
"username": "orderflow",
"password": "orderflow",
"database": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
host |
string | null | 🔒 Primary database hostname. (default: 0.0.0.0) |
username |
string | 🔒 Primary database username. |
password |
string | 🔒 Primary database password. |
database |
string | 🔒 Primary database name. |
port |
int | null | 🔒 Primary database port. (default: 3306) |
connection-ping |
int | string | null | Seconds (or duration string) between health-check pings; 0 disables the ping monitor. (default: 30s) |
connection-retries |
int | null | Reconnect attempts the ping monitor makes after a failed ping before giving up. (default: 3) |
ssl-mode |
bool | null | Enables TLS on the connection. (default: false) |
query-timeout |
int | string | null | Per-query timeout (or duration string). (default: 5s) |
parse-time |
bool | null | mysql only. Enables the driver's parseTime option, scanning DATE/DATETIME columns into time.Time. (default: true) |
max-open-conns |
int | null | Max number of open connections to the primary. (default: 25) |
max-idle-conns |
int | null | Max number of idle connections kept open to the primary. (default: 10) |
conn-max-lifetime |
int | string | null | Max time a connection to the primary may be reused before being closed. (default: 2h) |
replicas |
[]ReplicaConfig | host/port/username/password/database (optional, defaulting to the primary's) plus its own max-open-conns/max-idle-conns/conn-max-lifetime-minutes. |
ssh |
SSHConfig | Tunnels every connection this instance opens (the primary and every replica) through an SSH server instead of dialing host directly. Left unset, connects directly. (default values of SSHConfig) |
backup |
BackupConfig | Automatic, rotated, compressed and encrypted backups of this database, with a record of deleted personal data so restores never bring it back. Off unless this object is given with enabled: true (see the backup docs). (default values of BackupConfig) |
"gist-mysql-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host": "127.0.0.1",
"username": "orderflow",
"password": "orderflow",
"database": "orderflow",
"port": 3306,
"connection-ping": "30s",
"connection-retries": 3,
"ssl-mode": true,
"query-timeout": "5s",
"parse-time": true,
"max-open-conns": 25,
"max-idle-conns": 10,
"conn-max-lifetime": "2h",
"replicas": [
{
"host": "127.0.0.2",
"port": 3306,
"username": "root",
"password": "password",
"database": "mysql",
"max-open-conns": 10,
"max-idle-conns": 5,
"conn-max-lifetime-minutes": 30
}
],
"ssh": {
"host": "bastion.orderflow.example.com",
"port": 22,
"user": "deploy",
"private-key": "-----BEGIN OPENSSH PRIVATE KEY-----...",
"private-key-passphrase": "...",
"password": "...",
"known-hosts-key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"insecure-ignore-host-key": false,
"timeout": "10s"
},
"backup": {
"enabled": true,
"at": "03:00",
"timezone": "Europe/Stockholm",
"tiers": [
{
"name": "daily",
"every": "1d",
"keep": 7
}
],
"compression-level": 3,
"recipients": ["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"],
"local-path": "/var/backups/gist",
"cloud": {
"kind": "gist-aws-s3-client",
"id": "backups-s3",
"bucket": "backups",
"path": "gist",
"keep-local": false
},
"track": [
{
"table": "app.users",
"columns": ["id", "email"],
"hash": ["email"]
}
],
"tracking-table": "public.gist_backup_deletions",
"hash-key": "env://BACKUP_HASH_KEY",
"dump-binary": "/usr/lib/postgresql/18/bin/pg_dump",
"restore-binary": "/usr/lib/postgresql/18/bin/pg_restore",
"drill-reminder": "1M"
}
}
]
ReplicaConfig
| Field | Type | Description |
|---|---|---|
host |
string | 🔒 Replica hostname. |
port |
int | null | 🔒 Replica port; defaults to the primary's port when omitted. |
username |
string | null | 🔒 Replica username; defaults to the primary's username when omitted. |
password |
string | null | 🔒 Replica password; defaults to the primary's password when omitted. |
database |
string | null | Replica database name; defaults to the primary's database when omitted. |
max-open-conns |
int | null | Max number of open connections to this replica. (default: 10) |
max-idle-conns |
int | null | Max number of idle connections kept open to this replica. (default: 5) |
conn-max-lifetime-minutes |
int | string | null | Max minutes (or duration string) a connection to this replica may be reused before being closed. (default: 30m) |
SSHConfig
| Field | Type | Description |
|---|---|---|
host |
string | 🔒 SSH server hostname - not the database host, which stays configured on the primary/replica entries as usual. |
port |
int | null | 🔒 SSH server port. (default: 22) |
user |
string | 🔒 SSH username. |
private-key |
string | null | 🔒 PEM-encoded SSH private key. At least one of private-key/password is required - many bastions disable password auth entirely, so prefer this when it's available. |
private-key-passphrase |
string | null | 🔒 Passphrase decrypting private-key, if it's encrypted. Ignored if private-key isn't set. |
password |
string | null | 🔒 SSH password. At least one of private-key/password is required. |
known-hosts-key |
string | null | The SSH server's own public key, authorized_keys format, verified on every connect. Required unless insecure-ignore-host-key is true - get it from the server with e.g. ssh-keyscan. |
insecure-ignore-host-key |
bool | null | Skips SSH host key verification entirely - vulnerable to MITM. Only for a local/throwaway bastion; every other case should set known-hosts-key instead. (default: false) |
timeout |
int | string | null | Seconds (or duration string) allowed for the initial SSH handshake. (default: 10s) |
BackupConfig
| Field | Type | Description |
|---|---|---|
enabled |
bool | null | Turns the backups on. Off by default; the rest of the object is only read (and checked) when this is true. (default: false) |
at |
string | null | Time of day (HH:MM, in timezone) for the backup run when the shortest tier is a day or longer. Shorter tiers run every interval instead. (default: 03:00) |
timezone |
string | null | IANA time zone at is in. (default: UTC) |
tiers |
[]BackupTier | Rotation tiers: each keeps the newest backup of each of its last keep periods of length every (6h, 1d, 1w, 1M, 1y). One backup can count for several tiers. Left out: daily 7, weekly 5, monthly 3. |
compression-level |
int | null | zstd level, 1 (fastest) to 22 (smallest). (default: 3) |
recipients |
[]string | age public keys (age1...) every backup is encrypted to. The server only holds public keys: it can write backups but not read them; the private key stays offline and is needed only to restore. Required. |
local-path |
string | Folder the backups, deletion lists, manifests and the deletion journal are written to (a subfolder per database client id). Required - also with cloud, where each backup is made here first (so it needs room for one) and, unless cloud.keep-local, deleted once uploaded. |
cloud |
BackupCloud | Also copies every backup file there, and rotates it there too. Left out: local only. (default values of BackupCloud) |
track |
[]BackupTrack | Tables whose deletions are recorded (by the app, through RecordDeletion) - each tracked column becomes a col_<name> column of the tracking table. Needed to answer which backups still hold deleted data, and to delete it again on restore. |
tracking-table |
string | null | Table the deletions are recorded in, created (and extended with new col_ columns) at startup. Empty: public.gist_backup_deletions (Postgres) or gist_backup_deletions (MySQL). (default: empty string) |
hash-key |
string | null | 🔒 Secret for the keyed hash (HMAC-SHA-256) of hash columns, so the deletion record never holds the deleted personal data itself. Required when any track entry has hash. Changing it makes earlier hashed entries unmatchable. (default: empty string) |
dump-binary |
string | null | pg_dump / mysqldump to run. Empty: found on PATH. For Postgres it must be at least the server's major version (checked at startup). (default: empty string) |
restore-binary |
string | null | pg_restore / mysql used to verify dumps (Postgres) and to restore. Empty: found on PATH. (default: empty string) |
drill-reminder |
string | null | How often a restore drill is due (-backup <id> drill-done records one); an overdue drill is warned about after every backup. Empty: never. (default: 1M) |
BackupTier
| Field | Type | Description |
|---|---|---|
name |
string | Name, shown in listings (daily, weekly, ...). |
every |
string | Period: a number and h, d, w, M (month) or y. |
keep |
int | How many periods back a backup is kept for this tier. |
BackupCloud
| Field | Type | Description |
|---|---|---|
kind |
string | gist-aws-s3-client or gist-google-cloud-storage-client. |
id |
string | That entry's id - configured as its own service, like any storage client. |
bucket |
string | Bucket id, as that entry's own buckets map names it. |
path |
string | null | Prefix inside the bucket; the database client id is added below it. (default: empty string) |
keep-local |
bool | null | Also keep each backup in local-path. Off: the dump and its deletion list are deleted locally once their cloud copy is complete (local-path then only holds the one being made, manifests and the deletion journal). (default: false) |
BackupTrack
| Field | Type | Description |
|---|---|---|
table |
string | Table (schema-qualified on Postgres). |
columns |
[]string | Columns recorded for a deleted row. At least one must not be hashed: a restore deletes the row again by those. |
hash |
[]string | Columns (of columns) stored only as a keyed hash - personal data such as an email. |
Go API
package gistmysqlclient
// Count queues a Count[model] op; out is set once Run succeeds.
(b *Batch) Count[model any](out *int64, opts ...Option)
// Exists queues an Exists[model] op; out is set once Run succeeds.
(b *Batch) Exists[model any](out *bool, opts ...Option)
// Find queues a Find[model] op; out is set once Run succeeds.
(b *Batch) Find[model any](out *[]model, opts ...Option)
// FindOne queues a FindOne[model] op; out is set once Run succeeds.
(b *Batch) FindOne[model any](out **model, opts ...Option)
// Run sends every op queued on b so far as ONE BatchRepo RPC - Begin and the closing Rollback both folded into that same call, since a batch of reads has nothing to persist either way - then decodes each op's own response straight into the pointer its own queueing call was given.
(b *Batch) Run(ctx context.Context) error
AndConditions(cs ...Conditioner) Conditioner
NewBetweenCondition(field string, low, high any) Conditioner
NewCondition(field string, operator Operator, value any) Conditioner
NewNotBetweenCondition(field string, low, high any) Conditioner
OrConditions(cs ...Conditioner) Conditioner
WithConditions(conds ...Conditioner) Option
WithLimit(limit int64) Option
WithLock() Option
WithOffset(offset int64) Option
WithRange(start, end int64) Option
WithRelationConditions(relation string, conds ...Conditioner) Option
WithSorting(scends ...Scend) Option
NewService(server *gist.Server, serviceID string) *Service
(s *Service) Count[model any](opts ...Option) (int64, error)
(s *Service) Delete[model any](opts ...Option) (int64, error)
(s *Service) DeleteWithReturning[model any](opts ...Option) ([]model, int64, error)
(s *Service) Exists[model any](opts ...Option) (bool, error)
(s *Service) Find[model any](opts ...Option) ([]model, error)
(s *Service) FindOne[model any](opts ...Option) (*model, error)
(s *Service) InNamedReadTransaction(ctx context.Context, name string, fn func(tr *Transaction) error) error
(s *Service) InNamedTransaction(ctx context.Context, name string, fn func(tr *Transaction) error) error
(s *Service) InReadTransaction(ctx context.Context, fn func(tr *Transaction) error) error
// InTransaction, InReadTransaction, InNamedTransaction and InNamedReadTransaction are the callback form of NewTransaction/NewReadTransaction/NewNamedTransaction/ NewNamedReadTransaction: fn runs against a fresh Transaction that's committed if fn returns nil and rolled back otherwise, so a caller can't forget to close what it opens or leak it on an early return.
(s *Service) InTransaction(ctx context.Context, fn func(tr *Transaction) error) error
// NewBatch starts a new Batch against s.
(s *Service) NewBatch() *Batch
(s *Service) NewNamedReadTransaction(ctx context.Context, name string) (*Transaction, error)
(s *Service) NewNamedTransaction(ctx context.Context, name string) (*Transaction, error)
(s *Service) NewReadTransaction(ctx context.Context) (*Transaction, error)
(s *Service) NewTransaction(ctx context.Context) (*Transaction, error)
// RecordDeletion tells this database's backup (its config entry's "backup" object) that the app deleted a row of table - one of its "backup.track" tables, e.g.
(s *Service) RecordDeletion(ctx context.Context, table string, values map[string]string) (recorded bool, err error)
(s *Service) Save(models ...any) (int64, error)
(s *Service) SaveWithReturning[model any](models ...model) ([]model, int64, error)
(s *Service) Update(newValues any, opts ...Option) (int64, error)
(s *Service) UpdateWithReturning[model any](newValues any, opts ...Option) ([]model, int64, error)
// CloseAfter marks t to fold a Commit/Rollback into its very next Repo call - whichever op the caller calls next - instead of paying for a separate round trip afterward.
(t *Transaction) CloseAfter(action EndAction)
(t *Transaction) Commit() error
(tr *Transaction) Count[model any](opts ...Option) (int64, error)
(tr *Transaction) Delete[model any](opts ...Option) (int64, error)
(tr *Transaction) DeleteWithReturning[model any](opts ...Option) ([]model, int64, error)
(tr *Transaction) Exists[model any](opts ...Option) (bool, error)
(tr *Transaction) Find[model any](opts ...Option) ([]model, error)
(tr *Transaction) FindOne[model any](opts ...Option) (*model, error)
(t *Transaction) Rollback() error
(tr *Transaction) Save(models ...any) (int64, error)
(tr *Transaction) SaveWithReturning[model any](models ...model) ([]model, int64, error)
(tr *Transaction) Update(newValues any, opts ...Option) (int64, error)
(tr *Transaction) UpdateWithReturning[model any](newValues any, opts ...Option) ([]model, int64, error)
13
gist-parameter-store
Centralizes any named value - a host IP, a username, a database password, an API key - behind one alias per source: a plain literal stored directly in config.json, an environment variable (config.json names the variable, not the value), or Google Cloud Secret Manager (or a local emulator for testing) for values that actually need secret-manager-grade handling. Every other field elsewhere in config.json can reference an alias here - gist-ps://<instance-id>/<source>/<alias> - instead of holding the real value directly; a customer's own app code can also fetch one live via AccessValue. The real GCP secret id never has to appear in your code.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
"gist-parameter-store": [
{
"id": "customer-login",
"enabled": true
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
plain |
map[string]string | Maps an alias directly to its own literal value (a host IP, a username, anything), stored as-is in config.json - no indirection. |
env |
map[string]string | Maps an alias to the name of an environment variable this process reads the real value from. Every named variable must already be set - Load fails fast if one isn't. |
gcp-sm |
GCPSMConfig | Aliases backed by Google Cloud Secret Manager, or a local emulator for testing. Left unset, this instance has no gcp-sm backend at all. (default values of GCPSMConfig) |
"gist-parameter-store": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"plain": {
"key": "value"
},
"env": {
"db-password": "ORDERFLOW_DB_PASSWORD"
},
"gcp-sm": {
"project-id": "orderflow-prod",
"secrets": {
"key": "value"
},
"scope": "https://www.googleapis.com/auth/cloud-platform",
"endpoint": "http://0.0.0.0:8085/"
}
}
]
GCPSMConfig
| Field | Type | Description |
|---|---|---|
project-id |
string | GCP project every secret in secrets belongs to. |
secrets |
map[string]string | Maps a customer-chosen alias to the real Secret Manager secret ID. |
scope |
string | null | OAuth scope used to build the Application Default Credentials token source. (default: https://www.googleapis.com/auth/cloud-platform) |
endpoint |
string | null | When set, points the client at a local emulator instead of real Secret Manager; left empty, uses real Secret Manager with real ADC auth. |
Usage
Three ways to reach a value - pick whichever fits what you're actually doing.
1. Auto-resolving another service's own field, with no instance at all. Independent of this service entirely - no gist-parameter-store instance needs to be configured. Any field marked 🔒 elsewhere in these docs (e.g. a database password) can hold an env://VAR_NAME or gcp-sm://... reference directly as its own value, resolved automatically at startup - gcp-sm:// using the process's own ambient Application Default Credentials, the same auth every other client here already uses, never a credential stored in config.json:
"services": {
"gist-mysql-client": [
{
"id": "orders-db",
"password": "gcp-sm://projects/orderflow-prod/secrets/db-password/versions/latest"
}
]
}
No alias, no plain/env/gcp-sm.secrets map - the field's own value is the full projects/<project>/secrets/<secret>/versions/<version> resource path. This is why no instance is needed: resolving it talks to Secret Manager's REST API directly, the same way this service's own AccessValue does internally for a gcp-sm ref - it just never goes through a configured gist-parameter-store instance to get there.
2. Calling this service directly. Put an entry in your own config.json, with one alias per source you want to use - a literal under plain (a host IP, a username, anything), an environment variable name under env, or a real Secret Manager secret ID under gcp-sm.secrets:
"services": {
"gist-parameter-store": [
{
"id": "params1",
"enabled": true,
"plain": { "host-ip": "10.0.4.12", "username": "orderflow-svc" },
"env": { "db-password": "ORDERFLOW_DB_PASSWORD" },
"gcp-sm": {
"project-id": "orderflow-prod",
"secrets": { "stripe-key": "orderflow-stripe-key" }
}
}
]
}
Then call AccessValue with a ref of <instance-id>/<source>/<alias> - the real value/variable/secret ID never has to appear in your own code:
payload, found, err := svc.AccessValue(ctx, "params1/gcp-sm/stripe-key", "")
Works the same way for params1/plain/host-ip or params1/env/db-password - version (the second argument above) only means anything for gcp-sm, ignored otherwise. CreateGCPSecret/AddGCPSecretVersion/DeleteGCPSecret only ever operate on the gcp-sm backend - a plain literal or env-indirected alias is static config, nothing to create, version, or delete.
3. Pointing another field at this service's own alias. Builds on option 2 above - once a gist-parameter-store instance holds an alias in any of its three maps, any other 🔒-marked field can reference it directly via gist-ps://<instance-id>/<source>/<alias>, instead of repeating the real value/path itself. This is the one place to update a value when you're already running a gist-parameter-store instance for other reasons - change it once, in that instance's own map, and every field referencing that alias picks it up automatically:
"services": {
"gist-parameter-store": [
{
"id": "params1",
"enabled": true,
"gcp-sm": {
"project-id": "orderflow-prod",
"secrets": { "db-password": "orderflow-db-password" }
}
}
],
"gist-mysql-client": [
{
"id": "orders-db",
"password": "gist-ps://params1/gcp-sm/db-password"
}
]
}
The gist-ps:// prefix names all three pieces unambiguously - which gist-parameter-store instance (params1), which backend (gcp-sm), and which of its own aliases (db-password) - so it can never be mistaken for a plain password left in config.json by accident.
Go API
package gistparameterstore
NewService(server *gist.Server, instanceID string) *Service
// AccessValue fetches alias's own value from source ("plain", "env", or "gcp-sm" - matching this instance's own config.json plain/env/gcp-sm maps) at version (gcp-sm only; empty means "latest", ignored otherwise).
(s *Service) AccessValue(ctx context.Context, source, alias, version string) (payload []byte, found bool, err error)
// AddGCPSecretVersion adds payload as a new version of alias, in this instance's own gcp-sm backend.
(s *Service) AddGCPSecretVersion(ctx context.Context, alias string, payload []byte) (versionName string, err error)
// CreateGCPSecret creates a new, empty secret container for alias in this instance's own gcp-sm backend - see AddGCPSecretVersion for giving it a value; created is false (not an error) if it already existed.
(s *Service) CreateGCPSecret(ctx context.Context, alias string) (created bool, err error)
// DeleteGCPSecret deletes alias and every one of its versions, from this instance's own gcp-sm backend.
(s *Service) DeleteGCPSecret(ctx context.Context, alias string) (found bool, err error)
14
gist-postgres-client
A PostgreSQL connection pool with the same generic repo layer as gist-mysql-client — Find, Save, Update, Delete — plus transactions and a simple condition/sort DSL. Call it straight off the injected service, e.g. sg.OrdersDB.Find[Order](...). gist-mysql-client (previous) is API-identical, so switching between the two databases barely changes your code. Set ssh to tunnel every connection (primary and replicas) through a bastion instead of dialing the database host directly.
Backups. Give the entry a backup object with enabled: true and it backs itself up on a schedule — off unless you do. Each backup is a pg_dump dump, compressed with zstd and encrypted with age to the public keys in recipients: the server can write backups but never read them, and the private key needed to restore stays offline. Everything is streamed, so memory use doesn't grow with the database.
Rotation. tiers keep the newest backup of each of the last keep periods — by default 7 daily, 5 weekly and 3 monthly, so deleted data is gone from every backup within about 3 months. One backup can count for several tiers and is stored once.
Where. Backups are made in local-path; with cloud (a gist-aws-s3-client or gist-google-cloud-storage-client entry of the same config) they are also copied there and rotated there too, and the local copy is deleted once the upload is complete unless cloud.keep-local. A backup is checked for room first (plus 512 MB always left free), made under a temporary name, verified before it's sealed, and only counts once its manifest exists — a crash or a full disk never leaves a broken backup, and leftovers are cleaned up before the next run.
Deleted personal data. List the tables to track and call RecordDeletion from the app just before deleting a row (e.g. a user). It's recorded in a tracking table, with the hash columns stored only as a keyed hash, and at once in a deletion journal next to the backups (and in the cloud). A restore then deletes those rows again even if they were deleted after the backup was taken — even when the database itself was lost — so restoring never brings deleted people back.
Commands. Run by the app binary (./app -backup=<entry id> <command>) or gist-server (-config config.json -backup <entry id> <command>): run (a backup now), list, find <table> <column>=<value> (which backups still hold a deleted row, and when the last expires), restore -identity <age key file> -into <new database> [-at <backup>] (always into a new database, which is dropped again if anything fails), record-deletion and drill-done (records a restore drill; one is asked for every drill-reminder). Needs pg_dump and pg_restore at least as new as the database server on the server; entries that connect through ssh can't be backed up.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
username |
string | 🔒 Primary database username. |
password |
string | 🔒 Primary database password. |
database |
string | 🔒 Primary database name. |
migration |
MigrationConfig | gist-server -migrate against this config entry - never runs on its own, only when explicitly invoked (-migrate <this entry's own id>), and only once folder-path/source are both set - leave the whole object out entirely for a config entry that never needs migrations. |
"gist-postgres-client": [
{
"id": "customer-login",
"enabled": true,
"username": "orderflow",
"password": "orderflow",
"database": "orderflow",
"migration": {
"folder-path": "./migrations",
"source": "public.gist_migrations"
}
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
host |
string | null | 🔒 Primary database hostname. (default: 0.0.0.0) |
username |
string | 🔒 Primary database username. |
password |
string | 🔒 Primary database password. |
database |
string | 🔒 Primary database name. |
port |
int | null | 🔒 Primary database port. (default: 5432) |
connection-ping |
int | string | null | Seconds (or duration string) between health-check pings; 0 disables the ping monitor. (default: 30s) |
connection-retries |
int | null | Reconnect attempts the ping monitor makes after a failed ping before giving up. (default: 3) |
ssl-mode |
bool | null | Enables TLS on the connection (maps to libpq's sslmode=require/disable). (default: false) |
query-timeout |
int | string | null | Per-query timeout (or duration string). (default: 5s) |
max-open-conns |
int | null | Max number of open connections to the primary. (default: 25) |
max-idle-conns |
int | null | Max number of idle connections kept open to the primary. (default: 10) |
conn-max-lifetime |
int | string | null | Max time connection to the primary may be reused before being closed. (default: 2h) |
replicas |
[]ReplicaConfig | host/port/username/password/database (optional, defaulting to the primary's) plus its own max-open-conns/max-idle-conns/conn-max-lifetime-minutes. |
ssh |
SSHConfig | Tunnels every connection this instance opens (the primary and every replica) through an SSH server instead of dialing host directly. Left unset, connects directly. (default values of SSHConfig) |
migration |
MigrationConfig | gist-server -migrate against this config entry - never runs on its own, only when explicitly invoked (-migrate <this entry's own id>), and only once folder-path/source are both set - leave the whole object out entirely for a config entry that never needs migrations. |
backup |
BackupConfig | Automatic, rotated, compressed and encrypted backups of this database, with a record of deleted personal data so restores never bring it back. Off unless this object is given with enabled: true (see the backup docs). (default values of BackupConfig) |
"gist-postgres-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host": "127.0.0.1",
"username": "orderflow",
"password": "orderflow",
"database": "orderflow",
"port": 5432,
"connection-ping": "30s",
"connection-retries": 3,
"ssl-mode": true,
"query-timeout": "5s",
"max-open-conns": 25,
"max-idle-conns": 10,
"conn-max-lifetime": "2h",
"replicas": [
{
"host": "127.0.0.1",
"port": 5433,
"username": null,
"password": null,
"database": null,
"max-open-conns": 10,
"max-idle-conns": 5,
"conn-max-lifetime-minutes": 30
}
],
"ssh": {
"host": "bastion.orderflow.example.com",
"port": 22,
"user": "deploy",
"private-key": "-----BEGIN OPENSSH PRIVATE KEY-----...",
"private-key-passphrase": "...",
"password": "...",
"known-hosts-key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"insecure-ignore-host-key": false,
"timeout": "10s"
},
"migration": {
"folder-path": "./migrations",
"source": "public.gist_migrations"
},
"backup": {
"enabled": true,
"at": "03:00",
"timezone": "Europe/Stockholm",
"tiers": [
{
"name": "daily",
"every": "1d",
"keep": 7
}
],
"compression-level": 3,
"recipients": ["age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"],
"local-path": "/var/backups/gist",
"cloud": {
"kind": "gist-aws-s3-client",
"id": "backups-s3",
"bucket": "backups",
"path": "gist",
"keep-local": false
},
"track": [
{
"table": "app.users",
"columns": ["id", "email"],
"hash": ["email"]
}
],
"tracking-table": "public.gist_backup_deletions",
"hash-key": "env://BACKUP_HASH_KEY",
"dump-binary": "/usr/lib/postgresql/18/bin/pg_dump",
"restore-binary": "/usr/lib/postgresql/18/bin/pg_restore",
"drill-reminder": "1M"
}
}
]
ReplicaConfig
| Field | Type | Description |
|---|---|---|
host |
string | 🔒 Replica hostname. |
port |
int | null | 🔒 Replica port; defaults to the primary's port when omitted. |
username |
string | null | 🔒 Replica username; defaults to the primary's username when omitted. |
password |
string | null | 🔒 Replica password; defaults to the primary's password when omitted. |
database |
string | null | 🔒 Replica database name; defaults to the primary's database when omitted. |
max-open-conns |
int | Max number of open connections to this replica. |
max-idle-conns |
int | Max number of idle connections kept open to this replica. |
conn-max-lifetime-minutes |
int | string | Max minutes (or duration string) a connection to this replica may be reused before being closed. |
SSHConfig
| Field | Type | Description |
|---|---|---|
host |
string | 🔒 SSH server hostname - not the database host, which stays configured on the primary/replica entries as usual. |
port |
int | null | 🔒 SSH server port. (default: 22) |
user |
string | 🔒 SSH username. |
private-key |
string | null | 🔒 PEM-encoded SSH private key. At least one of private-key/password is required - many bastions disable password auth entirely, so prefer this when it's available. |
private-key-passphrase |
string | null | 🔒 Passphrase decrypting private-key, if it's encrypted. Ignored if private-key isn't set. |
password |
string | null | 🔒 SSH password. At least one of private-key/password is required. |
known-hosts-key |
string | null | The SSH server's own public key, authorized_keys format, verified on every connect. Required unless insecure-ignore-host-key is true - get it from the server with e.g. ssh-keyscan. |
insecure-ignore-host-key |
bool | null | Skips SSH host key verification entirely - vulnerable to MITM. Only for a local/throwaway bastion; every other case should set known-hosts-key instead. (default: false) |
timeout |
int | string | null | Seconds (or duration string) allowed for the initial SSH handshake. (default: 10s) |
MigrationConfig
| Field | Type | Description |
|---|---|---|
folder-path |
string | Directory holding this client's generated migration files (.sql) and its schema snapshot. Required to run gist-server -migrate against this config entry; unused otherwise. |
source |
string | Schema-qualified table this client's applied-migrations history is tracked in - created automatically (CREATE TABLE IF NOT EXISTS) on first use. Required to run gist-server -migrate against this config entry; unused otherwise. Change from the conventional public.gist_migrations only to keep more than one independent migration history in the same database (e.g. two apps sharing one Postgres instance). |
BackupConfig
| Field | Type | Description |
|---|---|---|
enabled |
bool | null | Turns the backups on. Off by default; the rest of the object is only read (and checked) when this is true. (default: false) |
at |
string | null | Time of day (HH:MM, in timezone) for the backup run when the shortest tier is a day or longer. Shorter tiers run every interval instead. (default: 03:00) |
timezone |
string | null | IANA time zone at is in. (default: UTC) |
tiers |
[]BackupTier | Rotation tiers: each keeps the newest backup of each of its last keep periods of length every (6h, 1d, 1w, 1M, 1y). One backup can count for several tiers. Left out: daily 7, weekly 5, monthly 3. |
compression-level |
int | null | zstd level, 1 (fastest) to 22 (smallest). (default: 3) |
recipients |
[]string | age public keys (age1...) every backup is encrypted to. The server only holds public keys: it can write backups but not read them; the private key stays offline and is needed only to restore. Required. |
local-path |
string | Folder the backups, deletion lists, manifests and the deletion journal are written to (a subfolder per database client id). Required - also with cloud, where each backup is made here first (so it needs room for one) and, unless cloud.keep-local, deleted once uploaded. |
cloud |
BackupCloud | Also copies every backup file there, and rotates it there too. Left out: local only. (default values of BackupCloud) |
track |
[]BackupTrack | Tables whose deletions are recorded (by the app, through RecordDeletion) - each tracked column becomes a col_<name> column of the tracking table. Needed to answer which backups still hold deleted data, and to delete it again on restore. |
tracking-table |
string | null | Table the deletions are recorded in, created (and extended with new col_ columns) at startup. Empty: public.gist_backup_deletions (Postgres) or gist_backup_deletions (MySQL). (default: empty string) |
hash-key |
string | null | 🔒 Secret for the keyed hash (HMAC-SHA-256) of hash columns, so the deletion record never holds the deleted personal data itself. Required when any track entry has hash. Changing it makes earlier hashed entries unmatchable. (default: empty string) |
dump-binary |
string | null | pg_dump / mysqldump to run. Empty: found on PATH. For Postgres it must be at least the server's major version (checked at startup). (default: empty string) |
restore-binary |
string | null | pg_restore / mysql used to verify dumps (Postgres) and to restore. Empty: found on PATH. (default: empty string) |
drill-reminder |
string | null | How often a restore drill is due (-backup <id> drill-done records one); an overdue drill is warned about after every backup. Empty: never. (default: 1M) |
BackupTier
| Field | Type | Description |
|---|---|---|
name |
string | Name, shown in listings (daily, weekly, ...). |
every |
string | Period: a number and h, d, w, M (month) or y. |
keep |
int | How many periods back a backup is kept for this tier. |
BackupCloud
| Field | Type | Description |
|---|---|---|
kind |
string | gist-aws-s3-client or gist-google-cloud-storage-client. |
id |
string | That entry's id - configured as its own service, like any storage client. |
bucket |
string | Bucket id, as that entry's own buckets map names it. |
path |
string | null | Prefix inside the bucket; the database client id is added below it. (default: empty string) |
keep-local |
bool | null | Also keep each backup in local-path. Off: the dump and its deletion list are deleted locally once their cloud copy is complete (local-path then only holds the one being made, manifests and the deletion journal). (default: false) |
BackupTrack
| Field | Type | Description |
|---|---|---|
table |
string | Table (schema-qualified on Postgres). |
columns |
[]string | Columns recorded for a deleted row. At least one must not be hashed: a restore deletes the row again by those. |
hash |
[]string | Columns (of columns) stored only as a keyed hash - personal data such as an email. |
Models and seed rows
Migrations are generated from your own model structs. Register them with gist.WithModels, keyed by this entry's id, and declare fixed rows for a catalog table (achievement definitions, plan tiers, ...) with gist.WithSeeds:
gist.NewApp(ctx,
gist.WithModels("orders-db", models.Order{}, models.Customer{}),
gist.WithSeeds("orders-db", []models.Plan{
{Id: new(uuid.MustParse("01a0cdf5-2a89-706a-b147-00fcece8d522")), Name: "free"},
{Id: new(uuid.MustParse("01a0cdf6-263e-7b5e-b86e-4286b4a58ca1")), Name: "pro"},
}),
).Run()
// WithSeeds(clientID string, seeds ...any) Option
// WithModels(clientID string, models ...any) Option
Seeds are desired state, and only -migrate applies them. App startup never touches seeds, and both Options do nothing when the app starts. Every -migrate run inserts or updates each seed row by its key, so the table matches your Go source after every run:
./myapp -migrate=orders-db
- Each argument to
WithSeedsis a slice of one model struct ([]Tor[]*T). Its element type is registered as a model too, so a seeded table doesn't also have to be passed toWithModels. - The key is the db column of the field tagged
id:"true", found through embedded structs too (e.g.BaseColumns.Id). With no tagged field, it's the column namedid. Every row must set its own fixed key: a nil or zero key, or two rows with the same key, fails-migratewith the client, struct and row index named. - The key column must be unique in the database: the primary key (a generated table's
idalways is) or a single-column unique index. For any other column, add that index in a hand-written migration first.-migraterefuses seeds keyed on a column that isn't unique, instead of overwriting every row that shares a key. - Every column is written except
updated_at, which its trigger owns. A nil pointer field sets its column to NULL, unless the column has a database default (id,created_at, adbdefaulttag). There, nil means the default when the row is inserted, and the current value is kept when it's updated. - Seed rows are never deleted. A row you remove from the Go source stays in the table, and
-migratewarns about it. - Calling
WithSeedsorWithModelsmore than once for the same client and struct concatenates the rows in declaration order. - A slice passed to
WithModelsstill works and is treated exactly as if it had been passed toWithSeeds. - Run
-migratebefore starting a new build, so the new schema and seeds are in place first. - An app that declares seeds refuses to hand its schema to a gist-server too old to apply them.
-migratethen fails withthis app declares seed rows, but the gist-server running -migrate is too old to apply them - upgrade gist-serverinstead of dropping the seeds. Upgrade gist-server to fix it. - Seeds are Postgres-only.
-migrateruns only against agist-postgres-cliententry, so seeds declared for agist-mysql-cliententry are never applied.
Go API
package gistpostgresclient
AndConditions(cs ...Conditioner) Conditioner
NewBetweenCondition(field string, low, high any) Conditioner
NewCondition(field string, operator Operator, value any) Conditioner
NewNotBetweenCondition(field string, low, high any) Conditioner
OrConditions(cs ...Conditioner) Conditioner
WithConditions(conds ...Conditioner) Option
WithLimit(limit int64) Option
WithLock() Option
WithOffset(offset int64) Option
WithRange(start, end int64) Option
WithRelationConditions(relation string, conds ...Conditioner) Option
WithSorting(scends ...Scend) Option
NewService(server *gist.Server, serviceID string) *Service
(s *Service) Count[model any](opts ...Option) (int64, error)
(s *Service) Delete[model any](opts ...Option) (int64, error)
(s *Service) DeleteWithReturning[model any](opts ...Option) ([]model, int64, error)
(s *Service) Exists[model any](opts ...Option) (bool, error)
(s *Service) Find[model any](opts ...Option) ([]model, error)
(s *Service) FindOne[model any](opts ...Option) (*model, error)
(s *Service) InNamedReadTransaction(ctx context.Context, name string, fn func(tr *Transaction) error) error
(s *Service) InNamedTransaction(ctx context.Context, name string, fn func(tr *Transaction) error) error
(s *Service) InReadTransaction(ctx context.Context, fn func(tr *Transaction) error) error
// InTransaction, InReadTransaction, InNamedTransaction and InNamedReadTransaction are the callback form of NewTransaction/NewReadTransaction/NewNamedTransaction/ NewNamedReadTransaction: fn runs against a fresh Transaction that's committed if fn returns nil and rolled back otherwise, so a caller can't forget to close what it opens or leak it on an early return.
(s *Service) InTransaction(ctx context.Context, fn func(tr *Transaction) error) error
(s *Service) NewNamedReadTransaction(ctx context.Context, name string) (*Transaction, error)
(s *Service) NewNamedTransaction(ctx context.Context, name string) (*Transaction, error)
(s *Service) NewReadTransaction(ctx context.Context) (*Transaction, error)
(s *Service) NewTransaction(ctx context.Context) (*Transaction, error)
// RecordDeletion tells this database's backup (its config entry's "backup" object) that the app deleted a row of table - one of its "backup.track" tables, e.g.
(s *Service) RecordDeletion(ctx context.Context, table string, values map[string]string) (recorded bool, err error)
(s *Service) Save[model any](models ...model) (int64, error)
(s *Service) SaveWithReturning[model any](models ...model) ([]model, int64, error)
(s *Service) Update(newValues any, opts ...Option) (int64, error)
(s *Service) UpdateWithReturning[model any](newValues any, opts ...Option) ([]model, int64, error)
// CloseAfter marks t to fold a Commit/Rollback into its very next Repo call - whichever op the caller calls next - instead of paying for a separate round trip afterward.
(t *Transaction) CloseAfter(action EndAction)
(t *Transaction) Commit() error
(tr *Transaction) Count[model any](opts ...Option) (int64, error)
(tr *Transaction) Delete[model any](opts ...Option) (int64, error)
(tr *Transaction) DeleteWithReturning[model any](opts ...Option) ([]model, int64, error)
(tr *Transaction) Exists[model any](opts ...Option) (bool, error)
(tr *Transaction) Find[model any](opts ...Option) ([]model, error)
(tr *Transaction) FindOne[model any](opts ...Option) (*model, error)
(t *Transaction) Rollback() error
(tr *Transaction) Save[model any](models ...model) (int64, error)
(tr *Transaction) SaveWithReturning[model any](models ...model) ([]model, int64, error)
(tr *Transaction) Update(newValues any, opts ...Option) (int64, error)
(tr *Transaction) UpdateWithReturning[model any](newValues any, opts ...Option) ([]model, int64, error)
15
gist-pub-sub-client
A Publish/Pull/Ack API for a Pub/Sub-style message queue, built on plain REST calls. Pull is a synchronous poll — you ask for messages on your own schedule and acknowledge whatever you've actually processed. If you'd rather have messages pushed to you instead, see gist-rabbit-mq-client below.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
project-id |
string | GCP project ID; used to build the topics-list readiness check URL. |
"gist-pub-sub-client": [
{
"id": "customer-login",
"enabled": true,
"project-id": "orderflow"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
project-id |
string | GCP project ID; used to build the topics-list readiness check URL. |
endpoint |
string | null | Points the check at a local emulator instead of real Pub/Sub — host:port, no scheme. |
timeout |
int | string | null | Seconds (or duration string) before the readiness check is aborted. (default: 5s) |
"gist-pub-sub-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"project-id": "orderflow",
"endpoint": "127.0.0.1:8085",
"timeout": "5s"
}
]
Go API
package gistpubsubclient
NewService(server *gist.Server, serviceID string) *Service
// Ack acknowledges every ID in ackIDs against subscription, so Pub/Sub doesn't redeliver them.
(s *Service) Ack(ctx context.Context, subscription string, ackIDs ...string) error
// Publish sends one message to topic (short name, e.g.
(s *Service) Publish(ctx context.Context, topic string, data []byte, attributes map[string]string) (messageID string, err error)
// Pull polls subscription once for up to maxMessages waiting messages.
(s *Service) Pull(ctx context.Context, subscription string, maxMessages int32) ([]PulledMessage, error)
16
gist-push-client
Send push notifications to iPhones and iPads through Apple's APNs and to Android devices through Google's FCM. The credentials stay in the server's config; your code sends a message to a list of device tokens and gets one result per device back - including which tokens no longer exist, so you can delete them.
Text can be sent as localization keys the phone's OS looks up in the app's own strings (so pushes arrive in the phone's language without the server knowing any translations), or as plain text. A silent push carries only data and wakes the app without showing anything.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
"gist-push-client": [
{
"id": "customer-login",
"enabled": true
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
apns |
APNsConfig | Apple Push Notification service credentials - needed to push to iPhones and iPads (apns and apns_sandbox targets). (default values of APNsConfig) |
fcm |
FCMConfig | Firebase Cloud Messaging credentials - needed to push to Android devices (fcm targets). (default values of FCMConfig) |
timeout |
int | string | null | Max seconds (or duration string) one Send may take, retries included. (default: 10) |
concurrency |
int | null | How many devices one Send pushes to at the same time. (default: 8) |
"gist-push-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"apns": {
"key": "env://APNS_KEY",
"key-id": "ABC123DEFG",
"team-id": "DEF123GHIJ",
"topic": "com.example.app",
"production-url": "https://api.push.apple.com",
"sandbox-url": "https://api.sandbox.push.apple.com",
"key-path": "./config/AuthKey_ABC123DEFG.p8"
},
"fcm": {
"project-id": "my-app-12345",
"service-account": "env://FCM_SERVICE_ACCOUNT",
"url": "https://fcm.googleapis.com",
"service-account-path": "./config/firebase-service-account.json"
},
"timeout": 10,
"concurrency": 8
}
]
APNsConfig
| Field | Type | Description |
|---|---|---|
key |
string | 🔒 The .p8 key's contents (PEM). Newlines may be written as \n, as in a one-line .env value. Give this or key-path. |
key-id |
string | 🔒 The key's 10-character id. |
team-id |
string | 🔒 The Apple developer team id. |
topic |
string | 🔒 The app's bundle id - which app the pushes are for. |
production-url |
string | APNs production endpoint, for apns targets; override only to test against a fake. (default: https://api.push.apple.com) |
sandbox-url |
string | APNs development endpoint, for apns_sandbox targets; override only to test against a fake. (default: https://api.sandbox.push.apple.com) |
key-path |
string | 🔒 Path to the .p8 key file, instead of key - for example the same key file Sign in with Apple uses, once APNs is enabled on it. A relative path is resolved from the working directory. |
FCMConfig
| Field | Type | Description |
|---|---|---|
project-id |
string | 🔒 The Firebase project id. |
service-account |
string | 🔒 The service account's JSON key (its contents). Give this or service-account-path. |
url |
string | FCM endpoint; override only to test against a fake. (default: https://fcm.googleapis.com) |
service-account-path |
string | 🔒 Path to the service account's JSON key file, instead of service-account (Firebase console: Project settings > Service accounts > Generate new private key). A relative path is resolved from the working directory. |
Go API
package gistpushclient
NewService(server *gist.Server, serviceID string) *Service
// Send pushes m to every target and returns one result per target, in order.
(s *Service) Send(ctx context.Context, targets []Target, m Message) ([]Result, error)
17
gist-rabbit-mq-client
A real AMQP 0-9-1 client connection to RabbitMQ. Publish messages and register consumers directly, with messages pushed to you as they arrive. It's the push-based counterpart to gist-pub-sub-client's polling model.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
url |
string | 🔒 Full AMQP connection URL — credentials and vhost live in the URL. |
"gist-rabbit-mq-client": [
{
"id": "customer-login",
"enabled": true,
"url": "amqp://orderflow:orderflow@127.0.0.1:5672/"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
url |
string | 🔒 Full AMQP connection URL — credentials and vhost live in the URL. |
timeout |
int | string | null | Seconds (or duration string) before a connection/operation is aborted. (default: 10s) |
"gist-rabbit-mq-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"url": "amqp://orderflow:orderflow@127.0.0.1:5672/",
"timeout": "10s"
}
]
Go API
package gistrabbitmqclient
NewService(server *gist.Server, serviceID string) *Service
// ExchangeDeclare declares exchange name of the given kind ("direct" | "fanout" | "topic" | "headers").
(s *Service) ExchangeDeclare(ctx context.Context, name, kind string, durable, autoDelete bool, args map[string]string) error
// Publish sends one message.
(s *Service) Publish(ctx context.Context, exchange, routingKey string, body []byte, contentType string, headers map[string]string) error
// QueueBind binds queue to exchange under routingKey.
(s *Service) QueueBind(ctx context.Context, queue, exchange, routingKey string, args map[string]string) error
// QueueDeclare declares a queue and returns its real name - only differs from name when name was empty, letting the broker generate one (RabbitMQ's usual pattern for a private, per-consumer queue).
(s *Service) QueueDeclare(ctx context.Context, name string, durable, autoDelete, exclusive bool, args map[string]string) (string, error)
18
gist-redis-client
A simple key/value cache backed by Redis — Get, Set, Del, Exists, Incr, IncrBy, Expire. It's exposed through one injectable service, so you call it straight off your own services group. See the Go API reference below for the exact method signatures.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
"gist-redis-client": [
{
"id": "customer-login",
"enabled": true
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
host |
string | null | 🔒 Redis server hostname/IP. (default: 0.0.0.0) |
port |
int | null | 🔒 Redis server port. (default: 6379) |
database |
int | null | 🔒 Redis logical database index (SELECT/DB option). (default: 0) |
password |
string | null | 🔒 Authenticates via Redis AUTH when set. Left empty, no AUTH is sent. |
timeout |
int | string | null | Seconds (or duration string) allowed for the startup connectivity ping. (default: 5s) |
"gist-redis-client": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"host": "127.0.0.1",
"port": 6379,
"database": 0,
"password": "orderflow",
"timeout": "5s"
}
]
Go API
package gistredisclient
NewService(server *gist.Server, serviceID string) *Service
// Del removes keys, returning how many actually existed to be removed.
(s *Service) Del(ctx context.Context, keys ...string) (deleted int64, err error)
// Exists reports how many of keys exist - a key repeated in the list counts twice, matching Redis's own EXISTS semantics.
(s *Service) Exists(ctx context.Context, keys ...string) (count int64, err error)
// Expire sets key's TTL.
(s *Service) Expire(ctx context.Context, key string, ttl time.Duration) (existed bool, err error)
// Get returns key's value.
(s *Service) Get(ctx context.Context, key string) (value []byte, found bool, err error)
// Incr increments key by 1 (creating it at 0 first if it doesn't exist) and returns the new value.
(s *Service) Incr(ctx context.Context, key string) (value int64, err error)
// IncrBy increments key by by and returns the new value.
(s *Service) IncrBy(ctx context.Context, key string, by int64) (value int64, err error)
// Set stores value under key.
(s *Service) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
19
gist-scheduler
Runs a cron job inside gist-server and calls back into your own process on every tick. You register what should happen on that tick as a plain function — gist-server only handles the timing. There's no queue or worker process to manage, just a scheduled callback.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
schedule |
string | Standard cron expression controlling when the tick callback fires. |
"gist-scheduler": [
{
"id": "customer-login",
"enabled": true,
"schedule": "0 3 * * *"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
schedule |
string | Standard cron expression controlling when the tick callback fires. |
"gist-scheduler": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"schedule": "0 3 * * *"
}
]
Go API
package gist
gist.RegisterScheduleFunc[dep any](schedulerID string, d dep, fn func(dep)) Option
20
gist-state-machine
A simple state machine: given an object's current state and a trigger name, it checks a configured transition graph and decides whether the move is allowed. Your own code still does the actual work — saving the object happens in the trigger's own callback, not here. You can also render the whole graph as a diagram (DOT, or a self-contained SVG) to see the flow at a glance.
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
initial-state |
string | Default starting state for a new object under this machine. |
transitions-file |
string | Path to the transitions JSON file, loaded into Transitions. |
"gist-state-machine": [
{
"id": "customer-login",
"enabled": true,
"initial-state": "pending",
"transitions-file": "./config/order-transitions.json"
}
]
| Field | Type | Description |
|---|---|---|
id |
string | Unique instance ID. |
enabled |
bool | If false, this entry is skipped entirely. |
delayed-start |
int | string | null | Seconds (or duration string) to wait/delay before starting this instance. (default: 0) |
wait-for |
[]string | IDs of other services this instance must wait to become ready before starting. |
initial-state |
string | Default starting state for a new object under this machine. |
transitions-file |
string | Path to the transitions JSON file, loaded into Transitions. |
swimlane-color |
string | null | Hex color (#rrggbb) for this instance's swimlane in RenderMultiLaneSVG's diagram. Optional - leave empty if you don't render this machine that way. (default: #000000) |
"gist-state-machine": [
{
"id": "customer-login",
"enabled": true,
"delayed-start": "5s",
"wait-for": [],
"initial-state": "pending",
"transitions-file": "./config/order-transitions.json",
"swimlane-color": "#3cb44b"
}
]
transitions-file format
A JSON object keyed by "from" state, each holding an array of transitions out of that state. Loaded and flattened into Config.Transitions, with From filled in from the map key. Startup fails if the same from/trigger pair appears twice.
| Field | Type | Description |
|---|---|---|
from | string | Source state (the object key, not a JSON field in each entry). |
trigger | string | Name the caller passes to attempt this transition. |
to | string | State reached when this trigger succeeds from from. |
is-substate | bool | Marks to as a substate of from rather than an independent top-level state. |
to-service | string, optional | Marks to as a state of a different, separately configured gist-state-machine instance (that instance's own id) instead of this machine's own state space - documents where the flow continues once this machine hands off. Transition treats it as documentation only, not a state it can locally reach. Startup fails if it names an unconfigured instance, its own instance, or a state that instance doesn't have. |
description | string, optional | A short human-readable note about the trigger, for diagrams that render one. |
Visualizing the graph
Two ways to get the configured transition graph out (client side): the structure itself, not any particular object's current state. Either way, a trigger with no attached handler (see Attach) comes back marked 🚧, the same "unimplemented" convention this doc's own endpoint tables use.
dot, err := svc.Graph(ctx)
// write dot to a .dot file, then render it with any Graphviz
// front-end, e.g.:
// dot -Tpng order-lifecycle.dot -o order-lifecycle.png
svg, err := svc.GraphSVG(ctx)
// self-contained SVG, hand-rendered by gist-server itself - no
// Graphviz/layout engine needed, just an SVG-capable viewer:
// os.WriteFile("order-lifecycle.svg", []byte(svg), 0o644)
Mount GraphSVG on your own HTTP server as a single handler:
http.HandleFunc("/order-lifecycle.svg", func(w http.ResponseWriter, r *http.Request) {
svg, err := svc.GraphSVG(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = w.Write([]byte(svg))
})
Go API
package giststatemachine
// Attach registers every one of handlers against serviceID - the id is named once here, not once per trigger.
Attach[servicesGroup any, M Statabler](serviceID string, handlers ...*TriggerHandler[servicesGroup, M]) gist.Option
// Transition fires trigger on statable against svc: every OnEnter function in order, the transition attempt itself, OnAction, then every OnExit function in order - see the package doc for the full sequencing and abort semantics.
Transition[M Statabler](ctx context.Context, svc *Service, trigger string, statable M) error
NewService(server *gist.Server, serviceID string) *Service
// Graph returns the DOT-format representation of svc's configured transition graph (https://graphviz.org/doc/info/lang.html) - render it with any Graphviz front-end (e.g.
(svc *Service) Graph(ctx context.Context) (string, error)
// GraphSVG returns a self-contained, hand-rendered SVG diagram of the same graph Graph describes - no external Graphviz/layout engine needed to view it, just an SVG-capable viewer (any browser, or an <img>/<object> tag in a page your own process already serves).
(svc *Service) GraphSVG(ctx context.Context) (string, error)
// MultiLaneGraphSVG returns one combined, hand-rendered SVG spanning every one of serviceIDs' configured gist-state-machine instances, each its own horizontal swimlane, connected wherever a transition's to-service names another instance in the list - see gist-server's own RenderMultiLaneSVG.
(svc *Service) MultiLaneGraphSVG(ctx context.Context, serviceIDs []string) (string, error)
(s *Statable) GetState() string
(s *Statable) SetState(state string)
// RegisterTriggerFunc builds one trigger: its name, its required OnAction (the trigger's own business logic - the reason it exists), and any OnEnter/OnExit phases via the OnEnter/OnExit options.
RegisterTriggerFunc[servicesGroup any, M Statabler](trigger string, onAction TransitionFn[servicesGroup, M], opts ...TriggerOption[servicesGroup, M]) *TriggerHandler[servicesGroup, M]
// OnEnter adds fns to a trigger's guard/preparation phase, run in the order given (appended to any already added by an earlier OnEnter option), before the transition is attempted - an error here aborts it, statable's state is left untouched.
OnEnter[servicesGroup any, M Statabler](fns ...TransitionFn[servicesGroup, M]) TriggerOption[servicesGroup, M]
// OnExit adds fns to a trigger's side-effect phase, run in the order given (appended to any already added by an earlier OnExit option), after OnAction succeeds.
OnExit[servicesGroup any, M Statabler](fns ...TransitionFn[servicesGroup, M]) TriggerOption[servicesGroup, M]