Documentation
¶
Overview ¶
Package jiku is a client for Jiku's API, which is served over NATS rather than HTTP: 23 read endpoints (queries) and 23 write endpoints (commands), request/reply, no REST anywhere.
Core also PUBLISHES 16 domain events, over JetStream rather than core NATS and with entirely different delivery guarantees. That plane lives in the events subpackage, which is opt-in: importing this one costs nothing to a caller that never consumes events.
Getting started ¶
src, err := auth.NewServiceUser(auth.ServiceUserConfig{
Issuer: "https://id.grava.io",
KeyFile: "/etc/jiku/service-account.json",
ProjectID: "275672248377933829",
})
client, err := jiku.Connect(ctx, jiku.Config{
Servers: "nats://localhost:4222",
Instance: "dev",
Creds: "/etc/jiku/sentinel-client.creds",
Auth: src,
})
defer client.Close()
col, err := client.List(ctx, "tasks", jiku.List{
Filter: jiku.F{"projectId": 15, "state": jiku.In("backlog", "activo")},
Sort: []string{"-createdAt"},
Limit: 20,
})
var tasks []Task
err = col.Into(&tasks)
Why this package exists rather than a bare NATS client ¶
The request itself is not complicated. Three things about this bus are, and each fails in a way that does not point at its cause:
THE INBOX PREFIX. A connection may subscribe to exactly one inbox, _INBOX.<hash(sub)>. Anything else — including the random default every NATS client generates — means the reply is published where you are not listening. The request then times out with no error visible to the caller: the permissions violation is recorded in the NATS SERVER's log. Connect always sets it; see InboxPrefix.
TWO CREDENTIALS, ONE OF WHICH GRANTS NOTHING. The sentinel creds file denies itself publish and subscribe on ">". What mints permissions is a Zitadel access token, and it must carry a roles claim — which it only does if the reserved Zitadel scopes were requested. See the auth subpackage.
TOKENS AND RECONNECTS. The auth-callout evaluates the token at connect time, and NATS does not re-check afterwards. A reconnect re-runs the callout, so a token that expired in the meantime means the reconnect is refused. Connect uses nats.TokenHandler, called on every reconnect, rather than a frozen string.
Reads are deny-by-default ¶
Every resource declares five closed lists — base, includable, filterable, sortable and an external scope. A name that is not declared DOES NOT EXIST: it answers invalid_fields, never a silently ignored lever. An ignored filter would return more data than was asked for.
Fetch those lists with Client.Contract, which calls meta.describe — the same structures the server's validator reads, so they cannot drift from it. Resource.Validate checks a query against them before it is published.
Filters: the operator is the shape of the value ¶
scalar equality
array IN
{"not": ...} negation
{"gte": x, "lte": y} range
{"key": k, "value": v} containment
Use F together with In, Not, Between, Gte and Contains rather than writing the maps by hand.
Pagination ¶
The ABSENCE of a cursor is the only end-of-collection signal. There is no hasMore, and a page can come back shorter than the limit because of a byte budget — so a short page does not mean the end. Use Client.Iterate rather than a hand-rolled loop.
Reads and writes are not symmetric ¶
The product roles (admin, user, external-user) authorise every query and NO command, enforced both by the bus permission template and by core's own role map. Writes go through the api over HTTP, because core does not hold the business rules that depend on the end user. Commands are for service identities.
Errors ¶
A failure envelope becomes an *Error, which matches errors.Is(err, ErrFailure). Test a specific code with IsCode, and call (*Error).Hint for advice on the codes whose name does not explain the cause. Requests this package rejects locally — a forbidden identity field, an undeclared name — match ErrInvalidRequest and never reach the network.
Further reading ¶
The repository's docs directory carries the protocol in detail, the authentication chain link by link, and the two AsyncAPI contracts that are the source of truth.
Index ¶
- Constants
- Variables
- func Between(from, to any) map[string]any
- func ConfigFile() string
- func Contains(key, value any) map[string]any
- func Gt(v any) map[string]any
- func Gte(v any) map[string]any
- func HashUserID(userID string) string
- func In(values ...any) []any
- func InboxPrefix(userID string) string
- func IsCode(err error, code string) bool
- func Lt(v any) map[string]any
- func Lte(v any) map[string]any
- func Not(value any) map[string]any
- func Range(gt, gte, lt, lte any) map[string]any
- func SplitMethod(method string) (resource, operation string, ok bool)
- func Subject(instance, userID, service, method string) string
- type Client
- func (c *Client) All(ctx context.Context, resource string, q List, dest any) error
- func (c *Client) Close() error
- func (c *Client) Command(ctx context.Context, method string, payload any) (json.RawMessage, error)
- func (c *Client) Conn() *nats.Conn
- func (c *Client) ConnectTiming() ConnectTrace
- func (c *Client) ConnectedURL() string
- func (c *Client) Contract(ctx context.Context) (*Contract, error)
- func (c *Client) Describe(ctx context.Context, resources ...string) (contract *Contract, err error)
- func (c *Client) Get(ctx context.Context, resource string, q Get) (*Item, error)
- func (c *Client) InboxPrefix() string
- func (c *Client) Instance() string
- func (c *Client) Iterate(ctx context.Context, resource string, q List) *Iterator
- func (c *Client) List(ctx context.Context, resource string, q List) (col *Collection, err error)
- func (c *Client) ListInto(ctx context.Context, resource string, q List, dest any) (page Page, err error)
- func (c *Client) Query(ctx context.Context, method string, payload any) (json.RawMessage, error)
- func (c *Client) Request(ctx context.Context, service, method string, payload any) (*Reply, error)
- func (c *Client) Tags(ctx context.Context, projectID int64, key string) (groups []TagGroup, err error)
- func (c *Client) UserID() string
- type Collection
- type Config
- type ConnectTrace
- type ContainsShape
- type Contract
- type Count
- type Defaults
- type Discriminator
- type EnumValue
- type Error
- type ErrorDetails
- type F
- type Field
- type Get
- type Item
- type Iterator
- type List
- type Page
- type Reply
- type RequestTrace
- type Resource
- func (r Resource) Coerce(name string, raw string) (any, error)
- func (r Resource) FieldNames() []string
- func (r Resource) FilterableNames() []string
- func (r Resource) ForVariant(name string) Resource
- func (r Resource) IncludableNames() []string
- func (r Resource) Validate(q List) error
- func (r Resource) VariantNames() []string
- type ServerSpan
- type ServerTiming
- type Status
- type TagGroup
- type Variant
- type ZitadelConfig
Examples ¶
Constants ¶
const ( DefaultTimeout = 15 * time.Second DefaultInstance = "dev" DefaultIssuer = "https://id.grava.io" )
Default values. NATS_QUERY_TIMEOUT_MS is 10s on the server side and PostgreSQL's statement_timeout is 8s, so the database cuts first and the caller gets `query_timeout` rather than silence. A client timeout below 10s would break that ordering and turn an explained failure back into a mute one, so the default sits above it.
const ( EnvServers = "JIKU_SERVERS" EnvInstance = "JIKU_INSTANCE" EnvCreds = "JIKU_CREDS" EnvTimeout = "JIKU_TIMEOUT" EnvIssuer = "JIKU_ISSUER" EnvClientID = "JIKU_CLIENT_ID" EnvProjectID = "JIKU_PROJECT_ID" EnvKeyFile = "JIKU_KEY_FILE" )
The environment variables, which override the file. A container gets configured with these.
const ( // CodeInvalidFields is a name or value the resource sheet does not declare. Deny by // default: a name that is not whitelisted does not exist. CodeInvalidFields = "invalid_fields" // CodeInvalidCursor is a cursor that does not decode, or whose scope no longer matches // the filter and sort it was minted for. CodeInvalidCursor = "invalid_cursor" // CodeCallerNotAuthorized is gate 1: this caller may not run this method. Usually the // wrong role for the plane — a person publishing a command, for instance. CodeCallerNotAuthorized = "caller_not_authorized" // CodeUnknownCaller is gate 2: the caller has no row in `users`. A different question // from authorisation, and merging the two would erase the rule that an unknown caller // gets an error rather than an empty list. CodeUnknownCaller = "unknown_caller" // CodeUnknownCommand is a method that is not in the registry. Check the spelling // against `jiku describe`. CodeUnknownCommand = "unknown_command" // CodeQueryTimeout is PostgreSQL's statement_timeout (8s) firing before the bus // timeout (10s) — by design, so the caller gets an explanation instead of silence. CodeQueryTimeout = "query_timeout" // CodeInternalError is the dispatcher's catch. The dispatcher never throws, because a // thrown exception would become a mute bus timeout on the caller's side. CodeInternalError = "internal_error" // The *_not_found codes. On a `get` they do NOT distinguish "does not exist" from "you // may not see it": answering a permission error would confirm to an external caller that // the resource exists. CodeClientNotFound = "client_not_found" CodeProjectNotFound = "project_not_found" CodeRequirementNotFound = "requirement_not_found" CodeTaskNotFound = "task_not_found" CodeCommentNotFound = "comment_not_found" CodeFileNotFound = "file_not_found" CodePersonNotFound = "person_not_found" CodeObjectiveNotFound = "objective_not_found" CodeUserNotFound = "user_not_found" CodeWorkedTimeNotFound = "worked_time_not_found" CodeUnworkedTimeNotFound = "unworked_time_not_found" CodeSubscriptionNotFound = "subscription_not_found" // Emitted by commands only. These are business-rule refusals rather than shape errors, so // a caller that retries the same request gets the same answer. CodeFileNotOwned = "file_not_owned" CodeAlreadySubscribed = "already_subscribed" CodeDailyLimitExceeded = "daily_limit_exceeded" CodeFileTooLarge = "file_too_large" CodeFileTypeNotAllowed = "file_type_not_allowed" CodeInvalidResponsiblePerson = "invalid_responsible_person" CodeRequirementProjectMismatch = "requirement_project_mismatch" // CodeResolutionRequired is the mandatory conclusion on resolve. REQ-012 narrowed it // back to requirements of type `incidencia`: resolving any other type no longer needs a // resolution type or a conclusion. CodeResolutionRequired = "resolution_required" CodeInvalidDateRange = "invalid_date_range" // CodeCommentNotOwned and CodeActivityNotEditable are the two codes REQ-011 added with // the comment-editing commands. Only the comment's author or an admin may edit one, and // the entry must actually be a comment — an activity row of any other kind is not // editable even for its own author. CodeCommentNotOwned = "comment_not_owned" CodeActivityNotEditable = "activity_not_editable" // CodeInvalidStateTransition HAS NO CURRENT EMITTER. It was the requirement state // workflow refusing a transition, until REQ-012 made transitions free by product // decision: `requirements.{id}.edit` and `.resolve` stopped emitting it. Core keeps it in // its own catalog on purpose (the catalog is not closed), so it is kept here too — do not // assume it is unreachable. CodeInvalidStateTransition = "invalid_state_transition" CodeStageNotFound = "stage_not_found" // CodeAccessDenied is the project-permission refusal: the caller may run the method, but // not against this project. Distinct from CodeCallerNotAuthorized, which is about the // method itself. CodeAccessDenied = "access_denied" // CodeFileNotAvailable and CodeInvalidAttachmentID are declared for completeness but have // no current emitter — core's own catalog keeps them on purpose, since the catalog is not // closed and nothing yet asks for their removal. Do not assume they are unreachable. CodeFileNotAvailable = "file_not_available" CodeInvalidAttachmentID = "invalid_attachment_id" )
The shared error catalog. One catalog for both planes, not one per plane.
The HTTP column of the spec is documentation for a future consumer, not behaviour, so it is deliberately not mapped here.
const ( // ServiceQueries serves the 23 read endpoints. Every product role may publish here. ServiceQueries = "jiku-queries" // ServiceCommands serves the 23 write commands. Since REQ-007 `admin` and `user` publish // most of them directly; `external-user` still writes only through the api. Which command // a role reaches, and whether it reaches it directly or only as a side effect of the api // acting on its behalf, is the role table in docs/auth.md. ServiceCommands = "jiku-commands" )
Service names of the two micro services core registers on the bus.
They are separate subject tokens on purpose, not nested under one prefix: two queue groups over overlapping subjects would deliver each message to BOTH subscriptions, and a plain request() returns the first reply and discards the second silently.
const ( HeaderSentAt = "Jiku-Sent-At" HeaderTraceID = "Jiku-Trace-Id" HeaderTiming = "Jiku-Timing" HeaderRecvAt = "Jiku-Recv-At" HeaderRespAt = "Jiku-Resp-At" )
Tracing headers. The request carries the first two when Config.Trace is set or Config.Logger is enabled for debug; core answers the last three when it runs with QUERY_TIMING=true. Without either side, nothing on the wire changes.
const ProtocolVersion = "v1"
ProtocolVersion is the {version} token of the subject grammar.
Variables ¶
var ( // ErrInvalidRequest is a request this library rejected locally, before publishing, // because core would answer invalid_fields. Fix the call. ErrInvalidRequest = errors.New("jiku: invalid request") // ErrFailure is a `status: failure` reply. Inspect it as *Error for the code. ErrFailure = errors.New("jiku: core answered failure") // ErrTimeout is a bus timeout: no reply arrived. On this bus the first suspect is a // wrong inbox prefix, not a slow core — see InboxPrefix. ErrTimeout = errors.New("jiku: no reply from core") // ErrNotConnected is use of a Client that was closed or never connected. ErrNotConnected = errors.New("jiku: not connected") // ErrNoEndpoint means nothing is subscribed to the subject: the bus said so immediately, // rather than the request timing out. It is a firmer signal than a timeout — the method // almost certainly does not exist, or it was asked on the wrong plane or instance. ErrNoEndpoint = errors.New("jiku: no endpoint for that method") )
Sentinel errors for the failure classes a caller can act on with errors.Is.
Functions ¶
func ConfigFile ¶
func ConfigFile() string
ConfigFile is the default config path: $XDG_CONFIG_HOME/jiku/config.yaml.
func Contains ¶
Contains builds a containment condition, valid only where the resource sheet declares the filterable as `contains` — `requirements.tags` is the case that exists today.
func HashUserID ¶
HashUserID derives the inbox token for a user id.
It mirrors HashUserID in the auth-callout (internal/authz/identity.go) byte for byte. The two implementations MUST agree: the callout uses it to mint the subscribe permission and the client uses it to pick its inbox, with no channel between them.
This hash hides nobody. The user id travels raw in every subject, so anyone who can see a subject has already seen the id — the inbox just needs one opaque, fixed-length token.
func In ¶
In builds an IN condition. A single value is still sent as an array, which core reads as a one-element IN — identical in meaning to equality.
func InboxPrefix ¶
InboxPrefix is the only inbox a caller is allowed to subscribe to:
_INBOX.<HashUserID(sub)>
THIS IS THE MOST EXPENSIVE MISTAKE ON THIS BUS ¶
The callout grants `sub.allow: _INBOX.{{user_id_hash}}.>` and nothing else. A client that does not set this prefix gets nats.go's default random `_INBOX.<nuid>`, which no permission authorises. The reply is then published to a subject the client is not subscribed to, so:
- the request TIMES OUT after the full timeout window,
- no permissions error is returned to the caller,
- and the violation is logged by the NATS SERVER, where nobody thinks to look.
The symptom points at core being down or slow. It is neither. Connect() always sets this, which is a large part of why this package exists — see docs/auth.md.
Example ¶
The inbox prefix, and why it is the most expensive mistake on this bus.
A connection may subscribe to exactly one inbox. Set anything else — including the random default every NATS client generates — and replies are published where nobody is listening: the request times out with no error visible to the caller. Connect always sets this; you only need it when building your own nats.Conn.
package main
import (
"fmt"
"github.com/gravadigital/jiku-go"
)
func main() {
fmt.Println(jiku.InboxPrefix("275649063808925701"))
}
Output: _INBOX.n3wi2tqwkmwccv4c
func IsCode ¶
IsCode reports whether err is a core failure carrying the given code.
if jiku.IsCode(err, jiku.CodeTaskNotFound) { ... }
Example ¶
Branching on failures.
package main
import (
"context"
"errors"
"fmt"
"github.com/gravadigital/jiku-go"
)
func main() {
var client *jiku.Client
ctx := context.Background()
_, err := client.Get(ctx, "tasks", jiku.Get{ID: 999999})
switch {
case jiku.IsCode(err, jiku.CodeTaskNotFound):
// Note: this does NOT distinguish "does not exist" from "you may not see it".
fmt.Println("not found")
case errors.Is(err, jiku.ErrInvalidRequest):
// Rejected locally, before publishing. Never reached the network.
fmt.Println("bad request:", err)
case errors.Is(err, jiku.ErrNoEndpoint):
// Nothing is subscribed to that subject: usually a misspelled method.
fmt.Println("no such method")
case errors.Is(err, jiku.ErrTimeout):
// Nothing replied. Suspect the instance or the method before suspecting core.
fmt.Println("timeout")
case errors.Is(err, jiku.ErrFailure):
// Any other refusal from core. The catalog is core's and it grows, so do not switch
// exhaustively — read the code and the details.
var e *jiku.Error
errors.As(err, &e)
fmt.Println(e.Code, e.Message)
if e.Details != nil {
fmt.Println(e.Details.Field, e.Details.Allowed)
}
if hint := e.Hint(); hint != "" {
fmt.Println(hint)
}
}
}
Output:
func Range ¶
Range builds a bounded condition from any of gt, gte, lt, lte. Nil bounds are omitted, so Range(nil, x, nil, nil) is an open-ended lower bound.
func SplitMethod ¶
SplitMethod splits a method like "tasks.list" into its resource and operation.
func Subject ¶
Subject builds a request subject from the grammar core subscribes to:
{instance}.{userID}.{service}.{version}.{method}
dev.275649063808925701.jiku-queries.v1.tasks.list
userID is the Zitadel token's `sub`, RAW, and it is the only source of caller identity: the auth-callout authorises publishing under one's own id only, so the subject cannot be forged while the body can. That is also why identity field names are rejected in payloads (see forbiddenIdentityFields).
Example ¶
Building a subject by hand, for the rare case something needs one.
package main
import (
"fmt"
"github.com/gravadigital/jiku-go"
)
func main() {
fmt.Println(jiku.Subject("dev", "275649063808925701", jiku.ServiceQueries, "tasks.list"))
}
Output: dev.275649063808925701.jiku-queries.v1.tasks.list
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a connection to Jiku's bus.
It is safe for concurrent use and should be long-lived: one per process, not one per request. Connecting costs a round trip to the identity provider and a NATS handshake that runs the auth-callout.
func Connect ¶
Connect opens the bus connection.
It does three things a hand-rolled nats.Connect does not, and each one is a failure mode somebody has already spent an afternoon on:
- It sets the inbox prefix to _INBOX.<hash(sub)>. Without it every request times out with no error anywhere the caller can see. See InboxPrefix.
- It takes the token from a TokenSource on every (re)connect via nats.TokenHandler, so a reconnect after the token expired re-authenticates instead of being refused.
- It derives the caller identity from the token's `sub`, so no subject has to be written by hand and none can disagree with the credential presenting it.
Example ¶
Connecting as a service, which is what an unattended integration should do.
package main
import (
"context"
"fmt"
"log"
"github.com/gravadigital/jiku-go"
"github.com/gravadigital/jiku-go/auth"
)
func main() {
ctx := context.Background()
// The key is the JSON file Zitadel produces for a machine user. ProjectID is what puts the
// ROLES in the token, and the auth-callout matches its rules on the role — so a token
// minted without it connects to nothing.
src, err := auth.NewServiceUser(auth.ServiceUserConfig{
Issuer: "https://id.grava.io",
KeyFile: "/etc/jiku/service-account.json",
ProjectID: "275672248377933829",
})
if err != nil {
log.Fatal(err)
}
client, err := jiku.Connect(ctx, jiku.Config{
Servers: "nats://localhost:4222",
Instance: "dev",
Creds: "/etc/jiku/sentinel-client.creds",
Auth: src,
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
fmt.Println(client.UserID(), client.InboxPrefix())
}
Output:
Example (Person) ¶
Connecting as a person, reusing the session `jiku login` stored.
package main
import (
"context"
"errors"
"log"
"github.com/gravadigital/jiku-go"
"github.com/gravadigital/jiku-go/auth"
)
func main() {
ctx := context.Background()
src, err := auth.NewDeviceFlow(auth.DeviceConfig{
Issuer: "https://id.grava.io",
ClientID: "385696162499330050@gestor_de_proyectos",
ProjectID: "275672248377933829",
Store: auth.DefaultStore("dev"),
})
if err != nil {
log.Fatal(err)
}
// Token never opens a browser. It reports that one is needed, so this same code is safe to
// run unattended — only Login is interactive.
if _, err := src.Token(ctx); errors.Is(err, auth.ErrLoginRequired) {
log.Fatal("run `jiku login` first")
}
cfg := jiku.FromEnv()
cfg.Auth = src
client, err := jiku.Connect(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
}
Output:
func (*Client) All ¶
All collects every item of a list into dest, following every cursor.
Convenient and dangerous in the same way: it holds the whole collection in memory and issues as many requests as it takes. Use Iterate for anything that might be large.
The items are joined into one JSON array and decoded once, rather than re-encoded element by element: this walks every page of a collection, so the per-item cost is multiplied by the whole sweep rather than by one page.
func (*Client) Command ¶
Command publishes to the write plane and returns the envelope's data, or a *Error on failure.
A COMMAND IS NOT THE MIRROR IMAGE OF A QUERY ¶
Three asymmetries, all deliberate on core's side:
- Which caller may run which command is deployment policy, decided per role AND per command by two independent layers — the bus template and core's role map — and it can differ WITHIN one role: a role may publish some commands directly and reach others only as a side effect of the api acting on its behalf (the reserved `actor` envelope, rejected from anyone else). See docs/commands.md.
- The acting person travels in the BODY (`creator`, `author`, `editor`), because the subject identifies the SERVICE that published, not the human behind it. Several of these fields are optional: core resolves the actor from the caller when absent.
- There is no JetStream and no retry. If core is down the request times out and the operation did not happen.
func (*Client) Conn ¶
Conn exposes the underlying NATS connection, for callers that need something this package does not wrap. The connection is already correctly authenticated and has the right inbox prefix, so building on it is safe.
func (*Client) ConnectTiming ¶ added in v1.2.0
func (c *Client) ConnectTiming() ConnectTrace
ConnectTiming reports how long Connect took, step by step.
func (*Client) ConnectedURL ¶
ConnectedURL is the server actually in use, which matters when Servers listed several.
func (*Client) Contract ¶
Contract returns the full contract, fetching it once per client and caching it.
The cache is per Client, so it lives as long as the connection and no longer. Nothing is written to disk here: a contract cached across runs is a contract that can be wrong after a deploy, and this one costs a single request that touches no database.
func (*Client) Describe ¶
Describe fetches the contract, for all resources or for the named ones.
An EMPTY (non-nil) resources slice is invalid_fields on the server, not "all" — so nil and empty are collapsed here to mean "all", which is what a caller passing no arguments means.
func (*Client) Get ¶
Get runs a `{resource}.get`.
A *_not_found does not distinguish "does not exist" from "you may not see it", on purpose: telling them apart would confirm to an external caller that the record exists.
Example ¶
Fetching one record.
package main
import (
"context"
"fmt"
"log"
"github.com/gravadigital/jiku-go"
)
// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
}
func main() {
var client *jiku.Client
ctx := context.Background()
item, err := client.Get(ctx, "tasks", jiku.Get{ID: 7, Include: []string{"person"}})
if err != nil {
log.Fatal(err)
}
var task Task
if err := item.Into(&task); err != nil {
log.Fatal(err)
}
fmt.Println(task.Title)
}
Output:
func (*Client) InboxPrefix ¶
InboxPrefix is the inbox this connection subscribes to, for diagnostics.
func (*Client) Iterate ¶
Iterate returns an Iterator over every page of a list.
Nothing is requested until the first call to Next.
Example ¶
Walking every page.
Use this rather than a hand-rolled loop: the ABSENCE of a cursor is the only end-of-collection signal. A page shorter than the limit does not mean the end — the engine can cut one on a byte budget and still have more to give.
package main
import (
"context"
"fmt"
"log"
"github.com/gravadigital/jiku-go"
)
// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
}
func main() {
var client *jiku.Client
ctx := context.Background()
it := client.Iterate(ctx, "tasks", jiku.List{Filter: jiku.F{"projectId": 15}})
for it.Next() {
var t Task
if err := it.Item().Into(&t); err != nil {
log.Fatal(err)
}
fmt.Println(t.ID, t.Title)
}
if err := it.Err(); err != nil {
log.Fatal(err)
}
fmt.Println(it.Count(), "items in", it.Pages(), "pages")
}
Output:
func (*Client) List ¶
List runs a `{resource}.list`.
col, err := c.List(ctx, "tasks", jiku.List{
Filter: jiku.F{"projectId": 15},
Sort: []string{"-createdAt"},
Limit: 20,
})
var tasks []Task
err = col.Into(&tasks)
Example ¶
Listing one page, with filters, a sort and an includable.
package main
import (
"context"
"fmt"
"log"
"github.com/gravadigital/jiku-go"
)
// Task decodes only the fields these examples print. A struct per use is normal here: the
// returned field set changes with Fields and Include, so no single type fits every call.
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
State string `json:"state"`
}
func main() {
var client *jiku.Client // from jiku.Connect
ctx := context.Background()
col, err := client.List(ctx, "tasks", jiku.List{
Filter: jiku.F{"projectId": 15, "state": jiku.In("backlog", "activo")},
Sort: []string{"-createdAt"},
Include: []string{"person"},
Limit: 20,
Count: jiku.CountOn,
})
if err != nil {
log.Fatal(err)
}
var tasks []Task
if err := col.Into(&tasks); err != nil {
log.Fatal(err)
}
// Limit is the EFFECTIVE limit, after the resource's silent clamp. Returned can be lower
// still, because the engine cuts a page on a byte budget.
fmt.Println(col.Page.Limit, col.Page.Returned, col.Page.HasMore())
}
Output:
func (*Client) ListInto ¶ added in v1.2.0
func (c *Client) ListInto(ctx context.Context, resource string, q List, dest any) (page Page, err error)
ListInto runs a `{resource}.list` and decodes the items straight into dest, returning the page.
var tasks []Task
page, err := c.ListInto(ctx, "tasks", jiku.List{Limit: 50}, &tasks)
It is List followed by Collection.Into with one decode instead of three — the envelope and the items in a single pass — for the common case where the caller already knows the shape they want. Use List when the items are to be passed around as raw JSON, or when the page is needed before deciding how to decode.
func (*Client) Query ¶
Query publishes to the read plane and returns the envelope's data, or a *Error on failure.
func (*Client) Request ¶
Request publishes a request and returns the decoded envelope, WITHOUT turning a failure into an error. Use it when you want to inspect a failure rather than handle it as one; Query and Command are the usual entry points.
type Collection ¶
type Collection struct {
Items []json.RawMessage `json:"items"`
Page Page `json:"page"`
// contains filtered or unexported fields
}
Collection is the reply of a list: the raw items plus the page.
Items stay as raw JSON so a caller decodes into whatever shape they want — the returned field set changes with Fields and Include, so there is no one struct that fits.
func (Collection) Into ¶
func (c Collection) Into(dest any) error
Into decodes the items into a slice pointer:
var tasks []Task err := col.Into(&tasks)
A collection that came off the wire decodes in ONE pass, straight from the bytes that arrived. That is the whole reason the raw array is kept: re-encoding Items and decoding the result again was the single most expensive thing this library did with a large reply, and it grew with the response — about 8 ms on 250 KB, a quarter of the time spent on the request.
A Collection assembled by hand rather than decoded from a reply has no raw array to use, so it falls back to the round trip. The length check is what keeps the two honest: a caller that filtered Items in place gets what Items says, not a stale array.
func (*Collection) UnmarshalJSON ¶ added in v1.2.0
func (c *Collection) UnmarshalJSON(b []byte) error
UnmarshalJSON decodes a list reply, keeping the items array whole as well as split.
The split form is the public one and the whole one is what makes Into cheap; decoding the array of RawMessage is a scan and a slice of sub-slices, so keeping both costs nothing beyond the header.
type Config ¶
type Config struct {
// Servers is a comma-separated list of NATS URLs, e.g. "nats://localhost:4222".
Servers string `yaml:"servers"`
// Instance is the deployment token of every subject: "dev" or "prod". Getting it wrong
// produces a request nobody is subscribed to, which looks exactly like a timeout.
Instance string `yaml:"instance"`
// Creds is the path to the sentinel NATS creds file.
//
// It grants no permissions by itself — the file's own JWT denies pub and sub on ">" —
// and exists only to let the connection reach the auth-callout, which is what mints real
// permissions from the Zitadel token.
Creds string `yaml:"creds"`
// Timeout is the per-request bus timeout. See DefaultTimeout for why 15s.
Timeout time.Duration `yaml:"timeout"`
// Name identifies this client in `nats server report connections`. Defaults to "jiku-cli".
Name string `yaml:"name"`
// Auth is the token source. Required, and the only thing that decides what you may do.
Auth auth.TokenSource `yaml:"-"`
// UserID overrides the caller identity used in subjects. LEAVE IT EMPTY: it is derived
// from the token's `sub`, and the callout only authorises publishing under one's own id,
// so a value that disagrees with the token produces an authorization violation rather
// than access to somebody else's namespace. It exists for diagnostics.
UserID string `yaml:"-"`
// Trace, when set, is called once per request with its timing breakdown, and the request
// carries the Jiku-Sent-At and Jiku-Trace-Id headers so core can time the inbound leg.
// Nil — the default — sends exactly what an untraced client sends.
Trace func(RequestTrace) `yaml:"-"`
// Logger receives debug logs of what the client does and how long each step took: the
// connect broken down (token origin, discovery, the HTTPS exchange with Zitadel, the dial),
// every request, reconnects. Nil — the default — logs nothing.
//
// A logger enabled for debug also times every request, so it attaches the same tracing
// headers Trace does: core's own breakdown is half of what makes a slow request readable.
Logger *slog.Logger `yaml:"-"`
// Zitadel holds the identity provider settings the CLI needs to obtain a token. A
// library caller that builds its own Auth can ignore this entirely.
Zitadel ZitadelConfig `yaml:"zitadel"`
}
Config is everything needed to connect. Load it from a file with LoadConfig, from the environment with FromEnv, or build it in code.
func FromEnv ¶
func FromEnv() Config
FromEnv builds a config from the environment and the defaults, with no file involved.
func LoadConfig ¶
LoadConfig reads a YAML config file, applies environment overrides and fills in defaults.
A missing file is not an error: the environment alone is a perfectly good way to configure this, and it is how a containerised service normally does it.
type ConnectTrace ¶ added in v1.2.0
type ConnectTrace struct {
// Subject is resolving the caller identity from the token source.
Subject time.Duration
// Token is obtaining the access token: a round trip to the identity provider unless cached.
Token time.Duration
// Dial is nats.Connect: TCP, TLS if any, and the handshake that runs the auth-callout.
Dial time.Duration
// Total is the whole of Connect.
Total time.Duration
// Auth is how the token source produced the token: from memory, from its store, or from
// Zitadel, with the HTTP exchange broken down. When Subject and Token each asked for one —
// a device flow does — it is the call that did the work.
Auth auth.TokenTrace
}
ConnectTrace is how long each step of Connect took.
type ContainsShape ¶
type ContainsShape struct {
Shape []string `json:"shape"`
}
ContainsShape is the shape a containment filter accepts, e.g. ["key", "value"].
The type is named apart from the Contains constructor in query.go: this one DESCRIBES what the server accepts, that one BUILDS a value for it.
type Contract ¶
Contract is what `meta.describe` returns: the five whitelists of every resource, as data.
WHY THIS IS WORTH FETCHING RATHER THAN HARDCODING ¶
meta.describe projects THE SAME STRUCTURES the validator reads to reject names. So every name it declares works and one it does not declare answers invalid_fields — there is no second copy to drift. A table compiled into this library would be exactly that second copy.
It describes the CONTRACT, not the data, so it is identical for every caller. Knowing that an includable `email` exists grants access to no email: row trimming and the field whitelist still apply to every query.
func (*Contract) Resource ¶
Resource looks a resource up, suggesting a near match when there is none.
func (*Contract) ResourceNames ¶
ResourceNames lists the resources in the contract, sorted.
type Defaults ¶
type Defaults struct {
Sort []string `json:"sort"`
// Limit is the page size applied when none is given.
Limit int `json:"limit"`
// MaxLimit is the cap. A limit above it is CLAMPED SILENTLY — success, not failure — so
// this is the only place a caller can learn the real ceiling.
MaxLimit int `json:"maxLimit"`
}
Defaults are a resource's default sort and page sizes.
type Discriminator ¶
Discriminator names the field that selects a variant.
On `comments.get` it is MANDATORY: the same id means different records under different entity types, so there is nothing sensible to default to.
type Error ¶
type Error struct {
Code string
Message string
Details *ErrorDetails
// Method is the method that failed, added by this library for context.
Method string
}
Error is a `status: failure` reply, with everything core said about it.
type ErrorDetails ¶
type ErrorDetails struct {
Field string `json:"field,omitempty"`
Value any `json:"value,omitempty"`
Allowed []string `json:"allowed,omitempty"`
Extra map[string]any `json:"-"`
// contains filtered or unexported fields
}
ErrorDetails is the structured half of a failure, so a caller never parses ErrorMessage with a regex.
The query plane populates it from day one: a rejected name comes back as {field, value, allowed}, where Allowed is the resource sheet's list by reference — which is exactly what makes meta.describe verifiable against the validator.
func (ErrorDetails) MarshalJSON ¶
func (d ErrorDetails) MarshalJSON() ([]byte, error)
MarshalJSON round-trips the original bytes when they are available.
func (*ErrorDetails) UnmarshalJSON ¶
func (d *ErrorDetails) UnmarshalJSON(b []byte) error
UnmarshalJSON keeps the known keys typed and every other key in Extra, so a field core starts sending tomorrow is not lost.
type F ¶
F is a filter map. Conditions are ANDed together, and THE OPERATOR IS DECIDED BY THE SHAPE OF THE VALUE — that shape grammar is the contract:
scalar equality
array IN
{"not": scalar|array} negation
{"gte": x, "lte": y} range (gt, gte, lt, lte)
{"key": k, "value": v} containment, where the sheet declares `contains`
Use the constructors below rather than writing the maps by hand:
jiku.F{
"projectId": 15, // equality
"state": jiku.In("analisis", "planificacion"), // IN
"createdAt": jiku.Gte("2026-01-01"), // range
"type": jiku.Not("otro"), // negation
}
Example ¶
The filter builders, over the bus's shape-based operator grammar: the OPERATOR is decided by the SHAPE of the value.
package main
import (
"fmt"
"github.com/gravadigital/jiku-go"
)
func main() {
filter := jiku.F{
"projectId": 15, // scalar -> equality
"state": jiku.In("backlog", "activo"), // array -> IN
"type": jiku.Not("otro"), // {not} -> negation
"createdAt": jiku.Between("2026-01-01", "2026-07-01"), // {gte,lte}
"updatedAt": jiku.Gte("2026-06-01"), // {gte}
"tag": jiku.Contains("modulo", "facturacion"), // {key,value}
}
fmt.Println(len(filter))
}
Output: 6
func ParseFilter ¶
ParseFilter turns command-line filter expressions into a wire filter.
The bus decides the operator by the SHAPE of the value, so the flag syntax is a surface over those shapes rather than an invention of its own:
projectId=15 {"projectId": 15} equality
state=analisis,activo {"state": ["analisis","activo"]} IN
state!=cancelado {"state": {"not": "cancelado"}} negation
createdAt>=2026-01-01 {"createdAt": {"gte": "2026-01-01"}} range
createdAt<2026-07-01 {"createdAt": {"lt": "2026-07-01"}} range
tags:modulo=facturacion {"tags": {"key":"modulo","value":"..."}} containment
Repeating a name MERGES range bounds, so the two halves of a window can be written separately, which is how anyone would type it:
--filter 'createdAt>=2026-01-01' --filter 'createdAt<2026-07-01'
-> {"createdAt": {"gte": "2026-01-01", "lt": "2026-07-01"}}
A repeat that is NOT a range is an error rather than a silent overwrite: two conditions on one name would otherwise leave the caller believing both applied.
Values are typed by the resource contract when one is given: a filter on an integer sends 15, not "15". Pass a zero Resource to skip coercion and send everything as a string.
Example ¶
Parsing filters from strings, which is what the CLI does with its --filter flags. Useful for anything else taking filters from config or a request.
package main
import (
"fmt"
"log"
"github.com/gravadigital/jiku-go"
)
func main() {
// A zero Resource skips type coercion and sends everything as a string. Pass a real one to
// have values typed from the contract's declared kind.
filter, err := jiku.ParseFilter([]string{
"projectId=15",
"state=backlog,activo",
"createdAt>=2026-01-01",
}, jiku.Resource{})
if err != nil {
log.Fatal(err)
}
fmt.Println(len(filter))
}
Output: 3
type Field ¶
type Field struct {
// Kind is what the name IS: field, computed, relation, or a scalar type (integer,
// string, date, boolean, enum).
//
// It is what makes local type coercion possible: a filter on an integer must send 15,
// not "15", because the comparison is decided by the JSON type.
Kind string `json:"kind"`
// Enum names the entry in Enums that lists the allowed values.
Enum string `json:"enum,omitempty"`
// Search marks a full-text filterable, conventionally `q`.
Search bool `json:"search,omitempty"`
// SearchNumeric marks a search filterable that also matches numbers.
SearchNumeric bool `json:"searchNumeric,omitempty"`
// Contains marks a filterable that takes a {key, value} containment shape instead of a
// scalar. It is non-nil only where the resource sheet allows it; build the value with the
// Contains constructor.
Contains *ContainsShape `json:"contains,omitempty"`
// Cardinality is "one" or "many" on a relation.
Cardinality string `json:"cardinality,omitempty"`
// Fields are the columns a relation includable brings back.
Fields []string `json:"fields,omitempty"`
// Scalar is set where a relation collapses to a single scalar column rather than an
// object — `subscriptors` comes back as a list of `userId`, for instance.
Scalar string `json:"scalar,omitempty"`
// Optional marks a relation that may be null.
Optional bool `json:"optional,omitempty"`
// Cap is the per-row limit of a collection includable.
Cap int `json:"cap,omitempty"`
// TruncatedFlag is the SIBLING key that marks a row whose collection hit the cap. It is
// a sibling of the collection (`commentsTruncated`), never a nested field.
TruncatedFlag string `json:"truncatedFlag,omitempty"`
}
Field describes one name in a whitelist.
type Get ¶
type Get struct {
// ID is required.
ID int64 `json:"id"`
// Fields restricts the returned set.
Fields []string `json:"fields,omitempty"`
// Include adds includables.
Include []string `json:"include,omitempty"`
// EntityType is the discriminator, accepted as a fourth key only where a resource
// declares one. On `comments` it is MANDATORY.
EntityType string `json:"entityType,omitempty"`
}
Get is the payload of a `{resource}.get`.
Filter, Sort, Page and Count are an ERROR here, not an ignorable extra: a get asks about one identified resource, and accepting a filter in silence would let the caller believe something had been trimmed. This struct simply has nowhere to put them.
type Item ¶
type Item struct {
Raw json.RawMessage
}
Item is the reply of a get: the record, flat under data.
type Iterator ¶
type Iterator struct {
// contains filtered or unexported fields
}
Iterator walks every page of a list, following cursors.
It exists because the end of a collection is signalled by the ABSENCE of a cursor, and a hand-rolled loop that checks anything else — a page smaller than the limit, for instance — is wrong: the byte budget can cut a page short and still emit a cursor.
it := c.Iterate(ctx, "tasks", jiku.List{Filter: jiku.F{"projectId": 15}})
for it.Next() {
var t Task
if err := it.Item().Into(&t); err != nil { return err }
fmt.Println(t.Title)
}
if err := it.Err(); err != nil { return err }
Iterating is not a snapshot: each page is its own query, so a record inserted between pages may appear and one deleted may vanish. The keyset cursor guarantees no row is SKIPPED for a stable ordering, which is the property that matters for a full sweep.
func (*Iterator) Next ¶
Next advances to the next item, fetching the next page when the current one runs out. It returns false at the end of the collection and on error — check Err to tell them apart.
type List ¶
type List struct {
// Filter conditions, ANDed. See F for the shape grammar.
Filter F `json:"filter,omitempty"`
// Sort criteria in order; a leading "-" is descending. The engine always appends `id`
// as the final tie-breaker, because the keyset cursor needs a total order.
Sort []string `json:"sort,omitempty"`
// Fields restricts the returned set to names from base ∪ includable. `id` is always
// returned whether asked for or not.
Fields []string `json:"fields,omitempty"`
// Include adds includables. A collection includable with a cap returns at most `cap`
// items per row and marks the row with its truncated flag.
Include []string `json:"include,omitempty"`
// Limit is the page size. A limit above the resource's maxLimit is CLAMPED SILENTLY —
// success, not failure. Read the effective value back from Page.Limit.
Limit int `json:"-"`
// Cursor continues a previous page. Valid only for the exact filter and sort it was
// minted for.
Cursor string `json:"-"`
// Count opts into the total.
Count Count `json:"-"`
}
List is the payload of a `{resource}.list`. Six levers and no more: any other top-level key is invalid_fields, and so is any of the eleven forbidden identity names.
The NAMES inside Filter, Sort, Fields and Include are decided by the resource sheet. Fetch it with Client.Describe, or run `jiku describe <resource>`.
type Page ¶
type Page struct {
// Limit is the EFFECTIVE limit, with the default and the silent cap applied.
Limit int `json:"limit"`
// Returned is how many items this page carries. It can be fewer than Limit because of
// the byte budget: the engine cuts the page before the reply exceeds what NATS accepts
// and emits the cursor at the cut.
Returned int `json:"returned"`
// Cursor is absent on the last page.
Cursor string `json:"cursor,omitempty"`
// Total appears only when Count was requested.
Total *int `json:"total,omitempty"`
}
Page is the pagination block of a list reply.
THE ABSENCE OF CURSOR IS THE ONLY END-OF-COLLECTION SIGNAL. There is no hasMore boolean, because two ways of saying the same thing eventually disagree.
type Reply ¶
type Reply struct {
Status Status `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ErrorDetails *ErrorDetails `json:"errorDetails,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
Reply is the envelope every endpoint answers with, shared by commands and queries.
On a failure the envelope travels in the BODY. The `Nats-Service-Error` headers are added alongside it, never as a replacement — so this struct is always the authority, and the micro transport's 500 is not the error's status.
type RequestTrace ¶ added in v1.2.0
type RequestTrace struct {
ID string
Method string
Subject string
Encode time.Duration
RoundTrip time.Duration
// Decode is parsing the envelope.
Decode time.Duration
// Unwrap is decoding the envelope's data into what the method returns, when that is a
// second pass: the Collection of List. Zero for ListInto, Describe and Tags, which decode
// envelope and data together so Decode covers both, and for Query, Command and Request,
// which return data undecoded.
Unwrap time.Duration
Inbound time.Duration
Outbound time.Duration
ReqBytes int
RespBytes int
Server *ServerTiming
// Total is the whole call, from encoding the payload to the value handed back.
Total time.Duration
// ErrorCode is the envelope's errorCode when core answered a failure.
ErrorCode string
Err error
// contains filtered or unexported fields
}
RequestTrace is the client's view of one request, handed to Config.Trace.
The legs only add up when client and core share a clock, which is the case on one machine:
Encode → [Inbound: bus + core's queue] → Server.TotalMs → [Outbound: bus back] → Decode
Inbound and Outbound are zero when core sent no timing headers.
type Resource ¶
type Resource struct {
// Base is what a list or a get returns without asking for anything.
Base map[string]Field `json:"base"`
// Includable is what `include` may add.
Includable map[string]Field `json:"includable"`
// Filterable is what `filter` may name.
Filterable map[string]Field `json:"filterable"`
// Sortable is what `sort` may name.
Sortable []string `json:"sortable"`
// Defaults are the sort, limit and maxLimit applied when the caller asks for none.
Defaults Defaults `json:"defaults"`
// Enums are the allowed values of the enum filterables, keyed by enum name.
Enums map[string][]EnumValue `json:"enums"`
// Discriminator is present on the three resources that have variants. It names the field
// that selects one and lists the accepted values.
Discriminator *Discriminator `json:"discriminator,omitempty"`
// Variants are the per-variant whitelists, keyed by discriminator value.
Variants map[string]Variant `json:"variants,omitempty"`
}
Resource is one resource's five whitelists.
DENY BY DEFAULT: a name that is not in one of these lists DOES NOT EXIST. It comes back as invalid_fields with errorDetails, never as a silently ignored lever — an ignored filter would return MORE data than asked for, which is the worst failure mode a read contract has.
THREE RESOURCES KEEP THEIR FIELDS SOMEWHERE ELSE ¶
`comments`, `activity` and `subscriptions` are DISCRIMINATED: their Base, Includable and Filterable are EMPTY, and the real whitelists live per variant under Variants, selected by the discriminator field (`entityType`). Only Sortable and Defaults stay at this level.
Read them through ForVariant rather than reaching into the maps, or those three resources will look like they have no fields at all.
func (Resource) Coerce ¶
Coerce turns a filter value parsed from a string into the JSON type the contract declares.
It matters because the operator is decided by the SHAPE of the value and the comparison by its TYPE: `{"projectId": "15"}` is not the same request as `{"projectId": 15}`. A CLI only ever has strings, so without the contract it would have to guess — and guessing "looks like a number, send a number" breaks any string field whose values happen to be digits, like a project code.
func (Resource) FieldNames ¶
FieldNames lists base ∪ includable, which is exactly what `fields` may name.
func (Resource) FilterableNames ¶
FilterableNames lists what `filter` may name.
func (Resource) ForVariant ¶
ForVariant returns the resource as it applies to one variant.
For an undiscriminated resource it returns the resource unchanged, so callers need no special case. For a discriminated one:
- a known variant name yields that variant's whitelists;
- an EMPTY name yields the UNION of every variant.
The union is deliberate. Validation must never reject what the server would accept, and without a variant chosen there is no way to know which one applies — so the permissive answer is the only correct one. An unknown name is left to the server, which owns that rule.
func (Resource) IncludableNames ¶
IncludableNames lists what `include` may name.
func (Resource) Validate ¶
Validate checks a list query against the resource's whitelists BEFORE it is published.
Every rejection here is one core would also make, with the same meaning — the point is only that it arrives without a round trip and can name the alternatives. It is deliberately conservative: it flags names that are certainly wrong and never invents a rule of its own, so it cannot refuse a query the server would have accepted.
Example ¶
Checking a query against the server's own whitelists before publishing it.
Every rejection here is one core would also make, with the same meaning — it just arrives without a round trip and can name the alternatives.
package main
import (
"context"
"fmt"
"log"
"github.com/gravadigital/jiku-go"
)
func main() {
var client *jiku.Client
ctx := context.Background()
contract, err := client.Contract(ctx) // meta.describe, cached per client
if err != nil {
log.Fatal(err)
}
tasks, err := contract.Resource("tasks") // suggests a near match if the name is wrong
if err != nil {
log.Fatal(err)
}
query := jiku.List{Filter: jiku.F{"projectId": 15}, Sort: []string{"-createdAt"}}
if err := tasks.Validate(query); err != nil {
log.Fatal(err) // names the bad name and lists what is allowed
}
fmt.Println(tasks.Defaults.MaxLimit)
}
Output:
func (Resource) VariantNames ¶
VariantNames lists the discriminator values that have a variant, sorted.
type ServerSpan ¶ added in v1.2.0
type ServerSpan struct {
Name string `json:"name"`
Start float64 `json:"start"`
Ms float64 `json:"ms"`
Rows *int `json:"rows,omitempty"`
}
ServerSpan is one timed section inside core, as core reports it.
type ServerTiming ¶ added in v1.2.0
type ServerTiming struct {
Subject string `json:"subject"`
TotalMs float64 `json:"totalMs"`
InboundMs *float64 `json:"inboundMs,omitempty"`
ReqBytes int `json:"reqBytes"`
RespBytes int `json:"respBytes"`
Spans []ServerSpan `json:"spans"`
}
ServerTiming is core's own breakdown of a request, from the Jiku-Timing header.
type Status ¶
type Status string
Status is the envelope's `status`. It is the only field that is always present.
type TagGroup ¶
TagGroup is one entry of the requirements.tags reply: a key and the values in use for it.
type Variant ¶
type Variant struct {
Base map[string]Field `json:"base"`
Includable map[string]Field `json:"includable"`
Filterable map[string]Field `json:"filterable"`
Enums map[string][]EnumValue `json:"enums"`
}
Variant is one variant's whitelists. It has no Sortable or Defaults of its own — those are shared by the resource.
type ZitadelConfig ¶
type ZitadelConfig struct {
// Issuer is the Zitadel instance, e.g. https://id.grava.io.
Issuer string `yaml:"issuer"`
// ClientID of a Native app with the Device Code grant, for `jiku login`.
ClientID string `yaml:"client_id"`
// ProjectID is the Zitadel project. It is what puts the ROLES in the token, and the
// callout reads the role to decide what you may do — so a token minted without it
// connects to nothing.
ProjectID string `yaml:"project_id"`
// KeyFile is a service account JSON key, for unattended use. When set, the CLI
// authenticates as that machine user instead of as a person.
KeyFile string `yaml:"key_file"`
}
ZitadelConfig is the identity provider half of the config file.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package auth obtains Zitadel access tokens for a Jiku bus connection.
|
Package auth obtains Zitadel access tokens for a Jiku bus connection. |
|
cmd
|
|
|
jiku
command
Command jiku is a command-line client for Jiku's NATS API.
|
Command jiku is a command-line client for Jiku's NATS API. |
|
Package events consumes the domain events Jiku's core publishes (REQ-014).
|
Package events consumes the domain events Jiku's core publishes (REQ-014). |
|
examples
|
|
|
events
command
Consuming the domain event stream.
|
Consuming the domain event stream. |
|
quickstart
command
Quickstart: connect as a PERSON and read.
|
Quickstart: connect as a PERSON and read. |
|
service
command
A long-running service reading from Jiku.
|
A long-running service reading from Jiku. |
|
tools
|
|
|
bench
command
Command bench measures where a query's time goes — in the library and in the CLI — against a LOCAL Jiku stack.
|
Command bench measures where a query's time goes — in the library and in the CLI — against a LOCAL Jiku stack. |
|
gendocs
command
Command gendocs regenerates docs/commands.md from Jiku's own AsyncAPI command contract.
|
Command gendocs regenerates docs/commands.md from Jiku's own AsyncAPI command contract. |