kv

package
v0.1.0-rc.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: Apache-2.0 Imports: 31 Imported by: 1

README

kv

An application's current state, in kv.db inside a tinystore.Store: sessions, one-time codes, claims, the answers of requests sent twice, drafts, flags, settings, a cache of another service's answers. Buckets hold values of one type by key, keys sit in branches, expiry runs on the store's clock and a version never repeats. The design and the measurements behind it are research's design/kv.md.

state, err := kv.Open(ctx, store, kv.Options{}) // data/kv.db

sessions, err := kv.OpenBucket[Session](ctx, state, "sessions", kv.Sliding(30*24*time.Hour))
codes, err := kv.OpenBucket[int64](ctx, state, "login-codes", kv.DefaultTTL(15*time.Minute))
seen, err := kv.OpenBucket[struct{}](ctx, state, "stripe-events") // a set
attempts, err := kv.OpenCounters(ctx, state, "login-attempts",
	kv.DefaultTTL(15*time.Minute), kv.LoseAtMost(time.Second))

err = sessions.Of(user.ID).Set(ctx, digest(token), Session{Device: device}) // a copy of the file signs nobody in
s, found, err := sessions.Of(user.ID).Get(ctx, digest(token))               // and thirty more days from now, once a day
err = sessions.Of(user.ID).Clear(ctx)                 // signed out everywhere
for entry, err := range sessions.Of(user.ID).All(ctx) { … }
userID, found, err := codes.Take(ctx, digest(code)) // read and burn
claim, first, err := seen.SetEntryIfAbsent(ctx, event.ID, struct{}{}, kv.TTL(10*time.Minute))
err = seen.Set(ctx, event.ID, struct{}{}, kv.IfVersion(claim.Version), kv.TTL(7*24*time.Hour))
n, err := attempts.Of("ip").Add(ctx, clientIP, 1) // in memory: 1, 2, 3…, and from 1 again fifteen minutes on

settings, err := kv.OpenConfig[Settings](ctx, state, "app",
	kv.Defaults(Settings{Port: 8080}), kv.Defaults(fromYAML), kv.FromEnv("APP", ".env"))
port := settings.Get().Port                                            // from memory
err = settings.Update(ctx, func(s *Settings) { s.Limits.RPS = 200 }) // kept; every handle sees it at once
for s := range settings.Watch(ctx) { server.SetRate(s.Limits.RPS) }

limit, err := kv.OpenLimiter(ctx, state, "api", kv.Rate(100, time.Second), kv.Burst(20))
allowed, err := limit.Of(tenant).Allow(ctx, userID) // allowed.OK, allowed.Left, allowed.RetryAfter

ai, err := kv.OpenQuota(ctx, state, "ai",
	kv.Window("session", 100, 5*time.Hour), kv.Window("weekly", 300, 7*24*time.Hour))
usage, err := ai.Allow(ctx, userID) // one of each window, or none: usage.Windows["weekly"].Left

charges, err := kv.OpenOnce[Receipt](ctx, state, "charges")         // answers kept a day
receipt, err := charges.Run(ctx, requestID, func(ctx context.Context) (Receipt, error) {
	return pay.Charge(ctx, order, requestID) // runs once; the same request again gets this receipt
})

example_test.go runs the five cases of design/kv.md as examples, and go doc shows them.

Contracts

  • A key is text. A string, a []byte or an integer; an integer is its decimal spelling, so 42 and "42" are one key. Anything else, an empty key, or a path of owners and key over 1 KiB is tinystore.ErrInvalid. Of(owners...) names a branch by the same rule; a branch exists while it holds keys. Scan reads a branch's own keys, a page at a time, in the byte order of their text: at most 1000 keys and 4 MiB of values a page, which ends before the value that would pass them, the next page beginning there.
  • A value is kept by its type. []byte and string as their bytes; bool and integers up to 32 unsigned bits as an integer of the row; uint64 as eight bytes; floats as their bits, named or not, -0, a NaN's payload and whether it signals included; struct{} as nothing, so a bucket of them is a set; anything else as JSON, and a value JSON cannot write is ErrInvalid at Set. WithCodec replaces the choice. A value over 512 bytes lives in a row of its own; one over 1 MiB is ErrLimit.
  • kv.Raw is a value as its row keeps it: nothing, an integer or bytes, an empty string as empty bytes. A bucket of Raw reads what a bucket of any type wrote under its name, and what it writes a bucket of a type kept the same way reads, so that a program without the types reads and writes every bucket; the server does, for its clients in other languages.
  • A counter is an int64 a key. OpenCounters opens them: Add returns the new value, Max keeps the larger, and an absent or expired counter is 0. An expired counter starts again from zero with the counters' DefaultTTL, and a live one keeps its expiry, so a window of attempts does not slide. A sum past the int64 range is tinystore.ErrLimit and changes nothing, since SQLite would quietly write a REAL. An option of one kind does not compile for the other: DefaultTTL fits both.
  • LoseAtMost(d) counters live in memory. The first change of a key since the last flush reads it from the file once; after that Add, Max and Delete change memory and return, and every d, Close and Maintain write what changed, 10,000 keys a transaction. A crash loses the changes since the last flush that committed, a Delete's as well: d, and on a busy file the time the flush waited for the writer and took. Memory holds at most 100,000 keys, changed or not: a change of a key it does not hold flushes first when it is full, and while the file refuses that flush the change is refused with the flush's error. Handles on one name opened with the same d share the memory; another d, or none, is ErrInvalid, and so is such a counter inside Tx.
  • Clear removes a branch and every branch under it. Up to 10,000 keys go in one transaction; a larger branch is marked cleared at the file's revision, which hides its keys from every call the moment it commits, and Maintain deletes them 10,000 a transaction. A key written after the Clear is a new key. Inside Tx a branch over 10,000 keys is tinystore.ErrLimit. A Clear of LoseAtMost counters drops what their memory holds under the branch once it commits, so no flush writes it again; one that fails leaves memory as it was, and one whose commit fails, kv.ErrOutcomeUnknown, lets go of the branch as a crash would.
  • Sliding(term) keeps a key term from its last read. A key created gets term; a Get, GetEntry or Has renews a live key to term from now once a thirtieth of the term has passed since it last did, and Scan does not. A read writes nothing: the renewal waits a second for the flush, bound to the version and expiry the read saw, so it never extends a key written again, touched or deleted since. A key in its last minute is renewed before its read returns. Sliding beside DefaultTTL is ErrInvalid.
  • All(ctx) walks a branch in Scan's pages: for entry, err := range sessions.Of(uid).All(ctx). No snapshot is held between pages, so a slow loop keeps no reader open, and a key written during the walk may or may not be met; View reads one snapshot.
  • Expiry follows three rules. A key created without kv.TTL or kv.ExpireAt gets the bucket's DefaultTTL, if it has one; a later Set keeps the expiry a live key has; Touch gives a new expiry and keeps the value and version. An expired key is absent to every call: reads do not see it, SetIfAbsent claims it, IfVersion conflicts with it. Maintain, every minute unless the store is Manual, deletes expired rows, 100,000 at most, and every ten seconds while the last one stopped there with rows left, so that up to 600,000 keys a minute expire without growing the file.
  • A version never repeats. It is the revision of the file that wrote the value, kept across a delete, an expiry and a reopen. kv.IfVersion lets Set, SetEntry, Take, Delete and Touch apply only to a live key at that version; anything else is tinystore.ErrConflict. A version marshals to text and compares only for equality.
  • A write returns once it is durable. Writes from many goroutines wait for the file's one writer and commit together, each in a savepoint of one transaction, with one fsync; a write that fails rolls back alone. A Take decodes what it took inside its savepoint, so a value that no longer decodes, or a codec that panics, is ErrCorrupt and the value stays, inside Tx as well. A caller whose context ends before its write starts writes nothing; a write that has started finishes with its group. A group whose commit fails answers kv.ErrOutcomeUnknown, and its caller reads the key before writing again.
  • A read is one statement. It sees the last commit before it, without a transaction of its own. Store.View reads several keys from one snapshot, held at most five seconds; Store.Tx writes several buckets in one transaction, nil committing, an error or a panic rolling back. A bucket works in either through WithTx; a write inside View is ErrInvalid.
  • A call holds the store's memory before it makes a value. Under Options.Memory a write takes its turn and the memory its row may hold before it encodes: a []byte or string its length, a number eight bytes, and a value JSON or a codec encodes the largest, 1 MiB, until the encoding says what it holds. A write waiting for its turn has made nothing. Get, GetEntry and Take hold the largest value while they read one, SetIfAbsent holds one beside its own for the value it may find, and a Scan page its 4 MiB and the row past them. A call inside Tx or View waits for none of it, since the calls holding memory wait for the writer or the reader it holds: it takes what is free, and past that it is tinystore.ErrLimit at once, for the caller to run it again.
  • An error names its key. A call refused because of one key is a *kv.KeyError with the bucket and the path of owners and key; errors.Is finds the store's sentinel in it. A bucket name is [a-z0-9][a-z0-9_-]{0,63} and keeps its kind: a name holding counters does not open for values.

Config

  • Layers in the order given, each over the one before: a Defaults, a T, which sets every field, or a map, as a JSON or YAML library reads a file, which sets the fields it names; a FromEnv, the .env files and the process's own environment over them; then what Update kept over them all. No variable is read without a FromEnv. Reset gives a field, or a struct of them, back to the layers under it.
  • A field is its path and its variable. A path is the JSON names of a field and the structs above it, limits.rps; its variable is the prefix and the path in upper snake case, APP_LIMITS_RPS, or its env:"NAME" tag. A variable is read by its field's type: a number, true, a duration 1h30m, a list a.com,b.com or JSON. One that does not read is ErrInvalid at OpenConfig, naming it. A field tagged secret:"true" comes from the layers alone: Update refuses it, Sources hides it, and a value kept for it is left out. A field tagged required:"true" that the layers leave its zero value, or an empty list, is ErrInvalid at OpenConfig, naming the variable that would give it, and Update refuses to empty it.
  • What changes is kept field by field, in kv.db, so that a default the code changes later still reaches a field nobody changed. A change is checked by Validate and by its fields' types before anything is written, and refused whole. A kept value that no longer fits its field, after the program changed, is left out and logged, and Sources says why; one that makes a config Validate refuses leaves all of them out.
  • Every handle sees a change at once. The store's one process makes every change, so it tells every handle on the config, Watch included, and the server's watchers, without reading the file again. Get reads memory: it builds the config once a change, and a watcher behind skips to the latest.
  • Bounds. A path is at most 256 bytes, a value 16 KiB of JSON and a config's kept fields 256 KiB, which reach a watcher in one message.

Limiter

  • The generic cell rate algorithm: a key holds one time, when its next request is due, so there is no window whose edge lets twice the rate through, and a key costs one int64. Rate(n, per) lets n requests through every per, Burst(b) lets b at once, n when not given. AllowN takes requests together or not at all; more than the burst is ErrInvalid.
  • An answer, not an error. Allowance.OK says whether the request passes, Left how many more would now, and RetryAfter how long until it would.
  • In memory, as LoseAtMost counters are: a key's time is written every second and on Close, so a crash forgets at most a second and lets at most one burst more through. A key whose time has come is as one never seen, and maintenance deletes its row.

Quota

  • Every window or none. Allow and AllowN check every window of a key and count in each, or in none when one has no room, in one durable write; more than a window's limit is ErrInvalid. The answer is a limiter's, OK, Left and RetryAfter, the wait until the windows that refused reset, and each window's Used, Limit, Left and ResetAt.
  • A window starts at a key's first use after the last one ended and lasts its span, as a counter with DefaultTTL does: each key resets on its own.
  • Get uses nothing, its OK saying whether one more use would pass. Refund and RefundN give uses back to the windows that have not reset since, never below nothing; Delete starts every window anew.
  • The windows are the program's, one to eight, each [a-z][a-z0-9_]{0,31}, and may change between runs: a window no longer given is forgotten, a limit changed counts what its window used, a window added starts at the next use.

Once

  • A key's function runs once and its answer is kept. Run returns the answer kept under a key without running anything, or runs the function and keeps what it returns, DefaultTTL long, a day unless it says. Get reads an answer and Delete forgets it, so that the next Run runs again.
  • One Run of a key at a time. A Run of a key another is running, in the store's process or through its server, waits for it and returns its answer, until its own context ends; one after a Run that failed runs its own function.
  • An error keeps nothing. The function's error is returned and the next Run runs again; an answer meant to be kept, a declined card, is a value. An answer is kept even when the caller's context ends after the function returned, and an error keeping it is returned beside it.
  • A claim lives as long as its Run. It is held in memory: a process that dies, or a client that leaves, lets the next Run run again, so an effect outside the store carries the key too, as a payment provider's own idempotency key does.

Testing without waiting

A Manual store with a clock the test moves runs no background work, so expiry, renewals and flushes happen when the test says:

now := time.Now()
store, err := tinystore.Open(ctx, t.TempDir(), tinystore.Options{Manual: true, Clock: func() time.Time { return now }})
state, err := kv.Open(ctx, store, kv.Options{})
codes, err := kv.OpenBucket[int64](ctx, state, "login-codes", kv.DefaultTTL(15*time.Minute))

err = codes.Set(ctx, "code", 42)
now = now.Add(16 * time.Minute)
_, found, err := codes.Take(ctx, "code") // false: expired
_, err = state.Maintain(ctx)             // writes counters and renewals, deletes what expired or was cleared

Not in the first version

What design/kv.md leaves for later: a bucket held in memory, a filter that answers a miss without SQLite, history, watching a bucket of values, listing a branch's branches.

Documentation

Overview

Package kv keeps an application's current state in kv.db inside a tinystore.Store: buckets of one value type and counters, keyed by text in branches, with expiry by the store's clock and versions that never repeat.

state, err := kv.Open(ctx, store, kv.Options{})
sessions, err := kv.OpenBucket[Session](ctx, state, "sessions")
err = sessions.Of(userID).Set(ctx, token, session, kv.TTL(24*time.Hour))
session, found, err := sessions.Of(userID).Get(ctx, token)

A write waits for the file's one writer beside the writes of other goroutines and commits with them, one fsync for all; a read is one statement. Start with kv.go and bucket.go; every other file holds one step:

kv.go        Open, Close, Snapshot: the handle, its revision and admission
bucket.go    OpenBucket, Of, WithTx: a handle on a bucket of values
counters.go  OpenCounters, Add, Max, Get, Delete: a handle on counters
memory.go    what LoseAtMost counters hold between flushes, and the flush
clear.go     Clear: a branch deleted at once, or marked and deleted by Maintain
branch.go    what both handles share: a branch's path and where its calls run
options.go   the engine's bounds, the options of buckets, counters and calls
keys.go      a key's text and the path owners and a key make
values.go    a value's bytes by its type, and where they are kept
entry.go     Entry, Version, Query, Page, KeyError
read.go      Get, GetEntry, Has, Scan, All
renew.go     what Sliding reads ask for, and the flush that writes it
write.go     Set, SetEntry, SetIfAbsent, SetEntryIfAbsent, Take, Delete, Touch
tx.go        Tx and View: several calls in one transaction or snapshot
expiry.go    Maintain: expired keys and the values they spilled
Example (AttemptLimits)

Attempts are counted in memory, fifteen minutes from the first; a crash may forget the last second of them, which a limit can afford.

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/tinyshed/tinystore"
	"github.com/tinyshed/tinystore/kv"
)

// clock is the store's clock in the examples, moved rather than waited for
type clock struct{ now time.Time }

func (c *clock) Now() time.Time { return c.now }

func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) }

// exampleState opens kv in a Manual store in a new directory, so that an
// example moves its clock instead of sleeping; done closes and removes it
func exampleState() (state *kv.Store, at *clock, done func()) {
	ctx := context.Background()
	directory, err := os.MkdirTemp("", "tinystore-kv-")
	check(err)
	at = &clock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
	store, err := tinystore.Open(ctx, directory, tinystore.Options{Manual: true, Clock: at.Now})
	check(err)
	state, err = kv.Open(ctx, store, kv.Options{})
	check(err)
	return state, at, func() {
		_ = store.Close(ctx)
		_ = os.RemoveAll(directory)
	}
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	ctx := context.Background()
	state, clock, done := exampleState()
	defer done()
	attempts, err := kv.OpenCounters(ctx, state, "login-attempts",
		kv.DefaultTTL(15*time.Minute), kv.LoseAtMost(time.Second))
	check(err)

	refused := func(ip string) bool {
		n, addErr := attempts.Of("ip").Add(ctx, ip, 1)
		check(addErr)
		return n > 3
	}
	var answers []bool
	for range 4 {
		answers = append(answers, refused("10.0.0.1"))
	}
	fmt.Println(answers)
	clock.advance(15 * time.Minute)
	fmt.Println(refused("10.0.0.1"))
}
Output:
[false false false true]
false
Example (Drafts)

Two tabs edit one draft: a save goes through only if nobody saved since the tab read it.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"time"

	"github.com/tinyshed/tinystore"
	"github.com/tinyshed/tinystore/kv"
)

// clock is the store's clock in the examples, moved rather than waited for
type clock struct{ now time.Time }

func (c *clock) Now() time.Time { return c.now }

func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) }

// exampleState opens kv in a Manual store in a new directory, so that an
// example moves its clock instead of sleeping; done closes and removes it
func exampleState() (state *kv.Store, at *clock, done func()) {
	ctx := context.Background()
	directory, err := os.MkdirTemp("", "tinystore-kv-")
	check(err)
	at = &clock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
	store, err := tinystore.Open(ctx, directory, tinystore.Options{Manual: true, Clock: at.Now})
	check(err)
	state, err = kv.Open(ctx, store, kv.Options{})
	check(err)
	return state, at, func() {
		_ = store.Close(ctx)
		_ = os.RemoveAll(directory)
	}
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	ctx := context.Background()
	state, _, done := exampleState()
	defer done()
	drafts, err := kv.OpenBucket[string](ctx, state, "drafts")
	check(err)
	check(drafts.Of(42).Set(ctx, "note-1", "milk"))

	first, _, err := drafts.Of(42).GetEntry(ctx, "note-1")
	check(err)
	second, _, err := drafts.Of(42).GetEntry(ctx, "note-1")
	check(err)

	_, err = drafts.Of(42).SetEntry(ctx, "note-1", "milk, bread", kv.IfVersion(first.Version))
	fmt.Println(err)
	_, err = drafts.Of(42).SetEntry(ctx, "note-1", "milk, eggs", kv.IfVersion(second.Version))
	fmt.Println(errors.Is(err, tinystore.ErrConflict))
}
Output:
<nil>
true
Example (Sessions)

Sessions live thirty days from their last request and are kept by a digest of their token, so that a copy of kv.db signs nobody in. A token is rotated in one write, a user sees where they are signed in, and signs out everywhere at once.

package main

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"os"
	"time"

	"github.com/tinyshed/tinystore"
	"github.com/tinyshed/tinystore/kv"
)

// clock is the store's clock in the examples, moved rather than waited for
type clock struct{ now time.Time }

func (c *clock) Now() time.Time { return c.now }

func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) }

// exampleState opens kv in a Manual store in a new directory, so that an
// example moves its clock instead of sleeping; done closes and removes it
func exampleState() (state *kv.Store, at *clock, done func()) {
	ctx := context.Background()
	directory, err := os.MkdirTemp("", "tinystore-kv-")
	check(err)
	at = &clock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
	store, err := tinystore.Open(ctx, directory, tinystore.Options{Manual: true, Clock: at.Now})
	check(err)
	state, err = kv.Open(ctx, store, kv.Options{})
	check(err)
	return state, at, func() {
		_ = store.Close(ctx)
		_ = os.RemoveAll(directory)
	}
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}

type Session struct {
	Device string
}

func main() {
	ctx := context.Background()
	state, clock, done := exampleState()
	defer done()
	sessions, err := kv.OpenBucket[Session](ctx, state, "sessions", kv.Sliding(30*24*time.Hour))
	check(err)

	check(sessions.Of(42).Set(ctx, digest("t1"), Session{Device: "phone"}))
	check(sessions.Of(42).Set(ctx, digest("t2"), Session{Device: "laptop"}))

	clock.advance(20 * 24 * time.Hour)
	s, found, err := sessions.Of(42).Get(ctx, digest("t1")) // and thirty more days from now
	check(err)
	fmt.Println(s.Device, found)

	// after a password change: a new token in the old one's place, in one write
	check(state.Tx(ctx, func(tx *kv.Tx) error {
		session, there, takeErr := sessions.WithTx(tx).Of(42).Take(ctx, digest("t1"))
		if takeErr != nil || !there {
			return takeErr
		}
		return sessions.WithTx(tx).Of(42).Set(ctx, digest("t3"), session)
	}))
	_, found, err = sessions.Of(42).Get(ctx, digest("t1"))
	check(err)
	fmt.Println("the old token signs in:", found)

	for entry, err := range sessions.Of(42).All(ctx) {
		check(err)
		fmt.Println("signed in:", entry.Value.Device)
	}

	check(sessions.Of(42).Clear(ctx))
	_, found, err = sessions.Of(42).Get(ctx, digest("t3"))
	check(err)
	fmt.Println(found)
}

func digest(code string) string {
	sum := sha256.Sum256([]byte(code))
	return hex.EncodeToString(sum[:])
}
Output:
phone true
the old token signs in: false
signed in: phone
signed in: laptop
false
Example (WebhookClaims)

A provider delivers an event until it gets a 200, so an event may arrive twice. A claim's version keeps a handler slower than its claim from finishing the next one's.

package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"time"

	"github.com/tinyshed/tinystore"
	"github.com/tinyshed/tinystore/kv"
)

// clock is the store's clock in the examples, moved rather than waited for
type clock struct{ now time.Time }

func (c *clock) Now() time.Time { return c.now }

func (c *clock) advance(d time.Duration) { c.now = c.now.Add(d) }

// exampleState opens kv in a Manual store in a new directory, so that an
// example moves its clock instead of sleeping; done closes and removes it
func exampleState() (state *kv.Store, at *clock, done func()) {
	ctx := context.Background()
	directory, err := os.MkdirTemp("", "tinystore-kv-")
	check(err)
	at = &clock{now: time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)}
	store, err := tinystore.Open(ctx, directory, tinystore.Options{Manual: true, Clock: at.Now})
	check(err)
	state, err = kv.Open(ctx, store, kv.Options{})
	check(err)
	return state, at, func() {
		_ = store.Close(ctx)
		_ = os.RemoveAll(directory)
	}
}

func check(err error) {
	if err != nil {
		panic(err)
	}
}

func main() {
	ctx := context.Background()
	state, clock, done := exampleState()
	defer done()
	seen, err := kv.OpenBucket[struct{}](ctx, state, "stripe-events")
	check(err)

	handle := func(eventID string) string {
		claim, first, claimErr := seen.SetEntryIfAbsent(ctx, eventID, struct{}{}, kv.TTL(10*time.Minute))
		check(claimErr)
		if !first {
			return "handled before, or being handled now"
		}
		check(seen.Set(ctx, eventID, struct{}{}, kv.IfVersion(claim.Version), kv.TTL(7*24*time.Hour)))
		return "handled"
	}
	fmt.Println(handle("evt_1"))
	fmt.Println(handle("evt_1"))

	slow, _, err := seen.SetEntryIfAbsent(ctx, "evt_2", struct{}{}, kv.TTL(10*time.Minute))
	check(err)
	clock.advance(11 * time.Minute)
	fmt.Println(handle("evt_2"))
	err = seen.Set(ctx, "evt_2", struct{}{}, kv.IfVersion(slow.Version))
	fmt.Println(errors.Is(err, tinystore.ErrConflict))
}
Output:
handled
handled before, or being handled now
handled
true

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrOutcomeUnknown = sqlite.ErrOutcomeUnknown

ErrOutcomeUnknown is returned for a write whose group's commit failed. The write may or may not be in the file, so its caller reads it back before writing again.

Functions

This section is empty.

Types

type Allowance

type Allowance struct {
	OK bool
	// Left is how many more requests would pass now.
	Left int64
	// RetryAfter is how long until the request would pass; zero when OK.
	RetryAfter time.Duration
}

Allowance is what a limiter answers a request.

type Bucket

type Bucket[V any] struct {
	// contains filtered or unexported fields
}

Bucket holds values of type V by key. Of and WithTx give handles on the same bucket, and a handle may be used from any number of goroutines.

func OpenBucket

func OpenBucket[V any](ctx context.Context, state *Store, name string, options ...BucketOption) (*Bucket[V], error)

OpenBucket opens the bucket name of kv.db for values of type V, creating it the first time. A name that holds counters is ErrInvalid.

func (*Bucket[V]) All

func (b *Bucket[V]) All(ctx context.Context) iter.Seq2[Entry[V], error]

All is every one of this branch's own keys, in Scan's order, a page of Scan at a time. Each page is its own snapshot and none is held between pages, so a slow loop keeps no reader open, and a key written or deleted during the walk may or may not be met; inside View or Tx the pages share its snapshot. An error ends the walk as its last element.

func (*Bucket[V]) Clear

func (b *Bucket[V]) Clear(ctx context.Context) error

Clear removes every key of this branch and of the branches under it. A branch of up to 10,000 keys is deleted in one transaction; a larger one is marked cleared at the file's revision, which hides its keys from every call at once, and Maintain deletes them 10,000 a transaction. A key written after the Clear is a new key. Inside Tx a branch over 10,000 keys is ErrLimit, since deleting it would hold the writer for seconds.

func (*Bucket[V]) Delete

func (b *Bucket[V]) Delete(ctx context.Context, key any, options ...Option) error

Delete removes key; an absent key is not an error, except to IfVersion.

func (*Bucket[V]) Get

func (b *Bucket[V]) Get(ctx context.Context, key any) (V, bool, error)

Get is the value under key, and whether a live key holds one.

func (*Bucket[V]) GetEntry

func (b *Bucket[V]) GetEntry(ctx context.Context, key any) (Entry[V], bool, error)

GetEntry is Get with the key's version and expiry, the expiry as the file has it: a Sliding renewal waiting for its flush is not in it yet.

func (*Bucket[V]) Has

func (b *Bucket[V]) Has(ctx context.Context, key any) (bool, error)

Has says whether a live key is there, without reading its value.

func (*Bucket[V]) Of

func (b *Bucket[V]) Of(owners ...any) *Bucket[V]

Of is the branch of this bucket that owners name, one after another: a string, a []byte or an integer each, an integer by its decimal text. A branch exists while it holds keys; there is nothing to create or drop.

func (*Bucket[V]) Scan

func (b *Bucket[V]) Scan(ctx context.Context, query Query) (Page[V], error)

Scan is a page of this branch's own keys, not those of the branches under it, from one snapshot, in the byte order of their text: "10" before "9".

func (*Bucket[V]) Set

func (b *Bucket[V]) Set(ctx context.Context, key any, value V, options ...Option) error

Set writes value under key. A key it creates, or one that had expired, takes the bucket's DefaultTTL unless kv.TTL or kv.ExpireAt says otherwise; a live key keeps the expiry it has unless one of them does.

func (*Bucket[V]) SetEntry

func (b *Bucket[V]) SetEntry(ctx context.Context, key any, value V, options ...Option) (Entry[V], error)

SetEntry is Set, returning what it wrote with its new version.

func (*Bucket[V]) SetEntryIfAbsent

func (b *Bucket[V]) SetEntryIfAbsent(ctx context.Context, key any, value V, options ...Option) (
	Entry[V], bool, error,
)

SetEntryIfAbsent is SetIfAbsent, returning the entry it wrote or the one that was there, each with its version.

func (*Bucket[V]) SetIfAbsent

func (b *Bucket[V]) SetIfAbsent(ctx context.Context, key any, value V, options ...Option) (bool, error)

SetIfAbsent writes value under key only if no live key is there, and says whether it did; an expired key is absent.

func (*Bucket[V]) Take

func (b *Bucket[V]) Take(ctx context.Context, key any, options ...Option) (V, bool, error)

Take reads the value under key and deletes it in one step, so that of the callers taking one key only one gets it. A value that no longer decodes is ErrCorrupt and stays where it was, inside Tx as well.

func (*Bucket[V]) Touch

func (b *Bucket[V]) Touch(ctx context.Context, key any, options ...Option) (bool, error)

Touch gives a live key a new expiry, kv.TTL's, kv.ExpireAt's or else the bucket's DefaultTTL, and keeps its value and version, so that a renewal fails no IfVersion. It says whether the key was there.

func (*Bucket[V]) WithTx

func (b *Bucket[V]) WithTx(tx *Tx) *Bucket[V]

WithTx is this bucket inside tx: its calls run in tx's transaction and see its writes, and after the function tx was given returns they are ErrClosed.

type BucketOption

type BucketOption interface {
	// contains filtered or unexported methods
}

BucketOption changes how a bucket serves its values; none changes what its bytes mean, so a program may change them between runs.

func Sliding

func Sliding(term time.Duration) BucketOption

Sliding gives a key term from the last time it was read: a key created without kv.TTL or kv.ExpireAt gets term, and a Get, GetEntry or Has renews a live key to term from now once a thirtieth of the term has passed since it last did. With DefaultTTL it is ErrInvalid.

A read does not write: the renewal waits a second for the next flush, and a crash forgets the renewals since the last. So a key read at t lives at least until t + term − term/30 unless it is renewed.

A key with less than a minute left is renewed before its read returns.

func WithCodec

func WithCodec[V any](codec Codec[V]) BucketOption

WithCodec writes a bucket's values through codec instead of the bytes their type would get; a codec of another value type is ErrInvalid at OpenBucket.

type Codec

type Codec[V any] interface {
	Encode(V) ([]byte, error)
	Decode([]byte) (V, error)
}

Codec writes a bucket's values when the bytes their type would get are not the ones wanted; see WithCodec.

type Config

type Config[T any] struct {
	// contains filtered or unexported fields
}

Config is an application's settings, a value of T made of layers in the order given, each over the one before it, and what Update kept over them all:

kv.Defaults(Config{Port: 8080}), kv.Defaults(fromYAML)
kv.FromEnv("APP", ".env")       APP_PORT=3000
config.Update(ctx, func(c *Config) { c.Port = 4000 })

Get reads it from memory. Update checks a change and keeps it field by field in kv.db, so that it outlives a restart, and every handle on the config sees it at once, in this process and through the server; Reset gives a field back to the layers under it.

T is a struct. A field's path is its JSON name, a nested struct's below its own: limits.rps. A field tagged `env:"NAME"` reads that variable; another reads the prefix and its path in upper snake case, APP_LIMITS_RPS. A field tagged `secret:"true"` comes from the layers alone: Update refuses it and Sources hides it. A field tagged `required:"true"` is ErrInvalid at OpenConfig while the layers leave it its zero value.

func OpenConfig

func OpenConfig[T any](ctx context.Context, state *Store, name string, options ...ConfigOption) (*Config[T], error)

OpenConfig opens the config name of kv.db, creating it the first time, and makes it from its layers. Defaults and an environment that do not fit T, or that fail Validate, are ErrInvalid; a kept value that no longer fits its field is left out, logged, and named by Sources.

func (*Config[T]) Get

func (c *Config[T]) Get() T

Get is the config now, built once a change and shared: it is not to be changed, which Update does.

func (*Config[T]) Reset

func (c *Config[T]) Reset(ctx context.Context, paths ...string) error

Reset forgets what Update kept for paths, each a field or a struct of fields, so that they take their values from the layers under it again; without paths it forgets everything kept.

func (*Config[T]) Sources

func (c *Config[T]) Sources() []Source

Sources says where each field's value came from, in the order of T's fields, and why a kept value is left out.

func (*Config[T]) Update

func (c *Config[T]) Update(ctx context.Context, change func(*T)) error

Update changes the config: change gets a copy of it, and each field it changes is checked, kept, and seen by every handle at once. A change that fails Validate, sets a secret or empties a required field is ErrInvalid and keeps nothing.

func (*Config[T]) Watch

func (c *Config[T]) Watch(ctx context.Context) iter.Seq[T]

Watch yields the config now and each time it changes, from this handle or any other, until ctx ends. A watcher that falls behind skips to the latest.

type ConfigOption

type ConfigOption func(*configSettings)

ConfigOption is where a config's values come from, and what checks them.

func Defaults

func Defaults(values any) ConfigOption

Defaults is a layer of values: a T, which sets every field, or a map, as a JSON or YAML library reads a file into one, which sets the fields it names. A duration may be text, "30s".

func FromEnv

func FromEnv(prefix string, files ...string) ConfigOption

FromEnv is a layer of the environment: each field from its variable, files first, as a dotenv library reads them, and the process's own over them. A file that is not there is skipped.

PORT=3000  ORIGINS=a.com,b.com  TIMEOUT=1h30m  LIMITS={"rps":5}

func Validate

func Validate[T any](check func(T) error) ConfigOption

Validate checks the config each time it is made, at open and before Update keeps a change: a change that fails is refused, and a kept value that fails is left out.

type CounterOption

type CounterOption interface {
	// contains filtered or unexported methods
}

func LoseAtMost

func LoseAtMost(d time.Duration) CounterOption

LoseAtMost keeps the counters in memory and writes what changed every d, on Close and on Maintain, so that an Add takes no write.

A crash loses the changes since the last flush that committed, a Delete's as an Add's. A flush waits for the writer as any write does, so on a busy file the loss can reach d plus the flush's own wait and time.

Handles on counters of one name share the memory and join no transaction. Opening them again with another d, or without LoseAtMost, is ErrInvalid.

type Counters

type Counters struct {
	// contains filtered or unexported fields
}

Counters holds an int64 by key. Of and WithTx give handles on the same counters, and a handle may be used from any number of goroutines.

func OpenCounters

func OpenCounters(ctx context.Context, state *Store, name string, options ...CounterOption) (*Counters, error)

OpenCounters opens the counters name of kv.db, creating them the first time. A name that holds values is ErrInvalid.

func (*Counters) Add

func (c *Counters) Add(ctx context.Context, key any, n int64) (int64, error)

Add adds n to the counter under key and returns what it holds now. An absent or expired counter starts from zero with the DefaultTTL of the counters, and a live one keeps its expiry, so a window does not slide. A sum past the int64 range is ErrLimit and changes nothing.

func (*Counters) Clear

func (c *Counters) Clear(ctx context.Context) error

Clear removes every counter of this branch and of the branches under it, as Bucket.Clear does. LoseAtMost counters it also removes from memory once it commits, so that no flush writes them again; a Clear that fails leaves them.

func (*Counters) Delete

func (c *Counters) Delete(ctx context.Context, key any) error

Delete removes the counter under key; an absent one is not an error.

func (*Counters) Get

func (c *Counters) Get(ctx context.Context, key any) (int64, error)

Get is what the counter under key holds; an absent or expired one holds 0.

func (*Counters) Max

func (c *Counters) Max(ctx context.Context, key any, n int64) (int64, error)

Max keeps the larger of the counter under key and n, an absent counter counting as zero, and returns what it holds now.

func (*Counters) Of

func (c *Counters) Of(owners ...any) *Counters

Of is the branch of these counters that owners name, as Bucket.Of names one.

func (*Counters) WithTx

func (c *Counters) WithTx(tx *Tx) *Counters

WithTx binds these counters to tx; LoseAtMost counters refuse calls through it.

type Entry

type Entry[V any] struct {
	// Key is the key's text, without the branch's owners.
	Key     string
	Value   V
	Version Version
	// ExpiresAt is zero for a key that never expires.
	ExpiresAt time.Time
}

Entry is a value with what the file knows of it.

type KeyError

type KeyError struct {
	Bucket, Path string
	Err          error
}

KeyError is a call refused because of one key: its bucket and the path of owners and key it named. errors.Is finds the store's sentinel in it.

func (*KeyError) Error

func (e *KeyError) Error() string

func (*KeyError) Unwrap

func (e *KeyError) Unwrap() error

type Limiter

type Limiter struct {
	// contains filtered or unexported fields
}

Limiter lets each key's requests through at a steady rate with room for a burst. It is the generic cell rate algorithm: a key holds one time, when its next request is due, so there is no window whose edge lets twice the rate through, and a key costs one int64.

Rate(3, time.Second), Burst(3): three pass at once, the fourth waits 333ms

Its times live in memory and reach the file every second and on Close, as LoseAtMost counters do: a crash forgets at most the last second, and so lets at most one burst more through. A key whose time has come is as one never seen, and maintenance deletes it.

func OpenLimiter

func OpenLimiter(ctx context.Context, state *Store, name string, options ...LimiterOption) (*Limiter, error)

OpenLimiter opens the limiter name of kv.db, creating it the first time. A name that holds values or counters is ErrInvalid; the rate is the caller's, and may change between runs.

func (*Limiter) Allow

func (l *Limiter) Allow(ctx context.Context, key any) (Allowance, error)

Allow asks for one request of key.

func (*Limiter) AllowN

func (l *Limiter) AllowN(ctx context.Context, key any, n int64) (Allowance, error)

AllowN asks for n requests of key at once: all of them pass or none does. More than the burst never passes, and is ErrInvalid.

func (*Limiter) Of

func (l *Limiter) Of(owners ...any) *Limiter

Of is the branch of this limiter that owners name, as Bucket.Of names one.

type LimiterOption

type LimiterOption func(*limiterSettings)

LimiterOption says how fast a limiter lets requests through.

func Burst

func Burst(n int64) LimiterOption

Burst is how many requests may pass at once before the rate holds the next; count of the Rate when it is not given.

func Rate

func Rate(count int64, per time.Duration) LimiterOption

Rate lets count requests through every per, each key alone: Rate(100, time.Second). A limiter needs one.

type Maintenance

type Maintenance struct {
	Flushed int // LoseAtMost counters written
	Renewed int // Sliding keys renewed
	Cleared int // rows a marked Clear hid, deleted
	Expired int
}

Maintenance is what one Maintain call did.

type Once

type Once[V any] struct {
	// contains filtered or unexported fields
}

Once runs a function at most once a key and keeps what it returns, so that a request sent again is answered as the first was rather than done twice: a charge, an import, a webhook's effect.

charges.Run(ctx, "req-7", charge) → runs charge, keeps its receipt a day
charges.Run(ctx, "req-7", charge) → that receipt; charge does not run

One Run of a key runs its function at a time in the store's process, the clients of its server included: a Run of a key another Run is running waits for it and returns its answer. A function's error keeps nothing, so the next Run runs it again; an answer a program wants kept, a card declined, is a value rather than an error.

A claim on a key lives in memory as long as its Run. A process that dies after a function's effect outside the store and before its answer was kept runs it again on the next Run, so such an effect carries the key too: a payment provider's own idempotency key.

func OpenOnce

func OpenOnce[V any](ctx context.Context, state *Store, name string, options ...BucketOption) (*Once[V], error)

OpenOnce opens the answers name of kv.db, creating them the first time. An answer lives a day unless DefaultTTL says. A name that holds anything else is ErrInvalid, and so is Sliding: an answer lives from when it was kept.

func (*Once[V]) Delete

func (o *Once[V]) Delete(ctx context.Context, key any) error

Delete forgets the answer kept under key, so that the next Run runs again.

func (*Once[V]) Get

func (o *Once[V]) Get(ctx context.Context, key any) (V, bool, error)

Get is the answer kept under key, and whether one is.

func (*Once[V]) Of

func (o *Once[V]) Of(owners ...any) *Once[V]

Of is the branch of these answers that owners name, as Bucket.Of names one.

func (*Once[V]) Run

func (o *Once[V]) Run(ctx context.Context, key any, fn func(context.Context) (V, error)) (V, error)

Run returns the answer kept under key, or runs fn and keeps what it returns.

While another Run of the key runs its function this one waits, until ctx ends, and then returns the answer it kept, or, when it failed, runs fn itself. fn's error is returned and keeps nothing. An answer is kept even when ctx ends after fn returned, since its work is done; an error keeping it is returned beside it.

type OpenOption

type OpenOption interface {
	BucketOption
	CounterOption
}

func DefaultTTL

func DefaultTTL(d time.Duration) OpenOption

DefaultTTL is the expiry a key gets when it is created without kv.TTL or kv.ExpireAt; a later Set or Add keeps the expiry a key has.

type Option

type Option func(*callOptions)

func ExpireAt

func ExpireAt(t time.Time) Option

ExpireAt gives the key until t by the store's clock.

func IfVersion

func IfVersion(v Version) Option

IfVersion lets a call write only to a live key whose version is v; any other is tinystore.ErrConflict, an absent or expired key included.

func TTL

func TTL(d time.Duration) Option

TTL gives the key d from now; see the rules of DefaultTTL.

type Options

type Options struct{}

Options holds nothing a program sets yet; it is here so that an option can arrive without breaking a caller.

type Page

type Page[V any] struct {
	Entries []Entry[V]
	// More says the limit, or the page's 4 MiB of values, ended the page before
	// the branch did.
	More bool
	// Next is the query that reads on.
	Next Query
}

Page is one page of a Scan, from one snapshot.

type Query

type Query struct {
	// After, when it is not empty, starts the page after that key.
	After string
	Limit int // keys a page returns: 100 when zero, at most 1000
}

Query asks Scan for a page of a branch's own keys, in the byte order of their text.

type Quota

type Quota struct {
	// contains filtered or unexported fields
}

Quota lets each key use up to a limit in every one of its windows, such as 100 messages every five hours and 300 a week, counted by one Allow.

A window starts at a key's first use after the last one ended and lasts its span, as a counter with DefaultTTL does, so that each key resets on its own:

Window("session", 100, 5*time.Hour): first use 13:42 → resets at 18:42;
a use at 19:07 starts the next window, which resets at 00:07

An Allow checks every window and counts in each, or in none, in one durable write: two quotas asked one after the other would leave the first counted when the second refused.

func OpenQuota

func OpenQuota(ctx context.Context, state *Store, name string, windows ...QuotaOption) (*Quota, error)

OpenQuota opens the quota name of kv.db, creating it the first time. A name that holds anything else is ErrInvalid. Its windows are the caller's and may change between runs: a window kept under a name no longer given is forgotten, and a limit changed counts what its window used before.

func (*Quota) Allow

func (q *Quota) Allow(ctx context.Context, key any) (QuotaUsage, error)

Allow uses one of key's every window, or none when one has no room left.

func (*Quota) AllowN

func (q *Quota) AllowN(ctx context.Context, key any, n int64) (QuotaUsage, error)

AllowN uses n of key's every window at once, or none when one has no room for n. More than a window's limit never passes, and is ErrInvalid.

func (*Quota) Delete

func (q *Quota) Delete(ctx context.Context, key any) error

Delete forgets key's windows, so that its next use starts each anew.

func (*Quota) Get

func (q *Quota) Get(ctx context.Context, key any) (QuotaUsage, error)

Get is key's windows without using them: OK says whether one more use would pass now.

func (*Quota) Of

func (q *Quota) Of(owners ...any) *Quota

Of is the branch of this quota that owners name, as Bucket.Of names one.

func (*Quota) Refund

func (q *Quota) Refund(ctx context.Context, key any) error

Refund gives one use back to each of key's windows that has not reset since; a window never goes below nothing.

func (*Quota) RefundN

func (q *Quota) RefundN(ctx context.Context, key any, n int64) error

RefundN gives n uses back, as Refund gives one.

type QuotaOption

type QuotaOption func(*quotaSettings)

QuotaOption is one of a quota's windows.

func Window

func Window(name string, limit int64, span time.Duration) QuotaOption

Window lets a key use up to limit every span, from its first use: Window("weekly", 300, 7*24*time.Hour). A name is [a-z][a-z0-9_]{0,31}, and a quota has one to eight windows.

type QuotaUsage

type QuotaUsage struct {
	Allowance
	Windows map[string]WindowUsage
}

QuotaUsage is what a quota answers a key: whether its use passed, how many more would pass now and, when it did not, how long until it would, as a limiter's Allowance says; and each window by its name.

type Raw

type Raw struct {
	Kind  RawKind
	Int   int64
	Bytes []byte
}

Raw is a value as its row keeps it: nothing, an integer or bytes.

A bucket of Raw reads what a bucket of any type wrote, and what it writes is read by a bucket of the type that kept it the same way. So a program without the types, such as the server for its clients, reads and writes every bucket:

Raw{}                                    nothing, as a struct{} of a set
Raw{Kind: RawInt, Int: 42}               an integer, as a bool or an int64
Raw{Kind: RawBytes, Bytes: []byte("x")}  bytes, as a string, a []byte, a float's bits or JSON

type RawConfig

type RawConfig struct {
	// contains filtered or unexported fields
}

RawConfig is what a config keeps, its changed fields as JSON by path, for a program that types them itself, as the server does for its clients. It checks that a value is JSON and the config within its bounds, and nothing a type would.

func OpenRawConfig

func OpenRawConfig(ctx context.Context, state *Store, name string) (*RawConfig, error)

OpenRawConfig opens the config name of kv.db, creating it the first time. A name that holds values, counters or a limiter is ErrInvalid.

func (*RawConfig) Change

func (r *RawConfig) Change(ctx context.Context, set map[string]json.RawMessage, reset []string) error

Change keeps set and forgets reset in one transaction; every watcher of the config sees the change once it commits.

func (*RawConfig) Kept

func (r *RawConfig) Kept() (map[string]json.RawMessage, int64)

Kept is the changed fields and how many changes the config has had since this process opened it.

func (*RawConfig) Watch

Watch yields the changed fields now and after each change, until ctx ends. A watcher that falls behind skips to the latest: it never sees a state the config did not have.

type RawKind

type RawKind uint8

RawKind is which of the three a Raw holds.

const (
	RawNothing RawKind = iota
	RawInt
	RawBytes
)

type Source

type Source struct {
	Path string
	// Value is its JSON, or *** for a secret.
	Value string
	// From is "default", "env NAME", "kept", or "" for T's zero value.
	From string
	// Ignored says why a kept value is left out, when it is.
	Ignored string
}

Source is where one field's value came from.

type Store

type Store struct {
	// contains filtered or unexported fields
}

func Open

func Open(ctx context.Context, store *tinystore.Store, _ Options) (*Store, error)

Open opens kv.db inside the store. The store closes it and, unless it is Manual, deletes expired keys every minute, and every ten seconds while the last pass stopped at its bound with expired or cleared rows left.

func (*Store) Close

func (s *Store) Close(ctx context.Context) error

Close lets the work in flight finish, writes what LoseAtMost counters hold and the renewals reads asked for, and closes kv.db; cancellation stops waiting, not the cleanup. The store calls it: an application closes the store instead.

func (*Store) Maintain

func (s *Store) Maintain(ctx context.Context) (Maintenance, error)

Maintain writes what LoseAtMost counters hold and the renewals Sliding reads asked for, deletes the rows a marked Clear hid, then deletes expired keys and the values they spilled. It deletes expired keys 10,000 a transaction and at most ten transactions a call.

The store calls it every minute unless it is Manual, and every ten seconds while the last call stopped at one of those bounds with rows left. It flushes counters and renewals on their own intervals.

No read returns an expired or cleared key, whether Maintain has deleted it or not.

func (*Store) Snapshot

func (s *Store) Snapshot(ctx context.Context, dir string) ([]tinystore.SnapshotFile, error)

Snapshot copies kv.db into dir while the engine keeps working.

func (*Store) Tx

func (s *Store) Tx(ctx context.Context, work func(*Tx) error) error

Tx runs work in one writer transaction over any buckets of kv.db: nil commits, an error or a panic rolls back. It never spans two engines.

func (*Store) View

func (s *Store) View(ctx context.Context, read func(*Tx) error) error

View runs read against one snapshot of kv.db, held at most five seconds, as a records read is; a write inside it is ErrInvalid.

type Tx

type Tx struct {
	// contains filtered or unexported fields
}

Tx is one transaction of kv.db, given to the function passed to Store.Tx or Store.View and used by the goroutine that runs it. A bucket works in it through WithTx.

type Version

type Version struct {
	// contains filtered or unexported fields
}

Version is the revision of kv.db that wrote a value. It never repeats in a file, not after a delete, an expiry or a reopen, so an old IfVersion cannot pass against a key deleted and written again. Two versions are equal or not; a version marshals to text, so that it can travel to a page and back.

func (Version) MarshalText

func (v Version) MarshalText() ([]byte, error)

func (Version) String

func (v Version) String() string

func (*Version) UnmarshalText

func (v *Version) UnmarshalText(text []byte) error

type WindowUsage

type WindowUsage struct {
	Used, Limit, Left int64
	// ResetAt is when the window ends, after which the next use starts
	// another; zero for a window not started.
	ResetAt time.Time
}

WindowUsage is one window of a key.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL